@svelte-vitals/core 0.55.1 → 0.55.3

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.
@@ -1,5 +1,5 @@
1
- import { parse } from "svelte/compiler";
2
1
  import { aria, roles } from "aria-query";
2
+ import { parse } from "svelte/compiler";
3
3
  //#region src/types.ts
4
4
  const defaultProject = {
5
5
  hasRobotsTxt: false,
@@ -152,7 +152,182 @@ const seoTitlePresence = {
152
152
  }
153
153
  };
154
154
  //#endregion
155
+ //#region src/kit-module.ts
156
+ /** The root layout — its page options cover the whole app. */
157
+ const ROOT_LAYOUT_RE = /^src\/routes\/\+layout(\.server)?\.(ts|js)$/;
158
+ /** Files whose `ssr` export is a page option — it has no effect in `+server` endpoints or hooks files. */
159
+ const PAGE_OPTION_FILE_RE = /\+(page|layout)(\.server)?\.(ts|js)$/;
160
+ const appSsrCache = /* @__PURE__ */ new WeakMap();
161
+ /**
162
+ * Whether no page is ever server-rendered: the hash router, or a root layout exporting `ssr = false`
163
+ * that no page-option file might turn back on. Universal loads and components then run only in the
164
+ * browser; server files (`+page.server`, `+server`, hooks) still run on the server.
165
+ */
166
+ function appSsrDisabled(scope) {
167
+ if (scope.project?.kitHashRouter) return true;
168
+ const kitModules = scope.kitModules;
169
+ if (!kitModules) return false;
170
+ let hit = appSsrCache.get(kitModules);
171
+ if (hit === void 0) {
172
+ hit = kitModules.some((m) => m.ssrDisabled && ROOT_LAYOUT_RE.test(m.file)) && !kitModules.some((m) => PAGE_OPTION_FILE_RE.test(m.file) && (m.ssrEnabled || m.parseFailed));
173
+ appSsrCache.set(kitModules, hit);
174
+ }
175
+ return hit;
176
+ }
177
+ const ROUTES_DIR$1 = "src/routes";
178
+ /** A `+page`/`+layout` file: its directory, kind, and the `@segment` layout reset (on `.svelte` names). */
179
+ const ROUTE_FILE_RE = /^(src\/routes(?:\/.+)?)\/\+(page|layout)(?:@([^/]*))?(?:\.server)?\.(?:svelte|ts|js)$/;
180
+ const parentDir = (dir) => dir === ROUTES_DIR$1 ? void 0 : dir.slice(0, dir.lastIndexOf("/"));
181
+ /** The nearest ancestor-or-self directory a `@segment` reset names (`''` is the root). */
182
+ function resetTarget(dir, segment) {
183
+ for (let d = dir; d !== void 0; d = parentDir(d)) if (segment === "" ? d === ROUTES_DIR$1 : d.slice(d.lastIndexOf("/") + 1) === segment) return d;
184
+ }
185
+ const routeOptionCache = {
186
+ ssr: /* @__PURE__ */ new WeakMap(),
187
+ csr: /* @__PURE__ */ new WeakMap()
188
+ };
189
+ const optionOn = (m, option) => m.parseFailed || (option === "ssr" ? m.ssrEnabled : m.csrEnabled);
190
+ const optionOff = (m, option) => (option === "ssr" ? m.ssrDisabled : m.csrDisabled) !== void 0;
191
+ /**
192
+ * Per route directory, whether `ssr` or `csr` is off for its page (`page:<dir>`) or layout
193
+ * (`layout:<dir>`). A page takes the nearest export of the option along its own files and then its
194
+ * layout chain, following `+page@x`/`+layout@x` resets as SvelteKit does; an export other than
195
+ * `false` (or `dev`), or an unparsed option file, counts as on. A layout is off only when every page
196
+ * using it is — and the root layout also needs its own opt-out, since it wraps the 404 page.
197
+ */
198
+ function routeOptionOff(scope, which) {
199
+ const kitModules = scope.kitModules ?? [];
200
+ let hit = routeOptionCache[which].get(scope);
201
+ if (hit) return hit;
202
+ const own = {
203
+ universal: /* @__PURE__ */ new Map(),
204
+ server: /* @__PURE__ */ new Map()
205
+ };
206
+ for (const m of kitModules) {
207
+ const match = ROUTE_FILE_RE.exec(m.file);
208
+ if (!match) continue;
209
+ const key = `${match[2]}:${match[1]}`;
210
+ if (optionOn(m, which)) own[m.kind].set(key, "on");
211
+ else if (optionOff(m, which)) own[m.kind].set(key, "off");
212
+ }
213
+ const option = new Map([...own.server, ...own.universal]);
214
+ hit = /* @__PURE__ */ new Map();
215
+ for (const [dir, chain] of pageChains(scope)) {
216
+ const off = [`page:${dir}`, ...chain.map((l) => `layout:${l}`)].map((k) => option.get(k)).find(Boolean) === "off";
217
+ hit.set(`page:${dir}`, off);
218
+ for (const l of chain) hit.set(`layout:${l}`, (hit.get(`layout:${l}`) ?? true) && off);
219
+ }
220
+ if (option.get(`layout:${ROUTES_DIR$1}`) !== "off") hit.delete(`layout:${ROUTES_DIR$1}`);
221
+ routeOptionCache[which].set(scope, hit);
222
+ return hit;
223
+ }
224
+ const chainCache = /* @__PURE__ */ new WeakMap();
225
+ /** Per page directory, the layout directories that wrap it, nearest first, following `+page@x`/`+layout@x` resets as SvelteKit does. */
226
+ function pageChains(scope) {
227
+ let hit = chainCache.get(scope);
228
+ if (hit) return hit;
229
+ const pages = /* @__PURE__ */ new Map();
230
+ const layouts = /* @__PURE__ */ new Map();
231
+ const files = [
232
+ ...scope.sourceFiles ?? [],
233
+ ...(scope.components ?? []).map((c) => c.file),
234
+ ...(scope.kitModules ?? []).map((m) => m.file)
235
+ ];
236
+ for (const file of files) {
237
+ const match = ROUTE_FILE_RE.exec(file);
238
+ if (!match) continue;
239
+ const map = match[2] === "page" ? pages : layouts;
240
+ if (match[3] !== void 0 || !map.has(match[1])) map.set(match[1], match[3]);
241
+ }
242
+ hit = /* @__PURE__ */ new Map();
243
+ for (const [dir, reset] of pages) {
244
+ const chain = [];
245
+ for (let d = reset === void 0 ? dir : resetTarget(dir, reset); d !== void 0;) {
246
+ const layoutReset = layouts.get(d);
247
+ if (layouts.has(d)) chain.push(d);
248
+ d = layoutReset === void 0 ? parentDir(d) : resetTarget(parentDir(d), layoutReset);
249
+ }
250
+ hit.set(dir, chain);
251
+ }
252
+ chainCache.set(scope, hit);
253
+ return hit;
254
+ }
255
+ /** Whether a `+page` file's route sends requests without a session away: its own load or a wrapping layout's does (`loadGated`). */
256
+ function routeGated(file, scope) {
257
+ const match = ROUTE_FILE_RE.exec(file);
258
+ if (!match || match[2] !== "page") return false;
259
+ const gated = /* @__PURE__ */ new Set();
260
+ for (const m of scope.kitModules ?? []) {
261
+ const g = m.loadGated && m.kind === "server" ? ROUTE_FILE_RE.exec(m.file) : null;
262
+ if (g) gated.add(`${g[2]}:${g[1]}`);
263
+ }
264
+ if (gated.size === 0) return false;
265
+ const chain = pageChains(scope).get(match[1]) ?? [];
266
+ return gated.has(`page:${match[1]}`) || chain.some((l) => gated.has(`layout:${l}`));
267
+ }
268
+ /**
269
+ * Whether a `+page` file's route never renders because a layout above it redirects or errors in its
270
+ * `load` on every call (`loadNeverRenders`). Not the root layout: one that sent every request away
271
+ * would leave the app nothing to serve, so that reading is more likely wrong than the app, and acting
272
+ * on it would silence every route.
273
+ */
274
+ function layoutNeverRenders(file, scope) {
275
+ const match = ROUTE_FILE_RE.exec(file);
276
+ if (!match || match[2] !== "page") return false;
277
+ const dead = /* @__PURE__ */ new Set();
278
+ for (const m of scope.kitModules ?? []) {
279
+ const layout = m.loadNeverRenders ? ROUTE_FILE_RE.exec(m.file) : null;
280
+ if (layout?.[2] === "layout" && layout[1] !== ROUTES_DIR$1) dead.add(layout[1]);
281
+ }
282
+ return dead.size > 0 && (pageChains(scope).get(match[1]) ?? []).some((dir) => dead.has(dir));
283
+ }
284
+ /**
285
+ * Whether a `+page` file's route never renders because its own `load` redirects or errors on every
286
+ * request, a build-time `if (building)` aside (`loadNeverRendersOutsideBuild`), and nothing on its chain
287
+ * exports `prerender`, so the build never renders it either.
288
+ */
289
+ function pageRendersOnlyWhenBuilding(file, scope) {
290
+ const match = ROUTE_FILE_RE.exec(file);
291
+ if (!match || match[2] !== "page") return false;
292
+ const at = (m) => ROUTE_FILE_RE.exec(m.file);
293
+ const modules = scope.kitModules ?? [];
294
+ if (!modules.some((m) => m.loadNeverRendersOutsideBuild && at(m)?.[2] === "page" && at(m)?.[1] === match[1])) return false;
295
+ const layouts = /* @__PURE__ */ new Set([match[1], ...pageChains(scope).get(match[1]) ?? []]);
296
+ return !modules.some((m) => {
297
+ const where = m.prerenderExported ? at(m) : null;
298
+ return !!where && (where[2] === "page" ? where[1] === match[1] : layouts.has(where[1]));
299
+ });
300
+ }
301
+ /** Whether a `+page`/`+layout` file (module or component) belongs to a route that is never server-rendered. */
302
+ function routeNeverSsr(file, scope) {
303
+ const match = ROUTE_FILE_RE.exec(file);
304
+ return !!match && (appSsrDisabled(scope) || routeOptionOff(scope, "ssr").get(`${match[2]}:${match[1]}`) === true);
305
+ }
306
+ /** Whether a universal `+page`/`+layout` module never runs in the browser: every page it serves has `csr` off, its own or inherited. */
307
+ function universalNeverCsr(m, scope) {
308
+ if (m.kind !== "universal") return false;
309
+ const match = ROUTE_FILE_RE.exec(m.file);
310
+ return !!match && routeOptionOff(scope, "csr").get(`${match[2]}:${match[1]}`) === true;
311
+ }
312
+ /** Whether a universal `+page`/`+layout` module never runs on the server — its own `ssr = false`, an app-wide one, or one its route inherits. */
313
+ function universalNeverSsr(m, scope) {
314
+ return m.kind === "universal" && (m.ssrDisabled !== void 0 || appSsrDisabled(scope) || routeNeverSsr(m.file, scope));
315
+ }
316
+ //#endregion
155
317
  //#region src/rules/seo/head-tag-rule.ts
318
+ /** Open Graph keys are read from `property=`; `name="og:*"` is the common slip. */
319
+ function ogMisspelled(key) {
320
+ return {
321
+ match: (t) => t.kind === "meta" && t.name === key && t.property === void 0,
322
+ label: `<meta name="${key}"> should be <meta property="${key}">`,
323
+ recommendation: `Open Graph reads ${key} from the property attribute: change name="${key}" to property="${key}".`,
324
+ fix: {
325
+ description: `Change name="${key}" to property="${key}" on the existing tag.`,
326
+ snippet: `<meta property="${key}" content="…" />`,
327
+ lang: "svelte"
328
+ }
329
+ };
330
+ }
156
331
  function detect(head, match) {
157
332
  const matches = head.tags.filter(match);
158
333
  const rank = (t) => (t.value !== "absent" ? 2 : 0) + (t.presence === "own" ? 1 : 0);
@@ -166,6 +341,7 @@ function detect(head, match) {
166
341
  value: "absent"
167
342
  };
168
343
  }
344
+ const GATED_NOTE = "Reported as info: this route's load turns away visitors without a session (a redirect or a 401/403), so search engines never index it.";
169
345
  /** Build a route-scope rule asserting the presence of a single head tag (design §11). */
