@svelte-vitals/core 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +31 -9
- package/dist/index.js +298 -103
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -309,6 +309,13 @@ interface HeadingInfo {
|
|
|
309
309
|
interface ResolvedHeadings {
|
|
310
310
|
route: string;
|
|
311
311
|
headings: HeadingInfo[];
|
|
312
|
+
/**
|
|
313
|
+
* Headings found in child components rendered (transitively) by this route's
|
|
314
|
+
* chain files — source mode only; absent in rendered mode. Kept separate from
|
|
315
|
+
* `headings` because their position in document order is unknown: safe for
|
|
316
|
+
* counting (seo/single-h1), unusable for outline order (seo/heading-level-skip).
|
|
317
|
+
*/
|
|
318
|
+
componentHeadings?: HeadingInfo[];
|
|
312
319
|
}
|
|
313
320
|
|
|
314
321
|
/**
|
|
@@ -971,7 +978,7 @@ interface Rule {
|
|
|
971
978
|
category: Category;
|
|
972
979
|
/** Default severity (overridable by config in later slices). */
|
|
973
980
|
severity: Severity;
|
|
974
|
-
/** 'route' = evaluated per route, 'project' = site-wide (design §10, §12). */
|
|
981
|
+
/** 'route' = evaluated per route, 'project' = site-wide, 'component' = evaluated per source file (design §10, §12). */
|
|
975
982
|
scope: Scope;
|
|
976
983
|
/** Why this rule matters — one or two sentences, surfaced by `svelte-vitals explain` (issue #24). */
|
|
977
984
|
rationale: string;
|
|
@@ -1119,9 +1126,15 @@ declare const seoHreflang: Rule;
|
|
|
1119
1126
|
|
|
1120
1127
|
/**
|
|
1121
1128
|
* seo/single-h1 — Heading hierarchy (single H1). Reads the per-route page-body headings
|
|
1122
|
-
* channel (collected by both providers)
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1129
|
+
* channel (collected by both providers), counting `headings` plus `componentHeadings`
|
|
1130
|
+
* (static mode only — headings found transitively in rendered child components) as one
|
|
1131
|
+
* combined list. Zero <h1> (no primary heading) is a `warning`: defensible, a page needs
|
|
1132
|
+
* a primary heading. Two or more is only `info`: a single <h1> is the conventional
|
|
1133
|
+
* signal, but no official source documents a ranking penalty for several (2026-08-09 v1
|
|
1134
|
+
* rule-validity review, P2 #11) — so it's flagged as a style nit, not a defect. Exactly
|
|
1135
|
+
* one passes. A route whose headings were not collected (channel unset) emits nothing. A
|
|
1136
|
+
* global `rules: { 'seo/single-h1': <severity> }` override flattens both arms to one
|
|
1137
|
+
* severity (design, `applyRuleSeverities`).
|
|
1125
1138
|
*/
|
|
1126
1139
|
declare const seoSingleH1: Rule;
|
|
1127
1140
|
|
|
@@ -1179,8 +1192,9 @@ declare const correctnessNonreactiveBuiltinState: Rule;
|
|
|
1179
1192
|
|
|
1180
1193
|
/**
|
|
1181
1194
|
* correctness/checkable-bind-value — bind:value binds the DOM value property. A
|
|
1182
|
-
* checkbox/radio's user interaction toggles checkedness, which bind:value never observes
|
|
1183
|
-
*
|
|
1195
|
+
* checkbox/radio's user interaction toggles checkedness, which bind:value never observes. A
|
|
1196
|
+
* checkbox throws bind_invalid_checkbox_value in dev (silently tracks value instead of
|
|
1197
|
+
* checkedness in prod); a radio throws nothing and its bound state silently never updates.
|
|
1184
1198
|
* bind:checked (single checkbox) / bind:group (checkbox list, radio group) are the correct
|
|
1185
1199
|
* bindings.
|
|
1186
1200
|
*/
|
|
@@ -1268,11 +1282,19 @@ declare const architectureDirectoryNaming: Rule;
|
|
|
1268
1282
|
|
|
1269
1283
|
/**
|
|
1270
1284
|
* architecture/reserved-directory-names — a directory's immediate subdirectories may only take names
|
|
1271
|
-
* the project declared for that position (design 2026-07-29
|
|
1285
|
+
* the project declared for that position (design 2026-07-29, extended 2026-08-08 for lowercase units —
|
|
1286
|
+
* issue #386).
|
|
1272
1287
|
*
|
|
1273
|
-
*
|
|
1288
|
+
* The option maps differ in what their keys name. A `scopes` key names the parent directly. A
|
|
1274
1289
|
* `unitScopes` key names a root, and the rule governs the children of whichever directories beneath
|
|
1275
|
-
* it are units — the shape a glob cannot reach, because units nest to arbitrary
|
|
1290
|
+
* it are units whose name begins A–Z — the shape a glob cannot reach, because units nest to arbitrary
|
|
1291
|
+
* depth. An `anyCaseUnitScopes` key names a root the same way, but governs units of *either* case:
|
|
1292
|
+
* `isUnitDir`'s letter test — A–Z plus a same-stemmed entry file, whatever its extension — excludes a
|
|
1293
|
+
* lowercase unit, so without this map no generic unit-map declaration governed one's children (a
|
|
1294
|
+
* `scopes` key naming the parent directly could still reach one) — measured at 129 of 299 units (43%)
|
|
1295
|
+
* on a real tree. Neither map is named with the bare word "unit": the
|
|
1296
|
+
* sibling rule `architecture/reserved-name-placement` records why that word alone is ambiguous between
|
|
1297
|
+
* the two predicates once both exist.
|
|
1276
1298
|
*
|
|
1277
1299
|
* There are no pass results. `computeScore` seeds every distinct `route` at 100 and averages, and the
|
|
1278
1300
|
* subject here is a directory with no pre-existing score key, so a pass per directory would add
|
package/dist/index.js
CHANGED
|
@@ -94,7 +94,7 @@ function attrTextOf(attr) {
|
|
|
94
94
|
// src/component-parse.ts
|
|
95
95
|
function unwrapTs(expr) {
|
|
96
96
|
let cur = expr;
|
|
97
|
-
while (cur.type === "TSSatisfiesExpression" || cur.type === "TSAsExpression" || cur.type === "TSNonNullExpression")
|
|
97
|
+
while (cur !== void 0 && (cur.type === "TSSatisfiesExpression" || cur.type === "TSAsExpression" || cur.type === "TSNonNullExpression"))
|
|
98
98
|
cur = cur.expression;
|
|
99
99
|
return cur;
|
|
100
100
|
}
|
|
@@ -623,6 +623,21 @@ function collectDirectiveEscapes(node, names, acc) {
|
|
|
623
623
|
}
|
|
624
624
|
}
|
|
625
625
|
var RUNE_NAMES = /* @__PURE__ */ new Set(["$state", "$derived", "$effect", "$props", "$bindable", "$inspect", "$host"]);
|
|
626
|
+
function collectImportedLocalNames(program, acc) {
|
|
627
|
+
for (const stmt of program?.body ?? []) {
|
|
628
|
+
if (stmt?.type !== "ImportDeclaration" || stmt.importKind === "type") continue;
|
|
629
|
+
for (const s of stmt.specifiers ?? []) {
|
|
630
|
+
if (s?.importKind === "type" || s?.local?.type !== "Identifier") continue;
|
|
631
|
+
acc.add(s.local.name);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function collectNewExprLocalNames(program, acc) {
|
|
636
|
+
walkEstree(program, (n) => {
|
|
637
|
+
if (n.type !== "VariableDeclarator" || n.id?.type !== "Identifier" || !n.init) return;
|
|
638
|
+
if (unwrapTs(n.init).type === "NewExpression") acc.add(n.id.name);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
626
641
|
function bodyReadsReactive(fn, reactiveNames) {
|
|
627
642
|
let reads = false;
|
|
628
643
|
const visit = (n) => {
|
|
@@ -826,7 +841,7 @@ function countProps(program) {
|
|
|
826
841
|
if (n.type !== "VariableDeclarator" || !n.init || !isPropsCall(unwrapTs(n.init))) return;
|
|
827
842
|
seen++;
|
|
828
843
|
const props = n.id?.type === "ObjectPattern" ? n.id.properties : void 0;
|
|
829
|
-
if (!Array.isArray(props)
|
|
844
|
+
if (!Array.isArray(props)) {
|
|
830
845
|
uncountable = true;
|
|
831
846
|
return;
|
|
832
847
|
}
|
|
@@ -1398,6 +1413,12 @@ function parseComponentFacts(source, filename) {
|
|
|
1398
1413
|
}
|
|
1399
1414
|
const stateNames = /* @__PURE__ */ new Set();
|
|
1400
1415
|
const reactiveNames = /* @__PURE__ */ new Set();
|
|
1416
|
+
collectImportedLocalNames(program, reactiveNames);
|
|
1417
|
+
collectNewExprLocalNames(program, reactiveNames);
|
|
1418
|
+
if (moduleProgram) {
|
|
1419
|
+
collectImportedLocalNames(moduleProgram, reactiveNames);
|
|
1420
|
+
collectNewExprLocalNames(moduleProgram, reactiveNames);
|
|
1421
|
+
}
|
|
1401
1422
|
const stateDecls = [];
|
|
1402
1423
|
walkEstree(program, (n) => {
|
|
1403
1424
|
if (n.type !== "VariableDeclarator" || !n.init) return;
|
|
@@ -2536,7 +2557,7 @@ function headTagRule(opts) {
|
|
|
2536
2557
|
var seoDescriptionPresence = headTagRule({
|
|
2537
2558
|
id: "seo/description-presence",
|
|
2538
2559
|
title: "Description presence",
|
|
2539
|
-
severity: "
|
|
2560
|
+
severity: "warning",
|
|
2540
2561
|
match: (t) => t.kind === "meta" && t.name === "description",
|
|
2541
2562
|
label: '<meta name="description">',
|
|
2542
2563
|
recommendation: 'Add a <meta name="description"> in <svelte:head>, or set the description on your meta component.',
|
|
@@ -2556,7 +2577,7 @@ var seoCanonicalUrl = headTagRule({
|
|
|
2556
2577
|
match: (t) => t.kind === "link" && t.rel === "canonical",
|
|
2557
2578
|
label: '<link rel="canonical">',
|
|
2558
2579
|
recommendation: 'Add <link rel="canonical"> in <svelte:head>, or set the canonical prop on your meta component.',
|
|
2559
|
-
rationale: "A canonical URL tells search engines which URL is authoritative, preventing duplicate-content dilution across query
|
|
2580
|
+
rationale: "A canonical URL tells search engines which URL is authoritative, preventing duplicate-content dilution across query-string variants of the same page.",
|
|
2560
2581
|
fix: {
|
|
2561
2582
|
description: 'Add <link rel="canonical"> inside <svelte:head>, or set the canonical prop on your meta component.',
|
|
2562
2583
|
snippet: '<svelte:head>\n <link rel="canonical" href="https://example.com/this-page" />\n</svelte:head>',
|
|
@@ -2693,7 +2714,7 @@ var seoHtmlLang = {
|
|
|
2693
2714
|
category: "seo",
|
|
2694
2715
|
severity: "warning",
|
|
2695
2716
|
scope: "project",
|
|
2696
|
-
rationale: "The <html lang> attribute
|
|
2717
|
+
rationale: "The <html lang> attribute tells screen readers how to pronounce the page, browsers whether to offer translation, and other assistive tools how to handle the content \u2014 Google has said it does not use lang for ranking.",
|
|
2697
2718
|
fix: FIX4,
|
|
2698
2719
|
async check(ctx) {
|
|
2699
2720
|
const detection = ctx.project.htmlLang;
|
|
@@ -2737,6 +2758,13 @@ function imageRule(opts) {
|
|
|
2737
2758
|
severity: opts.severity,
|
|
2738
2759
|
detection: { presence: "own", value: "static" },
|
|
2739
2760
|
route: route.route,
|
|
2761
|
+
// No single route-level file exists here (unlike ResolvedHead.file) — the
|
|
2762
|
+
// route's first image stands in as its attributed file (design
|
|
2763
|
+
// 2026-08-08-pass-result-location-design.md; this uncaught inline PASS literal
|
|
2764
|
+
// was missed by the design spike's grep and added to its blast-radius table
|
|
2765
|
+
// afterward, maintainer ruling, same date). `route.images.length === 0` already
|
|
2766
|
+
// continued above, so `[0]` is always defined here.
|
|
2767
|
+
location: route.images[0].file,
|
|
2740
2768
|
message: opts.label,
|
|
2741
2769
|
recommendation: opts.recommendation,
|
|
2742
2770
|
docsUrl: docsUrl12
|
|
@@ -2771,7 +2799,7 @@ var performanceImageDimensions = imageRule({
|
|
|
2771
2799
|
severity: "warning",
|
|
2772
2800
|
label: "<img> width/height",
|
|
2773
2801
|
recommendation: "Set explicit width and height on <img> to reserve space and avoid layout shift (CLS).",
|
|
2774
|
-
rationale: "An <img> without explicit width and height
|
|
2802
|
+
rationale: "An <img> without explicit width and height can trigger layout shift (CLS) as it loads, hurting Core Web Vitals and visual stability \u2014 unless the box is reserved another way, e.g. CSS aspect-ratio.",
|
|
2775
2803
|
fix: {
|
|
2776
2804
|
description: "Add explicit width and height attributes to the <img>.",
|
|
2777
2805
|
snippet: '<img src="/hero.jpg" width="1200" height="630" alt="\u2026" />',
|
|
@@ -2836,6 +2864,13 @@ function linkRule(opts) {
|
|
|
2836
2864
|
severity: opts.severity,
|
|
2837
2865
|
detection: { presence: "own", value: "static" },
|
|
2838
2866
|
route: head.route,
|
|
2867
|
+
// The route's own attributed file (design 2026-08-08-pass-result-location-design.md)
|
|
2868
|
+
// — this uncaught inline PASS literal was missed by the design spike's grep and
|
|
2869
|
+
// added to its blast-radius table afterward (maintainer ruling, same date). No
|
|
2870
|
+
// single per-tag location applies here (many links can back one pass), so the
|
|
2871
|
+
// route's own head file is the uniform attribution; per-tag penalized locations
|
|
2872
|
+
// above remain per-tag.
|
|
2873
|
+
location: head.file,
|
|
2839
2874
|
message: opts.label,
|
|
2840
2875
|
recommendation: opts.recommendation,
|
|
2841
2876
|
docsUrl: docsUrl12
|
|
@@ -2938,6 +2973,11 @@ var performanceLcpImage = {
|
|
|
2938
2973
|
severity: "warning",
|
|
2939
2974
|
detection: { presence: "own", value: "static" },
|
|
2940
2975
|
route: route.route,
|
|
2976
|
+
// Same `location` the penalized branch above uses (design
|
|
2977
|
+
// 2026-08-08-pass-result-location-design.md) — this uncaught inline PASS literal
|
|
2978
|
+
// was missed by the design spike's grep and added to its blast-radius table
|
|
2979
|
+
// afterward (maintainer ruling, same date).
|
|
2980
|
+
location: first.file,
|
|
2941
2981
|
message: "LCP image eager loading",
|
|
2942
2982
|
recommendation,
|
|
2943
2983
|
docsUrl
|
|
@@ -2992,6 +3032,10 @@ var performanceRenderBlockingScript = {
|
|
|
2992
3032
|
severity: "warning",
|
|
2993
3033
|
detection: { presence: "own", value: "static" },
|
|
2994
3034
|
route: head.route,
|
|
3035
|
+
// The route's own attributed file (design 2026-08-08-pass-result-location-design.md)
|
|
3036
|
+
// — this uncaught inline PASS literal was missed by the design spike's grep and
|
|
3037
|
+
// added to its blast-radius table afterward (maintainer ruling, same date).
|
|
3038
|
+
location: head.file,
|
|
2995
3039
|
message: "No render-blocking scripts",
|
|
2996
3040
|
recommendation: recommendation2,
|
|
2997
3041
|
docsUrl: docsUrl2
|
|
@@ -3210,7 +3254,7 @@ var performancePreconnect = {
|
|
|
3210
3254
|
rationale: "Connecting to a third-party origin (DNS + TCP + TLS) is costly; a preconnect/dns-prefetch hint starts it early so the resource arrives sooner.",
|
|
3211
3255
|
fix: {
|
|
3212
3256
|
description: "Add a preconnect hint for the third-party origin.",
|
|
3213
|
-
snippet: '<link rel="preconnect" href="https://fonts.googleapis.com" />',
|
|
3257
|
+
snippet: '<link rel="preconnect" href="https://fonts.googleapis.com" />\n<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />',
|
|
3214
3258
|
lang: "html"
|
|
3215
3259
|
},
|
|
3216
3260
|
options: OPTIONS,
|
|
@@ -3247,6 +3291,10 @@ var performancePreconnect = {
|
|
|
3247
3291
|
severity: "info",
|
|
3248
3292
|
detection: { presence: "own", value: "static" },
|
|
3249
3293
|
route: head.route,
|
|
3294
|
+
// head.file — the same target used to resolve options above (design
|
|
3295
|
+
// 2026-08-08-pass-result-location-design.md) — so a `files:`-scoped override can
|
|
3296
|
+
// also match this passing seed via `severity: 'off'`.
|
|
3297
|
+
location: head.file,
|
|
3250
3298
|
message: "Third-party origins are preconnected",
|
|
3251
3299
|
recommendation: recommendation3,
|
|
3252
3300
|
docsUrl: docsUrl3
|
|
@@ -3330,11 +3378,11 @@ var seoTwitterCard = headTagRule({
|
|
|
3330
3378
|
var seoOgDescription = headTagRule({
|
|
3331
3379
|
id: "seo/og-description",
|
|
3332
3380
|
title: "Open Graph description",
|
|
3333
|
-
severity: "
|
|
3381
|
+
severity: "info",
|
|
3334
3382
|
match: (t) => t.kind === "meta" && t.property === "og:description",
|
|
3335
3383
|
label: '<meta property="og:description">',
|
|
3336
3384
|
recommendation: 'Add <meta property="og:description">, or set openGraph.description on your meta component.',
|
|
3337
|
-
rationale: "og:description is the summary shown under the title in social previews; without it platforms guess or show nothing, lowering click-through.",
|
|
3385
|
+
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.",
|
|
3338
3386
|
fix: {
|
|
3339
3387
|
description: "Add an og:description meta tag in <svelte:head>.",
|
|
3340
3388
|
snippet: '<svelte:head>\n <meta property="og:description" content="A concise page summary." />\n</svelte:head>',
|
|
@@ -3346,11 +3394,11 @@ var seoOgDescription = headTagRule({
|
|
|
3346
3394
|
var seoOgUrl = headTagRule({
|
|
3347
3395
|
id: "seo/og-url",
|
|
3348
3396
|
title: "Open Graph URL",
|
|
3349
|
-
severity: "
|
|
3397
|
+
severity: "warning",
|
|
3350
3398
|
match: (t) => t.kind === "meta" && t.property === "og:url",
|
|
3351
3399
|
label: '<meta property="og:url">',
|
|
3352
3400
|
recommendation: 'Add <meta property="og:url"> with the canonical URL, or set openGraph.url on your meta component.',
|
|
3353
|
-
rationale: "og:url tells social platforms the canonical address to attribute shares and likes to, consolidating engagement on one URL.",
|
|
3401
|
+
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.",
|
|
3354
3402
|
fix: {
|
|
3355
3403
|
description: "Add an og:url meta tag in <svelte:head>.",
|
|
3356
3404
|
snippet: '<svelte:head>\n <meta property="og:url" content="https://example.com/this-page" />\n</svelte:head>',
|
|
@@ -3370,7 +3418,7 @@ var seoViewport = headTagRule({
|
|
|
3370
3418
|
// silent there instead of false-flagging "missing" on every route.
|
|
3371
3419
|
appliesTo: (head) => head.source === "rendered",
|
|
3372
3420
|
recommendation: 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> (usually in app.html).',
|
|
3373
|
-
rationale: "Without a viewport meta tag the page
|
|
3421
|
+
rationale: "Without a viewport meta tag mobile browsers render the page at a fixed ~980px layout viewport and scale it to fit, so text and controls end up too small to read or tap without pinch-zooming.",
|
|
3374
3422
|
fix: {
|
|
3375
3423
|
description: "Add the viewport meta tag (typically in src/app.html <head>).",
|
|
3376
3424
|
snippet: '<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
@@ -3535,19 +3583,25 @@ function hasNonEmpty(node, key) {
|
|
|
3535
3583
|
return true;
|
|
3536
3584
|
}
|
|
3537
3585
|
var REQUIRED_PROPS = {
|
|
3538
|
-
|
|
3539
|
-
BlogPosting: ["headline"],
|
|
3540
|
-
NewsArticle: ["headline"],
|
|
3541
|
-
Product: ["name", "offers"],
|
|
3586
|
+
Product: { all: ["name"], oneOf: ["review", "aggregateRating", "offers"] },
|
|
3542
3587
|
BreadcrumbList: ["itemListElement"],
|
|
3543
|
-
Organization: ["name", "url"],
|
|
3544
3588
|
WebSite: ["name", "url"],
|
|
3545
3589
|
Event: ["name", "startDate", "location"],
|
|
3546
|
-
Recipe: ["name", "image"
|
|
3547
|
-
|
|
3548
|
-
VideoObject: ["name", "description", "thumbnailUrl", "uploadDate"],
|
|
3590
|
+
Recipe: ["name", "image"],
|
|
3591
|
+
VideoObject: ["name", "thumbnailUrl", "uploadDate"],
|
|
3549
3592
|
LocalBusiness: ["name", "address"]
|
|
3550
3593
|
};
|
|
3594
|
+
function oneOfLabel(props) {
|
|
3595
|
+
const last = props.at(-1) ?? "";
|
|
3596
|
+
return props.length <= 1 ? last : `one of ${props.slice(0, -1).join(", ")} or ${last}`;
|
|
3597
|
+
}
|
|
3598
|
+
function missingRequiredProps(node, row) {
|
|
3599
|
+
const all = Array.isArray(row) ? row : row.all;
|
|
3600
|
+
const missing = all.filter((p) => !hasNonEmpty(node, p));
|
|
3601
|
+
const oneOf = Array.isArray(row) ? void 0 : row.oneOf;
|
|
3602
|
+
if (oneOf && !oneOf.some((p) => hasNonEmpty(node, p))) missing.push(oneOfLabel(oneOf));
|
|
3603
|
+
return missing;
|
|
3604
|
+
}
|
|
3551
3605
|
function jsonldTags(head) {
|
|
3552
3606
|
return head.tags.filter((t) => t.kind === "jsonld" && typeof t.jsonld === "string");
|
|
3553
3607
|
}
|
|
@@ -3588,6 +3642,9 @@ function jsonldRule(opts) {
|
|
|
3588
3642
|
severity: opts.severity,
|
|
3589
3643
|
detection: PASS,
|
|
3590
3644
|
route: head.route,
|
|
3645
|
+
// Same `location` the penalized branch above uses (design
|
|
3646
|
+
// 2026-08-08-pass-result-location-design.md).
|
|
3647
|
+
location: head.file,
|
|
3591
3648
|
message: opts.label,
|
|
3592
3649
|
recommendation: opts.recommendation,
|
|
3593
3650
|
docsUrl: docsUrl12
|
|
@@ -3641,6 +3698,9 @@ var seoJsonLdValidity = {
|
|
|
3641
3698
|
severity: "warning",
|
|
3642
3699
|
detection: PASS,
|
|
3643
3700
|
route: head.route,
|
|
3701
|
+
// Same `location` the penalized branch above uses (design
|
|
3702
|
+
// 2026-08-08-pass-result-location-design.md).
|
|
3703
|
+
location: head.file,
|
|
3644
3704
|
message: "JSON-LD validity",
|
|
3645
3705
|
recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
|
|
3646
3706
|
docsUrl: docsUrl12
|
|
@@ -3733,7 +3793,7 @@ var seoJsonLdRequiredProps = jsonldRule({
|
|
|
3733
3793
|
const required = REQUIRED_PROPS[t];
|
|
3734
3794
|
if (!required) continue;
|
|
3735
3795
|
hasKnownType = true;
|
|
3736
|
-
const missing =
|
|
3796
|
+
const missing = missingRequiredProps(node, required);
|
|
3737
3797
|
if (missing.length > 0) return `${t} JSON-LD is missing required ${missing.join(", ")}`;
|
|
3738
3798
|
}
|
|
3739
3799
|
}
|
|
@@ -3775,7 +3835,7 @@ function lengthRule(opts) {
|
|
|
3775
3835
|
const o = resolveRuleOptions(opts.id, spec, ctx.config, { route: head.route, file: location }, compiled);
|
|
3776
3836
|
const min = intOption(o, "min", opts.min);
|
|
3777
3837
|
const max = intOption(o, "max", opts.max);
|
|
3778
|
-
const
|
|
3838
|
+
const recommendation10 = typeof opts.recommendation === "function" ? opts.recommendation(o) : opts.recommendation;
|
|
3779
3839
|
const len = visibleLength(tag.text);
|
|
3780
3840
|
let problem;
|
|
3781
3841
|
if (len < min) problem = `${opts.noun} is too short (${len} chars; aim for ${min}\u2013${max})`;
|
|
@@ -3789,7 +3849,7 @@ function lengthRule(opts) {
|
|
|
3789
3849
|
route: head.route,
|
|
3790
3850
|
location,
|
|
3791
3851
|
message: problem,
|
|
3792
|
-
recommendation:
|
|
3852
|
+
recommendation: recommendation10,
|
|
3793
3853
|
docsUrl: docsUrl12
|
|
3794
3854
|
} : {
|
|
3795
3855
|
id: opts.id,
|
|
@@ -3797,8 +3857,13 @@ function lengthRule(opts) {
|
|
|
3797
3857
|
severity: "info",
|
|
3798
3858
|
detection: PASS,
|
|
3799
3859
|
route: head.route,
|
|
3860
|
+
// Same `location` the penalized branch above uses (design
|
|
3861
|
+
// 2026-08-08-pass-result-location-design.md) — without it, a `files:`-scoped
|
|
3862
|
+
// override can flip this result to PASS via its `options` but can never match
|
|
3863
|
+
// it to also apply `severity: 'off'`.
|
|
3864
|
+
location,
|
|
3800
3865
|
message: opts.label,
|
|
3801
|
-
recommendation:
|
|
3866
|
+
recommendation: recommendation10,
|
|
3802
3867
|
docsUrl: docsUrl12
|
|
3803
3868
|
}
|
|
3804
3869
|
);
|
|
@@ -3866,7 +3931,7 @@ var seoImageAlt = imageRule({
|
|
|
3866
3931
|
rationale: "An <img> with no alt attribute is invisible to image search and assistive technology; a descriptive alt is an image-SEO signal.",
|
|
3867
3932
|
fix: {
|
|
3868
3933
|
description: 'Add a descriptive alt attribute to the <img> (or alt="" if purely decorative).',
|
|
3869
|
-
snippet: '<img src="/photo.jpg" width="800" height="600" alt="
|
|
3934
|
+
snippet: '<img src="/photo.jpg" width="800" height="600" alt="Golden retriever catching a frisbee in a park" />',
|
|
3870
3935
|
lang: "svelte"
|
|
3871
3936
|
},
|
|
3872
3937
|
ok: (img) => img.hasAlt
|
|
@@ -3874,7 +3939,9 @@ var seoImageAlt = imageRule({
|
|
|
3874
3939
|
|
|
3875
3940
|
// src/rules/seo/hreflang.ts
|
|
3876
3941
|
var docsUrl4 = docsUrlFor("seo/hreflang");
|
|
3877
|
-
var
|
|
3942
|
+
var malformedRecommendation = 'Use valid hreflang codes, e.g. "en", "en-US", or the literal "x-default".';
|
|
3943
|
+
var noDefaultRecommendation = "x-default is a Google recommendation, not a requirement \u2014 most useful for a language-selector or auto-redirecting page. Add one if this page behaves that way; otherwise search engines fall back to matching the individual alternates.";
|
|
3944
|
+
var passRecommendation = 'Use valid hreflang codes (e.g. "en", "en-US", "x-default") for your language alternates.';
|
|
3878
3945
|
var HREFLANG_RE = /^[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|\d{3}))?$/i;
|
|
3879
3946
|
function isValidHreflang(v) {
|
|
3880
3947
|
return v.toLowerCase() === "x-default" || HREFLANG_RE.test(v);
|
|
@@ -3885,7 +3952,7 @@ var seoHreflang = {
|
|
|
3885
3952
|
category: "seo",
|
|
3886
3953
|
severity: "warning",
|
|
3887
3954
|
scope: "route",
|
|
3888
|
-
rationale: "A malformed hreflang code
|
|
3955
|
+
rationale: "A malformed hreflang code breaks international targeting outright. A missing x-default is a Google recommendation for language-selector or auto-redirecting pages, not a defect on every multilingual site.",
|
|
3889
3956
|
async check(ctx) {
|
|
3890
3957
|
const out = [];
|
|
3891
3958
|
for (const head of ctx.heads) {
|
|
@@ -3898,10 +3965,13 @@ var seoHreflang = {
|
|
|
3898
3965
|
let problem;
|
|
3899
3966
|
let location = head.file;
|
|
3900
3967
|
if (badTag) {
|
|
3901
|
-
problem = `Invalid hreflang value "${badTag.hreflang}"
|
|
3968
|
+
problem = { message: `Invalid hreflang value "${badTag.hreflang}"`, recommendation: malformedRecommendation };
|
|
3902
3969
|
location = badTag.file ?? head.file;
|
|
3903
3970
|
} else if (values.length >= 2 && !values.some((v) => v.toLowerCase() === "x-default")) {
|
|
3904
|
-
problem =
|
|
3971
|
+
problem = {
|
|
3972
|
+
message: "Multiple hreflang alternates with no x-default declared",
|
|
3973
|
+
recommendation: noDefaultRecommendation
|
|
3974
|
+
};
|
|
3905
3975
|
}
|
|
3906
3976
|
out.push(
|
|
3907
3977
|
problem ? {
|
|
@@ -3911,8 +3981,8 @@ var seoHreflang = {
|
|
|
3911
3981
|
detection: PENALIZED,
|
|
3912
3982
|
route: head.route,
|
|
3913
3983
|
location,
|
|
3914
|
-
message: problem,
|
|
3915
|
-
recommendation:
|
|
3984
|
+
message: problem.message,
|
|
3985
|
+
recommendation: problem.recommendation,
|
|
3916
3986
|
docsUrl: docsUrl4
|
|
3917
3987
|
} : {
|
|
3918
3988
|
id: "seo/hreflang",
|
|
@@ -3920,8 +3990,11 @@ var seoHreflang = {
|
|
|
3920
3990
|
severity: "warning",
|
|
3921
3991
|
detection: PASS,
|
|
3922
3992
|
route: head.route,
|
|
3993
|
+
// Same `location` the penalized branch above uses (design
|
|
3994
|
+
// 2026-08-08-pass-result-location-design.md).
|
|
3995
|
+
location,
|
|
3923
3996
|
message: "hreflang",
|
|
3924
|
-
recommendation:
|
|
3997
|
+
recommendation: passRecommendation,
|
|
3925
3998
|
docsUrl: docsUrl4
|
|
3926
3999
|
}
|
|
3927
4000
|
);
|
|
@@ -3932,26 +4005,33 @@ var seoHreflang = {
|
|
|
3932
4005
|
|
|
3933
4006
|
// src/rules/seo/single-h1.ts
|
|
3934
4007
|
var docsUrl5 = docsUrlFor("seo/single-h1");
|
|
3935
|
-
var
|
|
4008
|
+
var passRecommendation2 = "Use exactly one <h1> per page for its main topic; demote extra top-level headings to <h2>+.";
|
|
4009
|
+
var missingRecommendation = "Add a single, descriptive <h1> naming the page's main topic.";
|
|
4010
|
+
var multipleRecommendation = "A single, clear <h1> is the conventional signal for a page \u2014 consider demoting extra top-level headings to <h2>+.";
|
|
3936
4011
|
var seoSingleH1 = {
|
|
3937
4012
|
id: "seo/single-h1",
|
|
3938
4013
|
title: "Heading hierarchy",
|
|
3939
4014
|
category: "seo",
|
|
3940
4015
|
severity: "warning",
|
|
3941
4016
|
scope: "route",
|
|
3942
|
-
rationale: "
|
|
4017
|
+
rationale: "A page should have a primary heading naming its main topic. Zero <h1> leaves the page without one; a single, clear <h1> is the conventional signal, though multiple <h1>s are tolerated by modern heading algorithms.",
|
|
3943
4018
|
async check(ctx) {
|
|
3944
4019
|
const out = [];
|
|
3945
4020
|
for (const route of ctx.headings ?? []) {
|
|
3946
|
-
const
|
|
4021
|
+
const combined = [...route.headings, ...route.componentHeadings ?? []];
|
|
4022
|
+
const h1 = combined.filter((h) => h.level === 1);
|
|
3947
4023
|
let problem;
|
|
3948
4024
|
let where = {};
|
|
3949
4025
|
if (h1.length === 0) {
|
|
3950
|
-
problem = "Missing <h1>";
|
|
4026
|
+
problem = { message: "Missing <h1>", severity: "warning", recommendation: missingRecommendation };
|
|
3951
4027
|
const first = route.headings[0];
|
|
3952
4028
|
if (first) where = { location: first.file, ...first.line > 0 ? { line: first.line } : {} };
|
|
3953
4029
|
} else if (h1.length > 1) {
|
|
3954
|
-
problem =
|
|
4030
|
+
problem = {
|
|
4031
|
+
message: `Multiple <h1> (${h1.length}); a single <h1> is the conventional signal`,
|
|
4032
|
+
severity: "info",
|
|
4033
|
+
recommendation: multipleRecommendation
|
|
4034
|
+
};
|
|
3955
4035
|
const extra = h1[1];
|
|
3956
4036
|
where = { location: extra.file, ...extra.line > 0 ? { line: extra.line } : {} };
|
|
3957
4037
|
}
|
|
@@ -3959,12 +4039,12 @@ var seoSingleH1 = {
|
|
|
3959
4039
|
problem ? {
|
|
3960
4040
|
id: "seo/single-h1",
|
|
3961
4041
|
category: "seo",
|
|
3962
|
-
severity:
|
|
4042
|
+
severity: problem.severity,
|
|
3963
4043
|
detection: PENALIZED,
|
|
3964
4044
|
route: route.route,
|
|
3965
4045
|
...where,
|
|
3966
|
-
message: problem,
|
|
3967
|
-
recommendation:
|
|
4046
|
+
message: problem.message,
|
|
4047
|
+
recommendation: problem.recommendation,
|
|
3968
4048
|
docsUrl: docsUrl5
|
|
3969
4049
|
} : {
|
|
3970
4050
|
id: "seo/single-h1",
|
|
@@ -3972,8 +4052,12 @@ var seoSingleH1 = {
|
|
|
3972
4052
|
severity: "warning",
|
|
3973
4053
|
detection: PASS,
|
|
3974
4054
|
route: route.route,
|
|
4055
|
+
// No single route-level file exists here (unlike ResolvedHead.file) — the
|
|
4056
|
+
// passing route's own <h1> stands in as its attributed file (design
|
|
4057
|
+
// 2026-08-08-pass-result-location-design.md). Only reached when h1.length === 1.
|
|
4058
|
+
location: h1[0].file,
|
|
3975
4059
|
message: "Heading hierarchy",
|
|
3976
|
-
recommendation:
|
|
4060
|
+
recommendation: passRecommendation2,
|
|
3977
4061
|
docsUrl: docsUrl5
|
|
3978
4062
|
}
|
|
3979
4063
|
);
|
|
@@ -4021,6 +4105,9 @@ function uniquenessRule(opts) {
|
|
|
4021
4105
|
severity: "warning",
|
|
4022
4106
|
detection: PASS,
|
|
4023
4107
|
route: e.route,
|
|
4108
|
+
// Same `location` the penalized branch above uses (design
|
|
4109
|
+
// 2026-08-08-pass-result-location-design.md).
|
|
4110
|
+
location: e.file,
|
|
4024
4111
|
message: opts.label,
|
|
4025
4112
|
recommendation: opts.recommendation,
|
|
4026
4113
|
docsUrl: docsUrl12
|
|
@@ -4054,14 +4141,14 @@ var seoDuplicateDescription = uniquenessRule({
|
|
|
4054
4141
|
|
|
4055
4142
|
// src/rules/seo/heading-level-skip.ts
|
|
4056
4143
|
var docsUrl6 = docsUrlFor("seo/heading-level-skip");
|
|
4057
|
-
var
|
|
4144
|
+
var recommendation4 = "Increase heading levels one step at a time (do not jump, e.g. from <h2> straight to <h4>).";
|
|
4058
4145
|
var seoHeadingLevelSkip = {
|
|
4059
4146
|
id: "seo/heading-level-skip",
|
|
4060
4147
|
title: "Heading order",
|
|
4061
4148
|
category: "seo",
|
|
4062
4149
|
severity: "info",
|
|
4063
4150
|
scope: "route",
|
|
4064
|
-
rationale: "Skipping a heading level breaks the document outline that
|
|
4151
|
+
rationale: "Skipping a heading level breaks the document outline that assistive tech relies on to navigate page structure, and that search engines use as a structural signal.",
|
|
4065
4152
|
async check(ctx) {
|
|
4066
4153
|
const out = [];
|
|
4067
4154
|
for (const route of ctx.headings ?? []) {
|
|
@@ -4086,7 +4173,7 @@ var seoHeadingLevelSkip = {
|
|
|
4086
4173
|
location: skip.file,
|
|
4087
4174
|
...skip.line > 0 ? { line: skip.line } : {},
|
|
4088
4175
|
message: `Heading level skipped (<h${skip.prev}> to <h${skip.level}>)`,
|
|
4089
|
-
recommendation:
|
|
4176
|
+
recommendation: recommendation4,
|
|
4090
4177
|
docsUrl: docsUrl6
|
|
4091
4178
|
} : {
|
|
4092
4179
|
id: "seo/heading-level-skip",
|
|
@@ -4094,8 +4181,13 @@ var seoHeadingLevelSkip = {
|
|
|
4094
4181
|
severity: "info",
|
|
4095
4182
|
detection: PASS,
|
|
4096
4183
|
route: route.route,
|
|
4184
|
+
// No single route-level file exists here (unlike ResolvedHead.file) — the
|
|
4185
|
+
// route's first heading stands in as its attributed file (design
|
|
4186
|
+
// 2026-08-08-pass-result-location-design.md). `route.headings.length === 0`
|
|
4187
|
+
// already continued above, so `[0]` is always defined here.
|
|
4188
|
+
location: route.headings[0].file,
|
|
4097
4189
|
message: "Heading order",
|
|
4098
|
-
recommendation:
|
|
4190
|
+
recommendation: recommendation4,
|
|
4099
4191
|
docsUrl: docsUrl6
|
|
4100
4192
|
}
|
|
4101
4193
|
);
|
|
@@ -4133,6 +4225,9 @@ function kitModuleRule(opts) {
|
|
|
4133
4225
|
severity,
|
|
4134
4226
|
detection: PASS2,
|
|
4135
4227
|
route: m.file,
|
|
4228
|
+
// Uniform PASS-result attribution (design 2026-08-08-pass-result-location-design.md):
|
|
4229
|
+
// same location a penalized result for this file would carry.
|
|
4230
|
+
location: m.file,
|
|
4136
4231
|
message: opts.label,
|
|
4137
4232
|
recommendation: opts.recommendation,
|
|
4138
4233
|
docsUrl: docsUrl12
|
|
@@ -4202,7 +4297,7 @@ function componentRule(opts) {
|
|
|
4202
4297
|
const compiled = compileOverrides(ctx.config);
|
|
4203
4298
|
for (const c of ctx.components ?? []) {
|
|
4204
4299
|
const o = resolveRuleOptions(opts.id, opts.options, ctx.config, { route: c.file, file: c.file }, compiled);
|
|
4205
|
-
const
|
|
4300
|
+
const recommendation10 = typeof opts.recommendation === "function" ? opts.recommendation(o) : opts.recommendation;
|
|
4206
4301
|
if (!opts.applies(c, o, ctx)) continue;
|
|
4207
4302
|
const bad = opts.bad(c, o, ctx).filter((b) => !(b.line > 0 && isSuppressed2(c, opts.id, b.line)));
|
|
4208
4303
|
if (bad.length === 0) {
|
|
@@ -4212,8 +4307,11 @@ function componentRule(opts) {
|
|
|
4212
4307
|
severity,
|
|
4213
4308
|
detection: PASS3,
|
|
4214
4309
|
route: c.file,
|
|
4310
|
+
// Uniform PASS-result attribution (design 2026-08-08-pass-result-location-design.md):
|
|
4311
|
+
// same location a penalized result for this file would carry.
|
|
4312
|
+
location: c.file,
|
|
4215
4313
|
message: opts.label,
|
|
4216
|
-
recommendation:
|
|
4314
|
+
recommendation: recommendation10,
|
|
4217
4315
|
docsUrl: docsUrl12
|
|
4218
4316
|
});
|
|
4219
4317
|
continue;
|
|
@@ -4228,7 +4326,7 @@ function componentRule(opts) {
|
|
|
4228
4326
|
location: c.file,
|
|
4229
4327
|
...b.line > 0 ? { line: b.line } : {},
|
|
4230
4328
|
message: b.message,
|
|
4231
|
-
recommendation:
|
|
4329
|
+
recommendation: recommendation10,
|
|
4232
4330
|
docsUrl: docsUrl12,
|
|
4233
4331
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
4234
4332
|
});
|
|
@@ -4284,10 +4382,13 @@ var correctnessEffectAsOnMount = componentRule({
|
|
|
4284
4382
|
title: "Effect used as onMount",
|
|
4285
4383
|
category: "correctness",
|
|
4286
4384
|
label: "$effect usage",
|
|
4287
|
-
recommendation: "
|
|
4288
|
-
rationale: "An $effect
|
|
4385
|
+
recommendation: "If this runs in response to a user interaction, use an event handler; to sync an element with an external library, use {@attach} instead. For genuine one-time mount work, use onMount (import { onMount } from 'svelte'). Reserve $effect for logic that reacts to $state/$derived/$props.",
|
|
4386
|
+
rationale: "An $effect whose body reads no reactive value visible to this analysis runs once after mount and never re-runs on the paths it can see \u2014 usually a sign the code belongs in an event handler, {@attach}, or onMount instead of $effect. This can't see a reactive value reached only through a plain function's return value, so a genuinely reactive effect built that way can still be flagged.",
|
|
4289
4387
|
applies: (c) => c.effects.length > 0,
|
|
4290
|
-
bad: (c) => c.effects.filter((e) => e.mountOnly).map((e) => ({
|
|
4388
|
+
bad: (c) => c.effects.filter((e) => e.mountOnly).map((e) => ({
|
|
4389
|
+
line: e.line,
|
|
4390
|
+
message: "$effect reads no reactive value this analysis can see \u2014 consider an event handler, {@attach}, or onMount instead"
|
|
4391
|
+
}))
|
|
4291
4392
|
});
|
|
4292
4393
|
|
|
4293
4394
|
// src/rules/correctness/unmutated-state.ts
|
|
@@ -4367,14 +4468,14 @@ var correctnessCheckableBindValue = componentRule({
|
|
|
4367
4468
|
severity: "warning",
|
|
4368
4469
|
label: "bind:checked / bind:group on checkable inputs",
|
|
4369
4470
|
recommendation: "Replace bind:value with bind:checked (single checkbox) or bind:group (checkbox list / radio group).",
|
|
4370
|
-
rationale: "bind:value binds the DOM value property. A checkbox/radio's user interaction toggles checkedness, which bind:value never observes
|
|
4471
|
+
rationale: "bind:value binds the DOM value property. A checkbox/radio's user interaction toggles checkedness, which bind:value never observes. On a checkbox this throws bind_invalid_checkbox_value in a development build; in production the check is skipped and the binding silently tracks the value attribute instead of checkedness. On a radio it throws nothing in either build \u2014 it renders once with the initial value, then silently never updates. Svelte's checked/grouped bindings (bind:checked, bind:group) are built for exactly this.",
|
|
4371
4472
|
fix: {
|
|
4372
4473
|
description: "For a single checkbox, replace bind:value={x} with bind:checked={x} (x becomes a boolean). For a checkbox list or radio group, replace bind:value={x} with bind:group={x} on every input sharing the group, keeping each input's static value attribute to identify the option."
|
|
4373
4474
|
},
|
|
4374
4475
|
applies: (c) => c.checkableBindValues.length > 0,
|
|
4375
4476
|
bad: (c) => c.checkableBindValues.map((v) => ({
|
|
4376
4477
|
line: v.line,
|
|
4377
|
-
message: v.kind === "checkbox" ? "bind:value on a checkbox does not track its checked state \u2014
|
|
4478
|
+
message: v.kind === "checkbox" ? "bind:value on a checkbox does not track its checked state \u2014 it throws bind_invalid_checkbox_value in development; in a production build it silently tracks the value attribute instead of checkedness. Use bind:checked (single checkbox) or bind:group (checkbox list) instead." : "bind:value on a radio input does not track which option is selected \u2014 the bound value silently never updates when the user picks one. Use bind:group with a shared group variable across the radio inputs instead."
|
|
4378
4479
|
}))
|
|
4379
4480
|
});
|
|
4380
4481
|
|
|
@@ -4386,7 +4487,7 @@ var correctnessOrphanEffect = componentRule({
|
|
|
4386
4487
|
severity: "critical",
|
|
4387
4488
|
label: "$effect context",
|
|
4388
4489
|
recommendation: "Wrap the effect in $effect.root (and own the returned cleanup), or restructure so the effect is created during component initialisation (e.g. call a setup method from a component).",
|
|
4389
|
-
rationale: "An $effect created outside component initialisation throws effect_orphan at runtime \u2014 the compiler
|
|
4490
|
+
rationale: "An $effect created outside component initialisation throws effect_orphan at runtime. The compiler does not catch it \u2014 the server compiler deletes $effect calls entirely, so SSR renders without error \u2014 and the crash happens client-side, when the module evaluates in the browser, breaking hydration rather than producing a server error.",
|
|
4390
4491
|
// `orphanEffects` is typed required, but a facts object built by an older/external
|
|
4391
4492
|
// constructor may omit it — default to empty rather than let `applies` throw and
|
|
4392
4493
|
// take the whole `runRules` Promise.all down with it.
|
|
@@ -4405,6 +4506,14 @@ var DOCS_URL = docsUrlFor(ID);
|
|
|
4405
4506
|
var LABEL = "Lifecycle-call context";
|
|
4406
4507
|
var RECOMMENDATION = "Call lifecycle/context functions during component initialisation (the top level of a component's <script>). In load, return the data and call setContext in a layout/page component; in shared modules, expose a setup function that components call during init.";
|
|
4407
4508
|
var topLevelMessage = (name) => `${name}() runs at module evaluation, outside component initialisation \u2014 it throws lifecycle_outside_component at runtime`;
|
|
4509
|
+
var ALWAYS_THROWS = /* @__PURE__ */ new Set(["getContext", "setContext", "hasContext", "getAllContexts"]);
|
|
4510
|
+
function kitLifecycleMessage(name, kind, inHandler) {
|
|
4511
|
+
if (kind === "server" && !ALWAYS_THROWS.has(name)) {
|
|
4512
|
+
const where = inHandler ? `${name}() is called in a load/handler, outside component initialisation` : `${name}() runs outside component initialisation (module evaluation or the init hook)`;
|
|
4513
|
+
return name === "onDestroy" ? `${where} \u2014 on the server it still crashes, but with a plain TypeError, not lifecycle_outside_component (onDestroy has no component-context guard there)` : `${where} \u2014 on the server this is a silent no-op (it throws lifecycle_outside_component only if this module also runs in the browser)`;
|
|
4514
|
+
}
|
|
4515
|
+
return inHandler ? `${name}() is called in a load/handler \u2014 it runs on every request, outside component initialisation, and throws lifecycle_outside_component at runtime` : `${name}() runs outside component initialisation (module evaluation or the init hook) \u2014 it throws lifecycle_outside_component at runtime`;
|
|
4516
|
+
}
|
|
4408
4517
|
function isSuppressed3(suppressions, line) {
|
|
4409
4518
|
return (suppressions ?? []).some((s) => s.line === line && (!s.ruleIds || s.ruleIds.includes(ID)));
|
|
4410
4519
|
}
|
|
@@ -4417,6 +4526,9 @@ function emitFile(out, file, issues, suppressions) {
|
|
|
4417
4526
|
severity: "critical",
|
|
4418
4527
|
detection: PASS4,
|
|
4419
4528
|
route: file,
|
|
4529
|
+
// Uniform PASS-result attribution (design 2026-08-08-pass-result-location-design.md):
|
|
4530
|
+
// same location a penalized result for this file would carry.
|
|
4531
|
+
location: file,
|
|
4420
4532
|
message: LABEL,
|
|
4421
4533
|
recommendation: RECOMMENDATION,
|
|
4422
4534
|
docsUrl: DOCS_URL
|
|
@@ -4444,7 +4556,7 @@ var correctnessOrphanLifecycle = {
|
|
|
4444
4556
|
category: "correctness",
|
|
4445
4557
|
severity: "critical",
|
|
4446
4558
|
scope: "component",
|
|
4447
|
-
rationale: "Svelte lifecycle and context functions require an active component context; called at module scope, in a shared-state class constructor, or in a load/handler they throw lifecycle_outside_component at runtime \u2014 the compiler does not catch it, and it surfaces as a production crash.",
|
|
4559
|
+
rationale: "Svelte lifecycle and context functions require an active component context; called at module scope, in a shared-state class constructor, or in a load/handler they throw lifecycle_outside_component at runtime \u2014 the compiler does not catch it, and it surfaces as a production crash. Exception: in a Kit module that only ever runs on the server (+page.server.ts, +server.ts, hooks.server.ts), onMount/beforeUpdate/afterUpdate/createEventDispatcher are silent no-ops there instead, and onDestroy throws a plain TypeError rather than lifecycle_outside_component \u2014 only getContext/setContext/hasContext/getAllContexts still throw in that channel.",
|
|
4448
4560
|
async check(ctx) {
|
|
4449
4561
|
const out = [];
|
|
4450
4562
|
for (const c of ctx.components ?? []) {
|
|
@@ -4468,7 +4580,7 @@ var correctnessOrphanLifecycle = {
|
|
|
4468
4580
|
m.file,
|
|
4469
4581
|
calls.map((l) => ({
|
|
4470
4582
|
line: l.line,
|
|
4471
|
-
message: l.
|
|
4583
|
+
message: kitLifecycleMessage(l.name, m.kind, l.inHandler)
|
|
4472
4584
|
})),
|
|
4473
4585
|
m.suppressions
|
|
4474
4586
|
);
|
|
@@ -4508,6 +4620,9 @@ function emitFile2(out, file, links, suppressions) {
|
|
|
4508
4620
|
severity: "warning",
|
|
4509
4621
|
detection: PASS5,
|
|
4510
4622
|
route: file,
|
|
4623
|
+
// Uniform PASS-result attribution (design 2026-08-08-pass-result-location-design.md):
|
|
4624
|
+
// same location a penalized result for this file would carry.
|
|
4625
|
+
location: file,
|
|
4511
4626
|
message: LABEL2,
|
|
4512
4627
|
recommendation: RECOMMENDATION2,
|
|
4513
4628
|
docsUrl: DOCS_URL2
|
|
@@ -4575,6 +4690,9 @@ function emitFile3(out, file, issues, suppressions) {
|
|
|
4575
4690
|
severity: "critical",
|
|
4576
4691
|
detection: PASS6,
|
|
4577
4692
|
route: file,
|
|
4693
|
+
// Uniform PASS-result attribution (design 2026-08-08-pass-result-location-design.md):
|
|
4694
|
+
// same location a penalized result for this file would carry.
|
|
4695
|
+
location: file,
|
|
4578
4696
|
message: LABEL3,
|
|
4579
4697
|
recommendation: RECOMMENDATION3,
|
|
4580
4698
|
docsUrl: DOCS_URL3
|
|
@@ -4653,7 +4771,7 @@ var securityRawHtml = componentRule({
|
|
|
4653
4771
|
title: "Raw HTML render",
|
|
4654
4772
|
category: "security",
|
|
4655
4773
|
label: "{@html} usage",
|
|
4656
|
-
recommendation: "Sanitize the value before {@html} (e.g. DOMPurify), or render it as text/markup instead.",
|
|
4774
|
+
recommendation: "Sanitize the value before {@html} (e.g. DOMPurify), or render it as text/markup instead. A sanitizer keeps {@html} in the source, so the finding persists by design \u2014 once reviewed, suppress it with the inline directive.",
|
|
4657
4775
|
rationale: "{@html} renders its value as unescaped HTML; if the value can contain user input and is not sanitized, it is a cross-site-scripting (XSS) vector.",
|
|
4658
4776
|
applies: (c) => c.htmlTags.length > 0,
|
|
4659
4777
|
bad: (c) => c.htmlTags.map((h) => ({ line: h.line, message: "{@html} renders unescaped HTML \u2014 ensure it is sanitized" }))
|
|
@@ -4666,7 +4784,7 @@ var securityJavascriptUrl = componentRule({
|
|
|
4666
4784
|
category: "security",
|
|
4667
4785
|
label: "No javascript: URLs",
|
|
4668
4786
|
recommendation: "Use an event handler or a real URL instead of a javascript: URL.",
|
|
4669
|
-
rationale: "A javascript: URL in href/src/action
|
|
4787
|
+
rationale: "A javascript: URL in href/src/action/formaction breaks under a strict Content-Security-Policy and turns what should be a real navigation into inline script execution on activation \u2014 use an event handler on a <button> instead (the same shape is also a classic XSS vector, though detection here is literal-only, so every flagged URL is author-written, not injected).",
|
|
4670
4788
|
applies: (c) => c.javascriptUrls.length > 0,
|
|
4671
4789
|
bad: (c) => c.javascriptUrls.map((u) => ({ line: u.line, message: "javascript: URL in an attribute" }))
|
|
4672
4790
|
});
|
|
@@ -4678,12 +4796,12 @@ var securityHandlerStateWrite = kitModuleRule({
|
|
|
4678
4796
|
category: "security",
|
|
4679
4797
|
severity: "critical",
|
|
4680
4798
|
label: "Load/handler purity",
|
|
4681
|
-
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.",
|
|
4682
|
-
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
|
|
4683
|
-
applies: (m) => m.importedStateWrites.length > 0,
|
|
4799
|
+
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 \u2014 if that describes this write, add an inline suppression.",
|
|
4800
|
+
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.",
|
|
4801
|
+
applies: (m) => m.importedStateWrites.length > 0 && !(m.kind === "universal" && m.ssrDisabled),
|
|
4684
4802
|
bad: (m) => m.importedStateWrites.map((w) => ({
|
|
4685
4803
|
line: w.line,
|
|
4686
|
-
message: `a server-executed handler writes imported module state "${w.name}" \u2014 shared across all requests on the server, one user's data can leak to another`
|
|
4804
|
+
message: `a server-executed handler writes imported module state "${w.name}" \u2014 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 \u2014 verify which this is`
|
|
4687
4805
|
}))
|
|
4688
4806
|
});
|
|
4689
4807
|
|
|
@@ -4713,7 +4831,7 @@ var securitySharedStateImport = kitModuleRule({
|
|
|
4713
4831
|
label: "Server state imports",
|
|
4714
4832
|
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.",
|
|
4715
4833
|
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.",
|
|
4716
|
-
applies: (m) => m.runesModuleImports.length > 0,
|
|
4834
|
+
applies: (m) => m.runesModuleImports.length > 0 && !(m.kind === "universal" && m.ssrDisabled),
|
|
4717
4835
|
bad: (m, ctx) => {
|
|
4718
4836
|
const stateFiles = new Set((ctx.components ?? []).filter((c) => c.moduleStateDecls.length > 0).map((c) => c.file));
|
|
4719
4837
|
const writtenOutside = new Set(m.importedStateWritesOutsideHandlers.map((w) => w.name));
|
|
@@ -4773,7 +4891,7 @@ var architecturePropCount = componentRule({
|
|
|
4773
4891
|
|
|
4774
4892
|
// src/rules/architecture/private-scope-import.ts
|
|
4775
4893
|
var docsUrl7 = docsUrlFor("architecture/private-scope-import");
|
|
4776
|
-
var
|
|
4894
|
+
var recommendation5 = "Move the unit to the directory shared by all of its importers, or import it only from inside its own scope.";
|
|
4777
4895
|
var OPTIONS2 = { scopes: { kind: "string-list", default: [] } };
|
|
4778
4896
|
function ancestorDirs(file) {
|
|
4779
4897
|
const segments = file.split("/");
|
|
@@ -4853,8 +4971,13 @@ var architecturePrivateScopeImport = {
|
|
|
4853
4971
|
severity: "info",
|
|
4854
4972
|
detection: { presence: "own", value: "static" },
|
|
4855
4973
|
route: c.file,
|
|
4974
|
+
// Same location the penalized branch below uses (design
|
|
4975
|
+
// 2026-08-08-pass-result-location-design.md) — this uncaught inline PASS literal
|
|
4976
|
+
// was missed by the design spike's grep and added to its blast-radius table
|
|
4977
|
+
// afterward (maintainer ruling, same date).
|
|
4978
|
+
location: c.file,
|
|
4856
4979
|
message: "No private-scope imports",
|
|
4857
|
-
recommendation:
|
|
4980
|
+
recommendation: recommendation5,
|
|
4858
4981
|
docsUrl: docsUrl7
|
|
4859
4982
|
});
|
|
4860
4983
|
continue;
|
|
@@ -4869,7 +4992,7 @@ var architecturePrivateScopeImport = {
|
|
|
4869
4992
|
location: c.file,
|
|
4870
4993
|
...v.line > 0 ? { line: v.line } : {},
|
|
4871
4994
|
message: v.message,
|
|
4872
|
-
recommendation:
|
|
4995
|
+
recommendation: recommendation5,
|
|
4873
4996
|
docsUrl: docsUrl7,
|
|
4874
4997
|
fix: { ...architecturePrivateScopeImport.fix }
|
|
4875
4998
|
});
|
|
@@ -4994,7 +5117,7 @@ function classifyUnusedKeys(unused, excludedDirs, compile) {
|
|
|
4994
5117
|
// src/rules/architecture/unit-entry-file.ts
|
|
4995
5118
|
var ID4 = "architecture/unit-entry-file";
|
|
4996
5119
|
var docsUrl8 = docsUrlFor(ID4);
|
|
4997
|
-
var
|
|
5120
|
+
var recommendation6 = "Give every declared unit directory a file named after it, or stop declaring that directory a unit.";
|
|
4998
5121
|
var OPTIONS3 = {
|
|
4999
5122
|
units: { kind: "string-map", default: {} },
|
|
5000
5123
|
pascalCaseUnits: { kind: "string-map", default: {} },
|
|
@@ -5031,6 +5154,8 @@ var architectureUnitEntryFile = {
|
|
|
5031
5154
|
...Object.keys(mapOption(globalOptions, "pascalCaseUnits"))
|
|
5032
5155
|
]);
|
|
5033
5156
|
const usedKeys = /* @__PURE__ */ new Set();
|
|
5157
|
+
const examinedCounts = {};
|
|
5158
|
+
for (const key of globalKeys) examinedCounts[key] = 0;
|
|
5034
5159
|
const excludedDirs = [];
|
|
5035
5160
|
const matchedSurviving = /* @__PURE__ */ new Set();
|
|
5036
5161
|
for (const dir of [...dirs].sort()) {
|
|
@@ -5058,6 +5183,10 @@ var architectureUnitEntryFile = {
|
|
|
5058
5183
|
ext = byCasing.best === void 0 ? void 0 : pascalUnits[byCasing.best];
|
|
5059
5184
|
}
|
|
5060
5185
|
if (ext === void 0) continue;
|
|
5186
|
+
const winningKey = viaUnits ? byPath.best : byCasing.best;
|
|
5187
|
+
if (winningKey !== void 0 && globalKeys.has(winningKey)) {
|
|
5188
|
+
examinedCounts[winningKey] = (examinedCounts[winningKey] ?? 0) + 1;
|
|
5189
|
+
}
|
|
5061
5190
|
const expected = `${dir}/${baseName(dir)}${ext}`;
|
|
5062
5191
|
if (fileSet.has(expected)) {
|
|
5063
5192
|
out.push({
|
|
@@ -5067,7 +5196,7 @@ var architectureUnitEntryFile = {
|
|
|
5067
5196
|
detection: { presence: "own", value: "static" },
|
|
5068
5197
|
location: expected,
|
|
5069
5198
|
message: "Unit entry file",
|
|
5070
|
-
recommendation:
|
|
5199
|
+
recommendation: recommendation6,
|
|
5071
5200
|
docsUrl: docsUrl8
|
|
5072
5201
|
});
|
|
5073
5202
|
continue;
|
|
@@ -5082,7 +5211,7 @@ var architectureUnitEntryFile = {
|
|
|
5082
5211
|
route: dir,
|
|
5083
5212
|
location: at,
|
|
5084
5213
|
message: `${dir} declares a unit but has no ${expected}`,
|
|
5085
|
-
recommendation:
|
|
5214
|
+
recommendation: recommendation6,
|
|
5086
5215
|
docsUrl: docsUrl8,
|
|
5087
5216
|
// Which declaration matched decides the wording: a `units` match like functions/getFoo/
|
|
5088
5217
|
// is already camelCase, so telling its author to rename it would be nonsense.
|
|
@@ -5107,6 +5236,7 @@ var architectureUnitEntryFile = {
|
|
|
5107
5236
|
docsUrl: docsUrl8
|
|
5108
5237
|
});
|
|
5109
5238
|
}
|
|
5239
|
+
ctx.recordExamined?.(examinedCounts);
|
|
5110
5240
|
return out;
|
|
5111
5241
|
}
|
|
5112
5242
|
};
|
|
@@ -5146,7 +5276,7 @@ function satisfiesCasing(name, allowed) {
|
|
|
5146
5276
|
// src/rules/architecture/directory-naming.ts
|
|
5147
5277
|
var ID5 = "architecture/directory-naming";
|
|
5148
5278
|
var docsUrl9 = docsUrlFor(ID5);
|
|
5149
|
-
var
|
|
5279
|
+
var recommendation7 = "Name each directory in the casing its location declares, or narrow the declaration.";
|
|
5150
5280
|
var OPTIONS4 = {
|
|
5151
5281
|
directories: { kind: "string-map", default: {} },
|
|
5152
5282
|
exclude: { kind: "string-list", default: [] }
|
|
@@ -5180,6 +5310,8 @@ var architectureDirectoryNaming = {
|
|
|
5180
5310
|
const globalOptions = resolveRuleOptions(ID5, OPTIONS4, ctx.config);
|
|
5181
5311
|
const globalMap = mapOption(globalOptions, "directories");
|
|
5182
5312
|
const globalKeys = new Set(Object.keys(globalMap));
|
|
5313
|
+
const examinedCounts = {};
|
|
5314
|
+
for (const key of globalKeys) examinedCounts[key] = 0;
|
|
5183
5315
|
const usedKeys = /* @__PURE__ */ new Set();
|
|
5184
5316
|
const excludedDirs = [];
|
|
5185
5317
|
for (const dir of [...dirs].sort()) {
|
|
@@ -5197,6 +5329,7 @@ var architectureDirectoryNaming = {
|
|
|
5197
5329
|
if (m.best === void 0) continue;
|
|
5198
5330
|
const decoded = decodeSegment(baseName(dir));
|
|
5199
5331
|
if (decoded === void 0) continue;
|
|
5332
|
+
if (globalKeys.has(m.best)) examinedCounts[m.best] = (examinedCounts[m.best] ?? 0) + 1;
|
|
5200
5333
|
const allowed = casingsOf(declared[m.best]).known;
|
|
5201
5334
|
if (satisfiesCasing(decoded, allowed)) continue;
|
|
5202
5335
|
const at = reportAt(dir, files);
|
|
@@ -5209,7 +5342,7 @@ var architectureDirectoryNaming = {
|
|
|
5209
5342
|
route: dir,
|
|
5210
5343
|
location: at,
|
|
5211
5344
|
message: `${dir} must be ${allowed.join(" or ")}.`,
|
|
5212
|
-
recommendation:
|
|
5345
|
+
recommendation: recommendation7,
|
|
5213
5346
|
docsUrl: docsUrl9,
|
|
5214
5347
|
fix: { description: "Rename the directory, or narrow the declaration that governs it." }
|
|
5215
5348
|
});
|
|
@@ -5244,6 +5377,7 @@ var architectureDirectoryNaming = {
|
|
|
5244
5377
|
docsUrl: docsUrl9
|
|
5245
5378
|
});
|
|
5246
5379
|
}
|
|
5380
|
+
ctx.recordExamined?.(examinedCounts);
|
|
5247
5381
|
return out;
|
|
5248
5382
|
}
|
|
5249
5383
|
};
|
|
@@ -5251,10 +5385,11 @@ var architectureDirectoryNaming = {
|
|
|
5251
5385
|
// src/rules/architecture/reserved-directory-names.ts
|
|
5252
5386
|
var ID6 = "architecture/reserved-directory-names";
|
|
5253
5387
|
var docsUrl10 = docsUrlFor(ID6);
|
|
5254
|
-
var
|
|
5388
|
+
var recommendation8 = "Use one of the names this location declares, or add the new name to the declaration.";
|
|
5255
5389
|
var OPTIONS5 = {
|
|
5256
5390
|
scopes: { kind: "string-map", default: {} },
|
|
5257
5391
|
unitScopes: { kind: "string-map", default: {} },
|
|
5392
|
+
anyCaseUnitScopes: { kind: "string-map", default: {} },
|
|
5258
5393
|
exclude: { kind: "string-list", default: [] }
|
|
5259
5394
|
};
|
|
5260
5395
|
function stem(file) {
|
|
@@ -5270,6 +5405,7 @@ function isAnyCaseUnitDir(dir, filesIn) {
|
|
|
5270
5405
|
const own = filesIn.get(dir);
|
|
5271
5406
|
return own !== void 0 && own.some((f) => stem(f) === name);
|
|
5272
5407
|
}
|
|
5408
|
+
var PRIORITY = { scopes: 0, unitScopes: 1, anyCaseUnitScopes: 2 };
|
|
5273
5409
|
var architectureReservedDirectoryNames = {
|
|
5274
5410
|
id: ID6,
|
|
5275
5411
|
title: "Reserved directory names",
|
|
@@ -5301,26 +5437,45 @@ var architectureReservedDirectoryNames = {
|
|
|
5301
5437
|
const globalOptions = resolveRuleOptions(ID6, OPTIONS5, ctx.config);
|
|
5302
5438
|
const globalScopes = mapOption(globalOptions, "scopes");
|
|
5303
5439
|
const globalUnits = mapOption(globalOptions, "unitScopes");
|
|
5304
|
-
const
|
|
5440
|
+
const globalAnyUnits = mapOption(globalOptions, "anyCaseUnitScopes");
|
|
5441
|
+
const globalKeys = /* @__PURE__ */ new Set([
|
|
5442
|
+
...Object.keys(globalScopes),
|
|
5443
|
+
...Object.keys(globalUnits),
|
|
5444
|
+
...Object.keys(globalAnyUnits)
|
|
5445
|
+
]);
|
|
5305
5446
|
const usedKeys = /* @__PURE__ */ new Set();
|
|
5306
5447
|
const excludedDirs = [];
|
|
5307
5448
|
const nonUnitDirs = [];
|
|
5308
|
-
const
|
|
5309
|
-
const
|
|
5449
|
+
const nonAnyUnitDirs = [];
|
|
5450
|
+
const collisions = /* @__PURE__ */ new Map();
|
|
5451
|
+
const collisionMessage = (losers) => {
|
|
5452
|
+
const maps = ["scopes", ...losers];
|
|
5453
|
+
const list = maps.length === 2 ? `both ${maps[0]} and ${maps[1]}` : maps.join(", ");
|
|
5454
|
+
return `declared in ${list}, so the scopes entry wins wherever ${losers.length > 1 ? "they" : "both"} apply`;
|
|
5455
|
+
};
|
|
5456
|
+
const examinedCounts = {};
|
|
5457
|
+
for (const key of globalKeys) examinedCounts[key] = 0;
|
|
5458
|
+
const noteCollisions = (scopesMap, unitMap, anyUnitMap) => {
|
|
5310
5459
|
for (const key of Object.keys(scopesMap)) {
|
|
5311
|
-
if (!Object.hasOwn(unitMap, key)) continue;
|
|
5312
5460
|
if (namesOf(scopesMap[key]).length === 0) continue;
|
|
5313
|
-
|
|
5314
|
-
|
|
5461
|
+
const losers = [];
|
|
5462
|
+
if (Object.hasOwn(unitMap, key) && namesOf(unitMap[key]).length > 0) losers.push("unitScopes");
|
|
5463
|
+
if (Object.hasOwn(anyUnitMap, key) && namesOf(anyUnitMap[key]).length > 0) {
|
|
5464
|
+
losers.push("anyCaseUnitScopes");
|
|
5465
|
+
}
|
|
5466
|
+
if (losers.length > 0) collisions.set(key, collisionMessage(losers));
|
|
5315
5467
|
}
|
|
5316
5468
|
};
|
|
5317
|
-
noteCollisions(globalScopes, globalUnits);
|
|
5469
|
+
noteCollisions(globalScopes, globalUnits, globalAnyUnits);
|
|
5318
5470
|
for (const dir of [...dirs].sort()) {
|
|
5319
5471
|
const o = resolveRuleOptions(ID6, OPTIONS5, ctx.config, { route: dir, file: dir }, compiledOverrides);
|
|
5320
5472
|
const scopes = mapOption(o, "scopes");
|
|
5321
5473
|
const unitScopes = mapOption(o, "unitScopes");
|
|
5322
|
-
|
|
5323
|
-
|
|
5474
|
+
const anyCaseUnitScopes = mapOption(o, "anyCaseUnitScopes");
|
|
5475
|
+
if (Object.keys(scopes).length === 0 && Object.keys(unitScopes).length === 0 && Object.keys(anyCaseUnitScopes).length === 0) {
|
|
5476
|
+
continue;
|
|
5477
|
+
}
|
|
5478
|
+
noteCollisions(scopes, unitScopes, anyCaseUnitScopes);
|
|
5324
5479
|
const excluded = compile(listOption(o, "exclude"));
|
|
5325
5480
|
if (isExcluded(dir, ancestorDirs2(dir), excluded)) {
|
|
5326
5481
|
excludedDirs.push(dir);
|
|
@@ -5328,22 +5483,46 @@ var architectureReservedDirectoryNames = {
|
|
|
5328
5483
|
}
|
|
5329
5484
|
const liveScopes = Object.keys(scopes).filter((k) => namesOf(scopes[k]).length > 0);
|
|
5330
5485
|
const isUnit = isUnitDir(dir, filesIn);
|
|
5486
|
+
const isAnyUnit = isAnyCaseUnitDir(dir, filesIn);
|
|
5331
5487
|
const liveUnits = isUnit ? Object.keys(unitScopes).filter((k) => namesOf(unitScopes[k]).length > 0) : [];
|
|
5488
|
+
const liveAnyUnits = isAnyUnit ? Object.keys(anyCaseUnitScopes).filter((k) => namesOf(anyCaseUnitScopes[k]).length > 0) : [];
|
|
5332
5489
|
if (!isUnit) nonUnitDirs.push(dir);
|
|
5490
|
+
if (!isAnyUnit) nonAnyUnitDirs.push(dir);
|
|
5333
5491
|
const byPosition = matchKeys(dir, compile(liveScopes, true));
|
|
5334
5492
|
const byUnit = matchKeys(dir, compile(liveUnits, true));
|
|
5493
|
+
const byAnyUnit = matchKeys(dir, compile(liveAnyUnits, true));
|
|
5335
5494
|
for (const k of byPosition.matched) if (globalKeys.has(k)) usedKeys.add(k);
|
|
5336
5495
|
for (const k of byUnit.matched) if (globalKeys.has(k)) usedKeys.add(k);
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5496
|
+
for (const k of byAnyUnit.matched) if (globalKeys.has(k)) usedKeys.add(k);
|
|
5497
|
+
const candidates = [];
|
|
5498
|
+
if (byPosition.best !== void 0) {
|
|
5499
|
+
candidates.push({ kind: "scopes", best: byPosition.best, names: namesOf(scopes[byPosition.best]) });
|
|
5500
|
+
}
|
|
5501
|
+
if (byUnit.best !== void 0) {
|
|
5502
|
+
candidates.push({
|
|
5503
|
+
kind: "unitScopes",
|
|
5504
|
+
best: byUnit.best,
|
|
5505
|
+
names: namesOf(unitScopes[byUnit.best])
|
|
5506
|
+
});
|
|
5344
5507
|
}
|
|
5345
|
-
if (
|
|
5346
|
-
|
|
5508
|
+
if (byAnyUnit.best !== void 0) {
|
|
5509
|
+
candidates.push({
|
|
5510
|
+
kind: "anyCaseUnitScopes",
|
|
5511
|
+
best: byAnyUnit.best,
|
|
5512
|
+
names: namesOf(anyCaseUnitScopes[byAnyUnit.best])
|
|
5513
|
+
});
|
|
5514
|
+
}
|
|
5515
|
+
let winner;
|
|
5516
|
+
for (const c of candidates) {
|
|
5517
|
+
if (winner === void 0 || moreSpecificGlob(c.best, winner.best)) {
|
|
5518
|
+
winner = c;
|
|
5519
|
+
} else if (!moreSpecificGlob(winner.best, c.best) && PRIORITY[c.kind] < PRIORITY[winner.kind]) {
|
|
5520
|
+
winner = c;
|
|
5521
|
+
}
|
|
5522
|
+
}
|
|
5523
|
+
if (winner === void 0) continue;
|
|
5524
|
+
if (globalKeys.has(winner.best)) examinedCounts[winner.best] = (examinedCounts[winner.best] ?? 0) + 1;
|
|
5525
|
+
const allowed = new Set(winner.names);
|
|
5347
5526
|
for (const child of kids.get(dir) ?? []) {
|
|
5348
5527
|
if (allowed.has(baseName(child))) continue;
|
|
5349
5528
|
if (isExcluded(child, ancestorDirs2(child), excluded)) continue;
|
|
@@ -5364,8 +5543,8 @@ var architectureReservedDirectoryNames = {
|
|
|
5364
5543
|
detection: { presence: "none", value: "absent" },
|
|
5365
5544
|
route: child,
|
|
5366
5545
|
location: at,
|
|
5367
|
-
message: `${child} is not one of the names declared here: ${
|
|
5368
|
-
recommendation:
|
|
5546
|
+
message: `${child} is not one of the names declared here: ${winner.names.join(", ")}.`,
|
|
5547
|
+
recommendation: recommendation8,
|
|
5369
5548
|
docsUrl: docsUrl10,
|
|
5370
5549
|
fix: {
|
|
5371
5550
|
description: "Rename it to a declared name, move it under one of them, or add its name to the declaration."
|
|
@@ -5374,19 +5553,24 @@ var architectureReservedDirectoryNames = {
|
|
|
5374
5553
|
}
|
|
5375
5554
|
}
|
|
5376
5555
|
const notes = /* @__PURE__ */ new Map();
|
|
5377
|
-
for (const key of collisions)
|
|
5378
|
-
notes.set(key, "declared in both scopes and unitScopes, so the scopes entry wins wherever both apply");
|
|
5379
|
-
}
|
|
5556
|
+
for (const [key, message] of collisions) notes.set(key, message);
|
|
5380
5557
|
for (const key of globalKeys) {
|
|
5381
5558
|
if (notes.has(key)) continue;
|
|
5382
5559
|
const scopesEmpty = Object.hasOwn(globalScopes, key) && namesOf(globalScopes[key]).length === 0;
|
|
5383
5560
|
const unitsEmpty = Object.hasOwn(globalUnits, key) && namesOf(globalUnits[key]).length === 0;
|
|
5384
|
-
|
|
5561
|
+
const anyUnitsEmpty = Object.hasOwn(globalAnyUnits, key) && namesOf(globalAnyUnits[key]).length === 0;
|
|
5562
|
+
if (scopesEmpty || unitsEmpty || anyUnitsEmpty) {
|
|
5385
5563
|
notes.set(key, "names no directory name at all");
|
|
5386
5564
|
}
|
|
5387
5565
|
}
|
|
5388
5566
|
const unused = [...globalKeys].filter((k) => !notes.has(k) && !usedKeys.has(k));
|
|
5389
|
-
const
|
|
5567
|
+
const anyUnitOnly = unused.filter((k) => Object.hasOwn(globalAnyUnits, k) && !Object.hasOwn(globalScopes, k));
|
|
5568
|
+
for (const key of keysMatchingAny(anyUnitOnly, nonAnyUnitDirs, compile)) {
|
|
5569
|
+
notes.set(key, "matched directories but never a unit of either case");
|
|
5570
|
+
}
|
|
5571
|
+
const unitOnly = unused.filter(
|
|
5572
|
+
(k) => Object.hasOwn(globalUnits, k) && !Object.hasOwn(globalScopes, k) && !notes.has(k)
|
|
5573
|
+
);
|
|
5390
5574
|
for (const key of keysMatchingAny(unitOnly, nonUnitDirs, compile)) {
|
|
5391
5575
|
notes.set(key, "matched directories but never a unit");
|
|
5392
5576
|
}
|
|
@@ -5410,6 +5594,7 @@ var architectureReservedDirectoryNames = {
|
|
|
5410
5594
|
docsUrl: docsUrl10
|
|
5411
5595
|
});
|
|
5412
5596
|
}
|
|
5597
|
+
ctx.recordExamined?.(examinedCounts);
|
|
5413
5598
|
return out;
|
|
5414
5599
|
}
|
|
5415
5600
|
};
|
|
@@ -5417,7 +5602,7 @@ var architectureReservedDirectoryNames = {
|
|
|
5417
5602
|
// src/rules/architecture/reserved-name-placement.ts
|
|
5418
5603
|
var ID7 = "architecture/reserved-name-placement";
|
|
5419
5604
|
var docsUrl11 = docsUrlFor(ID7);
|
|
5420
|
-
var
|
|
5605
|
+
var recommendation9 = "Move it to one of the places declared for this name, or declare this place for it.";
|
|
5421
5606
|
var fixDescription = "Move the directory to one of the places declared for its name, rename it, or declare this place for the name.";
|
|
5422
5607
|
var OPTIONS6 = {
|
|
5423
5608
|
placements: { kind: "string-map", default: {} },
|
|
@@ -5535,7 +5720,7 @@ var architectureReservedNamePlacement = {
|
|
|
5535
5720
|
route: dir,
|
|
5536
5721
|
location: at,
|
|
5537
5722
|
message: `${dir} is not one of the places declared for '${name}'.`,
|
|
5538
|
-
recommendation:
|
|
5723
|
+
recommendation: recommendation9,
|
|
5539
5724
|
docsUrl: docsUrl11,
|
|
5540
5725
|
fix: {
|
|
5541
5726
|
description: fixDescription
|
|
@@ -5749,8 +5934,8 @@ var performanceNamespaceImport = componentRule({
|
|
|
5749
5934
|
// src/rules/perf/minify-disabled.ts
|
|
5750
5935
|
var PENALIZED7 = { presence: "none", value: "absent" };
|
|
5751
5936
|
var MINIFY_DISABLED_FIX = {
|
|
5752
|
-
description: "Remove the minify: false override from vite.config (Vite minifies
|
|
5753
|
-
snippet: "export default defineConfig({\n build: {\n minify:
|
|
5937
|
+
description: "Remove the minify: false override from vite.config (Vite minifies by default), or scope it to non-production builds.",
|
|
5938
|
+
snippet: "export default defineConfig({\n build: {\n // minify: false \u2014 removed; Vite minifies production builds by default\n }\n});",
|
|
5754
5939
|
lang: "ts"
|
|
5755
5940
|
};
|
|
5756
5941
|
var RECOMMENDATION4 = "Remove build.minify: false from vite.config, or scope it to non-production builds if it is intentional.";
|
|
@@ -6330,7 +6515,7 @@ function formatAgentReport(results, config) {
|
|
|
6330
6515
|
return lines.join("\n").replace(/\n+$/, "\n");
|
|
6331
6516
|
}
|
|
6332
6517
|
lines.push(
|
|
6333
|
-
`${failing.length} issue(s) to fix, ordered most-severe first. Fix critical issues first; warning and info items improve SEO but do not fail the default build. Apply each fix below, then re-run \`svelte-vitals\` (or the build) to confirm each rule passes.`,
|
|
6518
|
+
`${failing.length} issue(s) to fix, ordered most-severe first. Fix critical issues first; warning and info items improve SEO but do not fail the default build. Apply each fix below, then re-run \`svelte-vitals\` (or the build) to confirm each rule passes. Run \`svelte-vitals explain <rule-id>\` for any rule's rationale and options (works offline).`,
|
|
6334
6519
|
""
|
|
6335
6520
|
);
|
|
6336
6521
|
const groups = /* @__PURE__ */ new Map();
|
|
@@ -6612,6 +6797,7 @@ body{background:var(--ground);color:var(--ink);font-family:var(--sans);line-heig
|
|
|
6612
6797
|
.dv-cat-top{display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px}
|
|
6613
6798
|
.dv-bar{height:7px;border-radius:999px;background:var(--line);overflow:hidden}
|
|
6614
6799
|
.dv-bar>i{display:block;height:100%;border-radius:999px}
|
|
6800
|
+
.dv-cat-reach{font-size:11.5px;color:var(--muted);margin-top:5px}
|
|
6615
6801
|
.dv-filters{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0}
|
|
6616
6802
|
.dv-chip{font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;background:var(--panel);border:1px solid var(--line-strong);color:var(--muted);padding:5px 12px;border-radius:999px}
|
|
6617
6803
|
.dv-chip[aria-pressed="true"]{background:var(--active-bg);border-color:var(--active-bg);color:var(--active-ink)}
|
|
@@ -7084,12 +7270,21 @@ var APP_SCRIPT = `
|
|
|
7084
7270
|
var band = scoreBand(c.score);
|
|
7085
7271
|
var weight = s.report.weights[cat];
|
|
7086
7272
|
var name = cat === 'seo' ? 'SEO' : cat.charAt(0).toUpperCase() + cat.slice(1);
|
|
7273
|
+
// keys/affectedKeys are absent on hand-built snapshots (older fixtures, tests) \u2014
|
|
7274
|
+
// render nothing rather than "undefined of undefined". 0 affected of N keys is still
|
|
7275
|
+
// rendered: on a real project that's the signal a thin score can't give, that the
|
|
7276
|
+
// category is clean project-wide and not just on the one key it happened to look at
|
|
7277
|
+
// (design: 2026-08-05-score-floor-and-reach-design.md).
|
|
7278
|
+
var reach = typeof c.keys === 'number' && c.keys > 0
|
|
7279
|
+
? h('div', { class: 'dv-cat-reach', text: c.affectedKeys + ' of ' + c.keys + ' keys affected' }, [])
|
|
7280
|
+
: null;
|
|
7087
7281
|
return h('div', { class: 'dv-cat' }, [
|
|
7088
7282
|
h('div', { class: 'dv-cat-top' }, [
|
|
7089
7283
|
h('span', { text: name + (weight !== undefined ? ' (weight ' + weight + ')' : '') }, []),
|
|
7090
7284
|
h('span', { style: 'color:' + BAND_COLOR[band], text: String(c.score) }, [])
|
|
7091
7285
|
]),
|
|
7092
|
-
h('div', { class: 'dv-bar' }, [h('i', { style: 'width:' + c.score + '%;background:' + BAND_COLOR[band] }, [])])
|
|
7286
|
+
h('div', { class: 'dv-bar' }, [h('i', { style: 'width:' + c.score + '%;background:' + BAND_COLOR[band] }, [])]),
|
|
7287
|
+
reach
|
|
7093
7288
|
]);
|
|
7094
7289
|
});
|
|
7095
7290
|
var chips = renderFilterChips(s.report.categories);
|