@goflag/cli 0.2.10 → 0.2.11

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.
@@ -2741,6 +2741,7 @@ function rowOf(site, url, pathname) {
2741
2741
  }
2742
2742
  function sitemapLocalesByRoute(site) {
2743
2743
  const byRoute = /* @__PURE__ */ new Map();
2744
+ const axis = new Set(site.localeAxis.locales.map((l) => l.toLowerCase()));
2744
2745
  for (const entry of site.discovery?.urls ?? []) {
2745
2746
  let pathname;
2746
2747
  try {
@@ -2750,6 +2751,7 @@ function sitemapLocalesByRoute(site) {
2750
2751
  }
2751
2752
  const { locale } = splitRoute(pathname);
2752
2753
  if (locale === "x-default") continue;
2754
+ if (!axis.has(locale.toLowerCase())) continue;
2753
2755
  const route = rowOf(site, entry.loc, pathname);
2754
2756
  const set = byRoute.get(route) ?? /* @__PURE__ */ new Set();
2755
2757
  set.add(locale.toLowerCase());
@@ -2764,6 +2766,8 @@ var hreflangMissing = {
2764
2766
  id: "hreflang.missing",
2765
2767
  severity: "error",
2766
2768
  summary: "Pages on a multilingual site must advertise their locale alternates",
2769
+ rigor: "vendor-spec",
2770
+ sources: ["google-hreflang", "whatwg-html-link-types"],
2767
2771
  appliesTo: isMultilingual,
2768
2772
  check: ({ site, issue }) => {
2769
2773
  const locales = site.localeAxis.locales.join(", ");
@@ -2797,62 +2801,57 @@ var hreflangMissing = {
2797
2801
  );
2798
2802
  }
2799
2803
  };
2800
- var hreflangSitemapMismatch = {
2801
- id: "hreflang.sitemap-mismatch",
2802
- severity: "warning",
2803
- summary: "`<head>` alternates and sitemap locale coverage must agree",
2804
- appliesTo: (site) => isMultilingual(site) && (site.discovery?.urls.length ?? 0) > 0,
2805
- check: ({ site, issue }) => {
2806
- const bySitemap = sitemapLocalesByRoute(site);
2807
- const findings = [];
2808
- for (const page of site.pages) {
2809
- const head = declaredLocales(page);
2810
- if (head.size === 0) continue;
2811
- let pathname;
2812
- try {
2813
- pathname = new URL(page.fetch.finalUrl).pathname;
2814
- } catch {
2815
- continue;
2816
- }
2817
- const route = rowOf(site, page.fetch.finalUrl, pathname);
2818
- const inSitemap = bySitemap.get(route);
2819
- if (!inSitemap || inSitemap.size === 0) continue;
2820
- const onlyInSitemap = sorted(new Set([...inSitemap].filter((l) => !head.has(l))));
2821
- const onlyInHead = sorted(new Set([...head].filter((l) => !inSitemap.has(l))));
2822
- if (onlyInSitemap.length === 0 && onlyInHead.length === 0) continue;
2823
- const parts = [];
2824
- if (onlyInSitemap.length > 0) {
2825
- parts.push(
2826
- `the sitemap lists ${onlyInSitemap.join(", ")} but the \`<head>\` does not advertise ${onlyInSitemap.length > 1 ? "them" : "it"}`
2827
- );
2828
- }
2829
- if (onlyInHead.length > 0) {
2830
- parts.push(
2831
- `the \`<head>\` advertises ${onlyInHead.join(", ")} but the sitemap has no entry for ${onlyInHead.length > 1 ? "them" : "it"}`
2832
- );
2833
- }
2834
- findings.push(
2835
- issue({
2836
- pageUrl: page.fetch.finalUrl,
2837
- message: `Route \`${route}\`: ${parts.join("; ")}. Both are derived from the same intent and must not disagree.`,
2838
- origin: { kind: "link", rel: "alternate" },
2839
- fix: {
2840
- title: "Derive both from one locale-availability source",
2841
- snippet: [
2842
- "// Compute availability once, feed both the <head> and the sitemap.",
2843
- "const localesFor = (slug: string) =>",
2844
- " allDocs.filter((d) => d.slug === slug && !d.draft).map((d) => d.locale);",
2845
- "",
2846
- "// generateMetadata(): alternates.languages \u2190 localesFor(slug)",
2847
- "// sitemap.ts: alternates.languages \u2190 localesFor(slug)"
2848
- ].join("\n"),
2849
- language: "ts"
2850
- }
2851
- })
2852
- );
2804
+ function coverageGaps(site) {
2805
+ const bySitemap = sitemapLocalesByRoute(site);
2806
+ const gaps = [];
2807
+ for (const page of site.pages) {
2808
+ const head = declaredLocales(page);
2809
+ if (head.size === 0) continue;
2810
+ let pathname;
2811
+ try {
2812
+ pathname = new URL(page.fetch.finalUrl).pathname;
2813
+ } catch {
2814
+ continue;
2853
2815
  }
2854
- return findings;
2816
+ const route = rowOf(site, page.fetch.finalUrl, pathname);
2817
+ const inSitemap = bySitemap.get(route);
2818
+ if (!inSitemap || inSitemap.size === 0) continue;
2819
+ gaps.push({
2820
+ page,
2821
+ route,
2822
+ onlyInSitemap: sorted(new Set([...inSitemap].filter((l) => !head.has(l))))
2823
+ });
2855
2824
  }
2825
+ return gaps;
2826
+ }
2827
+ var ONE_SOURCE_FIX = {
2828
+ title: "Derive both from one locale-availability source",
2829
+ snippet: [
2830
+ "// Compute availability once, feed both the <head> and the sitemap.",
2831
+ "const localesFor = (slug: string) =>",
2832
+ " allDocs.filter((d) => d.slug === slug && !d.draft).map((d) => d.locale);",
2833
+ "",
2834
+ "// generateMetadata(): alternates.languages \u2190 localesFor(slug)",
2835
+ "// sitemap.ts: alternates.languages \u2190 localesFor(slug)"
2836
+ ].join("\n"),
2837
+ language: "ts"
2838
+ };
2839
+ var bothApply = (site) => isMultilingual(site) && (site.discovery?.urls.length ?? 0) > 0;
2840
+ var hreflangClusterIncomplete = {
2841
+ id: "hreflang.cluster-incomplete",
2842
+ severity: "warning",
2843
+ summary: "Every locale the sitemap publishes must appear in the route's `hreflang` cluster",
2844
+ rigor: "vendor-spec",
2845
+ sources: ["google-hreflang"],
2846
+ appliesTo: bothApply,
2847
+ check: ({ site, issue }) => coverageGaps(site).filter((gap) => gap.onlyInSitemap.length > 0).map(
2848
+ ({ page, route, onlyInSitemap }) => issue({
2849
+ pageUrl: page.fetch.finalUrl,
2850
+ message: `Route \`${route}\`: the sitemap lists ${onlyInSitemap.join(", ")} but the \`<head>\` does not advertise ${onlyInSitemap.length > 1 ? "them" : "it"}. The site publishes ${onlyInSitemap.length > 1 ? "those versions" : "that version"} and leaves ${onlyInSitemap.length > 1 ? "them" : "it"} outside the cluster, so ${onlyInSitemap.length > 1 ? "they compete" : "it competes"} with this page instead of consolidating with it.`,
2851
+ origin: { kind: "link", rel: "alternate" },
2852
+ fix: ONE_SOURCE_FIX
2853
+ })
2854
+ )
2856
2855
  };
2857
2856
  function metaRobotsTokens(page) {
2858
2857
  const raw = page.meta.robots?.value ?? "";
@@ -3156,6 +3155,84 @@ var sitemapEntryInvalidUrl = {
3156
3155
  });
3157
3156
  }
3158
3157
  };
3158
+ var DOCUMENT_URL_LIMIT = 5e4;
3159
+ var DOCUMENT_BYTE_LIMIT = 52428800;
3160
+ function declaredCount(doc) {
3161
+ return doc.kind === "index" ? doc.childLocs.length : doc.urlCount;
3162
+ }
3163
+ function oversizedDocuments(site) {
3164
+ return (site.discovery?.documents ?? []).filter(
3165
+ (doc) => doc.kind !== "unparsable" && (declaredCount(doc) > DOCUMENT_URL_LIMIT || doc.byteLength > DOCUMENT_BYTE_LIMIT)
3166
+ );
3167
+ }
3168
+ var sitemapLimitsExceeded = {
3169
+ id: "sitemap.limits.exceeded",
3170
+ severity: "error",
3171
+ summary: "No single sitemap document may exceed 50,000 entries or 50 MB",
3172
+ rigor: "spec-required",
3173
+ sources: ["sitemaps-protocol"],
3174
+ appliesTo: (site) => oversizedDocuments(site).length > 0,
3175
+ check: ({ site, issue }) => oversizedDocuments(site).map((doc) => {
3176
+ const count2 = declaredCount(doc);
3177
+ const noun = doc.kind === "index" ? "child sitemaps" : "URLs";
3178
+ const over = [];
3179
+ if (count2 > DOCUMENT_URL_LIMIT) {
3180
+ over.push(`${count2.toLocaleString("en-US")} ${noun} against a ceiling of 50,000`);
3181
+ }
3182
+ if (doc.byteLength > DOCUMENT_BYTE_LIMIT) {
3183
+ over.push(
3184
+ `${(doc.byteLength / 1048576).toFixed(1)} MB uncompressed against a ceiling of 50 MB`
3185
+ );
3186
+ }
3187
+ return issue({
3188
+ pageUrl: doc.url,
3189
+ message: `This sitemap document declares ${over.join(" and ")}. A consumer may stop reading at the limit, so everything past it is published in name only \u2014 split the document and reference the parts from an index.`,
3190
+ origin: { kind: "computed" }
3191
+ });
3192
+ })
3193
+ };
3194
+ function outOfScopeEntries(site) {
3195
+ const out = [];
3196
+ for (const entry of site.discovery?.urls ?? []) {
3197
+ if (!entry.documentUrl) continue;
3198
+ let scope;
3199
+ let loc;
3200
+ try {
3201
+ scope = new URL(entry.documentUrl);
3202
+ loc = new URL(entry.loc);
3203
+ } catch {
3204
+ continue;
3205
+ }
3206
+ const prefix = scope.pathname.replace(/[^/]*$/, "");
3207
+ if (prefix === "/") continue;
3208
+ if (loc.origin !== scope.origin) continue;
3209
+ if (loc.pathname.startsWith(prefix)) continue;
3210
+ out.push({ loc: entry.loc, from: entry.documentUrl });
3211
+ }
3212
+ return out;
3213
+ }
3214
+ var sitemapEntryOutOfScope = {
3215
+ id: "sitemap.entry.out-of-scope",
3216
+ severity: "error",
3217
+ summary: "A sitemap may only list URLs under its own directory",
3218
+ rigor: "spec-required",
3219
+ sources: ["sitemaps-protocol"],
3220
+ appliesTo: (site) => outOfScopeEntries(site).length > 0,
3221
+ check: ({ site, issue }) => {
3222
+ const bad = outOfScopeEntries(site);
3223
+ const byDocument = /* @__PURE__ */ new Map();
3224
+ for (const { loc, from } of bad) {
3225
+ byDocument.set(from, [...byDocument.get(from) ?? [], loc]);
3226
+ }
3227
+ return [...byDocument].map(
3228
+ ([from, locs]) => issue({
3229
+ pageUrl: from,
3230
+ message: `${locs.length} entr${locs.length === 1 ? "y is" : "ies are"} outside this sitemap's directory \`${new URL(from).pathname.replace(/[^/]*$/, "")}\`: ${sample(locs)}. A sitemap only speaks for the path it is served from, so a consumer may drop ${locs.length === 1 ? "it" : "them"} \u2014 serve the document from the root, or move the entries into a sitemap that covers them.`,
3231
+ origin: { kind: "computed" }
3232
+ })
3233
+ );
3234
+ }
3235
+ };
3159
3236
  var sitemapEntryCrossHost = {
3160
3237
  id: "sitemap.entry.cross-host",
3161
3238
  severity: "error",
@@ -3485,7 +3562,7 @@ function isAbsolute(value) {
3485
3562
  }
3486
3563
  var SITE_RULES = [
3487
3564
  hreflangMissing,
3488
- hreflangSitemapMismatch,
3565
+ hreflangClusterIncomplete,
3489
3566
  iconsIcoMissing,
3490
3567
  robotsBlocksPage,
3491
3568
  robotsBlocksSite,
@@ -3501,12 +3578,14 @@ var SITE_RULES = [
3501
3578
  sitemapEntryInvalidUrl,
3502
3579
  sitemapEntryNoindex,
3503
3580
  sitemapEntryNonCanonical,
3581
+ sitemapEntryOutOfScope,
3504
3582
  sitemapEntryProtocolMismatch,
3505
3583
  sitemapEntryRedirects,
3506
3584
  sitemapEntryUnreachable,
3507
3585
  sitemapFieldInvalid,
3508
3586
  sitemapIndexChildError,
3509
3587
  sitemapLastmodInvalid,
3588
+ sitemapLimitsExceeded,
3510
3589
  sitemapMissing,
3511
3590
  sitemapOrphans,
3512
3591
  sitemapUnparsable
@@ -3778,9 +3857,23 @@ async function discoverSitemap(baseUrl, options = {}) {
3778
3857
  diagnostics.atWellKnownPath = isWellKnownPath(candidate.url);
3779
3858
  const collected = [];
3780
3859
  const seen = /* @__PURE__ */ new Set();
3860
+ const documents = [];
3781
3861
  let truncated = false;
3862
+ documents.push({
3863
+ url: candidate.url,
3864
+ status: doc.status,
3865
+ byteLength: doc.byteLength,
3866
+ gzipped: doc.gzipped,
3867
+ kind: parsed.kind === "index" ? "index" : "urlset",
3868
+ childLocs: parsed.kind === "index" ? [...parsed.sitemaps] : [],
3869
+ // What the document declares, not what survived dedupe and the cap: the
3870
+ // ceiling is on the file's contents, and a post-dedupe count would answer
3871
+ // a question the protocol never asked.
3872
+ urlCount: parsed.kind === "urlset" ? parsed.urls.length : 0,
3873
+ declaredInRobots: candidate.declaredInRobots
3874
+ });
3782
3875
  if (parsed.kind === "urlset") {
3783
- truncated = pushEntries(collected, seen, parsed.urls, maxUrls);
3876
+ truncated = pushEntries(collected, seen, parsed.urls, maxUrls, candidate.url);
3784
3877
  } else {
3785
3878
  diagnostics.isIndex = true;
3786
3879
  const children = parsed.sitemaps.slice(0, maxSitemaps);
@@ -3798,17 +3891,44 @@ async function discoverSitemap(baseUrl, options = {}) {
3798
3891
  }
3799
3892
  const childDoc = await fetchDoc(childUrl, options);
3800
3893
  if (!childDoc.body) {
3894
+ documents.push({
3895
+ url: childUrl,
3896
+ status: childDoc.status,
3897
+ byteLength: childDoc.byteLength,
3898
+ gzipped: childDoc.gzipped,
3899
+ kind: "unparsable",
3900
+ childLocs: [],
3901
+ urlCount: 0,
3902
+ declaredInRobots: declaredUrls.includes(childUrl),
3903
+ parentUrl: candidate.url
3904
+ });
3801
3905
  diagnostics.childSitemapErrors += 1;
3802
3906
  diagnostics.warnings.push(`Child sitemap unreachable: ${childUrl}`);
3803
3907
  continue;
3804
3908
  }
3805
3909
  const childParsed = parseSitemap(childDoc.body);
3910
+ documents.push({
3911
+ url: childUrl,
3912
+ status: childDoc.status,
3913
+ byteLength: childDoc.byteLength,
3914
+ gzipped: childDoc.gzipped,
3915
+ // A child that is itself an index is recorded as one. This run still
3916
+ // treats it as an error below, because that is what it has always
3917
+ // done and replacing that verdict needs a source no specification
3918
+ // provides — see `docs/sitemap-robots-plan.md` §4.3. The tree now
3919
+ // says which of the two it was, which is the part that was lost.
3920
+ kind: childParsed.kind === "unknown" ? "unparsable" : childParsed.kind,
3921
+ childLocs: childParsed.kind === "index" ? [...childParsed.sitemaps] : [],
3922
+ urlCount: childParsed.kind === "urlset" ? childParsed.urls.length : 0,
3923
+ declaredInRobots: declaredUrls.includes(childUrl),
3924
+ parentUrl: candidate.url
3925
+ });
3806
3926
  if (childParsed.kind !== "urlset") {
3807
3927
  diagnostics.childSitemapErrors += 1;
3808
3928
  diagnostics.warnings.push(`Child sitemap not a urlset: ${childUrl}`);
3809
3929
  continue;
3810
3930
  }
3811
- if (pushEntries(collected, seen, childParsed.urls, maxUrls)) truncated = true;
3931
+ if (pushEntries(collected, seen, childParsed.urls, maxUrls, childUrl)) truncated = true;
3812
3932
  }
3813
3933
  }
3814
3934
  diagnostics.urlCount = collected.length;
@@ -3820,6 +3940,7 @@ async function discoverSitemap(baseUrl, options = {}) {
3820
3940
  baseUrl,
3821
3941
  source: candidate.source,
3822
3942
  urls: collected,
3943
+ documents,
3823
3944
  diagnostics,
3824
3945
  truncated
3825
3946
  };
@@ -3828,7 +3949,15 @@ async function discoverSitemap(baseUrl, options = {}) {
3828
3949
  return crawlSite(baseUrl, origin, options, diagnostics);
3829
3950
  }
3830
3951
  diagnostics.warnings.push("No sitemap found.");
3831
- return { origin, baseUrl, source: "well-known", urls: [], diagnostics, truncated: false };
3952
+ return {
3953
+ origin,
3954
+ baseUrl,
3955
+ source: "well-known",
3956
+ urls: [],
3957
+ documents: [],
3958
+ diagnostics,
3959
+ truncated: false
3960
+ };
3832
3961
  }
3833
3962
  async function crawlSite(baseUrl, origin, options, diagnostics) {
3834
3963
  const result = await crawl({
@@ -3852,16 +3981,20 @@ async function crawlSite(baseUrl, origin, options, diagnostics) {
3852
3981
  baseUrl,
3853
3982
  source: "crawl",
3854
3983
  urls,
3984
+ // No document declared these: the crawl found them by following links. An
3985
+ // empty tree is the honest answer, and the rules that read it are the ones
3986
+ // that judge documents — which this run has none of.
3987
+ documents: [],
3855
3988
  diagnostics,
3856
3989
  truncated: result.truncated
3857
3990
  };
3858
3991
  }
3859
- function pushEntries(out, seen, entries, cap) {
3992
+ function pushEntries(out, seen, entries, cap, documentUrl) {
3860
3993
  for (const entry of entries) {
3861
3994
  if (out.length >= cap) return true;
3862
3995
  if (seen.has(entry.loc)) continue;
3863
3996
  seen.add(entry.loc);
3864
- out.push(entry);
3997
+ out.push({ ...entry, documentUrl });
3865
3998
  }
3866
3999
  return out.length >= cap;
3867
4000
  }
@@ -3890,26 +4023,29 @@ async function fetchDoc(url, options) {
3890
4023
  accept: "application/xml,text/xml,application/gzip;q=0.9,*/*;q=0.5"
3891
4024
  }
3892
4025
  });
3893
- if (!res.ok) return { status: res.status };
4026
+ if (!res.ok) return { status: res.status, byteLength: 0, gzipped: false };
3894
4027
  const encoding = res.headers.get("content-encoding")?.toLowerCase() ?? "";
3895
4028
  const looksGzip = url.toLowerCase().endsWith(".gz") || encoding.includes("gzip");
3896
4029
  if (looksGzip) {
3897
4030
  const buf = Buffer.from(await res.arrayBuffer());
3898
4031
  try {
3899
- return { status: res.status, body: gunzipSync(buf).toString("utf8") };
4032
+ return withLength(res.status, gunzipSync(buf).toString("utf8"), true);
3900
4033
  } catch {
3901
- return { status: res.status, body: buf.toString("utf8") };
4034
+ return withLength(res.status, buf.toString("utf8"), true);
3902
4035
  }
3903
4036
  }
3904
- return { status: res.status, body: await res.text() };
4037
+ return withLength(res.status, await res.text(), false);
3905
4038
  } catch (err) {
3906
4039
  const reason = err instanceof Error ? err.message : String(err);
3907
- return { status: 0, error: reason || "network error" };
4040
+ return { status: 0, error: reason || "network error", byteLength: 0, gzipped: false };
3908
4041
  } finally {
3909
4042
  cleanup();
3910
4043
  restoreTls();
3911
4044
  }
3912
4045
  }
4046
+ function withLength(status, body, gzipped) {
4047
+ return { status, body, byteLength: Buffer.byteLength(body, "utf8"), gzipped };
4048
+ }
3913
4049
  function relaxTlsIfRequested2(enabled) {
3914
4050
  if (!enabled) return () => void 0;
3915
4051
  const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
@@ -4794,6 +4930,89 @@ var PROSE_RULES = [
4794
4930
  ];
4795
4931
  var PROSE_RULE_BY_ID = new Map(PROSE_RULES.map((r) => [r.id, r]));
4796
4932
 
4933
+ // src/lib/rules/site-prose.ts
4934
+ var hreflangSitemapMismatch = {
4935
+ id: "hreflang.sitemap-mismatch",
4936
+ summary: "`<head>` alternates advertise a locale the sitemap does not list",
4937
+ prose: "Your `<head>` advertises a translation your sitemap does not list. Is the sitemap missing an entry, or is the alternate pointing at a page you did not mean to publish?",
4938
+ why: "The two are derived from one intent by different code paths, so a disagreement means one of them is wrong. No specification says which: the declaration methods are equivalent, and a page can legitimately be cross-linked and kept out of the sitemap.",
4939
+ appliesTo: (site) => site.localeAxis.multilingual && (site.discovery?.urls.length ?? 0) > 0,
4940
+ ask: (site) => {
4941
+ const axis = new Set(site.localeAxis.locales.map((l) => l.toLowerCase()));
4942
+ const bySitemap = /* @__PURE__ */ new Map();
4943
+ for (const entry of site.discovery?.urls ?? []) {
4944
+ let pathname;
4945
+ try {
4946
+ pathname = new URL(entry.loc).pathname;
4947
+ } catch {
4948
+ continue;
4949
+ }
4950
+ const { locale } = splitRoute(pathname);
4951
+ if (locale === "x-default") continue;
4952
+ if (!axis.has(locale.toLowerCase())) continue;
4953
+ const route = site.clusterRouteOf?.(entry.loc) ?? splitRoute(pathname).route;
4954
+ const set = bySitemap.get(route) ?? /* @__PURE__ */ new Set();
4955
+ set.add(locale.toLowerCase());
4956
+ bySitemap.set(route, set);
4957
+ }
4958
+ const drafts = [];
4959
+ for (const page of site.pages) {
4960
+ const head = /* @__PURE__ */ new Set();
4961
+ for (const alt of page.links.alternates) {
4962
+ if (alt.isXDefault) continue;
4963
+ const tag = alt.hreflang?.trim().toLowerCase();
4964
+ if (tag) head.add(tag);
4965
+ }
4966
+ if (head.size === 0) continue;
4967
+ let pathname;
4968
+ try {
4969
+ pathname = new URL(page.fetch.finalUrl).pathname;
4970
+ } catch {
4971
+ continue;
4972
+ }
4973
+ const route = site.clusterRouteOf?.(page.fetch.finalUrl) ?? splitRoute(pathname).route;
4974
+ const inSitemap = bySitemap.get(route);
4975
+ if (!inSitemap || inSitemap.size === 0) continue;
4976
+ const unlisted = [...head].filter((l) => !inSitemap.has(l)).sort();
4977
+ if (unlisted.length === 0) continue;
4978
+ drafts.push({
4979
+ pageUrl: page.fetch.finalUrl,
4980
+ evidence: {
4981
+ route,
4982
+ // Both sides, verbatim and sorted, so an agent can see the shape of
4983
+ // the disagreement rather than take our word for its size.
4984
+ headAdvertises: [...head].sort(),
4985
+ sitemapLists: [...inSitemap].sort(),
4986
+ advertisedButUnlisted: unlisted
4987
+ }
4988
+ });
4989
+ }
4990
+ return drafts;
4991
+ }
4992
+ };
4993
+ var SITE_PROSE_RULES = [hreflangSitemapMismatch];
4994
+ function collectSiteAdvisories(site, rules = SITE_PROSE_RULES) {
4995
+ const out = [];
4996
+ for (const rule of rules) {
4997
+ if (rule.appliesTo && !rule.appliesTo(site)) continue;
4998
+ for (const draft of rule.ask(site)) {
4999
+ out.push({
5000
+ ruleId: rule.id,
5001
+ kind: "prose",
5002
+ prose: rule.prose,
5003
+ // `null` where nothing supports the question, which is exactly the
5004
+ // state this rule is in and the reason it stopped being a verdict.
5005
+ rigor: rule.rigor ?? null,
5006
+ sources: rule.sources ?? [],
5007
+ evidence: draft.evidence,
5008
+ verdict: "needs-judgment",
5009
+ pageUrl: draft.pageUrl
5010
+ });
5011
+ }
5012
+ }
5013
+ return out;
5014
+ }
5015
+
4797
5016
  // src/lib/core/links/extract.ts
4798
5017
  import * as cheerio3 from "cheerio";
4799
5018
  var ANCHOR_SPEC = { selector: "a[href]", attr: "href", source: "a" };
@@ -5501,6 +5720,14 @@ async function runAudit(inputUrl, options = {}) {
5501
5720
  sources: draft.sources
5502
5721
  };
5503
5722
  });
5723
+ if (options.advisories) {
5724
+ for (const advisory of collectSiteAdvisories(siteContext)) {
5725
+ advisories.push({
5726
+ ...advisory,
5727
+ id: fingerprint("advisory", advisory.ruleId, routeKey(advisory.pageUrl))
5728
+ });
5729
+ }
5730
+ }
5504
5731
  const brokenLinks = [];
5505
5732
  for (const { pageUrl, broken } of linkReport.brokenByPage) {
5506
5733
  for (const check of broken) {
@@ -5637,6 +5864,10 @@ function syntheticDiscovery(origin, baseUrl, pages, truncated) {
5637
5864
  baseUrl,
5638
5865
  source: "crawl",
5639
5866
  urls: pages.map((p) => ({ loc: p.fetch.finalUrl })),
5867
+ // Crawl-derived: no sitemap document was read, so the tree is empty rather
5868
+ // than absent. The document rules ask "what did each document declare?" and
5869
+ // must see that nothing declared anything.
5870
+ documents: [],
5640
5871
  diagnostics: {
5641
5872
  found: false,
5642
5873
  status: 0,
@@ -6277,6 +6508,7 @@ export {
6277
6508
  PROFILES,
6278
6509
  PROFILE_NAMES,
6279
6510
  PROSE_RULES,
6511
+ SITE_PROSE_RULES,
6280
6512
  routeKey,
6281
6513
  targetKey,
6282
6514
  fingerprint,
@@ -6289,4 +6521,4 @@ export {
6289
6521
  summarize,
6290
6522
  Logger
6291
6523
  };
6292
- //# sourceMappingURL=chunk-DKGBLJ4H.js.map
6524
+ //# sourceMappingURL=chunk-CWEJ6EXT.js.map