170
346
  function headTagRule(opts) {
171
347
  const docsUrl = docsUrlFor(opts.id);
@@ -180,18 +356,20 @@ function headTagRule(opts) {
180
356
  async check(ctx) {
181
357
  return (opts.appliesTo ? ctx.heads.filter(opts.appliesTo) : ctx.heads).map((head) => {
182
358
  const detection = detect(head, opts.match);
183
- const message = detection.presence === "none" ? `Missing ${opts.label}` : detection.value === "absent" ? `Empty ${opts.label}` : opts.label;
359
+ const misspelled = detection.presence === "none" && opts.misspelled && head.tags.some(opts.misspelled.match) ? opts.misspelled : void 0;
360
+ const message = misspelled ? misspelled.label : detection.presence === "none" ? `Missing ${opts.label}` : detection.value === "absent" ? `Empty ${opts.label}` : opts.label;
361
+ const gated = opts.infoWhenGated && routeGated(head.file, ctx);
184
362
  return {
185
363
  id: opts.id,
186
364
  category: "seo",
187
- severity: opts.severity,
365
+ severity: gated ? "info" : opts.severity,
188
366
  detection,
189
367
  route: head.route,
190
368
  location: head.file,
191
369
  message,
192
- recommendation: opts.recommendation,
370
+ recommendation: misspelled?.recommendation ?? (gated ? `${opts.recommendation} ${GATED_NOTE}` : opts.recommendation),
193
371
  docsUrl,
194
- ...opts.fix ? { fix: { ...opts.fix } } : {}
372
+ ...misspelled ? { fix: { ...misspelled.fix } } : opts.fix ? { fix: { ...opts.fix } } : {}
195
373
  };
196
374
  });
197
375
  }
@@ -203,6 +381,7 @@ const seoDescriptionPresence = headTagRule({
203
381
  id: "seo/description-presence",
204
382
  title: "Description presence",
205
383
  severity: "warning",
384
+ infoWhenGated: true,
206
385
  match: (t) => t.kind === "meta" && t.name === "description",
207
386
  label: "<meta name=\"description\">",
208
387
  recommendation: "Add a <meta name=\"description\"> in <svelte:head>, or set the description on your meta component.",
@@ -214,12 +393,50 @@ const seoDescriptionPresence = headTagRule({
214
393
  }
215
394
  });
216
395
  //#endregion
396
+ //#region src/head.ts
397
+ /** Whether a `<link>`'s `rel` holds `token`: `rel` is a set of space-separated keywords (`dns-prefetch preconnect`). */
398
+ function hasRel(tag, token) {
399
+ return tag.rel?.split(/\s+/).includes(token) ?? false;
400
+ }
401
+ const JS_MIME_TYPES = /* @__PURE__ */ new Set([
402
+ "application/ecmascript",
403
+ "application/javascript",
404
+ "application/x-ecmascript",
405
+ "application/x-javascript",
406
+ "text/ecmascript",
407
+ "text/javascript",
408
+ "text/javascript1.0",
409
+ "text/javascript1.1",
410
+ "text/javascript1.2",
411
+ "text/javascript1.3",
412
+ "text/javascript1.4",
413
+ "text/javascript1.5",
414
+ "text/jscript",
415
+ "text/livescript",
416
+ "text/x-ecmascript",
417
+ "text/x-javascript"
418
+ ]);
419
+ /** HTML strips only ASCII whitespace from a `type` before matching — `trim()` would also strip
420
+ * U+00A0 etc. and misread a data block as a classic script. */
421
+ const ASCII_WHITESPACE_EDGES = /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g;
422
+ /**
423
+ * Whether a `type` attribute (undefined = absent) makes a `<script>` a classic,
424
+ * render-blocking-capable script. Per the HTML spec, absent and the literal empty string are
425
+ * classic; any other value is ASCII-whitespace-stripped and must match a JavaScript MIME type —
426
+ * so a whitespace-only `type` is a data block, not a classic script.
427
+ */
428
+ function isClassicScriptType(type) {
429
+ if (type === void 0 || type === "") return true;
430
+ return JS_MIME_TYPES.has(type.replace(ASCII_WHITESPACE_EDGES, "").toLowerCase());
431
+ }
432
+ //#endregion
217
433
  //#region src/rules/seo/canonical-url.ts
218
434
  const seoCanonicalUrl = headTagRule({
219
435
  id: "seo/canonical-url",
220
436
  title: "Canonical URL",
221
437
  severity: "warning",
222
- match: (t) => t.kind === "link" && t.rel === "canonical",
438
+ infoWhenGated: true,
439
+ match: (t) => t.kind === "link" && hasRel(t, "canonical"),
223
440
  label: "<link rel=\"canonical\">",
224
441
  recommendation: "Add <link rel=\"canonical\"> in <svelte:head>, or set the canonical prop on your meta component.",
225
442
  rationale: "A canonical URL tells search engines which URL is authoritative, preventing duplicate-content dilution across query-string variants of the same page.",
@@ -236,6 +453,7 @@ const seoOgImage = headTagRule({
236
453
  title: "Open Graph image",
237
454
  severity: "warning",
238
455
  match: (t) => t.kind === "meta" && t.property === "og:image",
456
+ misspelled: ogMisspelled("og:image"),
239
457
  label: "<meta property=\"og:image\">",
240
458
  recommendation: "Add <meta property=\"og:image\">, or set openGraph.images on your meta component.",
241
459
  rationale: "og:image is the preview thumbnail shown when the page is shared on social platforms; without it links render bare and get fewer clicks.",
@@ -252,6 +470,7 @@ const seoOgTitle = headTagRule({
252
470
  title: "Open Graph title",
253
471
  severity: "warning",
254
472
  match: (t) => t.kind === "meta" && t.property === "og:title",
473
+ misspelled: ogMisspelled("og:title"),
255
474
  label: "<meta property=\"og:title\">",
256
475
  recommendation: "Add <meta property=\"og:title\">, or set openGraph.title on your meta component.",
257
476
  rationale: "og:title controls the headline shown when the page is shared on social platforms, independent of the document <title>.",
@@ -510,7 +729,7 @@ const performanceResponsiveImage = imageRule({
510
729
  snippet: "<img src=\"/hero.jpg\" srcset=\"/hero-800.jpg 800w, /hero-1600.jpg 1600w\" sizes=\"100vw\" width=\"1600\" height=\"900\" alt=\"…\" />",
511
730
  lang: "svelte"
512
731
  },
513
- ok: (img) => img.hasSrcset
732
+ ok: (img) => img.hasSrcset || img.svg === true
514
733
  });
515
734
  //#endregion
516
735
  //#region src/rules/perf/link-rule.ts
@@ -541,7 +760,7 @@ const performancePreloadMissingAs = linkRule({
541
760
  snippet: "<link rel=\"preload\" href=\"/app.css\" as=\"style\" />",
542
761
  lang: "html"
543
762
  },
544
- relevant: (t) => t.rel === "preload",
763
+ relevant: (t) => hasRel(t, "preload"),
545
764
  ok: (t) => t.hasAs === true
546
765
  });
547
766
  //#endregion
@@ -558,7 +777,7 @@ const performanceFontPreloadCrossorigin = linkRule({
558
777
  snippet: "<link rel=\"preload\" href=\"/inter.woff2\" as=\"font\" type=\"font/woff2\" crossorigin />",
559
778
  lang: "html"
560
779
  },
561
- relevant: (t) => t.rel === "preload" && t.as === "font",
780
+ relevant: (t) => hasRel(t, "preload") && t.as === "font",
562
781
  ok: (t) => t.hasCrossorigin === true
563
782
  });
564
783
  //#endregion
@@ -647,7 +866,8 @@ const performanceRenderBlockingScript = {
647
866
  async check(ctx) {
648
867
  const out = [];
649
868
  for (const head of ctx.heads) {
650
- const scripts = head.tags.filter((t) => t.kind === "script");
869
+ const clientHead = routeNeverSsr(head.file, ctx);
870
+ const scripts = head.tags.filter((t) => t.kind === "script" && !(clientHead && t.file !== "src/app.html"));
651
871
  if (scripts.length === 0) continue;
652
872
  const blocking = scripts.filter((t) => t.blocking);
653
873
  if (blocking.length > 0) for (const tag of blocking) out.push({
@@ -1159,7 +1379,7 @@ const performancePreconnect = {
1159
1379
  if (tag.kind !== "link" && tag.kind !== "script" || typeof tag.href !== "string") continue;
1160
1380
  const host = hostOf(tag.href);
1161
1381
  if (!host) continue;
1162
- if (tag.kind === "link" && (tag.rel === "preconnect" || tag.rel === "dns-prefetch")) {
1382
+ if (tag.kind === "link" && (hasRel(tag, "preconnect") || hasRel(tag, "dns-prefetch"))) {
1163
1383
  covered.add(host);
1164
1384
  continue;
1165
1385
  }
@@ -1252,7 +1472,7 @@ const seoTwitterCard = headTagRule({
1252
1472
  id: "seo/twitter-card",
1253
1473
  title: "Twitter Card",
1254
1474
  severity: "info",
1255
- match: (t) => t.kind === "meta" && t.name === "twitter:card",
1475
+ match: (t) => t.kind === "meta" && (t.name === "twitter:card" || t.property === "twitter:card"),
1256
1476
  label: "<meta name=\"twitter:card\">",
1257
1477
  recommendation: "Add <meta name=\"twitter:card\" content=\"summary_large_image\"> so X/Twitter renders a rich card.",
1258
1478
  rationale: "twitter:card selects how the page renders when shared on X/Twitter; without it the platform falls back to a basic link (Open Graph tags are used as fallbacks for the rest).",
@@ -1269,6 +1489,7 @@ const seoOgDescription = headTagRule({
1269
1489
  title: "Open Graph description",
1270
1490
  severity: "info",
1271
1491
  match: (t) => t.kind === "meta" && t.property === "og:description",
1492
+ misspelled: ogMisspelled("og:description"),
1272
1493
  label: "<meta property=\"og:description\">",
1273
1494
  recommendation: "Add <meta property=\"og:description\">, or set openGraph.description on your meta component.",
1274
1495
  rationale: "og:description is the summary shown under the title in social previews; without it platforms guess or show nothing, lowering click-through. The Open Graph protocol lists it as an optional property.",
@@ -1285,6 +1506,7 @@ const seoOgUrl = headTagRule({
1285
1506
  title: "Open Graph URL",
1286
1507
  severity: "warning",
1287
1508
  match: (t) => t.kind === "meta" && t.property === "og:url",
1509
+ misspelled: ogMisspelled("og:url"),
1288
1510
  label: "<meta property=\"og:url\">",
1289
1511
  recommendation: "Add <meta property=\"og:url\"> with the canonical URL, or set openGraph.url on your meta component.",
1290
1512
  rationale: "og:url tells social platforms the canonical address to attribute shares and likes to, consolidating engagement on one URL. The Open Graph protocol lists it as a required property.",
@@ -2042,7 +2264,7 @@ const seoHreflang = {
2042
2264
  async check(ctx) {
2043
2265
  const out = [];
2044
2266
  for (const head of ctx.heads) {
2045
- const alternates = head.tags.filter((t) => t.kind === "link" && t.rel === "alternate" && typeof t.hreflang === "string");
2267
+ const alternates = head.tags.filter((t) => t.kind === "link" && hasRel(t, "alternate") && typeof t.hreflang === "string");
2046
2268
  if (alternates.length === 0) continue;
2047
2269
  const values = alternates.map((t) => t.hreflang);
2048
2270
  const badTag = alternates.find((t) => !isValidHreflang(t.hreflang));
@@ -2084,6 +2306,223 @@ const seoHreflang = {
2084
2306
  }
2085
2307
  };
2086
2308
  //#endregion
2309
+ //#region src/rules/a11y/aria-data.ts
2310
+ /**
2311
+ * ARIA names `aria-query@5.3.2` does not carry. It is not the clean ARIA 1.2 snapshot it looks
2312
+ * like — it holds 1.2's 48 attributes plus three 1.3 additions — so the gap is patched by name
2313
+ * rather than by version. Every entry here is defined in the ARIA 1.3 editor's draft, which is
2314
+ * what makes reporting it as an unknown name wrong — how far implementations have got is a
2315
+ * separate question this rule does not ask. Re-check this list whenever the aria-query dependency
2316
+ * moves.
2317
+ */
2318
+ const ARIA_1_3_ROLES = /* @__PURE__ */ new Set([
2319
+ "comment",
2320
+ "image",
2321
+ "sectionheader",
2322
+ "sectionfooter",
2323
+ "suggestion"
2324
+ ]);
2325
+ const ARIA_1_3_ATTRIBUTES = /* @__PURE__ */ new Set(["aria-colindextext", "aria-rowindextext"]);
2326
+ /**
2327
+ * Roles whose `requiredProps` row in aria-query is stale. Neither ARIA 1.2 nor the 1.3 editor's
2328
+ * draft lists required states or properties for these — the 1.3 draft re-lists inherited
2329
+ * requirements where they apply (`menuitemradio` → `aria-checked`), so the absence is deliberate
2330
+ * rather than an inheritance the table left implicit. Without this, idiomatic APG listbox and
2331
+ * tree markup is flagged.
2332
+ */
2333
+ const NO_REQUIRED_PROPS = /* @__PURE__ */ new Set(["option", "treeitem"]);
2334
+ function isKnownRole(role) {
2335
+ return ARIA_1_3_ROLES.has(role) || roles.has(role);
2336
+ }
2337
+ function isAbstractRole(role) {
2338
+ return roles.get(role)?.abstract === true;
2339
+ }
2340
+ /** A concrete (non-abstract) role is the one a user agent resolves a role token list to. */
2341
+ function isConcreteRole(role) {
2342
+ return isKnownRole(role) && !isAbstractRole(role);
2343
+ }
2344
+ /**
2345
+ * The role a user agent applies: the first token naming a concrete role, or undefined when none
2346
+ * does. Shared so the role rules cannot disagree about what a fallback list means.
2347
+ */
2348
+ function resolveRole(tokens) {
2349
+ return tokens.find(isConcreteRole);
2350
+ }
2351
+ function isKnownAriaAttribute(name) {
2352
+ return ARIA_1_3_ATTRIBUTES.has(name) || aria.has(name);
2353
+ }
2354
+ function requiredAriaProps(role) {
2355
+ if (NO_REQUIRED_PROPS.has(role)) return [];
2356
+ const def = roles.get(role);
2357
+ return def ? Object.keys(def.requiredProps) : [];
2358
+ }
2359
+ function ariaValueKind(name) {
2360
+ const def = aria.get(name);
2361
+ if (!def) return void 0;
2362
+ return {
2363
+ type: def.type,
2364
+ ...def.values ? { values: def.values.map(String) } : {}
2365
+ };
2366
+ }
2367
+ //#endregion
2368
+ //#region src/a11y.ts
2369
+ /**
2370
+ * Branch-aware occurrence fold (design "Control-flow semantics"): within a branch
2371
+ * occurrences sum, across the arms of one exclusive block the arm with the most
2372
+ * occurrences wins (tie → lowest branch index) and ITS occurrences are the group's
2373
+ * representatives — so a caller's count is always `list.length`, with a location per
2374
+ * representative. `{#each}`/`{#snippet}` occurrences render 0..N times and drop out.
2375
+ * The max is per key: there is no scalar total to maximize.
2376
+ */
2377
+ function foldOccurrences(nodes) {
2378
+ const byKey = /* @__PURE__ */ new Map();
2379
+ for (const node of nodes) {
2380
+ if (node.repeatable) continue;
2381
+ const list = byKey.get(node.key);
2382
+ if (list) list.push(node);
2383
+ else byKey.set(node.key, [node]);
2384
+ }
2385
+ const folded = /* @__PURE__ */ new Map();
2386
+ for (const [key, list] of byKey) folded.set(key, foldAt(list, 0));
2387
+ return folded;
2388
+ }
2389
+ function foldAt(nodes, depth) {
2390
+ const unconditional = [];
2391
+ const groups = /* @__PURE__ */ new Map();
2392
+ for (const node of nodes) {
2393
+ const step = node.path[depth];
2394
+ if (!step) {
2395
+ unconditional.push(node);
2396
+ continue;
2397
+ }
2398
+ let branches = groups.get(step.group);
2399
+ if (!branches) groups.set(step.group, branches = /* @__PURE__ */ new Map());
2400
+ const list = branches.get(step.branch);
2401
+ if (list) list.push(node);
2402
+ else branches.set(step.branch, [node]);
2403
+ }
2404
+ const representatives = [...unconditional];
2405
+ for (const branches of groups.values()) {
2406
+ let best = [];
2407
+ let bestBranch = Number.POSITIVE_INFINITY;
2408
+ for (const [branch, list] of branches) {
2409
+ const arm = foldAt(list, depth + 1);
2410
+ if (arm.length > best.length || arm.length === best.length && branch < bestBranch) {
2411
+ best = arm;
2412
+ bestBranch = branch;
2413
+ }
2414
+ }
2415
+ representatives.push(...best);
2416
+ }
2417
+ return representatives;
2418
+ }
2419
+ /**
2420
+ * Decode a fragment identifier the way navigation does before matching an element id
2421
+ * (`href="#caf%C3%A9"` targets `id="café"`). Malformed escapes are kept verbatim —
2422
+ * the browser would also fail to decode them, so the raw text is the comparable form.
2423
+ */
2424
+ function decodeFragmentId(fragment) {
2425
+ try {
2426
+ return decodeURIComponent(fragment);
2427
+ } catch {
2428
+ return fragment;
2429
+ }
2430
+ }
2431
+ /** Whitespace-split tokens of a (possibly undefined) literal attribute value. */
2432
+ function splitTokens(value) {
2433
+ return value ? value.trim().split(/\s+/).filter(Boolean) : [];
2434
+ }
2435
+ /** Explicit `role` values that map to the landmark kinds the route rules inspect. */
2436
+ const LANDMARK_ROLES = /* @__PURE__ */ new Set([
2437
+ "main",
2438
+ "banner",
2439
+ "contentinfo",
2440
+ "complementary"
2441
+ ]);
2442
+ /** Sectioning content (HTML-AAM): a `<header>`/`<footer>` below one of these is not a landmark. */
2443
+ const SECTIONING_TAGS = /* @__PURE__ */ new Set([
2444
+ "article",
2445
+ "aside",
2446
+ "main",
2447
+ "nav",
2448
+ "section"
2449
+ ]);
2450
+ /**
2451
+ * Tags whose landmark-ness depends on sectioning ancestry. A provider that cannot see ancestry
2452
+ * (the per-file AST walk) uses this same set to mark the deferral (topLevel) — reading it from
2453
+ * here keeps the deferral set from drifting when the policy widens.
2454
+ */
2455
+ const ANCESTRY_DEPENDENT_TAGS = /* @__PURE__ */ new Set(["header", "footer"]);
2456
+ /** Attributes that give an element the accessible name the `<aside>` landmark decision reads. */
2457
+ const NAMING_ATTRS$1 = ["aria-label", "aria-labelledby"];
2458
+ /**
2459
+ * HTML-AAM: an `<aside>` scoped to `body` or `main` is a `complementary` landmark; scoped to
2460
+ * sectioning content it is one only when it has an accessible name. `main` is deliberately absent
2461
+ * — it is a scope in which an `aside` *is* a landmark, unlike the sectioning set that decides
2462
+ * `<header>`/`<footer>`.
2463
+ */
2464
+ const ASIDE_DEMOTING_TAGS = /* @__PURE__ */ new Set([
2465
+ "article",
2466
+ "aside",
2467
+ "nav",
2468
+ "section"
2469
+ ]);
2470
+ /**
2471
+ * The landmark kind an element contributes ('main'|'banner'|'contentinfo'|'complementary'), or
2472
+ * undefined. The one home for the decision both providers apply — the source AST walk and the
2473
+ * rendered HTML walk each feed it their own tree's context. ARIA fallback role lists resolve to
2474
+ * the first token naming a concrete role (resolveRole).
2475
+ */
2476
+ function resolveLandmark(input) {
2477
+ const { tag, roleTokens, named, insideSectioning, insideAsideDemoting } = input;
2478
+ if (roleTokens !== void 0) {
2479
+ const role = resolveRole(roleTokens);
2480
+ return role && LANDMARK_ROLES.has(role) ? role : void 0;
2481
+ }
2482
+ if (tag === "main") return "main";
2483
+ if (tag !== void 0 && ANCESTRY_DEPENDENT_TAGS.has(tag)) return insideSectioning ? void 0 : tag === "header" ? "banner" : "contentinfo";
2484
+ if (tag === "aside") return !insideAsideDemoting || named ? "complementary" : void 0;
2485
+ }
2486
+ /**
2487
+ * Attributes whose (whitespace-tokenized) values reference element ids: the ARIA id-reference and
2488
+ * id-reference-list properties, and HTML's own (`for`, `list`, `headers`, `form`, the popover and
2489
+ * command targets). `href="#…"` is handled separately — its value is a URL, not a token list.
2490
+ */
2491
+ const IDREF_ATTRS = [
2492
+ "for",
2493
+ "list",
2494
+ "headers",
2495
+ "form",
2496
+ "popovertarget",
2497
+ "commandfor",
2498
+ "aria-labelledby",
2499
+ "aria-describedby",
2500
+ "aria-controls",
2501
+ "aria-activedescendant",
2502
+ "aria-owns",
2503
+ "aria-details",
2504
+ "aria-errormessage",
2505
+ "aria-flowto"
2506
+ ];
2507
+ /**
2508
+ * Whether a decoded URL fragment is HTML's "top of the document" indicator: `#top` (ASCII
2509
+ * case-insensitive) scrolls to the top when no element has that id, so it is never a missing
2510
+ * reference. Compare AFTER percent-decoding — `#%74op` navigates identically to `#top`.
2511
+ */
2512
+ function isTopFragment(id) {
2513
+ return id.toLowerCase() === "top";
2514
+ }
2515
+ /**
2516
+ * A fragment with its text directive removed. Everything from the first `:~:` on is user-agent
2517
+ * instructions for finding text and names no element, while anything before it is still an
2518
+ * ordinary element fragment — `#section:~:text=hi` targets `id="section"`, `#:~:text=hi` targets
2519
+ * nothing. Returns an empty string when the fragment is a directive and nothing else.
2520
+ */
2521
+ function stripTextDirective(fragment) {
2522
+ const i = fragment.indexOf(":~:");
2523
+ return i === -1 ? fragment : fragment.slice(0, i);
2524
+ }
2525
+ //#endregion
2087
2526
  //#region src/rules/seo/single-h1.ts
2088
2527
  const docsUrl$6 = docsUrlFor("seo/single-h1");
2089
2528
  const passRecommendation = "Use exactly one <h1> per page for its main topic; demote extra top-level headings to <h2>+.";
@@ -2098,10 +2537,24 @@ const multipleRecommendation = "A single, clear <h1> is the conventional signal
2098
2537
  * signal, but no official source documents a ranking penalty for several (2026-08-09 v1
2099
2538
  * rule-validity review, P2 #11) — so it's flagged as a style nit, not a defect. Exactly
2100
2539
  * one passes. A route whose headings were not collected (channel unset) emits nothing, as does
2101
- * one whose `dynamicHeading` flag says an undeterminable `<svelte:element>` may be its <h1>. A
2540
+ * one whose `dynamicHeading` flag says a heading of undeterminable level may be its <h1>. A
2102
2541
  * global `rules: { 'seo/single-h1': <severity> }` override flattens both arms to one
2103
2542
  * severity (design, `applyRuleSeverities`).
2104
2543
  */
2544
+ /**
2545
+ * The <h1>s that can render together, in input order: the arms of one `{#if}`/`{#await}`/
2546
+ * `<svelte:boundary>` contribute only their largest arm. Separate blocks still sum, since both may be true.
2547
+ */
2548
+ function renderableH1s(headings) {
2549
+ const nodes = headings.filter((h) => h.level === 1 && !h.aria).map((heading) => ({
2550
+ key: "h1",
2551
+ path: heading.path ?? [],
2552
+ repeatable: false,
2553
+ heading
2554
+ }));
2555
+ const kept = new Set([...foldOccurrences(nodes).values()].flat().map((n) => n.heading));
2556
+ return headings.filter((h) => kept.has(h));
2557
+ }
2105
2558
  const seoSingleH1 = {
2106
2559
  id: "seo/single-h1",
2107
2560
  title: "Heading hierarchy",
@@ -2112,7 +2565,7 @@ const seoSingleH1 = {
2112
2565
  async check(ctx) {
2113
2566
  const out = [];
2114
2567
  for (const route of ctx.headings ?? []) {
2115
- const h1 = [...route.headings, ...route.componentHeadings ?? []].filter((h) => h.level === 1);
2568
+ const h1 = renderableH1s([...route.headings, ...route.componentHeadings ?? []]);
2116
2569
  let problem;
2117
2570
  let where = {};
2118
2571
  if (h1.length === 0) {
@@ -2122,7 +2575,7 @@ const seoSingleH1 = {
2122
2575
  severity: "warning",
2123
2576
  recommendation: missingRecommendation
2124
2577
  };
2125
- const first = route.headings[0];
2578
+ const first = route.headings.find((h) => !h.aria);
2126
2579
  if (first) where = {
2127
2580
  location: first.file,
2128
2581
  ...first.line > 0 ? { line: first.line } : {}
@@ -2246,8 +2699,23 @@ const seoDuplicateDescription = uniquenessRule({
2246
2699
  rationale: "Duplicate meta descriptions give search engines no per-page summary, so they are often ignored or rewritten."
2247
2700
  });
2248
2701
  //#endregion
2702
+ //#region src/headings.ts
2703
+ /** Sort comparator for `order`: element by element, a prefix first; headings without one keep their place. */
2704
+ function documentOrder(a, b) {
2705
+ if (!a.order || !b.order) return 0;
2706
+ for (let i = 0; i < Math.min(a.order.length, b.order.length); i++) if (a.order[i] !== b.order[i]) return a.order[i] - b.order[i];
2707
+ return a.order.length - b.order.length;
2708
+ }
2709
+ /** Two headings can never render together: different arms of one block. */
2710
+ function exclusiveHeadings(a, b) {
2711
+ if (!a.path || !b.path) return false;
2712
+ return a.path.some((s) => b.path.some((t) => s.group === t.group && s.branch !== t.branch));
2713
+ }
2714
+ //#endregion
2249
2715
  //#region src/rules/seo/heading-level-skip.ts
2250
2716
  const docsUrl$5 = docsUrlFor("seo/heading-level-skip");
2717
+ /** Every arm `p` sits in that can be skipped, `h` sits in too: whenever `h` renders, so does `p` (or one like it). */
2718
+ const rendersWith = (p, h) => (p.path ?? []).every((s) => s.always || (h.path ?? []).some((t) => t.group === s.group && t.branch === s.branch));
2251
2719
  const recommendation$5 = "Increase heading levels one step at a time (do not jump, e.g. from <h2> straight to <h4>).";
2252
2720
  /**
2253
2721
  * seo/heading-level-skip — Skipped heading level. Walking a route's body headings in
@@ -2266,20 +2734,36 @@ const seoHeadingLevelSkip = {
2266
2734
  const out = [];
2267
2735
  for (const route of ctx.headings ?? []) {
2268
2736
  if (route.headings.length === 0) continue;
2269
- let prev = route.headings[0].level;
2737
+ const placed = (route.componentHeadings ?? []).filter((h) => h.order);
2738
+ const own = new Set(route.headings);
2739
+ const headings = [...route.headings, ...placed].sort(documentOrder);
2270
2740
  let skip;
2271
- for (let i = 1; i < route.headings.length; i++) {
2272
- const h = route.headings[i];
2273
- if (h.level > prev + 1) {
2274
- skip = {
2275
- level: h.level,
2276
- prev,
2277
- line: h.line,
2278
- file: h.file
2279
- };
2280
- break;
2741
+ for (let i = 1; i < headings.length && !skip; i++) {
2742
+ const h = headings[i];
2743
+ if (!own.has(h)) continue;
2744
+ const before = headings.slice(0, i).filter((p) => !exclusiveHeadings(p, h) && (own.has(p) || rendersWith(p, h)));
2745
+ const prev = before.at(-1);
2746
+ if (!prev) continue;
2747
+ const levels = [prev.level];
2748
+ const arms = /* @__PURE__ */ new Set();
2749
+ const looped = prev.path?.some((s) => s.repeat && !h.path?.some((t) => t.group === s.group));
2750
+ for (let j = looped ? -1 : before.length - 2; j >= 0; j--) {
2751
+ const q = before[j];
2752
+ const step = q.path?.find((s) => prev.path?.some((t) => t.group === s.group && t.branch !== s.branch));
2753
+ const arm = step && `${step.group}:${step.branch}`;
2754
+ if (arm && !arms.has(arm)) {
2755
+ arms.add(arm);
2756
+ levels.push(q.level);
2757
+ }
2281
2758
  }
2282
- prev = h.level;
2759
+ const known = levels.filter((level) => level > 0);
2760
+ const lowest = Math.min(...known);
2761
+ if (known.length === levels.length && h.level > lowest + 1) skip = {
2762
+ level: h.level,
2763
+ prev: lowest,
2764
+ line: h.line,
2765
+ file: h.file
2766
+ };
2283
2767
  }
2284
2768
  out.push(skip ? {
2285
2769
  id: "seo/heading-level-skip",
@@ -2410,10 +2894,6 @@ function kitModuleRule(opts) {
2410
2894
  }
2411
2895
  //#endregion
2412
2896
  //#region src/rules/seo/ssr-disabled.ts
2413
- /** The root layout — disabling SSR there turns the whole app into an SPA. */
2414
- const ROOT_LAYOUT_RE = /^src\/routes\/\+layout(\.server)?\.(ts|js)$/;
2415
- /** `ssr` is a page option — it has no effect in `+server` endpoints or hooks files. */
2416
- const PAGE_OPTION_FILE_RE = /\+(page|layout)(\.server)?\.(ts|js)$/;
2417
2897
  const seoSsrDisabled = kitModuleRule({
2418
2898
  id: "seo/ssr-disabled",
2419
2899
  title: "SSR disabled",
@@ -2755,9 +3235,19 @@ function messageFor(link) {
2755
3235
  if (link.kind === "goto") return `goto('${link.path}') is root-relative — it navigates outside this project's kit.paths.base and 404s in production. Use goto(resolve('${link.path}')) with resolve from '$app/paths'.`;
2756
3236
  return `redirect(…, '${link.path}') is root-relative — the Location header points outside this project's kit.paths.base and 404s in production. Use resolve('${link.path}') from '$app/paths'.`;
2757
3237
  }
3238
+ /**
3239
+ * A literal that already carries a statically known base is correct as written; only a dynamic
3240
+ * base (no `value`) leaves every root-relative literal suspect.
3241
+ */
3242
+ function startsWithBase(path, base) {
3243
+ if (base === void 0 || !path.startsWith(base)) return false;
3244
+ if (/(^|\/)(\.|%2e){2}(\/|$)/i.test(path.split(/[?#]/)[0])) return false;
3245
+ const next = path.charAt(base.length);
3246
+ return next === "" || next === "/" || next === "?" || next === "#";
3247
+ }
2758
3248
  /** Emit one file's PASS/PENALIZED results — same shapes as componentRule/kitModuleRule. */
2759
- function emitFile$1(out, file, links, suppressions) {
2760
- const bad = links.filter((l) => !(l.line > 0 && isSuppressed(suppressions, ID$8, l.line)));
3249
+ function emitFile$1(out, file, links, suppressions, base) {
3250
+ const bad = links.filter((l) => !startsWithBase(l.path, base) && !(l.line > 0 && isSuppressed(suppressions, ID$8, l.line)));
2761
3251
  if (bad.length === 0) {
2762
3252
  out.push({
2763
3253
  id: ID$8,
@@ -2802,16 +3292,17 @@ const correctnessBasePathNavigation = {
2802
3292
  fix: { ...FIX$2 },
2803
3293
  async check(ctx) {
2804
3294
  if (!ctx.project.kitPathsBase) return [];
3295
+ const base = ctx.project.kitPathsBase.value;
2805
3296
  const out = [];
2806
3297
  for (const c of ctx.components ?? []) {
2807
3298
  const links = c.basePathLinks ?? [];
2808
3299
  if (links.length === 0) continue;
2809
- emitFile$1(out, c.file, links, c.suppressions);
3300
+ emitFile$1(out, c.file, links, c.suppressions, base);
2810
3301
  }
2811
3302
  for (const m of ctx.kitModules ?? []) {
2812
3303
  const links = m.basePathLinks ?? [];
2813
3304
  if (links.length === 0) continue;
2814
- emitFile$1(out, m.file, links, m.suppressions);
3305
+ emitFile$1(out, m.file, links, m.suppressions, base);
2815
3306
  }
2816
3307
  return out;
2817
3308
  }
@@ -2878,7 +3369,8 @@ const correctnessServerBrowserGlobal = {
2878
3369
  })), c.suppressions);
2879
3370
  }
2880
3371
  for (const m of ctx.kitModules ?? []) {
2881
- const refs = m.browserGlobalRefs ?? [];
3372
+ const neverSsr = universalNeverSsr(m, ctx);
3373
+ const refs = (m.browserGlobalRefs ?? []).filter((r) => !(neverSsr && r.inHandler));
2882
3374
  if (refs.length === 0) continue;
2883
3375
  emitFile(out, m.file, refs.map((r) => ({
2884
3376
  line: r.line,
@@ -2897,7 +3389,7 @@ const correctnessInstanceBrowserGlobal = componentRule({
2897
3389
  label: "Server-safe component init",
2898
3390
  recommendation: "Move browser-only code into onMount or $effect (they never run on the server), or guard it with browser from $app/environment (or a typeof check).",
2899
3391
  rationale: "A component instance script runs on the server on every SSR render, where window/document/localStorage do not exist. Warning, not critical: a component rendered only behind a parent {#if browser} (or a client-only dynamic import) is a legitimate pattern that static analysis cannot prove cross-file.",
2900
- applies: (c) => (c.browserGlobalRefs ?? []).some((r) => r.context === "instance"),
3392
+ applies: (c, _o, ctx) => (c.browserGlobalRefs ?? []).some((r) => r.context === "instance") && !appSsrDisabled(ctx) && !routeNeverSsr(c.file, ctx),
2901
3393
  bad: (c) => (c.browserGlobalRefs ?? []).filter((r) => r.context === "instance").map((r) => ({
2902
3394
  line: r.line,
2903
3395
  message: `${r.name} is accessed during component initialisation — during SSR this runs on the server, where ${r.name} is not defined`
@@ -2943,7 +3435,7 @@ const securityHandlerStateWrite = kitModuleRule({
2943
3435
  label: "Load/handler purity",
2944
3436
  recommendation: "Return the data from load (or the action) and pass it via page data instead of writing it to module state; per-user data belongs in cookies/locals plus a database. Caches and rate limiters keyed by non-personal data are the benign shape — if that describes this write, add an inline suppression.",
2945
3437
  rationale: "SvelteKit's docs mark this NEVER-DO-THIS: the server is one long-lived process shared by every user, so module state written during a request is visible to ALL later requests.",
2946
- applies: (m) => m.importedStateWrites.length > 0 && !(m.kind === "universal" && m.ssrDisabled),
3438
+ applies: (m, ctx) => m.importedStateWrites.length > 0 && !universalNeverSsr(m, ctx),
2947
3439
  bad: (m) => m.importedStateWrites.map((w) => ({
2948
3440
  line: w.line,
2949
3441
  message: `a server-executed handler writes imported module state "${w.name}" — module state is shared across all requests on the server; if this holds per-request or per-user data, one user's data can leak to another. Caches and rate limiters keyed by non-personal data are the benign shape — verify which this is`
@@ -2958,7 +3450,7 @@ const securityServerModuleState = kitModuleRule({
2958
3450
  label: "Server module state",
2959
3451
  recommendation: "Do not keep request data in module scope on the server — authenticate with cookies/locals and persist per-user data in a database. For a deliberate process-wide cache, prefer a const container (e.g. a Map) or add an inline suppression.",
2960
3452
  rationale: "Module scope on the server is one shared, long-lived instance (SvelteKit docs: \"Avoid shared state on the server\"): a value reassigned during one user's request is served to every other user, and it silently resets on every deploy or restart.",
2961
- applies: (m) => m.moduleStateReassignments.length > 0,
3453
+ applies: (m, ctx) => m.moduleStateReassignments.length > 0 && !universalNeverSsr(m, ctx),
2962
3454
  bad: (m) => m.moduleStateReassignments.map((r) => ({
2963
3455
  line: r.line,
2964
3456
  message: r.inHandler ? `module-scope variable "${r.name}" is reassigned from a request handler — its value is shared across all requests on the server` : `module-scope variable "${r.name}" is reassigned from a function — if it runs during a request, the value is shared across all requests on the server`
@@ -2977,7 +3469,7 @@ const securitySharedStateImport = kitModuleRule({
2977
3469
  label: "Server state imports",
2978
3470
  recommendation: "Keep module-scope $state out of server-executed code: return data from load and share it via page data or the context API. If the module is genuinely client-only, restructure so server files do not import it, or add an inline suppression.",
2979
3471
  rationale: "A .svelte.ts module with module-scope $state is one shared instance on the server: mutated, it leaks data between users; read-only, every request sees the same boot-time value instead of per-user data.",
2980
- applies: (m) => m.runesModuleImports.length > 0 && !(m.kind === "universal" && m.ssrDisabled),
3472
+ applies: (m, ctx) => m.runesModuleImports.length > 0 && !universalNeverSsr(m, ctx),
2981
3473
  bad: (m, ctx) => {
2982
3474
  const stateFiles = new Set((ctx.components ?? []).filter((c) => c.moduleStateDecls.length > 0).map((c) => c.file));
2983
3475
  const writtenOutside = new Set(m.importedStateWritesOutsideHandlers.map((w) => w.name));
@@ -3085,7 +3577,8 @@ const PREPROCESSED_STYLE_RE = /(<style\b[^>]*\slang\s*=\s*['"]?[^'"\s>]+['"]?[^>
3085
3577
  * the text scan's imprecision cannot reach it. A file that does not parse is already a hard failure,
3086
3578
  * which the retry can only improve on.
3087
3579
  */
3088
- function parseSvelte(source, filename) {
3580
+ function parseSvelte(raw, filename) {
3581
+ const source = raw.charCodeAt(0) === 65279 ? ` ${raw.slice(1)}` : raw;
3089
3582
  try {
3090
3583
  return parse(source, {
3091
3584
  modern: true,
@@ -3151,12 +3644,25 @@ function attrValue(attributes, name) {
3151
3644
  const attr = findAttr(attributes, name);
3152
3645
  return attr ? attrValueOf(attr) : "absent";
3153
3646
  }
3647
+ const lineStarts = /* @__PURE__ */ new Map();
3154
3648
  function lineOf(source, offset) {
3155
3649
  if (typeof offset !== "number" || offset < 0) return 0;
3156
- let line = 1;
3650
+ let starts = lineStarts.get(source);
3651
+ if (!starts) {
3652
+ starts = [0];
3653
+ for (let i = source.indexOf("\n"); i !== -1; i = source.indexOf("\n", i + 1)) starts.push(i + 1);
3654
+ if (lineStarts.size >= 8) lineStarts.delete(lineStarts.keys().next().value);
3655
+ lineStarts.set(source, starts);
3656
+ }
3157
3657
  const end = Math.min(offset, source.length);
3158
- for (let i = 0; i < end; i++) if (source[i] === "\n") line++;
3159
- return line;
3658
+ let lo = 0;
3659
+ let hi = starts.length;
3660
+ while (lo < hi) {
3661
+ const mid = lo + hi >> 1;
3662
+ if (starts[mid] <= end) lo = mid + 1;
3663
+ else hi = mid;
3664
+ }
3665
+ return lo;
3160
3666
  }
3161
3667
  /**
3162
3668
  * An element's attribute by name, matched **case-insensitively**: HTML attribute names are, so
@@ -3189,7 +3695,7 @@ function attrTextOf(attr) {
3189
3695
  //#region src/module-ast.ts
3190
3696
  function unwrapTs(expr) {
3191
3697
  let cur = expr;
3192
- while (cur !== void 0 && (cur.type === "TSSatisfiesExpression" || cur.type === "TSAsExpression" || cur.type === "TSNonNullExpression")) cur = cur.expression;
3698
+ while (cur != null && (cur.type === "TSSatisfiesExpression" || cur.type === "TSAsExpression" || cur.type === "TSNonNullExpression")) cur = cur.expression;
3193
3699
  return cur;
3194
3700
  }
3195
3701
  /** AST metadata keys every walker skips — never traversed as child nodes. Shared with the Kit-module parser (the security kit-module rules). */
@@ -3242,8 +3748,8 @@ function addBoundNames(id, acc) {
3242
3748
  }
3243
3749
  /** The base identifier name of a (possibly nested) member expression or identifier, else undefined. Shared with the Kit-module parser (the security kit-module rules). */
3244
3750
  function rootObjectName(node) {
3245
- let cur = node;
3246
- while (cur?.type === "MemberExpression") cur = cur.object;
3751
+ let cur = unwrapTs(node);
3752
+ while (cur?.type === "MemberExpression") cur = unwrapTs(cur.object);
3247
3753
  return cur?.type === "Identifier" ? cur.name : void 0;
3248
3754
  }
3249
3755
  /**
@@ -3258,7 +3764,8 @@ function rootObjectName(node) {
3258
3764
  * `then`/`catch` value/error bindings, and a fragment's own `{@const ...}` /
3259
3765
  * `{let ...}` / `{const ...}` declaration tags (attributed to the enclosing Fragment,
3260
3766
  * shadowing the whole fragment like a block's `let` — a write to one of these
3261
- * template-locals, possible for `{let}`, is still not a write to the outer binding).
3767
+ * template-locals, possible for `{let}`, is still not a write to the outer binding), and an
3768
+ * element's or component's `let:` directive bindings.
3262
3769
  * Used by `walkScoped` so a write/mutation detector
3263
3770
  * doesn't misattribute a write to one of these locals as a write to an outer `$state`/prop
3264
3771
  * of the same name (issue #140 — originally a deliberately partial mitigation that left
@@ -3284,7 +3791,20 @@ function scopeIntroducedNames(node) {
3284
3791
  else if (node.type === "AwaitBlock") {
3285
3792
  if (node.value) addBoundNames(node.value, introduced);
3286
3793
  if (node.error) addBoundNames(node.error, introduced);
3287
- } else if (node.type === "Fragment") {
3794
+ } else if (Array.isArray(node.attributes) && node.fragment) for (const attr of node.attributes) {
3795
+ if (attr?.type !== "LetDirective") continue;
3796
+ const e = attr.expression;
3797
+ const asPattern = {
3798
+ ObjectExpression: "ObjectPattern",
3799
+ ArrayExpression: "ArrayPattern"
3800
+ }[e?.type];
3801
+ if (e) addBoundNames(asPattern ? {
3802
+ ...e,
3803
+ type: asPattern
3804
+ } : e, introduced);
3805
+ else introduced.add(attr.name);
3806
+ }
3807
+ else if (node.type === "Fragment") {
3288
3808
  for (const child of node.nodes ?? []) if (child?.type === "ConstTag" || child?.type === "DeclarationTag") for (const d of child.declaration?.declarations ?? []) addBoundNames(d.id, introduced);
3289
3809
  }
3290
3810
  return introduced;
@@ -3340,11 +3860,21 @@ function walkEvalScope(node, visit, shadowed = /* @__PURE__ */ new Set(), bounda
3340
3860
  const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
3341
3861
  if (visit(node, scope)) return;
3342
3862
  if (boundaries.has(node.type)) return;
3863
+ const outer = introduced.size > 0 ? outerScopeKeys(node) : void 0;
3343
3864
  for (const key of Object.keys(node)) {
3344
3865
  if (WALK_IGNORED_KEYS.has(key)) continue;
3345
- walkEvalScope(node[key], visit, scope, boundaries);
3866
+ if (!outer?.has(key)) walkEvalScope(node[key], visit, scope, boundaries);
3867
+ else if (!Array.isArray(node[key])) walkEvalScope(node[key], visit, shadowed, boundaries);
3868
+ else for (const child of node[key]) walkEvalScope(child, visit, child?.type === "LetDirective" ? scope : shadowed, boundaries);
3346
3869
  }
3347
3870
  }
3871
+ /** Children of a binding construct that are evaluated before its bindings exist. */
3872
+ function outerScopeKeys(node) {
3873
+ if (node.type === "EachBlock" || node.type === "AwaitBlock") return OUTER_SCOPE_KEYS;
3874
+ if (Array.isArray(node.attributes) && node.fragment) return ATTRIBUTE_KEYS;
3875
+ }
3876
+ const OUTER_SCOPE_KEYS = /* @__PURE__ */ new Set(["expression"]);
3877
+ const ATTRIBUTE_KEYS = /* @__PURE__ */ new Set(["attributes"]);
3348
3878
  /**
3349
3879
  * Unwrap a top-level statement's `export`/`export default` wrapper to the declaration
3350
3880
  * (or expression) it wraps; a non-export statement is returned as-is. Used so pattern 2
@@ -3449,12 +3979,13 @@ function collectNamedImportAliases(program, moduleSource, names) {
3449
3979
  }
3450
3980
  const BROWSER_GUARD_NAMES = /* @__PURE__ */ new Set(["browser"]);
3451
3981
  /**
3452
- * Local names of `browser` value-imported from '$app/environment' (alias-resolved) —
3982
+ * Local names of `browser` value-imported from '$app/environment' or its SvelteKit 3 name
3983
+ * '$app/env' (alias-resolved) —
3453
3984
  * the guard binding recognised by the browser-global scanner (correctness/server-browser-global, correctness/instance-browser-global).
3454
3985
  * Shared with the Kit-module parser.
3455
3986
  */
3456
3987
  function collectBrowserGuardImports(program) {
3457
- return collectNamedImportAliases(program, "$app/environment", BROWSER_GUARD_NAMES);
3988
+ return /* @__PURE__ */ new Set([...collectNamedImportAliases(program, "$app/environment", BROWSER_GUARD_NAMES), ...collectNamedImportAliases(program, "$app/env", BROWSER_GUARD_NAMES)]);
3458
3989
  }
3459
3990
  /**
3460
3991
  * Names bound at the program's top level: every import's local name plus every
@@ -3470,6 +4001,11 @@ function collectProgramBindings(program) {
3470
4001
  for (const s of stmt.specifiers ?? []) if (s?.local?.type === "Identifier") bound.add(s.local.name);
3471
4002
  continue;
3472
4003
  }
4004
+ if (stmt?.type === "LabeledStatement" && stmt.label?.name === "$") {
4005
+ const e = stmt.body?.type === "ExpressionStatement" ? stmt.body.expression : void 0;
4006
+ if (e?.type === "AssignmentExpression" && e.operator === "=") addBoundNames(e.left, bound);
4007
+ continue;
4008
+ }
3473
4009
  const decl = unwrapExport(stmt);
3474
4010
  if (decl?.type === "VariableDeclaration") for (const d of decl.declarations ?? []) addBoundNames(d?.id, bound);
3475
4011
  else if ((decl?.type === "FunctionDeclaration" || decl?.type === "ClassDeclaration") && decl.id?.type === "Identifier") bound.add(decl.id.name);
@@ -3490,10 +4026,12 @@ function guardTerminates(consequent) {
3490
4026
  /**
3491
4027
  * Whether a guard-clause test establishes a browser environment: it references the
3492
4028
  * `$app/environment` `browser` binding, or contains a
3493
- * `typeof <tracked-global> === | !== 'undefined'` comparison (correctness/server-browser-global, correctness/instance-browser-global).
4029
+ * `typeof <tracked-global>` comparison against any type string (`'undefined'`, `'function'`, …)
4030
+ * (correctness/server-browser-global, correctness/instance-browser-global).
3494
4031
  * Over-matching here only widens the skip — a conservative miss, never a false positive.
3495
4032
  */
3496
4033
  function isBrowserGuardTest(test, guardBindings) {
4034
+ if (test?.type === "LogicalExpression" && test.operator !== "&&") return isBrowserGuardTest(test.left, guardBindings) && isBrowserGuardTest(test.right, guardBindings);
3497
4035
  let guarded = false;
3498
4036
  walkEstree(test, (n) => {
3499
4037
  if (n.type === "Identifier" && guardBindings.has(n.name)) guarded = true;
@@ -3505,12 +4043,20 @@ function isBrowserGuardTest(test, guardBindings) {
3505
4043
  ].includes(n.operator)) {
3506
4044
  const sides = [n.left, n.right];
3507
4045
  const hasTypeofGlobal = sides.some((s) => s?.type === "UnaryExpression" && s.operator === "typeof" && s.argument?.type === "Identifier" && BROWSER_GLOBALS.has(s.argument.name));
3508
- const hasUndefinedString = sides.some((s) => s?.type === "Literal" && s.value === "undefined");
3509
- if (hasTypeofGlobal && hasUndefinedString) guarded = true;
4046
+ const type = sides.find((s) => s?.type === "Literal" && typeof s.value === "string")?.value;
4047
+ const positive = n.operator === "===" || n.operator === "==";
4048
+ if (hasTypeofGlobal && type !== void 0 && (type === "undefined" || positive)) guarded = true;
3510
4049
  }
3511
4050
  });
3512
4051
  return guarded;
3513
4052
  }
4053
+ /** The tracked global `G` of a `typeof G === 'undefined'` test: the test is false only where `G` is defined. */
4054
+ function missingGlobalOf(n) {
4055
+ if (n?.type !== "BinaryExpression" || n.operator !== "===" && n.operator !== "==") return void 0;
4056
+ const sides = [n.left, n.right];
4057
+ if (!sides.some((s) => s?.type === "Literal" && s.value === "undefined")) return void 0;
4058
+ return sides.find((s) => s?.type === "UnaryExpression" && s.operator === "typeof" && s.argument?.type === "Identifier" && BROWSER_GLOBALS.has(s.argument.name))?.argument.name;
4059
+ }
3514
4060
  /**
3515
4061
  * Names of derived guard bindings (one level, no fixpoint chasing): a top-level
3516
4062
  * (export-unwrapped) `const`/`let` declarator whose init passes `isBrowserGuardTest` —
@@ -3530,6 +4076,95 @@ function collectDerivedGuardBindings(program, guards) {
3530
4076
  }
3531
4077
  return derived;
3532
4078
  }
4079
+ const DOM_ONLY_GLOBALS = /* @__PURE__ */ new Set(["window", "document"]);
4080
+ /**
4081
+ * Unlike `isBrowserGuardTest` this keeps the polarity and each outcome apart, because a write the
4082
+ * guard leaves reachable on the server must still be reported: `!browser && url.search` being falsy
4083
+ * proves nothing.
4084
+ */
4085
+ function guardImplies(test, guards, shadowed) {
4086
+ if (test?.type === "Identifier") return !shadowed.has(test.name) && guards.get(test.name) || {};
4087
+ if (test?.type === "UnaryExpression" && test.operator === "!") {
4088
+ const inner = guardImplies(test.argument, guards, shadowed);
4089
+ return {
4090
+ truthy: inner.falsy,
4091
+ falsy: inner.truthy
4092
+ };
4093
+ }
4094
+ if (test?.type === "LogicalExpression" && (test.operator === "&&" || test.operator === "||")) {
4095
+ const a = guardImplies(test.left, guards, shadowed);
4096
+ const b = guardImplies(test.right, guards, shadowed);
4097
+ const agree = (x, y) => x === y ? x : void 0;
4098
+ return test.operator === "&&" ? {
4099
+ truthy: a.truthy ?? b.truthy,
4100
+ falsy: agree(a.falsy, b.falsy)
4101
+ } : {
4102
+ truthy: agree(a.truthy, b.truthy),
4103
+ falsy: a.falsy ?? b.falsy
4104
+ };
4105
+ }
4106
+ if (test?.type === "BinaryExpression" && [
4107
+ "===",
4108
+ "!==",
4109
+ "==",
4110
+ "!="
4111
+ ].includes(test.operator)) {
4112
+ const sides = [test.left, test.right];
4113
+ const hasTypeofGlobal = sides.some((s) => s?.type === "UnaryExpression" && s.operator === "typeof" && s.argument?.type === "Identifier" && DOM_ONLY_GLOBALS.has(s.argument.name) && !shadowed.has(s.argument.name));
4114
+ const type = sides.find((s) => s?.type === "Literal" && typeof s.value === "string")?.value;
4115
+ if (!hasTypeofGlobal || type === void 0) return {};
4116
+ const equal = test.operator === "===" || test.operator === "==";
4117
+ if (type === "undefined") return equal ? {
4118
+ truthy: "server",
4119
+ falsy: "browser"
4120
+ } : {
4121
+ truthy: "browser",
4122
+ falsy: "server"
4123
+ };
4124
+ return equal ? { truthy: "browser" } : { falsy: "browser" };
4125
+ }
4126
+ return {};
4127
+ }
4128
+ /**
4129
+ * Source ranges only the browser reaches: the arm of an `if`/ternary its browser guard selects, the
4130
+ * right side of `browser && x` or `!browser || x`, and the statements after a terminating early return
4131
+ * the server always takes (`if (!browser) return;`) in their block. A top-level `const` derived from a
4132
+ * guard (`const serverOnly = !browser`) carries its own polarity.
4133
+ */
4134
+ function browserOnlyRanges(program, envGuards) {
4135
+ const guards = new Map([...envGuards].map((name) => [name, {
4136
+ truthy: "browser",
4137
+ falsy: "server"
4138
+ }]));
4139
+ const bound = collectProgramBindings(program);
4140
+ for (const name of envGuards) bound.delete(name);
4141
+ for (const stmt of program.body ?? []) {
4142
+ const decl = unwrapExport(stmt);
4143
+ if (decl?.type !== "VariableDeclaration" || decl.kind !== "const") continue;
4144
+ for (const d of decl.declarations ?? []) {
4145
+ const implied = d?.id?.type === "Identifier" && d.init ? guardImplies(d.init, guards, bound) : {};
4146
+ if (implied.truthy || implied.falsy) guards.set(d.id.name, implied);
4147
+ }
4148
+ }
4149
+ for (const name of guards.keys()) bound.delete(name);
4150
+ const out = [];
4151
+ const push = (n) => n && out.push([n.start, n.end]);
4152
+ walkScoped(program, (n, shadowed) => {
4153
+ const implies = (test) => guardImplies(test, guards, shadowed);
4154
+ if (n.type === "IfStatement" || n.type === "ConditionalExpression") {
4155
+ const implied = implies(n.test);
4156
+ if (implied.truthy === "browser") push(n.consequent);
4157
+ if (implied.falsy === "browser") push(n.alternate);
4158
+ } else if (n.type === "LogicalExpression" && (n.operator === "&&" || n.operator === "||")) {
4159
+ if (implies(n.left)[n.operator === "&&" ? "truthy" : "falsy"] === "browser") push(n.right);
4160
+ } else if (n.type === "BlockStatement" || n.type === "Program") {
4161
+ const body = n.body ?? [];
4162
+ const at = body.findIndex((stmt) => stmt?.type === "IfStatement" && implies(stmt.test).falsy === "browser" && guardTerminates(stmt.consequent));
4163
+ if (at >= 0 && at < body.length - 1) out.push([body[at + 1].start, body[body.length - 1].end]);
4164
+ }
4165
+ }, bound);
4166
+ return out;
4167
+ }
3533
4168
  /**
3534
4169
  * Browser-global reads in code that executes when `program` (or a passed function body)
3535
4170
  * is evaluated (correctness/server-browser-global, correctness/instance-browser-global). Position-aware — only read positions match: never a
@@ -3564,7 +4199,13 @@ function collectBrowserGlobalRefs(program, source, extra) {
3564
4199
  if (typeof n !== "object" || typeof n.type !== "string") return;
3565
4200
  if (EVAL_SCOPE_BOUNDARIES.has(n.type)) return;
3566
4201
  if ((n.type === "IfStatement" || n.type === "ConditionalExpression") && isBrowserGuardTest(n.test, guards)) return;
3567
- if (n.type === "LogicalExpression" && isBrowserGuardTest(n.left, guards)) return;
4202
+ if (n.type === "LogicalExpression" && (n.operator === "&&" ? isBrowserGuardTest(n.left, guards) : n.left?.type === "UnaryExpression" && n.left.operator === "!" && isBrowserGuardTest(n.left.argument, guards))) return;
4203
+ const missing = n.type === "LogicalExpression" && n.operator === "||" ? missingGlobalOf(n.left) : void 0;
4204
+ if (missing && !bound.has(missing) && !shadowed.has(missing)) {
4205
+ visit(n.left, shadowed);
4206
+ if (!DOM_ONLY_GLOBALS.has(missing)) visit(n.right, /* @__PURE__ */ new Set([...shadowed, missing]));
4207
+ return;
4208
+ }
3568
4209
  const introduced = scopeIntroducedNames(n);
3569
4210
  const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
3570
4211
  switch (n.type) {
@@ -3659,6 +4300,11 @@ const HANDLER_NAMES = /* @__PURE__ */ new Set([
3659
4300
  "OPTIONS",
3660
4301
  "fallback"
3661
4302
  ]);
4303
+ /** Whether `arg` can be a store updater: a function literal, or a function this module declares. */
4304
+ function isUpdaterArgument(arg, localFunctions) {
4305
+ const e = arg ? unwrapTs(arg) : void 0;
4306
+ return isFunctionNode(e) || e?.type === "Identifier" && localFunctions.has(e.name);
4307
+ }
3662
4308
  function isFunctionNode(n) {
3663
4309
  return n?.type === "FunctionDeclaration" || n?.type === "FunctionExpression" || n?.type === "ArrowFunctionExpression";
3664
4310
  }
@@ -3760,7 +4406,7 @@ function collectStartupFunctions(program) {
3760
4406
  }
3761
4407
  /**
3762
4408
  * The `export const ssr = false` / `export const csr = false` opt-out, when
3763
- * present: inline form (`satisfies`/`as` unwrapped) or same-file alias export
4409
+ * present (`= dev` too): inline form (`satisfies`/`as` unwrapped) or same-file alias export
3764
4410
  * (`const ssr = false; export { ssr };`). Returns the declaration's line in the
3765
4411
  * WRAPPED source (the caller applies the −1 shift). An `ssr = false` file never
3766
4412
  * runs on the server — correctness/server-browser-global skips its browser-global scan, and seo/ssr-disabled
@@ -3768,14 +4414,41 @@ function collectStartupFunctions(program) {
3768
4414
  * performance/load-waterfall exempts it (see `csrDisabled` on `KitModuleFacts`).
3769
4415
  */
3770
4416
  function findFalseOptOut(program, source, name) {
4417
+ const dev = collectNamedImportAliases(program, "$app/environment", DEV_NAMES);
3771
4418
  let hit;
3772
4419
  forEachNamedExport(program, (exported, value, anchor) => {
3773
- if (exported !== name || value?.type !== "Literal" || value.value !== false) return void 0;
3774
- hit = { line: lineOf(source, anchor.start) };
4420
+ const literal = value?.type === "Literal" && value.value === false;
4421
+ if (exported !== name || !(literal || value?.type === "Identifier" && dev.has(value.name))) return void 0;
4422
+ hit = {
4423
+ line: lineOf(source, anchor.start),
4424
+ literal
4425
+ };
3775
4426
  return true;
3776
4427
  });
3777
4428
  return hit;
3778
4429
  }
4430
+ const DEV_NAMES = /* @__PURE__ */ new Set(["dev"]);
4431
+ const BUILDING_NAMES = /* @__PURE__ */ new Set(["building"]);
4432
+ /** Whether the module exports `name` as the literal `true` (inline or same-file alias export). */
4433
+ function exportsTrue(program, name) {
4434
+ let hit = false;
4435
+ forEachNamedExport(program, (exported, value) => {
4436
+ if (exported !== name) return void 0;
4437
+ hit = value?.type === "Literal" && value.value === true;
4438
+ return true;
4439
+ });
4440
+ return hit;
4441
+ }
4442
+ /** Whether the module exports a binding named `name` in any form, re-exports and unresolved aliases included. */
4443
+ function exportsName(program, name) {
4444
+ return (program.body ?? []).some((stmt) => {
4445
+ if (stmt?.type !== "ExportNamedDeclaration" || stmt.exportKind === "type") return false;
4446
+ const decl = stmt.declaration;
4447
+ if (decl?.type === "VariableDeclaration") return decl.declarations?.some((d) => d?.id?.name === name);
4448
+ if (decl) return decl.id?.name === name;
4449
+ return (stmt.specifiers ?? []).some((s) => s?.exportKind !== "type" && (s?.exported?.name ?? s?.exported?.value) === name);
4450
+ });
4451
+ }
3779
4452
  /**
3780
4453
  * The exported `load` function node (inline `export function load` / `export const
3781
4454
  * load = …`, `satisfies`/`as` unwrapped) or a same-file alias export. Cross-file
@@ -3805,7 +4478,25 @@ function collectAwaits(node, out = []) {
3805
4478
  }
3806
4479
  return out;
3807
4480
  }
4481
+ const MUTATING_METHODS = /* @__PURE__ */ new Set([
4482
+ "push",
4483
+ "unshift",
4484
+ "splice",
4485
+ "add",
4486
+ "set"
4487
+ ]);
4488
+ const ITERATING_METHODS = /* @__PURE__ */ new Set([
4489
+ "forEach",
4490
+ "map",
4491
+ "flatMap",
4492
+ "filter",
4493
+ "some",
4494
+ "every",
4495
+ "reduce"
4496
+ ]);
3808
4497
  const REDIRECT_NAMES = /* @__PURE__ */ new Set(["redirect"]);
4498
+ const EXIT_NAMES = /* @__PURE__ */ new Set(["redirect", "error"]);
4499
+ const ERROR_NAMES = /* @__PURE__ */ new Set(["error"]);
3809
4500
  /**
3810
4501
  * Root-relative `redirect(status, '/…')` targets (correctness/base-path-navigation). Argument 1
3811
4502
  * is the location (argument 0 is the status). Only a plain string literal counts, which
@@ -3826,6 +4517,359 @@ function collectRedirectCalls(node, locals, out = []) {
3826
4517
  return out;
3827
4518
  }
3828
4519
  /**
4520
+ * The string literals a `transformPageChunk` option replaces in the page HTML (`html.replace(TOKEN, …)`,
4521
+ * `.replaceAll`), wherever the file passes one to `resolve`, a same-file `const` holding the string
4522
+ * included. A token the hook builds at run time is not read.
4523
+ */
4524
+ function pageChunkTokens(program) {
4525
+ const consts = /* @__PURE__ */ new Map();
4526
+ for (const stmt of program.body ?? []) {
4527
+ const decl = stmt?.type === "ExportNamedDeclaration" ? stmt.declaration : stmt;
4528
+ if (decl?.type !== "VariableDeclaration" || decl.kind !== "const") continue;
4529
+ for (const d of decl.declarations ?? []) {
4530
+ const init = unwrapTs(d.init);
4531
+ if (d.id?.type === "Identifier" && init?.type === "Literal" && typeof init.value === "string") consts.set(d.id.name, init.value);
4532
+ }
4533
+ }
4534
+ const out = /* @__PURE__ */ new Set();
4535
+ const visit = (n, inChunk) => {
4536
+ if (Array.isArray(n)) return n.forEach((c) => visit(c, inChunk));
4537
+ if (!n || typeof n !== "object" || typeof n.type !== "string") return;
4538
+ const chunk = inChunk || n.type === "Property" && !n.computed && n.key?.type === "Identifier" && n.key.name === "transformPageChunk";
4539
+ if (chunk && n.type === "CallExpression" && n.callee?.type === "MemberExpression" && !n.callee.computed) {
4540
+ const method = n.callee.property?.name;
4541
+ const arg = unwrapTs(n.arguments?.[0]);
4542
+ const token = arg?.type === "Literal" && typeof arg.value === "string" ? arg.value : arg?.type === "Identifier" ? consts.get(arg.name) : void 0;
4543
+ if ((method === "replace" || method === "replaceAll") && token) out.add(token);
4544
+ }
4545
+ for (const key of Object.keys(n)) if (!WALK_IGNORED_KEYS.has(key)) visit(n[key], chunk);
4546
+ };
4547
+ visit(program, false);
4548
+ return [...out];
4549
+ }
4550
+ /** Whether `node` calls `name` outside nested functions. */
4551
+ function callsOutsideFunctions(node, name) {
4552
+ if (Array.isArray(node)) return node.some((n) => callsOutsideFunctions(n, name));
4553
+ if (!node || typeof node !== "object" || typeof node.type !== "string") return false;
4554
+ if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === name) return true;
4555
+ return Object.keys(node).some((key) => !WALK_IGNORED_KEYS.has(key) && callsOutsideFunctions(node[key], name));
4556
+ }
4557
+ /**
4558
+ * The request paths the exported `handle` answers on every request, before calling `resolve`: an `if` on
4559
+ * the path alone (`pathname === '/x'`, `pathname.startsWith('/x')`, or an `||` of those, with `pathname`
4560
+ * read from the `event` parameter, directly or through a `const`) whose body returns or throws, among the
4561
+ * statements before the first one that calls `resolve`. A test that also reads anything else, a `sequence(…)`
4562
+ * handle and a guard in another function are not read.
4563
+ */
4564
+ function handleAnsweredPaths(program, exits) {
4565
+ let handle;
4566
+ forEachNamedExport(program, (name, value) => {
4567
+ if (name === "handle" && isFunctionNode(value)) handle = value;
4568
+ return handle !== void 0;
4569
+ });
4570
+ const param = handle?.params?.[0];
4571
+ if (handle?.body?.type !== "BlockStatement" || param?.type !== "ObjectPattern") return [];
4572
+ const local = (key) => {
4573
+ const p = param.properties?.find((q) => q.type === "Property" && q.key?.type === "Identifier" && q.key.name === key);
4574
+ return p?.value?.type === "Identifier" ? p.value.name : void 0;
4575
+ };
4576
+ const event = local("event");
4577
+ const resolve = local("resolve");
4578
+ if (!event || !resolve) return [];
4579
+ const isUrl = (n) => n?.type === "MemberExpression" && !n.computed && n.property?.name === "url" && n.object?.type === "Identifier" && n.object.name === event;
4580
+ const aliases = /* @__PURE__ */ new Set();
4581
+ const isPath = (n) => {
4582
+ const e = unwrapTs(n);
4583
+ if (e?.type === "Identifier") return aliases.has(e.name);
4584
+ return e?.type === "MemberExpression" && !e.computed && e.property?.name === "pathname" && isUrl(e.object);
4585
+ };
4586
+ const literal = (n) => n?.type === "Literal" && typeof n.value === "string" ? n.value : void 0;
4587
+ const conds = (t) => {
4588
+ if (t?.type === "LogicalExpression" && t.operator === "||") {
4589
+ const l = conds(t.left);
4590
+ const r = conds(t.right);
4591
+ return l && r ? [...l, ...r] : void 0;
4592
+ }
4593
+ if (t?.type === "BinaryExpression" && (t.operator === "===" || t.operator === "==")) {
4594
+ const v = isPath(t.left) ? literal(t.right) : isPath(t.right) ? literal(t.left) : void 0;
4595
+ return v === void 0 ? void 0 : [{
4596
+ op: "eq",
4597
+ v
4598
+ }];
4599
+ }
4600
+ const callee = t?.type === "CallExpression" ? t.callee : void 0;
4601
+ const v = literal(t?.arguments?.[0]);
4602
+ return callee?.type === "MemberExpression" && callee.property?.name === "startsWith" && isPath(callee.object) && v !== void 0 ? [{
4603
+ op: "starts",
4604
+ v
4605
+ }] : void 0;
4606
+ };
4607
+ const exitsAlways = (s) => {
4608
+ if (s?.type === "BlockStatement") return s.body.length > 0 && exitsAlways(s.body[s.body.length - 1]);
4609
+ if (s?.type === "ReturnStatement" || s?.type === "ThrowStatement") return true;
4610
+ const call = s?.type === "ExpressionStatement" ? unwrapTs(s.expression) : void 0;
4611
+ return call?.type === "CallExpression" && call.callee?.type === "Identifier" && exits.has(call.callee.name);
4612
+ };
4613
+ const out = [];
4614
+ for (const stmt of handle.body.body) {
4615
+ if (callsOutsideFunctions(stmt, resolve)) break;
4616
+ if (stmt.type === "VariableDeclaration" && stmt.kind === "const") for (const d of stmt.declarations ?? []) {
4617
+ if (d.id?.type === "Identifier" && isPath(d.init)) aliases.add(d.id.name);
4618
+ if (d.id?.type === "ObjectPattern" && isUrl(unwrapTs(d.init))) {
4619
+ const p = d.id.properties?.find((q) => q.type === "Property" && q.key?.name === "pathname");
4620
+ if (p?.value?.type === "Identifier") aliases.add(p.value.name);
4621
+ }
4622
+ }
4623
+ if (stmt.type !== "IfStatement" || !exitsAlways(stmt.consequent)) continue;
4624
+ const c = conds(stmt.test);
4625
+ if (c) out.push(...c);
4626
+ }
4627
+ return out;
4628
+ }
4629
+ /** Whether `node` holds a `return` outside nested functions. */
4630
+ function containsReturn(node) {
4631
+ if (Array.isArray(node)) return node.some(containsReturn);
4632
+ if (!node || typeof node !== "object" || typeof node.type !== "string" || isFunctionNode(node)) return false;
4633
+ if (node.type === "ReturnStatement") return true;
4634
+ return Object.keys(node).some((key) => !WALK_IGNORED_KEYS.has(key) && containsReturn(node[key]));
4635
+ }
4636
+ /**
4637
+ * Whether the exported `load` redirects or errors on every call: every path through its body reaches a
4638
+ * `redirect(…)` / `error(…)` (thrown or not) before any `return` — through both arms of an `if`/`else`,
4639
+ * through a `try` whose `catch` redirects or rethrows, and through a call to a top-level function
4640
+ * of the same file that itself always redirects. A redirect under an `if` without an `else`, in a
4641
+ * loop, or in a function declared inside `load` does not count.
4642
+ */
4643
+ function loadNeverRenders(program, locals, building = /* @__PURE__ */ new Set()) {
4644
+ const load = locals.size > 0 ? findLoadFunction(program) : void 0;
4645
+ return !!load?.body && alwaysExits(program, load, locals, building);
4646
+ }
4647
+ /**
4648
+ * Whether `fn`, a function of `program`, redirects or errors on every call (see `loadNeverRenders`).
4649
+ * `building` holds the names `building` from `$app/environment` is imported as: an `if` on one of them
4650
+ * is read with the test false, as it is on every request.
4651
+ */
4652
+ function alwaysExits(program, fn, locals, building = /* @__PURE__ */ new Set()) {
4653
+ const topLevel = collectTopLevelBindings(program);
4654
+ const fnRedirects = (fn, seen, observed) => {
4655
+ const shadow = /* @__PURE__ */ new Set();
4656
+ for (const p of fn.params ?? []) addBoundNames(p, shadow);
4657
+ const frame = {
4658
+ seen: /* @__PURE__ */ new Set([...seen, fn]),
4659
+ observed
4660
+ };
4661
+ return fn.body?.type === "BlockStatement" ? always(fn.body.body, shadow, void 0, frame) : isRedirect(fn.body, shadow, frame, observed);
4662
+ };
4663
+ const isRedirect = (expr, shadow, frame, returned = false) => {
4664
+ let e = unwrapTs(expr);
4665
+ const awaited = e?.type === "AwaitExpression";
4666
+ if (awaited) e = unwrapTs(e.argument);
4667
+ if (e?.type !== "CallExpression" || e.callee?.type !== "Identifier" || shadow.has(e.callee.name)) return false;
4668
+ if (locals.has(e.callee.name)) return true;
4669
+ const fn = topLevel.get(e.callee.name);
4670
+ if (!isFunctionNode(fn) || fn.generator || frame.seen.has(fn) || fn.async && !awaited && !returned) return false;
4671
+ return fnRedirects(fn, frame.seen, awaited || returned);
4672
+ };
4673
+ const within = (body, outer, param) => {
4674
+ const own = /* @__PURE__ */ new Set();
4675
+ addBoundNames(param, own);
4676
+ for (const stmt of body ?? []) if (stmt?.type === "VariableDeclaration") for (const d of stmt.declarations ?? []) addBoundNames(d?.id, own);
4677
+ else if ((stmt?.type === "FunctionDeclaration" || stmt?.type === "ClassDeclaration") && stmt.id) addBoundNames(stmt.id, own);
4678
+ return own.size === 0 ? outer : /* @__PURE__ */ new Set([...outer, ...own]);
4679
+ };
4680
+ const redirects = (stmt, shadow, rethrow, frame) => {
4681
+ if (stmt?.type === "ExpressionStatement") return isRedirect(stmt.expression, shadow, frame);
4682
+ if (stmt?.type === "ThrowStatement" || stmt?.type === "ReturnStatement") {
4683
+ if (isRedirect(stmt.argument, shadow, frame, stmt.type === "ReturnStatement" && frame.observed)) return true;
4684
+ return stmt.type === "ThrowStatement" && stmt.argument?.type === "Identifier" && stmt.argument.name === rethrow;
4685
+ }
4686
+ if (stmt?.type === "BlockStatement") return always(stmt.body, shadow, rethrow, frame);
4687
+ if (stmt?.type === "IfStatement") return !!stmt.alternate && redirects(stmt.consequent, shadow, rethrow, frame) && redirects(stmt.alternate, shadow, rethrow, frame);
4688
+ if (stmt?.type === "TryStatement") {
4689
+ const param = stmt.handler?.param?.type === "Identifier" ? stmt.handler.param.name : void 0;
4690
+ return always(stmt.block?.body, shadow, void 0, frame) && (!stmt.handler || always(stmt.handler.body?.body, within([], shadow, stmt.handler.param), param, frame)) && !containsReturn(stmt.finalizer);
4691
+ }
4692
+ return false;
4693
+ };
4694
+ const plainReturn = (node, shadow, frame) => {
4695
+ if (Array.isArray(node)) return node.some((n) => plainReturn(n, shadow, frame));
4696
+ if (!node || typeof node !== "object" || typeof node.type !== "string" || isFunctionNode(node)) return false;
4697
+ if (node.type === "ReturnStatement") return !isRedirect(node.argument, shadow, frame, frame.observed);
4698
+ const scope = node.type === "BlockStatement" ? within(node.body, shadow) : shadow;
4699
+ return Object.keys(node).some((key) => !WALK_IGNORED_KEYS.has(key) && plainReturn(node[key], scope, frame));
4700
+ };
4701
+ const always = (body, outer, rethrow, frame) => {
4702
+ const shadow = within(body, outer);
4703
+ for (let stmt of body ?? []) {
4704
+ const test = stmt?.type === "IfStatement" ? unwrapTs(stmt.test) : void 0;
4705
+ if (test?.type === "Identifier" && building.has(test.name) && !shadow.has(test.name)) {
4706
+ if (!stmt.alternate) continue;
4707
+ stmt = stmt.alternate;
4708
+ }
4709
+ if (redirects(stmt, shadow, rethrow, frame)) return true;
4710
+ if (plainReturn(stmt, shadow, frame)) return false;
4711
+ }
4712
+ return false;
4713
+ };
4714
+ return fnRedirects(fn, /* @__PURE__ */ new Set(), true);
4715
+ }
4716
+ /**
4717
+ * The binding a load's leading flag guard reads: a top-level `if (!FLAG) <exit>` (or `!NS.FLAG`) before
4718
+ * any `return`, where `FLAG` is imported from a repo-local module. When that export is the literal
4719
+ * `false`, every request takes the exit; `collectKitModuleFacts` reads the module to decide.
4720
+ */
4721
+ function leadingFlagGuard(program, exits, imported) {
4722
+ const load = exits.size > 0 ? findLoadFunction(program) : void 0;
4723
+ if (load?.body?.type !== "BlockStatement") return void 0;
4724
+ const local = /* @__PURE__ */ new Set();
4725
+ for (const param of load.params ?? []) addBoundNames(param, local);
4726
+ for (const stmt of load.body.body) if (stmt?.type === "VariableDeclaration") for (const d of stmt.declarations ?? []) addBoundNames(d?.id, local);
4727
+ else if ((stmt?.type === "FunctionDeclaration" || stmt?.type === "ClassDeclaration") && stmt.id) local.add(stmt.id.name);
4728
+ const exitCall = (e) => {
4729
+ const x = unwrapTs(e);
4730
+ return x?.type === "CallExpression" && x.callee?.type === "Identifier" && exits.has(x.callee.name) && !local.has(x.callee.name);
4731
+ };
4732
+ const exitsFirst = (stmt) => {
4733
+ if (stmt?.type === "BlockStatement") {
4734
+ for (const inner of stmt.body) {
4735
+ if (exitsFirst(inner)) return true;
4736
+ if (containsReturn(inner)) return false;
4737
+ }
4738
+ return false;
4739
+ }
4740
+ return stmt?.type === "ExpressionStatement" && exitCall(stmt.expression) || (stmt?.type === "ThrowStatement" || stmt?.type === "ReturnStatement") && exitCall(stmt.argument);
4741
+ };
4742
+ for (const stmt of load.body.body) {
4743
+ const test = stmt?.type === "IfStatement" && exitsFirst(stmt.consequent) ? unwrapTs(stmt.test) : void 0;
4744
+ if (test?.type === "UnaryExpression" && test.operator === "!") {
4745
+ const arg = unwrapTs(test.argument);
4746
+ const root = arg?.type === "MemberExpression" && !arg.computed ? unwrapTs(arg.object) : arg;
4747
+ const member = arg !== root && arg.property?.type === "Identifier" ? arg.property.name : void 0;
4748
+ const from = root?.type === "Identifier" && !local.has(root.name) && (arg === root || member) ? imported(root.name) : void 0;
4749
+ if (from) return {
4750
+ ...from,
4751
+ ...member ? { member } : {}
4752
+ };
4753
+ }
4754
+ if (containsReturn(stmt)) return void 0;
4755
+ }
4756
+ }
4757
+ /** The imported function `export const load = factory(…)` calls, for `collectKitModuleFacts` to read. */
4758
+ function loadFactory(program, imported) {
4759
+ let call;
4760
+ forEachNamedExport(program, (name, value) => {
4761
+ if (name !== "load") return void 0;
4762
+ call = unwrapTs(value);
4763
+ return true;
4764
+ });
4765
+ return call?.type === "CallExpression" && call.callee?.type === "Identifier" ? imported(call.callee.name) : void 0;
4766
+ }
4767
+ /**
4768
+ * The exported functions of a module that return a function which redirects or errors on every call
4769
+ * (`createRedirect = (to) => () => redirect(307, to)`), read like a `load` that does.
4770
+ */
4771
+ function parseExitFactoryExports(source, filename) {
4772
+ const { program } = parseModuleProgram(source, filename);
4773
+ const exits = collectNamedImportAliases(program, "@sveltejs/kit", EXIT_NAMES);
4774
+ const names = /* @__PURE__ */ new Set();
4775
+ if (exits.size === 0) return names;
4776
+ forEachNamedExport(program, (name, value) => {
4777
+ if (!isFunctionNode(value)) return void 0;
4778
+ const first = (value.body?.type === "BlockStatement" ? value.body.body : [{
4779
+ type: "ReturnStatement",
4780
+ argument: value.body
4781
+ }]).find((stmt) => containsReturn(stmt));
4782
+ const returned = first?.type === "ReturnStatement" ? first.argument : void 0;
4783
+ if (isFunctionNode(returned) && alwaysExits(program, returned, exits)) names.add(name);
4784
+ });
4785
+ return names;
4786
+ }
4787
+ /** The exports of a module that are the literal `false`, as `NAME` or `NAME.member` (an object literal's own property). */
4788
+ function parseFalseExports(source, filename) {
4789
+ return collectFalseExports(parseModuleProgram(source, filename).program);
4790
+ }
4791
+ /** `parseFalseExports` over a parsed module. */
4792
+ function collectFalseExports(program) {
4793
+ const names = /* @__PURE__ */ new Set();
4794
+ for (const stmt of program?.body ?? []) {
4795
+ if (stmt?.type !== "ExportNamedDeclaration" || stmt.declaration?.type !== "VariableDeclaration") continue;
4796
+ if (stmt.declaration.kind !== "const") continue;
4797
+ for (const d of stmt.declaration.declarations ?? []) {
4798
+ const init = unwrapTs(d?.init);
4799
+ if (d?.id?.type !== "Identifier") continue;
4800
+ if (init?.type === "Literal" && init.value === false) names.add(d.id.name);
4801
+ if (init?.type === "ObjectExpression") {
4802
+ const members = /* @__PURE__ */ new Map();
4803
+ for (const p of init.properties ?? []) {
4804
+ const v = unwrapTs(p?.value);
4805
+ if (p?.type !== "Property" || p.computed || p.key?.type !== "Identifier") members.clear();
4806
+ else members.set(p.key.name, v?.type === "Literal" && v.value === false);
4807
+ }
4808
+ for (const [key, isFalse] of members) if (isFalse) names.add(`${d.id.name}.${key}`);
4809
+ }
4810
+ }
4811
+ }
4812
+ return names;
4813
+ }
4814
+ /**
4815
+ * Whether the exported `load` sends a request without a session away before rendering: a top-level
4816
+ * `if` whose test is only negated reads of the request's `locals` (`!locals.user`,
4817
+ * `!(await locals.auth())`, or a `const` taken from one earlier in the body) and whose branch
4818
+ * redirects or errors 401/403. A test that also reads anything else — the URL, params — gates only
4819
+ * some requests and does not count; neither does a gate after a `return`.
4820
+ */
4821
+ function loadGatesOnLocals(program) {
4822
+ const redirects = collectNamedImportAliases(program, "@sveltejs/kit", REDIRECT_NAMES);
4823
+ const errors = collectNamedImportAliases(program, "@sveltejs/kit", ERROR_NAMES);
4824
+ const load = redirects.size + errors.size > 0 ? findLoadFunction(program) : void 0;
4825
+ if (load?.body?.type !== "BlockStatement") return false;
4826
+ const param = load.params?.[0];
4827
+ const event = param?.type === "Identifier" ? param.name : void 0;
4828
+ const derived = /* @__PURE__ */ new Set();
4829
+ if (param?.type === "ObjectPattern") {
4830
+ for (const p of param.properties ?? []) if (p?.type === "Property" && (p.key?.name ?? p.key?.value) === "locals") addBoundNames(p.value, derived);
4831
+ }
4832
+ const fromLocals = (expr) => {
4833
+ let cur = unwrapTs(expr);
4834
+ let prev;
4835
+ for (;;) if (cur?.type === "AwaitExpression" || cur?.type === "ChainExpression") cur = unwrapTs(cur.argument ?? cur.expression);
4836
+ else if (cur?.type === "CallExpression" && (cur.arguments ?? []).length === 0) cur = unwrapTs(cur.callee);
4837
+ else if (cur?.type === "MemberExpression" && !cur.computed) [prev, cur] = [cur, unwrapTs(cur.object)];
4838
+ else break;
4839
+ if (cur?.type !== "Identifier") return false;
4840
+ return derived.has(cur.name) || cur.name === event && prev?.computed === false && prev.property?.name === "locals";
4841
+ };
4842
+ const negated = (test) => {
4843
+ const t = unwrapTs(test);
4844
+ if (t?.type === "LogicalExpression") return t.operator === "||" && negated(t.left) && negated(t.right);
4845
+ return t?.type === "UnaryExpression" && t.operator === "!" && fromLocals(t.argument);
4846
+ };
4847
+ const exitCall = (expr) => {
4848
+ const e = unwrapTs(expr);
4849
+ if (e?.type !== "CallExpression" || e.callee?.type !== "Identifier") return false;
4850
+ if (redirects.has(e.callee.name)) return true;
4851
+ if (!errors.has(e.callee.name)) return false;
4852
+ const status = e.arguments?.[0];
4853
+ return status?.type === "Literal" && (status.value === 401 || status.value === 403);
4854
+ };
4855
+ const exitsNow = (stmt) => {
4856
+ if (stmt?.type !== "BlockStatement") return stmt?.type === "ExpressionStatement" && exitCall(stmt.expression) || (stmt?.type === "ThrowStatement" || stmt?.type === "ReturnStatement") && exitCall(stmt.argument);
4857
+ for (const inner of stmt.body) {
4858
+ if (exitsNow(inner)) return true;
4859
+ if (containsReturn(inner)) return false;
4860
+ }
4861
+ return false;
4862
+ };
4863
+ for (const stmt of load.body.body) {
4864
+ if (stmt?.type === "IfStatement" && negated(stmt.test) && exitsNow(stmt.consequent)) return true;
4865
+ if (containsReturn(stmt)) return false;
4866
+ if (stmt?.type === "VariableDeclaration" && stmt.kind === "const") {
4867
+ for (const d of stmt.declarations ?? []) if (d?.init && fromLocals(d.init)) addBoundNames(d.id, derived);
4868
+ }
4869
+ }
4870
+ return false;
4871
+ }
4872
+ /**
3829
4873
  * Whether `await`'s argument is a `parent()` / `<x>.parent()` call (Kit's parent-load
3830
4874
  * step, exempt from performance/load-waterfall and performance/sequential-awaits). Any `<expr>.parent()` member call matches — over-broad
3831
4875
  * in the false-negative direction only, which is the conservative side.
@@ -3845,6 +4889,7 @@ const BODY_METHODS = /* @__PURE__ */ new Set([
3845
4889
  "formData",
3846
4890
  "bytes"
3847
4891
  ]);
4892
+ const PROMISE_COMBINATORS = /* @__PURE__ */ new Set(["all", "allSettled"]);
3848
4893
  /**
3849
4894
  * Whether `await`'s argument is a response-body read (`res.json()`, `res.text()`, …):
3850
4895
  * parsing an already-received body costs no extra round trip, so it is not a
@@ -3853,9 +4898,32 @@ const BODY_METHODS = /* @__PURE__ */ new Set([
3853
4898
  */
3854
4899
  function isBodyParseCall(arg) {
3855
4900
  const e = unwrapTs(arg);
3856
- if (e?.type !== "CallExpression" || e.arguments?.length) return false;
4901
+ if (e?.type !== "CallExpression") return false;
3857
4902
  const callee = e.callee;
3858
- return callee?.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && BODY_METHODS.has(callee.property.name);
4903
+ if (callee?.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return false;
4904
+ if (BODY_METHODS.has(callee.property.name)) return !e.arguments?.length;
4905
+ const list = e.arguments?.length === 1 ? unwrapTs(e.arguments[0]) : void 0;
4906
+ return callee.object.type === "Identifier" && callee.object.name === "Promise" && PROMISE_COMBINATORS.has(callee.property.name) && list?.type === "ArrayExpression" && list.elements.length > 0 && list.elements.every((el) => el && isBodyParseCall(el));
4907
+ }
4908
+ const WORK_STARTING = /* @__PURE__ */ new Set([
4909
+ "CallExpression",
4910
+ "NewExpression",
4911
+ "ImportExpression",
4912
+ "TaggedTemplateExpression"
4913
+ ]);
4914
+ /** Whether an await argument starts work (a call, tagged template, `new`, or `import()`), as opposed to awaiting an existing promise. */
4915
+ function startsWork(arg) {
4916
+ let e = unwrapTs(arg);
4917
+ if (e?.type === "ChainExpression") e = e.expression;
4918
+ return WORK_STARTING.has(e?.type);
4919
+ }
4920
+ /** Whether evaluating `node` may start work: a work-starting expression anywhere outside a nested function. */
4921
+ function containsWork(node) {
4922
+ if (Array.isArray(node)) return node.some(containsWork);
4923
+ if (!node || typeof node !== "object" || typeof node.type !== "string") return false;
4924
+ if (isFunctionNode(node)) return false;
4925
+ if (WORK_STARTING.has(node.type)) return true;
4926
+ return Object.keys(node).some((key) => !WALK_IGNORED_KEYS.has(key) && containsWork(node[key]));
3859
4927
  }
3860
4928
  /**
3861
4929
  * Whether the expression references any tainted name. Threads nested-function
@@ -3889,15 +4957,36 @@ function refsTainted(node, tainted) {
3889
4957
  return hit;
3890
4958
  }
3891
4959
  /**
4960
+ * Awaits that only run when a tainted value says so — `a ?? await x`, `t ? await x : y` — and so
4961
+ * cannot start before the earlier await that decides them.
4962
+ */
4963
+ function guardedAwaits(node, tainted, out = /* @__PURE__ */ new Set()) {
4964
+ if (Array.isArray(node)) {
4965
+ for (const child of node) guardedAwaits(child, tainted, out);
4966
+ return out;
4967
+ }
4968
+ if (!node || typeof node !== "object" || typeof node.type !== "string" || isFunctionNode(node)) return out;
4969
+ if (node.type === "LogicalExpression" && refsTainted(node.left, tainted)) for (const a of collectAwaits(node.right)) out.add(a);
4970
+ else if (node.type === "ConditionalExpression" && refsTainted(node.test, tainted)) for (const a of collectAwaits([node.consequent, node.alternate])) out.add(a);
4971
+ for (const key of Object.keys(node)) {
4972
+ if (WALK_IGNORED_KEYS.has(key)) continue;
4973
+ guardedAwaits(node[key], tainted, out);
4974
+ }
4975
+ return out;
4976
+ }
4977
+ /**
3892
4978
  * performance/load-waterfall, performance/sequential-awaits — forward-taint analysis of the exported `load`'s straight-line
3893
4979
  * statements (direct `try` blocks inlined; `if`/loops/`switch` are not classified
3894
4980
  * but still propagate taint from their assignments; nested functions are never
3895
4981
  * entered). One await site per statement; a site whose awaits' argument subtrees
3896
4982
  * reference an earlier site's bindings (transitively, through intermediate consts
3897
- * and assignments — member-expression targets taint their root object) is
3898
- * dependent, anchored at the first dependent await; otherwise independent when a
3899
- * prior site exists, unless every await merely resumes an already-created promise
3900
- * (a bare identifier argument starts no request). `await parent()` and
4983
+ * and assignments — member-expression targets taint their root object), or that a
4984
+ * tainted `??`/`&&`/`||`/`?:` guard decides (`guardedAwaits`), is
4985
+ * dependent, anchored at the first dependent await — unless it starts no work and its
4986
+ * promise came straight from an await result (`await deferred.state` after
4987
+ * `await parent()`); otherwise independent when a prior site exists, unless every
4988
+ * await merely resumes an already-created promise (a bare identifier argument starts
4989
+ * no request). `await parent()` and
3901
4990
  * response-body reads are never sites, but their bindings taint. Lines are
3902
4991
  * returned in ORIGINAL-source coordinates (the −1 wrap shift is applied here).
3903
4992
  */
@@ -3911,12 +5000,60 @@ function collectLoadWaterfalls(program, wrapped) {
3911
5000
  };
3912
5001
  const line = (start) => Math.max(0, lineOf(wrapped, start) - 1);
3913
5002
  const tainted = /* @__PURE__ */ new Set();
5003
+ const ownBindings = /* @__PURE__ */ new Set();
5004
+ const collectOwn = (body) => {
5005
+ for (const stmt of body ?? []) {
5006
+ if (stmt?.type === "TryStatement") collectOwn(stmt.block?.body);
5007
+ if (stmt?.type !== "VariableDeclaration") continue;
5008
+ for (const d of stmt.declarations ?? []) {
5009
+ let init = unwrapTs(d?.init);
5010
+ if (init?.type === "AwaitExpression") init = unwrapTs(init.argument);
5011
+ if (init?.type === "CallExpression" || init?.type === "NewExpression") addBoundNames(d?.id, ownBindings);
5012
+ }
5013
+ }
5014
+ };
5015
+ collectOwn(load.body.body);
5016
+ const syncTainted = /* @__PURE__ */ new Set();
3914
5017
  let sawAwaitSite = false;
3915
- const taintAssignTarget = (left) => {
3916
- if (left?.type === "MemberExpression") {
3917
- const root = rootObjectName(left);
3918
- if (root) tainted.add(root);
3919
- } else addBoundNames(left, tainted);
5018
+ const taintTarget = (target, rhs) => {
5019
+ const awaited = collectAwaits(rhs).length > 0;
5020
+ if (!awaited && !refsTainted(rhs, tainted)) return;
5021
+ const names = /* @__PURE__ */ new Set();
5022
+ if (target?.type === "MemberExpression") {
5023
+ const root = rootObjectName(target);
5024
+ if (root) names.add(root);
5025
+ } else addBoundNames(target, names);
5026
+ for (const name of names) {
5027
+ tainted.add(name);
5028
+ if (!awaited && (containsWork(rhs) || refsTainted(rhs, syncTainted))) syncTainted.add(name);
5029
+ }
5030
+ };
5031
+ const taintMutation = (call) => {
5032
+ const callee = unwrapTs(call?.callee);
5033
+ if (call?.type !== "CallExpression" || callee?.type !== "MemberExpression" || callee.computed) return;
5034
+ if (!MUTATING_METHODS.has(callee.property?.name) || !(call.arguments ?? []).some((a) => refsTainted(a, tainted))) return;
5035
+ const root = rootObjectName(callee);
5036
+ if (root) tainted.add(root);
5037
+ };
5038
+ const taintLoopFill = (call) => {
5039
+ const callee = unwrapTs(call?.callee);
5040
+ if (call?.type !== "CallExpression" || callee?.type !== "MemberExpression" || callee.computed) return;
5041
+ if (!ITERATING_METHODS.has(callee.property?.name) || !refsTainted(callee.object, tainted)) return;
5042
+ const fill = (node) => {
5043
+ if (Array.isArray(node)) return node.forEach(fill);
5044
+ if (!node || typeof node !== "object" || typeof node.type !== "string") return;
5045
+ if (isFunctionNode(node)) return;
5046
+ const c = node.type === "CallExpression" ? unwrapTs(node.callee) : void 0;
5047
+ if (c?.type === "MemberExpression" && !c.computed && MUTATING_METHODS.has(c.property?.name)) {
5048
+ const root = rootObjectName(c);
5049
+ if (root) tainted.add(root);
5050
+ }
5051
+ if (c?.type === "MemberExpression" && !c.computed && ITERATING_METHODS.has(c.property?.name)) {
5052
+ for (const arg of node.arguments ?? []) if (isFunctionNode(arg)) fill(arg.body);
5053
+ }
5054
+ for (const key of Object.keys(node)) if (!WALK_IGNORED_KEYS.has(key)) fill(node[key]);
5055
+ };
5056
+ for (const arg of call.arguments ?? []) if (isFunctionNode(arg)) fill(arg.body);
3920
5057
  };
3921
5058
  const taintOnly = (node) => {
3922
5059
  if (Array.isArray(node)) {
@@ -3925,10 +5062,19 @@ function collectLoadWaterfalls(program, wrapped) {
3925
5062
  }
3926
5063
  if (!node || typeof node !== "object" || typeof node.type !== "string") return;
3927
5064
  if (isFunctionNode(node)) return;
3928
- if (node.type === "AssignmentExpression") {
3929
- if (collectAwaits(node.right).length > 0 || refsTainted(node.right, tainted)) taintAssignTarget(node.left);
5065
+ if (node.type === "AssignmentExpression") taintTarget(node.left, node.right);
5066
+ else if (node.type === "CallExpression") {
5067
+ taintMutation(node);
5068
+ taintLoopFill(node);
5069
+ } else if ((node.type === "ForOfStatement" || node.type === "ForInStatement") && refsTainted(node.right, tainted)) {
5070
+ const names = /* @__PURE__ */ new Set();
5071
+ addBoundNames(node.left?.type === "VariableDeclaration" ? node.left.declarations?.[0]?.id : node.left, names);
5072
+ for (const name of names) tainted.add(name);
3930
5073
  } else if (node.type === "VariableDeclaration") {
3931
- for (const d of node.declarations ?? []) if (d?.id && d.init && (collectAwaits(d.init).length > 0 || refsTainted(d.init, tainted))) addBoundNames(d.id, tainted);
5074
+ for (const d of node.declarations ?? []) if (d?.id && d.init) {
5075
+ taintTarget(d.id, d.init);
5076
+ taintLoopFill(unwrapTs(d.init));
5077
+ }
3932
5078
  }
3933
5079
  for (const key of Object.keys(node)) {
3934
5080
  if (WALK_IGNORED_KEYS.has(key)) continue;
@@ -3947,12 +5093,13 @@ function collectLoadWaterfalls(program, wrapped) {
3947
5093
  if (stmt.type === "VariableDeclaration" || stmt.type === "ExpressionStatement" || stmt.type === "ReturnStatement") {
3948
5094
  const sites = collectAwaits(stmt).filter((a) => !isParentCall(a.argument) && !isBodyParseCall(a.argument));
3949
5095
  if (sites.length > 0) {
3950
- const dependent = sites.filter((a) => refsTainted(a.argument, tainted));
5096
+ const guarded = guardedAwaits(stmt, tainted);
5097
+ const dependent = sites.filter((a) => (guarded.has(a) || refsTainted(a.argument, tainted)) && (startsWork(a.argument) || refsTainted(a.argument, syncTainted)));
3951
5098
  if (dependent.length > 0) {
3952
5099
  const anchor = dependent.reduce((m, a) => a.start < m.start ? a : m);
3953
5100
  dependentLines.push(line(anchor.start));
3954
5101
  } else if (sawAwaitSite) {
3955
- const workSites = sites.filter((a) => unwrapTs(a.argument)?.type !== "Identifier");
5102
+ const workSites = sites.filter((a) => startsWork(a.argument));
3956
5103
  if (workSites.length > 0) {
3957
5104
  const anchor = workSites.reduce((m, a) => a.start < m.start ? a : m);
3958
5105
  independentLines.push(line(anchor.start));
@@ -3960,15 +5107,22 @@ function collectLoadWaterfalls(program, wrapped) {
3960
5107
  }
3961
5108
  sawAwaitSite = true;
3962
5109
  }
3963
- if (stmt.type === "VariableDeclaration") for (const d of stmt.declarations ?? []) {
3964
- if (!d?.id || !d.init) continue;
3965
- if (collectAwaits(d.init).length > 0 || refsTainted(d.init, tainted)) addBoundNames(d.id, tainted);
3966
- }
3967
- else if (stmt.type === "ExpressionStatement") {
5110
+ if (stmt.type === "VariableDeclaration") {
5111
+ for (const d of stmt.declarations ?? []) if (d?.id && d.init) {
5112
+ taintTarget(d.id, d.init);
5113
+ taintLoopFill(unwrapTs(d.init));
5114
+ }
5115
+ } else if (stmt.type === "ExpressionStatement") {
3968
5116
  const expr = unwrapTs(stmt.expression);
3969
- if (expr?.type === "AssignmentExpression") {
3970
- if (collectAwaits(expr.right).length > 0 || refsTainted(expr.right, tainted)) taintAssignTarget(expr.left);
5117
+ if (expr?.type === "AssignmentExpression") taintTarget(expr.left, expr.right);
5118
+ else {
5119
+ taintMutation(expr);
5120
+ taintLoopFill(expr);
3971
5121
  }
5122
+ const call = expr?.type === "AwaitExpression" ? unwrapTs(expr.argument) : void 0;
5123
+ const callee = call?.type === "CallExpression" ? unwrapTs(call.callee) : void 0;
5124
+ const owner = callee?.type === "MemberExpression" && !callee.computed ? unwrapTs(callee.object) : void 0;
5125
+ if (owner?.type === "Identifier" && ownBindings.has(owner.name)) tainted.add(owner.name);
3972
5126
  }
3973
5127
  } else taintOnly(stmt);
3974
5128
  }
@@ -4005,23 +5159,19 @@ function walkKit(node, handlerFns, startupFns, visit, shadowed = /* @__PURE__ */
4005
5159
  }
4006
5160
  }
4007
5161
  /**
4008
- * Normalize a posix path, resolving `.` and `..` segments — string-only, no I/O.
4009
- * Returns undefined when a `..` segment pops an empty stack, i.e. the path escapes
4010
- * its root (e.g. `../../../../src/lib/server/db` from a shallow route file): the
4011
- * real target lies outside the project tree we can see, so there is no
4012
- * project-relative path to return at all.
5162
+ * Normalize a posix path, resolving `.` and `..` segments — string-only, no I/O. `..` segments
5163
+ * that climb above the root are kept as a leading `../` run (`../../plugins/x`).
4013
5164
  */
4014
5165
  function normalizePosix(path) {
4015
5166
  const out = [];
4016
5167
  for (const seg of path.split("/")) {
4017
5168
  if (seg === "" || seg === ".") continue;
4018
- if (seg === "..") {
4019
- if (out.length === 0) return void 0;
4020
- out.pop();
4021
- } else out.push(seg);
5169
+ if (seg === ".." && out.length > 0 && out[out.length - 1] !== "..") out.pop();
5170
+ else out.push(seg);
4022
5171
  }
4023
5172
  return out.join("/");
4024
5173
  }
5174
+ const escapesRoot = (path) => path === ".." || path.startsWith("../");
4025
5175
  /**
4026
5176
  * What `resolveRepoLocalPath` assumes when no config was read: SvelteKit's own `$lib`, at the
4027
5177
  * default `src/lib`. This single entry is the whole of this analyzer's pre-alias behaviour.
@@ -4048,8 +5198,12 @@ function aliasMatches(entry, spec) {
4048
5198
  * be a project-local module: the caller's `aliases` list decides which non-relative
4049
5199
  * specifiers resolve, defaulting to `$lib` → `src/lib`; `./`/`../` resolve against the
4050
5200
  * importing file's directory; bare packages and other aliases are skipped (they can't
4051
- * be resolved to a project-local path at all). Also undefined when a relative
4052
- * specifier's `..` segments escape the project root (see `normalizePosix`), or when a
5201
+ * be resolved to a project-local path at all). A path above the project root (a leading `../`
5202
+ * run) resolves only inside a directory a `kit.alias` value itself names above the root
5203
+ * (`$plugins: '../../plugins'` in a monorepo app): the user's config says the app builds from
5204
+ * there, so it is read like `src/` — likewise inside a workspace package's directory an entry
5205
+ * names as its `root` — and anywhere else above the root is undefined, as nothing says the app
5206
+ * reaches it. Also undefined when a
4053
5207
  * matched alias's value is itself absolute (e.g. `/opt/shared/src`, or a posixified Windows
4054
5208
  * drive-letter path like `C:/shared/src`): an absolute target is outside the analyzed project by
4055
5209
  * definition, and without this check `normalizePosix` would quietly drop the leading empty
@@ -4058,21 +5212,27 @@ function aliasMatches(entry, spec) {
4058
5212
  * Exported from the package's public barrel because `architecture/private-scope-import`
4059
5213
  * and `architecture/route-component-import` (inside `packages/core`) both need resolution
4060
5214
  * that is not restricted to runes modules, unlike `resolveRunesModuleSpecifier` — and
4061
- * because `resolveComponentPath` (`packages/cli/src/providers/source/resolve.ts`), which
5215
+ * because component resolution (`packages/cli/src/providers/source/resolve.ts`), which
4062
5216
  * drives transitive `<head>`/heading resolution, delegates its alias/`$lib`/relative
4063
5217
  * mapping here too, rather than duplicating it. This is the single site for every
4064
5218
  * repo-local specifier resolution in the repo.
4065
5219
  */
4066
5220
  function resolveRepoLocalPath(spec, importerFile, aliases = DEFAULT_KIT_ALIASES) {
4067
5221
  let path;
4068
- if (spec.startsWith("./") || spec.startsWith("../")) path = `${importerFile.split("/").slice(0, -1).join("/")}/${spec}`;
5222
+ if (spec === "." || spec === ".." || spec.startsWith("./") || spec.startsWith("../")) path = `${importerFile.split("/").slice(0, -1).join("/")}/${spec}`;
4069
5223
  else {
4070
5224
  const entry = aliases.find((a) => aliasMatches(a, spec));
4071
5225
  if (entry?.replacement == null) return void 0;
4072
5226
  if (entry.replacement.startsWith("/") || /^[A-Za-z]:\//.test(entry.replacement)) return void 0;
4073
- path = entry.replacement + spec.slice(entry.find.length);
5227
+ path = entry.replacement + spec.slice(entry.find.length) + (entry.suffix ?? "");
4074
5228
  }
4075
- return normalizePosix(path);
5229
+ const normalized = normalizePosix(path);
5230
+ if (!escapesRoot(normalized)) return normalized;
5231
+ return aliases.some((a) => {
5232
+ const named = a.root ?? a.replacement;
5233
+ const dir = named === null ? "" : normalizePosix(named);
5234
+ return escapesRoot(dir) && (normalized === dir || normalized.startsWith(`${dir}/`));
5235
+ }) ? normalized : void 0;
4076
5236
  }
4077
5237
  /**
4078
5238
  * Resolve an import specifier to a repo-relative `.svelte.ts`/`.svelte.js` path, or
@@ -4080,7 +5240,7 @@ function resolveRepoLocalPath(spec, importerFile, aliases = DEFAULT_KIT_ALIASES)
4080
5240
  * `aliases` (defaulting to `$lib` → `src/lib` when omitted), so a project's declared
4081
5241
  * `kit.alias`/`kit.files.lib` resolve here exactly as they do at rule time; `./`/`../`
4082
5242
  * resolve against the importing file's directory; bare packages, unmatched aliases, and
4083
- * a relative specifier whose `..` segments escape the repo root are skipped. An
5243
+ * a path above the project root outside every `kit.alias` directory are skipped. An
4084
5244
  * extensionless `…/x.svelte` specifier canonicalises to `….svelte.ts` (security/shared-state-import also
4085
5245
  * tries the `.js` sibling when matching).
4086
5246
  */
@@ -4115,8 +5275,8 @@ function libServerRoot(aliases) {
4115
5275
  * an equivalent relative path, resolving to exactly that directory — importing the
4116
5276
  * index file rather than a named submodule) is exempt too. A specifier that
4117
5277
  * resolves to undefined — a bare package, an alias that can't map to a repo path, or
4118
- * a relative specifier whose `..` segments escape the repo root (see
4119
- * `normalizePosix`) — is conservatively NOT local state: we can't see that file, so
5278
+ * a relative specifier whose `..` segments escape the project root (outside every
5279
+ * `kit.alias` directory, see `resolveRepoLocalPath`) — is conservatively NOT local state: we can't see that file, so
4120
5280
  * we don't flag writes to it. Installed packages (drizzle, redis, @vercel/kv, …) are
4121
5281
  * excluded: `.set()`/`.update()` on those is persistence, not shared-module-state
4122
5282
  * mutation. When the `$lib` entry is opaque (`libServerRoot` returns undefined — an unreadable
@@ -4125,13 +5285,9 @@ function libServerRoot(aliases) {
4125
5285
  * risks a false positive in this default-on security rule, and staying silent only costs a
4126
5286
  * missed finding.
4127
5287
  */
4128
- /** The resolved repo path when `spec` lands on, or under, the `$lib` server root; else undefined. */
4129
- function serverRootRelativePath(spec, importerFile, aliases) {
4130
- const serverRoot = libServerRoot(aliases);
4131
- if (serverRoot === void 0) return void 0;
4132
- const path = resolveRepoLocalPath(spec, importerFile, aliases);
4133
- if (path === void 0) return void 0;
4134
- return path === serverRoot || path.startsWith(`${serverRoot}/`) ? path : void 0;
5288
+ /** The resolved repo path of a repo-local `spec`, or undefined when it is a package or the `$lib` root is opaque. */
5289
+ function localModulePath(spec, importerFile, aliases) {
5290
+ return libServerRoot(aliases) === void 0 ? void 0 : resolveRepoLocalPath(spec, importerFile, aliases);
4135
5291
  }
4136
5292
  function isLocalStateSpecifier(spec, importerFile, aliases) {
4137
5293
  const serverRoot = libServerRoot(aliases);
@@ -4147,15 +5303,62 @@ const IN_MEMORY_CTORS = /* @__PURE__ */ new Set([
4147
5303
  "WeakMap",
4148
5304
  "WeakSet"
4149
5305
  ]);
4150
- /** Whether an initializer positively identifies an in-memory container. */
4151
- function isInMemoryInit(init) {
5306
+ const isContainerCtor = (n) => n?.type === "NewExpression" && n.callee?.type === "Identifier" && IN_MEMORY_CTORS.has(n.callee.name);
5307
+ /**
5308
+ * Whether an initializer positively identifies an in-memory container. An object literal with a
5309
+ * spread is a composite of values this parse cannot see — typically a facade over clients and
5310
+ * imported modules (`{ ...prismaModels, ...handlers }`) — so it counts only when one of its own
5311
+ * properties is itself a container. One whose properties are all functions (`{ get, set }` of the
5312
+ * module's own functions) holds no data: `.set()` on it calls its own method.
5313
+ */
5314
+ function isInMemoryInit(init, fns = /* @__PURE__ */ new Set()) {
4152
5315
  if (!init) return false;
4153
- if (init.type === "ObjectExpression" || init.type === "ArrayExpression") return true;
5316
+ if (init.type === "ArrayExpression") return true;
5317
+ if (init.type === "ObjectExpression") {
5318
+ const props = init.properties ?? [];
5319
+ const isFn = (p) => p?.type === "Property" && (p.method || p.value?.type === "FunctionExpression" || p.value?.type === "ArrowFunctionExpression" || p.value?.type === "Identifier" && fns.has(p.value.name));
5320
+ if (props.length > 0 && props.every(isFn)) return false;
5321
+ if (!props.some((p) => p?.type === "SpreadElement")) return true;
5322
+ return props.some((p) => p?.type === "Property" && isInMemoryInit(p.value, fns));
5323
+ }
4154
5324
  return init.type === "NewExpression" && init.callee?.type === "Identifier" && IN_MEMORY_CTORS.has(init.callee.name);
4155
5325
  }
4156
5326
  /**
5327
+ * The exported bindings of a module that are a persistence or API client: `new C()` of a class imported
5328
+ * from an installed package, or of a class this module declares that neither extends another class nor
5329
+ * keeps a `Map`/`Set`/`WeakMap`/`WeakSet` field; or a call into an installed package (`drizzle(…)`,
5330
+ * `createClient(…)`). A write through one of them reaches a database or service, not module state.
5331
+ */
5332
+ function parseClientExports(source, filename, aliases) {
5333
+ const names = /* @__PURE__ */ new Set();
5334
+ const { program } = parseModuleProgram(source, filename);
5335
+ const fromPackage = /* @__PURE__ */ new Set();
5336
+ for (const stmt of program?.body ?? []) {
5337
+ const src = stmt?.type === "ImportDeclaration" ? String(stmt.source?.value ?? "") : "";
5338
+ if (!(!src || /^[./$~#]|^@\//.test(src) || resolveRepoLocalPath(src, filename, aliases) !== void 0) && !/^svelte|store/i.test(src)) for (const sp of stmt.specifiers ?? []) fromPackage.add(sp.local.name);
5339
+ }
5340
+ const plainClasses = /* @__PURE__ */ new Set();
5341
+ for (const stmt of program?.body ?? []) {
5342
+ const decl = unwrapExport(stmt);
5343
+ if (decl?.type !== "ClassDeclaration" || !decl.id || decl.superClass) continue;
5344
+ if (!(decl.body?.body ?? []).some((m) => isContainerCtor(m?.value) || m?.kind === "constructor" && m.value?.body?.body?.some((st) => st?.type === "ExpressionStatement" && isContainerCtor(st.expression?.right)))) plainClasses.add(decl.id.name);
5345
+ }
5346
+ for (const stmt of program?.body ?? []) {
5347
+ if (stmt?.type !== "ExportNamedDeclaration" || stmt.declaration?.type !== "VariableDeclaration") continue;
5348
+ for (const d of stmt.declaration.declarations ?? []) {
5349
+ let init = unwrapTs(d?.init);
5350
+ if (init?.type === "AwaitExpression") init = unwrapTs(init.argument);
5351
+ if (d?.id?.type !== "Identifier") continue;
5352
+ const ctor = init?.type === "NewExpression" && init.callee?.type === "Identifier" ? init.callee.name : void 0;
5353
+ if (ctor !== void 0 && (fromPackage.has(ctor) || plainClasses.has(ctor))) names.add(d.id.name);
5354
+ if (init?.type === "CallExpression" && fromPackage.has(rootObjectName(init.callee) ?? "")) names.add(d.id.name);
5355
+ }
5356
+ }
5357
+ return names;
5358
+ }
5359
+ /**
4157
5360
  * The exported bindings of a module that are initialized to an in-memory container — an object
4158
- * or array literal, or `new Map`/`Set`/`WeakMap`/`WeakSet`. Used to arbitrate a `.set()` call on
5361
+ * or array literal (a spread literal only as `isInMemoryInit` allows), or `new Map`/`Set`/`WeakMap`/`WeakSet`. Used to arbitrate a `.set()` call on
4159
5362
  * an import from under the `$lib` server root, where the call shape alone cannot separate a
4160
5363
  * hand-rolled store from a database client.
4161
5364
  *
@@ -4167,11 +5370,16 @@ function isInMemoryInit(init) {
4167
5370
  function parseInMemoryExports(source, filename) {
4168
5371
  const names = /* @__PURE__ */ new Set();
4169
5372
  const { program } = parseModuleProgram(source, filename);
5373
+ const fns = /* @__PURE__ */ new Set();
5374
+ for (const stmt of program?.body ?? []) {
5375
+ const decl = unwrapExport(stmt);
5376
+ if (decl?.type === "FunctionDeclaration" && decl.id) fns.add(decl.id.name);
5377
+ }
4170
5378
  for (const stmt of program?.body ?? []) {
4171
5379
  if (stmt?.type !== "ExportNamedDeclaration" || !stmt.declaration) continue;
4172
5380
  const decl = stmt.declaration;
4173
5381
  if (decl.type !== "VariableDeclaration") continue;
4174
- for (const d of decl.declarations ?? []) if (d?.id?.type === "Identifier" && isInMemoryInit(d.init)) names.add(d.id.name);
5382
+ for (const d of decl.declarations ?? []) if (d?.id?.type === "Identifier" && isInMemoryInit(d.init, fns)) names.add(d.id.name);
4175
5383
  }
4176
5384
  return names;
4177
5385
  }
@@ -4187,6 +5395,7 @@ function parseKitModuleFacts(source, filename, aliases) {
4187
5395
  const importedStateWrites = [];
4188
5396
  const importedStateWritesOutsideHandlers = [];
4189
5397
  const pendingServerStoreWrites = [];
5398
+ const storeWriteTargets = [];
4190
5399
  const runesModuleImports = [];
4191
5400
  const lifecycleCalls = [];
4192
5401
  const browserGlobalRefs = [];
@@ -4204,6 +5413,12 @@ function parseKitModuleFacts(source, filename, aliases) {
4204
5413
  const line = (start) => Math.max(0, lineOf(wrapped, start) - 1);
4205
5414
  const importedSpecifiers = /* @__PURE__ */ new Map();
4206
5415
  const importedNames = /* @__PURE__ */ new Map();
5416
+ const namespaceImports = /* @__PURE__ */ new Set();
5417
+ const localFunctions = /* @__PURE__ */ new Set();
5418
+ walkEstree(program, (n) => {
5419
+ if (n.type === "FunctionDeclaration" && n.id?.name) localFunctions.add(n.id.name);
5420
+ if (n.type === "VariableDeclarator" && n.id?.type === "Identifier" && n.init && isFunctionNode(unwrapTs(n.init))) localFunctions.add(n.id.name);
5421
+ });
4207
5422
  for (const stmt of program.body ?? []) {
4208
5423
  if (stmt?.type !== "ImportDeclaration" || stmt.importKind === "type") continue;
4209
5424
  const spec = typeof stmt.source?.value === "string" ? stmt.source.value : "";
@@ -4212,6 +5427,7 @@ function parseKitModuleFacts(source, filename, aliases) {
4212
5427
  if (s?.importKind === "type" || s?.local?.type !== "Identifier") continue;
4213
5428
  names.push(s.local.name);
4214
5429
  importedSpecifiers.set(s.local.name, spec);
5430
+ if (s.type === "ImportNamespaceSpecifier") namespaceImports.add(s.local.name);
4215
5431
  importedNames.set(s.local.name, s.type === "ImportSpecifier" && s.imported?.type === "Identifier" ? s.imported.name : s.local.name);
4216
5432
  }
4217
5433
  if (names.length === 0) continue;
@@ -4234,7 +5450,7 @@ function parseKitModuleFacts(source, filename, aliases) {
4234
5450
  const ssrOptOut = findFalseOptOut(program, wrapped, "ssr");
4235
5451
  const csrOptOut = findFalseOptOut(program, wrapped, "csr");
4236
5452
  const waterfalls = collectLoadWaterfalls(program, wrapped);
4237
- if (!ssrOptOut) {
5453
+ if (!ssrOptOut?.literal) {
4238
5454
  const shiftLine = (l) => Math.max(0, l - 1);
4239
5455
  const browserImports = collectBrowserGuardImports(program);
4240
5456
  const guards = /* @__PURE__ */ new Set([...browserImports, ...collectDerivedGuardBindings(program, browserImports)]);
@@ -4266,10 +5482,12 @@ function parseKitModuleFacts(source, filename, aliases) {
4266
5482
  scanFn(fn, false);
4267
5483
  }
4268
5484
  }
5485
+ const browserOnly = browserOnlyRanges(program, collectBrowserGuardImports(program));
5486
+ const reachedOnlyByBrowser = (at) => browserOnly.some(([from, to]) => at >= from && at < to);
4269
5487
  walkKit(program, handlerFns, startupFns, (n, shadowed, inFunction, inHandler, inStartup) => {
4270
5488
  if (inFunction && !inStartup) {
4271
5489
  const flagLet = (name) => {
4272
- if (name && !shadowed.has(name) && moduleLets.has(name)) moduleStateReassignments.push({
5490
+ if (name && !shadowed.has(name) && moduleLets.has(name) && !reachedOnlyByBrowser(n.start)) moduleStateReassignments.push({
4273
5491
  name,
4274
5492
  line: line(n.start),
4275
5493
  inHandler
@@ -4309,23 +5527,26 @@ function parseKitModuleFacts(source, filename, aliases) {
4309
5527
  };
4310
5528
  } else if (n.type === "CallExpression" && n.callee?.type === "MemberExpression") {
4311
5529
  const method = n.callee.property?.type === "Identifier" ? n.callee.property.name : void 0;
4312
- if (method === "set" || method === "update") {
5530
+ const nsFunction = n.callee.object?.type === "Identifier" && namespaceImports.has(n.callee.object.name);
5531
+ const updater = method !== "update" || isUpdaterArgument(n.arguments?.[0], localFunctions);
5532
+ if ((method === "set" || method === "update") && !nsFunction && updater) {
4313
5533
  const r = importedRoot(n.callee.object);
4314
5534
  const spec = r ? importedSpecifiers.get(r) : void 0;
4315
5535
  if (r && spec !== void 0) {
4316
- if (isLocalStateSpecifier(spec, filename, aliases)) write = {
5536
+ const resolved = localModulePath(spec, filename, aliases);
5537
+ const target = {
4317
5538
  name: r,
4318
- via: "set-call"
5539
+ imported: importedNames.get(r) ?? r,
5540
+ resolved,
5541
+ line: line(n.start)
4319
5542
  };
4320
- else {
4321
- const resolved = serverRootRelativePath(spec, filename, aliases);
4322
- if (resolved !== void 0 && inHandler) pendingServerStoreWrites.push({
5543
+ if (isLocalStateSpecifier(spec, filename, aliases)) {
5544
+ write = {
4323
5545
  name: r,
4324
- imported: importedNames.get(r) ?? r,
4325
- resolved,
4326
- line: line(n.start)
4327
- });
4328
- }
5546
+ via: "set-call"
5547
+ };
5548
+ if (inHandler) storeWriteTargets.push(target);
5549
+ } else if (resolved !== void 0 && inHandler) pendingServerStoreWrites.push(target);
4329
5550
  }
4330
5551
  }
4331
5552
  } else if (n.type === "AssignmentExpression" && (n.left?.type === "ObjectPattern" || n.left?.type === "ArrayPattern")) {
@@ -4346,6 +5567,7 @@ function parseKitModuleFacts(source, filename, aliases) {
4346
5567
  };
4347
5568
  scanPatternTargets(n.left);
4348
5569
  }
5570
+ if (write && reachedOnlyByBrowser(n.start)) write = void 0;
4349
5571
  if (write) {
4350
5572
  if (inHandler) importedStateWrites.push({
4351
5573
  ...write,
@@ -4377,17 +5599,51 @@ function parseKitModuleFacts(source, filename, aliases) {
4377
5599
  line: line(call.start)
4378
5600
  });
4379
5601
  }
5602
+ const exitLocals = collectNamedImportAliases(program, "@sveltejs/kit", EXIT_NAMES);
5603
+ const neverRenders = loadNeverRenders(program, exitLocals);
5604
+ const building = collectNamedImportAliases(program, "$app/environment", BUILDING_NAMES);
5605
+ const hooks = /(^|\/)hooks\.server\.(ts|js)$/.test(filename);
5606
+ const answered = hooks ? handleAnsweredPaths(program, exitLocals) : [];
5607
+ const chunkTokens = hooks ? pageChunkTokens(program) : [];
5608
+ const flagGuard = leadingFlagGuard(program, exitLocals, (local) => {
5609
+ const spec = namespaceImports.has(local) ? void 0 : importedSpecifiers.get(local);
5610
+ const resolved = spec === void 0 ? void 0 : localModulePath(spec, filename, aliases);
5611
+ return resolved === void 0 ? void 0 : {
5612
+ resolved,
5613
+ name: importedNames.get(local) ?? local
5614
+ };
5615
+ });
5616
+ const factory = loadFactory(program, (local) => {
5617
+ const spec = namespaceImports.has(local) ? void 0 : importedSpecifiers.get(local);
5618
+ const resolved = spec === void 0 ? void 0 : localModulePath(spec, filename, aliases);
5619
+ return resolved === void 0 ? void 0 : {
5620
+ resolved,
5621
+ name: importedNames.get(local) ?? local
5622
+ };
5623
+ });
4380
5624
  return {
4381
5625
  moduleStateReassignments: byLine(moduleStateReassignments),
4382
5626
  importedStateWrites: byLine(importedStateWrites),
4383
5627
  importedStateWritesOutsideHandlers: byLine(importedStateWritesOutsideHandlers),
4384
5628
  pendingServerStoreWrites: byLine(pendingServerStoreWrites),
5629
+ ...storeWriteTargets.length > 0 ? { storeWriteTargets } : {},
4385
5630
  runesModuleImports: byLine(runesModuleImports),
4386
5631
  lifecycleCalls: byLine(lifecycleCalls),
4387
5632
  browserGlobalRefs: byLine(browserGlobalRefs),
4388
5633
  basePathLinks: byLine(basePathLinks),
4389
5634
  ...ssrOptOut ? { ssrDisabled: { line: Math.max(0, ssrOptOut.line - 1) } } : {},
5635
+ ...!ssrOptOut && exportsName(program, "ssr") ? { ssrEnabled: true } : {},
5636
+ ...!ssrOptOut && exportsTrue(program, "ssr") ? { ssrTrue: true } : {},
4390
5637
  ...csrOptOut ? { csrDisabled: { line: Math.max(0, csrOptOut.line - 1) } } : {},
5638
+ ...!csrOptOut && exportsName(program, "csr") ? { csrEnabled: true } : {},
5639
+ ...neverRenders ? { loadNeverRenders: true } : {},
5640
+ ...!neverRenders && building.size > 0 && loadNeverRenders(program, exitLocals, building) ? { loadNeverRendersOutsideBuild: true } : {},
5641
+ ...exportsName(program, "prerender") ? { prerenderExported: true } : {},
5642
+ ...answered.length > 0 ? { answeredPaths: answered } : {},
5643
+ ...chunkTokens.length > 0 ? { pageChunkTokens: chunkTokens } : {},
5644
+ ...flagGuard ? { pendingFlagGuard: flagGuard } : {},
5645
+ ...factory ? { pendingLoadFactory: factory } : {},
5646
+ ...loadGatesOnLocals(program) ? { loadGated: true } : {},
4391
5647
  ...waterfalls.dependentLines.length > 0 || waterfalls.independentLines.length > 0 ? { loadWaterfalls: waterfalls } : {},
4392
5648
  suppressions
4393
5649
  };
@@ -5609,8 +6865,8 @@ function isRouteEntry(path) {
5609
6865
  /** The route entries this component imports, with the line each import sits on. */
5610
6866
  function routeEntryImports(c, ctx) {
5611
6867
  const out = [];
5612
- for (const { source, line, type } of c.importSpans ?? []) {
5613
- if (type) continue;
6868
+ for (const { source, line, type, named } of c.importSpans ?? []) {
6869
+ if (type || named) continue;
5614
6870
  const target = resolveRepoLocalPath(source, c.file, ctx.project.kitAliases);
5615
6871
  if (target !== void 0 && isRouteEntry(target)) out.push({
5616
6872
  line,
@@ -5859,8 +7115,8 @@ const MESSAGE$1 = "Sequential dependent awaits in a universal load create a clie
5859
7115
  /**
5860
7116
  * performance/load-waterfall — dependent await chains in universal loads. Server loads are exempt:
5861
7117
  * a dependent chain cannot be parallelized, and on the server there is no better
5862
- * placement to suggest. csr = false files are exempt too — without a client
5863
- * runtime the universal load only runs during SSR.
7118
+ * placement to suggest. Loads on a route with `csr = false` (its own or a layout's) are exempt
7119
+ * too — without a client runtime the universal load only runs during SSR.
5864
7120
  */
5865
7121
  const performanceLoadWaterfall = kitModuleRule({
5866
7122
  id: "performance/load-waterfall",
@@ -5875,7 +7131,7 @@ const performanceLoadWaterfall = kitModuleRule({
5875
7131
  snippet: "// +page.server.ts — same chain, server-side hops\nexport async function load({ fetch }) {\n const user = await fetch(`/api/user`).then((r) => r.json());\n const posts = await fetch(`/api/posts/${user.id}`).then((r) => r.json());\n return { user, posts };\n}",
5876
7132
  lang: "ts"
5877
7133
  },
5878
- applies: (m) => m.kind === "universal" && m.csrDisabled === void 0 && (m.loadWaterfalls?.dependentLines.length ?? 0) > 0,
7134
+ applies: (m, ctx) => m.kind === "universal" && (m.loadWaterfalls?.dependentLines.length ?? 0) > 0 && !universalNeverCsr(m, ctx),
5879
7135
  bad: (m) => m.loadWaterfalls.dependentLines.map((line) => ({
5880
7136
  line,
5881
7137
  message: MESSAGE$1
@@ -5956,223 +7212,6 @@ const performanceIframeLoading = componentRule({
5956
7212
  }))
5957
7213
  });
5958
7214
  //#endregion
5959
- //#region src/rules/a11y/aria-data.ts
5960
- /**
5961
- * ARIA names `aria-query@5.3.2` does not carry. It is not the clean ARIA 1.2 snapshot it looks
5962
- * like — it holds 1.2's 48 attributes plus three 1.3 additions — so the gap is patched by name
5963
- * rather than by version. Every entry here is defined in the ARIA 1.3 editor's draft, which is
5964
- * what makes reporting it as an unknown name wrong — how far implementations have got is a
5965
- * separate question this rule does not ask. Re-check this list whenever the aria-query dependency
5966
- * moves.
5967
- */
5968
- const ARIA_1_3_ROLES = /* @__PURE__ */ new Set([
5969
- "comment",
5970
- "image",
5971
- "sectionheader",
5972
- "sectionfooter",
5973
- "suggestion"
5974
- ]);
5975
- const ARIA_1_3_ATTRIBUTES = /* @__PURE__ */ new Set(["aria-colindextext", "aria-rowindextext"]);
5976
- /**
5977
- * Roles whose `requiredProps` row in aria-query is stale. Neither ARIA 1.2 nor the 1.3 editor's
5978
- * draft lists required states or properties for these — the 1.3 draft re-lists inherited
5979
- * requirements where they apply (`menuitemradio` → `aria-checked`), so the absence is deliberate
5980
- * rather than an inheritance the table left implicit. Without this, idiomatic APG listbox and
5981
- * tree markup is flagged.
5982
- */
5983
- const NO_REQUIRED_PROPS = /* @__PURE__ */ new Set(["option", "treeitem"]);
5984
- function isKnownRole(role) {
5985
- return ARIA_1_3_ROLES.has(role) || roles.has(role);
5986
- }
5987
- function isAbstractRole(role) {
5988
- return roles.get(role)?.abstract === true;
5989
- }
5990
- /** A concrete (non-abstract) role is the one a user agent resolves a role token list to. */
5991
- function isConcreteRole(role) {
5992
- return isKnownRole(role) && !isAbstractRole(role);
5993
- }
5994
- /**
5995
- * The role a user agent applies: the first token naming a concrete role, or undefined when none
5996
- * does. Shared so the role rules cannot disagree about what a fallback list means.
5997
- */
5998
- function resolveRole(tokens) {
5999
- return tokens.find(isConcreteRole);
6000
- }
6001
- function isKnownAriaAttribute(name) {
6002
- return ARIA_1_3_ATTRIBUTES.has(name) || aria.has(name);
6003
- }
6004
- function requiredAriaProps(role) {
6005
- if (NO_REQUIRED_PROPS.has(role)) return [];
6006
- const def = roles.get(role);
6007
- return def ? Object.keys(def.requiredProps) : [];
6008
- }
6009
- function ariaValueKind(name) {
6010
- const def = aria.get(name);
6011
- if (!def) return void 0;
6012
- return {
6013
- type: def.type,
6014
- ...def.values ? { values: def.values.map(String) } : {}
6015
- };
6016
- }
6017
- //#endregion
6018
- //#region src/a11y.ts
6019
- /**
6020
- * Branch-aware occurrence fold (design "Control-flow semantics"): within a branch
6021
- * occurrences sum, across the arms of one exclusive block the arm with the most
6022
- * occurrences wins (tie → lowest branch index) and ITS occurrences are the group's
6023
- * representatives — so a caller's count is always `list.length`, with a location per
6024
- * representative. `{#each}`/`{#snippet}` occurrences render 0..N times and drop out.
6025
- * The max is per key: there is no scalar total to maximize.
6026
- */
6027
- function foldOccurrences(nodes) {
6028
- const byKey = /* @__PURE__ */ new Map();
6029
- for (const node of nodes) {
6030
- if (node.repeatable) continue;
6031
- const list = byKey.get(node.key);
6032
- if (list) list.push(node);
6033
- else byKey.set(node.key, [node]);
6034
- }
6035
- const folded = /* @__PURE__ */ new Map();
6036
- for (const [key, list] of byKey) folded.set(key, foldAt(list, 0));
6037
- return folded;
6038
- }
6039
- function foldAt(nodes, depth) {
6040
- const unconditional = [];
6041
- const groups = /* @__PURE__ */ new Map();
6042
- for (const node of nodes) {
6043
- const step = node.path[depth];
6044
- if (!step) {
6045
- unconditional.push(node);
6046
- continue;
6047
- }
6048
- let branches = groups.get(step.group);
6049
- if (!branches) groups.set(step.group, branches = /* @__PURE__ */ new Map());
6050
- const list = branches.get(step.branch);
6051
- if (list) list.push(node);
6052
- else branches.set(step.branch, [node]);
6053
- }
6054
- const representatives = [...unconditional];
6055
- for (const branches of groups.values()) {
6056
- let best = [];
6057
- let bestBranch = Number.POSITIVE_INFINITY;
6058
- for (const [branch, list] of branches) {
6059
- const arm = foldAt(list, depth + 1);
6060
- if (arm.length > best.length || arm.length === best.length && branch < bestBranch) {
6061
- best = arm;
6062
- bestBranch = branch;
6063
- }
6064
- }
6065
- representatives.push(...best);
6066
- }
6067
- return representatives;
6068
- }
6069
- /**
6070
- * Decode a fragment identifier the way navigation does before matching an element id
6071
- * (`href="#caf%C3%A9"` targets `id="café"`). Malformed escapes are kept verbatim —
6072
- * the browser would also fail to decode them, so the raw text is the comparable form.
6073
- */
6074
- function decodeFragmentId(fragment) {
6075
- try {
6076
- return decodeURIComponent(fragment);
6077
- } catch {
6078
- return fragment;
6079
- }
6080
- }
6081
- /** Whitespace-split tokens of a (possibly undefined) literal attribute value. */
6082
- function splitTokens(value) {
6083
- return value ? value.trim().split(/\s+/).filter(Boolean) : [];
6084
- }
6085
- /** Explicit `role` values that map to the landmark kinds the route rules inspect. */
6086
- const LANDMARK_ROLES = /* @__PURE__ */ new Set([
6087
- "main",
6088
- "banner",
6089
- "contentinfo",
6090
- "complementary"
6091
- ]);
6092
- /** Sectioning content (HTML-AAM): a `<header>`/`<footer>` below one of these is not a landmark. */
6093
- const SECTIONING_TAGS = /* @__PURE__ */ new Set([
6094
- "article",
6095
- "aside",
6096
- "main",
6097
- "nav",
6098
- "section"
6099
- ]);
6100
- /**
6101
- * Tags whose landmark-ness depends on sectioning ancestry. A provider that cannot see ancestry
6102
- * (the per-file AST walk) uses this same set to mark the deferral (topLevel) — reading it from
6103
- * here keeps the deferral set from drifting when the policy widens.
6104
- */
6105
- const ANCESTRY_DEPENDENT_TAGS = /* @__PURE__ */ new Set(["header", "footer"]);
6106
- /** Attributes that give an element the accessible name the `<aside>` landmark decision reads. */
6107
- const NAMING_ATTRS$1 = ["aria-label", "aria-labelledby"];
6108
- /**
6109
- * HTML-AAM: an `<aside>` scoped to `body` or `main` is a `complementary` landmark; scoped to
6110
- * sectioning content it is one only when it has an accessible name. `main` is deliberately absent
6111
- * — it is a scope in which an `aside` *is* a landmark, unlike the sectioning set that decides
6112
- * `<header>`/`<footer>`.
6113
- */
6114
- const ASIDE_DEMOTING_TAGS = /* @__PURE__ */ new Set([
6115
- "article",
6116
- "aside",
6117
- "nav",
6118
- "section"
6119
- ]);
6120
- /**
6121
- * The landmark kind an element contributes ('main'|'banner'|'contentinfo'|'complementary'), or
6122
- * undefined. The one home for the decision both providers apply — the source AST walk and the
6123
- * rendered HTML walk each feed it their own tree's context. ARIA fallback role lists resolve to
6124
- * the first token naming a concrete role (resolveRole).
6125
- */
6126
- function resolveLandmark(input) {
6127
- const { tag, roleTokens, named, insideSectioning, insideAsideDemoting } = input;
6128
- if (roleTokens !== void 0) {
6129
- const role = resolveRole(roleTokens);
6130
- return role && LANDMARK_ROLES.has(role) ? role : void 0;
6131
- }
6132
- if (tag === "main") return "main";
6133
- if (tag !== void 0 && ANCESTRY_DEPENDENT_TAGS.has(tag)) return insideSectioning ? void 0 : tag === "header" ? "banner" : "contentinfo";
6134
- if (tag === "aside") return !insideAsideDemoting || named ? "complementary" : void 0;
6135
- }
6136
- /**
6137
- * Attributes whose (whitespace-tokenized) values reference element ids: the ARIA id-reference and
6138
- * id-reference-list properties, and HTML's own (`for`, `list`, `headers`, `form`, the popover and
6139
- * command targets). `href="#…"` is handled separately — its value is a URL, not a token list.
6140
- */
6141
- const IDREF_ATTRS = [
6142
- "for",
6143
- "list",
6144
- "headers",
6145
- "form",
6146
- "popovertarget",
6147
- "commandfor",
6148
- "aria-labelledby",
6149
- "aria-describedby",
6150
- "aria-controls",
6151
- "aria-activedescendant",
6152
- "aria-owns",
6153
- "aria-details",
6154
- "aria-errormessage",
6155
- "aria-flowto"
6156
- ];
6157
- /**
6158
- * Whether a decoded URL fragment is HTML's "top of the document" indicator: `#top` (ASCII
6159
- * case-insensitive) scrolls to the top when no element has that id, so it is never a missing
6160
- * reference. Compare AFTER percent-decoding — `#%74op` navigates identically to `#top`.
6161
- */
6162
- function isTopFragment(id) {
6163
- return id.toLowerCase() === "top";
6164
- }
6165
- /**
6166
- * A fragment with its text directive removed. Everything from the first `:~:` on is user-agent
6167
- * instructions for finding text and names no element, while anything before it is still an
6168
- * ordinary element fragment — `#section:~:text=hi` targets `id="section"`, `#:~:text=hi` targets
6169
- * nothing. Returns an empty string when the fragment is a directive and nothing else.
6170
- */
6171
- function stripTextDirective(fragment) {
6172
- const i = fragment.indexOf(":~:");
6173
- return i === -1 ? fragment : fragment.slice(0, i);
6174
- }
6175
- //#endregion
6176
7215
  //#region src/rules/a11y/invalid-role.ts
6177
7216
  const a11yInvalidRole = componentRule({
6178
7217
  id: "a11y/invalid-role",
@@ -6765,7 +7804,7 @@ const a11yPatternTitle = componentRule({
6765
7804
  //#endregion
6766
7805
  //#region src/html-spec/generated.ts
6767
7806
  /*!
6768
- * HTML spec data projected from @markuplint/html-spec@5.0.0 — https://github.com/markuplint/markuplint
7807
+ * HTML spec data projected from @markuplint/html-spec@5.0.1 — https://github.com/markuplint/markuplint
6769
7808
  *
6770
7809
  * MIT License
6771
7810
  *
@@ -6872,7 +7911,7 @@ const ELEMENT_FACT_OVERRIDES = {
6872
7911
  };
6873
7912
  /**
6874
7913
  * The role(s) an `AriaElementFact` may have. Explicit first: a literal role's first concrete token
6875
- * (`resolveRole`); an expression role, or a spread with no literal role, makes the role unknowable
7914
+ * (`resolveRole`); an expression role, or a spread or `use:` action with no literal role, makes the role unknowable
6876
7915
  * and yields `undefined`. Otherwise the element's implicit role — never one role but the set of
6877
7916
  * candidates the dataset's conditional outcomes leave open, since the selectors that decide between
6878
7917
  * them are not evaluated (design 2026-08-19-aria-role-table-rules, "Which role an element has").
@@ -6887,7 +7926,7 @@ function roleCandidates(e) {
6887
7926
  namingProhibited: false
6888
7927
  } : void 0;
6889
7928
  }
6890
- if (e.hasSpread) return void 0;
7929
+ if (e.hasSpread || e.hasAction) return void 0;
6891
7930
  const el = Object.hasOwn(HTML_SPEC.elements, e.tag) ? HTML_SPEC.elements[e.tag] : void 0;
6892
7931
  if (!el) return void 0;
6893
7932
  const aria = (Object.hasOwn(ELEMENT_FACT_OVERRIDES, e.tag) ? ELEMENT_FACT_OVERRIDES[e.tag] : void 0) ?? el.aria;
@@ -7454,7 +8493,12 @@ function surplusRule(spec) {
7454
8493
  first ??= reps[0];
7455
8494
  for (let i = 1; i < reps.length; i++) {
7456
8495
  surplus = true;
7457
- out.push(result(route.route, PENALIZED$1, reps[i], spec.message(key, i, reps.length, reps[0])));
8496
+ const finding = result(route.route, PENALIZED$1, reps[i], spec.message(key, i, reps.length, reps[0]));
8497
+ const recommendation = spec.recommendationFor?.(reps[i], reps[0]);
8498
+ out.push(recommendation ? {
8499
+ ...finding,
8500
+ recommendation
8501
+ } : finding);
7458
8502
  }
7459
8503
  }
7460
8504
  if (first && !surplus) out.push(result(route.route, PASS, {
@@ -7632,6 +8676,7 @@ const a11yIdDuplication = surplusRule({
7632
8676
  recommendation: "Every id in a route should be unique.",
7633
8677
  map: (route) => Object.entries(route.ids).sort(([, a], [, b]) => a[0].file.localeCompare(b[0].file) || a[0].line - b[0].line),
7634
8678
  message: (id, _i, _n, first) => first.file === "src/app.html" ? `Duplicate id "${id}" — also defined by the src/app.html shell (line ${first.line})` : `Duplicate id "${id}"`,
8679
+ recommendationFor: (rep, first) => first.file === "src/app.html" || first.line === 0 ? void 0 : rep.file === first.file && rep.line === first.line ? "This id is hardcoded in a component rendered more than once on this route. Generate it per instance with $props.id() (Svelte 5.20+): `const uid = $props.id();`, then `id=\"{uid}-name\"`." : "Rename one of them. Separate {#if} blocks are treated as able to render together; if these never render at the same time, make them branches of one {#if}…{:else}.",
7635
8680
  passMessage: "No duplicate ids"
7636
8681
  });
7637
8682
  //#endregion
@@ -8152,8 +9197,29 @@ function buildJsonReport(results, config, meta, ruleIds, examined, skipped) {
8152
9197
  };
8153
9198
  }
8154
9199
  /** Render results as the documented JSON report string (design §7). */
8155
- function formatJsonReport(results, config, meta, ruleIds, examined, skipped) {
8156
- return JSON.stringify(buildJsonReport(results, config, meta, ruleIds, examined, skipped), null, 2);
9200
+ function formatJsonReport(...args) {
9201
+ return [...formatJsonReportChunks(...args)].join("");
9202
+ }
9203
+ /**
9204
+ * `formatJsonReport`'s text in pieces, one route at a time: a large prerendered site's report is
9205
+ * longer than the longest string V8 can hold.
9206
+ */
9207
+ function* formatJsonReportChunks(results, config, meta, ruleIds, examined, skipped) {
9208
+ const report = buildJsonReport(results, config, meta, ruleIds, examined, skipped);
9209
+ const marker = JSON.stringify("\0routes");
9210
+ const text = JSON.stringify({
9211
+ ...report,
9212
+ routes: "\0routes"
9213
+ }, null, 2);
9214
+ const at = text.indexOf(marker);
9215
+ yield text.slice(0, at);
9216
+ if (report.routes.length === 0) yield "[]";
9217
+ else {
9218
+ yield "[\n";
9219
+ for (const [i, route] of report.routes.entries()) yield `${i > 0 ? ",\n" : ""} ${JSON.stringify(route, null, 2).replaceAll("\n", "\n ")}`;
9220
+ yield "\n ]";
9221
+ }
9222
+ yield text.slice(at + marker.length);
8157
9223
  }
8158
9224
  //#endregion
8159
9225
  //#region src/reporter/sanitize.ts
@@ -8299,4 +9365,4 @@ function formatMarkdownReport(results, config, meta) {
8299
9365
  return lines.join("\n");
8300
9366
  }
8301
9367
  //#endregion
8302
- export { SECTIONING_TAGS as $, formatFailedRuleWarning as $n, valueFromNodes as $t, a11yAbbrTitle as A, seoDescriptionLength as An, collectDerivedGuardBindings as At, a11yUseList as B, seoOgUrl as Bn, unwrapTs as Bt, a11yDisallowedElement as C, seoHeadingLevelSkip as Cn, summarize as Cr, parseKitModuleFacts as Ct, a11yDeprecatedElement as D, seoHreflang as Dn, defaultConfig as Dr, addBoundNames as Dt, a11yDeprecatedAttr as E, seoSingleH1 as En, CATEGORIES as Er, WALK_IGNORED_KEYS as Et, isInteractiveContainer as F, seoJsonLdRelativeUrl as Fn, matchLifecycleCall as Ft, a11yInvalidAriaValue as G, isPlainObject as Gn, attrText as Gt, a11yAccessibleName as H, seoTwitterCard as Hn, walkEvalScope as Ht, isInteractiveElement as I, seoJsonLdDeprecatedType as In, parseModuleProgram as It, a11yInvalidRole as J, validateRuleSetting as Jn, attrValueOf as Jt, a11yRequiredAriaProps as K, resolveRuleOptions as Kn, attrTextOf as Kt, literalTabindexValue as L, seoJsonLdValidity as Ln, rootObjectName as Lt, a11yNoAutofocus as M, seoJsonLdRequiredProps as Mn, collectProgramBindings as Mt, a11yNoAccesskey as N, seoJsonLdPlaceholder as Nn, collectSuppressions as Nt, HTML_SPEC as O, seoImageAlt as On, defaultProject as Or, collectBrowserGlobalRefs as Ot, a11yPositiveTabindex as P, seoJsonLdDateFormat as Pn, collectSvelteLifecycleImports as Pt, NAMING_ATTRS$1 as Q, compileOverrides as Qn, textFromNodes as Qt, a11yRequireDatetime as R, seoSitemapInRobots as Rn, scopeIntroducedNames as Rt, a11yPermittedContents as S, seoSsrDisabled as Sn, hasFailureAtOrAbove as Sr, parseInMemoryExports as St, a11yDisallowedAriaProps as T, seoDuplicateTitle as Tn, isPenalized as Tr, isRootRelativePath as Tt, a11yAriaHiddenFocus as U, seoIndexability as Un, walkScoped as Ut, a11yLabelHasControl as V, seoOgDescription as Vn, walkEstree as Vt, a11yInteractiveNesting as W, performancePreconnect as Wn, CHILD_NODE_KEYS as Wt, ASIDE_DEMOTING_TAGS as X, applyOverrides as Xn, lineOf as Xt, ANCESTRY_DEPENDENT_TAGS as Y, SEARCH_RESULT_RULES as Yn, findAttr as Yt, IDREF_ATTRS as Z, applyRuleSeverities as Zn, parseSvelte as Zt, a11yIdDuplication as _, correctnessUnmutatedState as _n, seoCanonicalUrl as _r, architectureReservedDirectoryNames as _t, formatJsonReport as a, securityJavascriptUrl as an, performanceLcpImage as ar, stripTextDirective as at, a11yDoctype as b, correctnessEachIndexKey as bn, classify as br, architecturePrivateScopeImport as bt, formatGithubReport as c, correctnessServerBrowserGlobal as cn, performanceResponsiveImage as cr, performanceStateRaw as ct, ruleMetaById as d, correctnessOrphanEffect as dn, seoHtmlLang as dr, performanceMinifyDisabled as dt, architecturePropCount as en, selectRules as er, decodeFragmentId as et, severityToSarifLevel as f, correctnessAutoplayMuted as fn, seoSitemapXml as fr, performanceNamespaceImport as ft, a11yNoMissingIdRef as g, correctnessPropMutation as gn, seoOgImage as gr, architectureReservedNamePlacement as gt, a11yUnverifiedIdRef as h, correctnessStalePropDerivation as hn, seoOgTitle as hr, architectureRouteComponentImport as ht, buildJsonReport as i, securityHandlerStateWrite as in, performanceRenderBlockingScript as ir, splitTokens as it, a11yNoDuplicateDt as j, seoTitleLength as jn, collectNamedImportAliases as jt, a11yPatternTitle as k, seoCharset as kn, defineConfig as kr, collectBrowserGuardImports as kt, SEVERITY_RANK as l, correctnessBasePathNavigation as ln, performanceImageLoadingHint as lr, performanceSequentialAwaits as lt, explainRule as m, correctnessNonreactiveBuiltinState as mn, seoJsonLd as mr, architectureDocLinkTarget as mt, mdEscape as n, securitySharedStateImport as nn, withFailedRulesOff as nr, isTopFragment as nt, computeHealth as o, securityRawHtml as on, performanceFontPreloadCrossorigin as or, resolveRole as ot, allRules as p, correctnessCheckableBindValue as pn, seoRobotsTxt as pr, performanceHeavyImport as pt, a11yUnknownAriaAttribute as q, shouldSkipRangeCheck as qn, attrValue as qt, terminalSafe as r, securityServerModuleState as rn, withIndexableOff as rr, resolveLandmark as rt, computeScore as s, correctnessInstanceBrowserGlobal as sn, performancePreloadMissingAs as sr, performanceIframeLoading as st, formatMarkdownReport as t, architectureComponentSize as tn, settingSeverity as tr, foldOccurrences as tt, messageText as u, correctnessOrphanLifecycle as un, performanceImageDimensions as ur, performanceLoadWaterfall as ut, a11yTopLevelLandmark as v, correctnessEffectAsOnMount as vn, seoDescriptionPresence as vr, architectureDirectoryNaming as vt, a11yDeprecatedAria as w, seoDuplicateDescription as wn, docsUrlFor as wr, resolveRepoLocalPath as wt, a11yRequiredElement as x, correctnessEachKey as xn, effectiveSeverity as xr, collectTopLevelBindings as xt, a11yDuplicateLandmark as y, correctnessEffectAsDerived as yn, seoTitlePresence as yr, architectureUnitEntryFile as yt, a11yPlaceholderLabelOption as z, seoViewport as zn, unwrapExport as zt };
9368
+ export { performanceLoadWaterfall as $, shouldSkipRangeCheck as $n, securityJavascriptUrl as $t, a11yPatternTitle as A, splitTokens as An, seoTitlePresence as Ar, parseModuleProgram as At, a11yPlaceholderLabelOption as B, seoJsonLdDateFormat as Bn, defineConfig as Br, attrTextOf as Bt, a11yPermittedContents as C, IDREF_ATTRS as Cn, seoCanonicalUrl as Cr, collectBrowserGuardImports as Ct, a11yDeprecatedAttr as D, foldOccurrences as Dn, layoutNeverRenders as Dr, collectSuppressions as Dt, a11yDisallowedAriaProps as E, decodeFragmentId as En, seoDescriptionPresence as Er, collectProgramBindings as Et, a11yPositiveTabindex as F, seoCharset as Fn, docsUrlFor as Fr, walkEstree as Ft, a11yInteractiveNesting as G, seoViewport as Gn, parseSvelte as Gt, a11yLabelHasControl as H, seoJsonLdDeprecatedType as Hn, attrValueOf as Ht, isInteractiveContainer as I, seoDescriptionLength as In, isPenalized as Ir, walkEvalScope as It, a11yUnknownAriaAttribute as J, seoTwitterCard as Jn, architecturePropCount as Jt, a11yInvalidAriaValue as K, seoOgUrl as Kn, textFromNodes as Kt, isInteractiveElement as L, seoTitleLength as Ln, CATEGORIES as Lr, walkScoped as Lt, a11yNoDuplicateDt as M, resolveRole as Mn, effectiveSeverity as Mr, scopeIntroducedNames as Mt, a11yNoAutofocus as N, seoHreflang as Nn, hasFailureAtOrAbove as Nr, unwrapExport as Nt, a11yDeprecatedElement as O, isTopFragment as On, pageRendersOnlyWhenBuilding as Or, collectSvelteLifecycleImports as Ot, a11yNoAccesskey as P, seoImageAlt as Pn, summarize as Pr, unwrapTs as Pt, performanceSequentialAwaits as Q, resolveRuleOptions as Qn, securityHandlerStateWrite as Qt, literalTabindexValue as R, seoJsonLdRequiredProps as Rn, defaultConfig as Rr, CHILD_NODE_KEYS as Rt, a11yRequiredElement as S, ASIDE_DEMOTING_TAGS as Sn, seoOgImage as Sr, collectBrowserGlobalRefs as St, a11yDeprecatedAria as T, SECTIONING_TAGS as Tn, isClassicScriptType as Tr, collectNamedImportAliases as Tt, a11yAccessibleName as U, seoJsonLdValidity as Un, findAttr as Ut, a11yUseList as V, seoJsonLdRelativeUrl as Vn, attrValue as Vt, a11yAriaHiddenFocus as W, seoSitemapInRobots as Wn, lineOf as Wt, performanceIframeLoading as X, performancePreconnect as Xn, securitySharedStateImport as Xt, a11yInvalidRole as Y, seoIndexability as Yn, architectureComponentSize as Yt, performanceStateRaw as Z, isPlainObject as Zn, securityServerModuleState as Zt, a11yNoMissingIdRef as _, seoHeadingLevelSkip as _n, seoHtmlLang as _r, parseKitModuleFacts as _t, formatJsonReport as a, correctnessOrphanEffect as an, formatFailedRuleWarning as ar, architectureReservedNamePlacement as at, a11yDuplicateLandmark as b, seoSingleH1 as bn, seoJsonLd as br, WALK_IGNORED_KEYS as bt, computeScore as c, correctnessNonreactiveBuiltinState as cn, withFailedRulesOff as cr, architectureUnitEntryFile as ct, messageText as d, correctnessUnmutatedState as dn, performanceLcpImage as dr, collectFalseExports as dt, securityRawHtml as en, validateRuleSetting as er, performanceMinifyDisabled as et, ruleMetaById as f, correctnessEffectAsOnMount as fn, performanceFontPreloadCrossorigin as fr, collectTopLevelBindings as ft, a11yUnverifiedIdRef as g, seoSsrDisabled as gn, performanceImageDimensions as gr, parseInMemoryExports as gt, explainRule as h, correctnessEachKey as hn, performanceImageLoadingHint as hr, parseFalseExports as ht, buildJsonReport as i, correctnessOrphanLifecycle as in, compileOverrides as ir, architectureRouteComponentImport as it, a11yAbbrTitle as j, stripTextDirective as jn, classify as jr, rootObjectName as jt, HTML_SPEC as k, resolveLandmark as kn, routeNeverSsr as kr, matchLifecycleCall as kt, formatGithubReport as l, correctnessStalePropDerivation as ln, withIndexableOff as lr, architecturePrivateScopeImport as lt, allRules as m, correctnessEachIndexKey as mn, performanceResponsiveImage as mr, parseExitFactoryExports as mt, mdEscape as n, correctnessServerBrowserGlobal as nn, applyOverrides as nr, performanceHeavyImport as nt, formatJsonReportChunks as o, correctnessAutoplayMuted as on, selectRules as or, architectureReservedDirectoryNames as ot, severityToSarifLevel as p, correctnessEffectAsDerived as pn, performancePreloadMissingAs as pr, parseClientExports as pt, a11yRequiredAriaProps as q, seoOgDescription as qn, valueFromNodes as qt, terminalSafe as r, correctnessBasePathNavigation as rn, applyRuleSeverities as rr, architectureDocLinkTarget as rt, computeHealth as s, correctnessCheckableBindValue as sn, settingSeverity as sr, architectureDirectoryNaming as st, formatMarkdownReport as t, correctnessInstanceBrowserGlobal as tn, SEARCH_RESULT_RULES as tr, performanceNamespaceImport as tt, SEVERITY_RANK as u, correctnessPropMutation as un, performanceRenderBlockingScript as ur, DEFAULT_KIT_ALIASES as ut, a11yIdDuplication as v, seoDuplicateDescription as vn, seoSitemapXml as vr, resolveRepoLocalPath as vt, a11yDisallowedElement as w, NAMING_ATTRS$1 as wn, hasRel as wr, collectDerivedGuardBindings as wt, a11yDoctype as x, ANCESTRY_DEPENDENT_TAGS as xn, seoOgTitle as xr, addBoundNames as xt, a11yTopLevelLandmark as y, seoDuplicateTitle as yn, seoRobotsTxt as yr, isRootRelativePath as yt, a11yRequireDatetime as z, seoJsonLdPlaceholder as zn, defaultProject as zr, attrText as zt };