@goflag/cli 0.2.7 → 0.2.8

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/README.md CHANGED
@@ -16,7 +16,7 @@ on the site. A human cannot check those on 400 pages. That is the whole job.
16
16
  ## What it checks
17
17
 
18
18
  - **Broken links** — scrapes every crawled page, dedupes targets globally (a footer link on 500 pages is probed once), and checks each with `HEAD`→`GET` fallback, redirect-chain/loop detection, soft-404 and anti-bot (403/429) triage. Broken links are mapped back to the pages that reference them.
19
- - **Missing translation pages** — builds a `route × locale` matrix and flags every route that exists in one locale but is missing in another, plus hreflang reciprocity gaps (`A → B` with no `B → A`), missing `x-default`, and locale tags naming a language, script or region that does not exist. Rows are keyed by pathname, unless your sitemap declares its translation clusters with `xhtml:link` — then those declarations decide which URLs are one page, whatever their slugs, and the run reports `diagnostics.declaredClusters`.
19
+ - **Missing translation pages** — builds a `route × locale` matrix and flags every route that exists in one locale but is missing in another, plus hreflang reciprocity gaps (`A → B` with no `B → A`), missing `x-default`, and locale tags naming a language, script or region that does not exist. Rows are keyed by pathname, unless the site declares its translation clusters — either with `xhtml:link` in the sitemap, or with reciprocal `hreflang` between two crawled pages' `<head>`. Then those declarations decide which URLs are one page, whatever their slugs, and the run reports `diagnostics.declaredClusters`. The sitemap source survives `--coverage structural`, where the two locales of a slug-translating family are rarely both sampled; the `<head>` source needs both pages in hand and is what fixes the common site that declares properly there and nothing in its sitemap.
20
20
  - **robots.txt policy** — flags a site that forbids crawling while its pages ask to be indexed. The two declarations cannot both hold, and robots.txt wins, so the meta tag is never even read.
21
21
  - **SEO metadata** — lints each page's `<head>` for the handful of mistakes that actually hurt in search and social and are invisible in a browser: missing/oversized `<title>` and description, missing/relative canonical, missing `og:title`/`og:description`/`og:image`, missing viewport, and contradictory `robots`/`googlebot`/`X-Robots-Tag` directives.
22
22
  - **Unreachable pages** — a page the crawl reached that answered non-2xx, or that never answered at all (timeout, reset, DNS failure — recorded as `status: 0`), is reported in `unreachablePages`. goflag asks a second time, from the back of the queue, before calling a page unreachable. Each one is an `error`-severity finding: it turns the verdict red, counts toward `--max-debt`, and fails the build even under `--fail-on error`. A page that silently dropped out of the audited set is what poisons a baseline, so it is reported rather than warned about.
@@ -146,6 +146,18 @@ authority nobody assigned. Write your consumer against that. The same document
146
146
  ships inside the package as `rules.json`, if you would rather read it than run
147
147
  anything.
148
148
 
149
+ The flag list below is data too:
150
+
151
+ ```sh
152
+ npx @goflag/cli flags > flags.json
153
+ ```
154
+
155
+ Same idea, one step further: `flags` prints the very table `goflag --help` is
156
+ rendered from and the argument parser dispatches on, so a flag cannot exist in
157
+ one and not the other. Each entry carries its long and short forms, its argument
158
+ placeholder, its default, the group it belongs to and any flag it requires. It
159
+ ships inside the package as `flags.json` as well.
160
+
149
161
  It deliberately omits the message a finding prints — that is built at audit time
150
162
  from what the page actually says, so a static copy would be a sample rather than
151
163
  a fact, and a sample presented as the truth is how this project's own
@@ -424,6 +436,17 @@ goflag https://example.com --ignore-holes /legal --ignore-holes "/blog/**"
424
436
  Suppressed gaps are counted in `diagnostics.ignoredHoles`, so a quiet report
425
437
  never means "nothing was wrong" by accident.
426
438
 
439
+ The opposite failure is quieter still. A cell in the matrix can be filled by an
440
+ `hreflang` on another page and by nothing else — not crawled, not listed in your
441
+ sitemap — and a filled cell reads as a translation that exists. A page
442
+ advertising a translation the site does not serve therefore plugs the very gap
443
+ it should reveal. Those cells are counted in
444
+ `diagnostics.unverifiedAlternates`, and goflag deliberately does **not** act on
445
+ them: refusing to believe an alternate the sitemap omits would invent holes on
446
+ every site using `@goflag/next`'s `sitemap: false` on purpose. Read that number
447
+ next to `missingTranslations` — a `0` beside a non-zero count is the one place
448
+ this report can be quietly wrong.
449
+
427
450
  ## In CI
428
451
 
429
452
  Two moments are worth auditing, and they answer different questions.
@@ -455,7 +478,7 @@ Three rules, whichever you use:
455
478
  stages: [build, deploy, audit]
456
479
 
457
480
  variables:
458
- GOFLAG_VERSION: "0.2.7"
481
+ GOFLAG_VERSION: "0.2.8"
459
482
 
460
483
  # Before the merge: build the branch, let goflag boot it, audit that.
461
484
  seo:mr:
@@ -507,7 +530,7 @@ jobs:
507
530
  - run: corepack enable && pnpm install --frozen-lockfile
508
531
  - run: pnpm build
509
532
  - run: |
510
- npx --yes @goflag/cli@0.2.7 http://localhost:3000 \
533
+ npx --yes @goflag/cli@0.2.8 http://localhost:3000 \
511
534
  --start "pnpm start" --static --no-external \
512
535
  --baseline .goflag/baseline.json --regressions-only --max-debt 41 \
513
536
  --report goflag-report.json
@@ -1856,13 +1856,18 @@ function buildI18nMatrix(pages, options = {}) {
1856
1856
  const grid = /* @__PURE__ */ new Map();
1857
1857
  const locales = /* @__PURE__ */ new Set();
1858
1858
  const routes = /* @__PURE__ */ new Set();
1859
+ const declaredSet = new Set(options.declaredUrls ?? []);
1859
1860
  for (const locale of options.locales ?? []) {
1860
1861
  const tag = locale.trim().toLowerCase();
1861
1862
  if (tag) locales.add(tag);
1862
1863
  }
1863
- function rowOf(url, pathnameRoute) {
1864
+ function rowOf2(url, pathnameRoute) {
1864
1865
  return options.clusterRouteOf?.(url) ?? pathnameRoute;
1865
1866
  }
1867
+ function sourceOf(url) {
1868
+ if (inspectedByUrl.has(url)) return "crawled";
1869
+ return declaredSet.has(url) ? "sitemap" : "alternate";
1870
+ }
1866
1871
  function record(route, locale, url) {
1867
1872
  routes.add(route);
1868
1873
  locales.add(locale);
@@ -1872,13 +1877,13 @@ function buildI18nMatrix(pages, options = {}) {
1872
1877
  grid.set(route, row);
1873
1878
  }
1874
1879
  if (!row.has(locale)) {
1875
- row.set(locale, { url, inspected: inspectedByUrl.has(url) });
1880
+ row.set(locale, { url, inspected: inspectedByUrl.has(url), source: sourceOf(url) });
1876
1881
  }
1877
1882
  }
1878
1883
  for (const page of pages) {
1879
1884
  const url = new URL(page.fetch.finalUrl);
1880
1885
  const { route: selfRoute, locale: selfLocale } = splitRoute(url.pathname);
1881
- record(rowOf(page.fetch.finalUrl, selfRoute), selfLocale, page.fetch.finalUrl);
1886
+ record(rowOf2(page.fetch.finalUrl, selfRoute), selfLocale, page.fetch.finalUrl);
1882
1887
  for (const alt of page.links.alternates) {
1883
1888
  let altUrl;
1884
1889
  try {
@@ -1888,7 +1893,7 @@ function buildI18nMatrix(pages, options = {}) {
1888
1893
  }
1889
1894
  const { route } = splitRoute(altUrl.pathname);
1890
1895
  const locale = alt.isXDefault ? "x-default" : localeIdentity(alt.hreflang);
1891
- record(rowOf(altUrl.toString(), route || selfRoute), locale, altUrl.toString());
1896
+ record(rowOf2(altUrl.toString(), route || selfRoute), locale, altUrl.toString());
1892
1897
  }
1893
1898
  }
1894
1899
  for (const declared of options.declaredUrls ?? []) {
@@ -1899,7 +1904,7 @@ function buildI18nMatrix(pages, options = {}) {
1899
1904
  continue;
1900
1905
  }
1901
1906
  const { route, locale } = splitRoute(url.pathname);
1902
- record(rowOf(url.toString(), route), locale, url.toString());
1907
+ record(rowOf2(url.toString(), route), locale, url.toString());
1903
1908
  }
1904
1909
  const sortedLocales = sortLocales([...locales]);
1905
1910
  const sortedRoutes = [...routes].sort((a, b) => a.localeCompare(b));
@@ -1908,11 +1913,20 @@ function buildI18nMatrix(pages, options = {}) {
1908
1913
  cells[route] = {};
1909
1914
  for (const locale of sortedLocales) {
1910
1915
  const slot = grid.get(route)?.get(locale);
1911
- cells[route][locale] = slot ? { url: slot.url, inspected: slot.inspected } : { url: null, inspected: false };
1916
+ cells[route][locale] = slot ? { url: slot.url, inspected: slot.inspected, source: slot.source } : { url: null, inspected: false };
1912
1917
  }
1913
1918
  }
1914
1919
  return { locales: sortedLocales, routes: sortedRoutes, cells };
1915
1920
  }
1921
+ function countUnverifiedAlternates(matrix) {
1922
+ let count2 = 0;
1923
+ for (const route of matrix.routes) {
1924
+ for (const locale of matrix.locales) {
1925
+ if (matrix.cells[route]?.[locale]?.source === "alternate") count2++;
1926
+ }
1927
+ }
1928
+ return count2;
1929
+ }
1916
1930
  function reciprocityIssues(pages) {
1917
1931
  const issues = [];
1918
1932
  const byUrl = /* @__PURE__ */ new Map();
@@ -2013,6 +2027,9 @@ function declaredLocales(page) {
2013
2027
  }
2014
2028
  return out;
2015
2029
  }
2030
+ function rowOf(site, url, pathname) {
2031
+ return site.clusterRouteOf?.(url) ?? splitRoute(pathname).route;
2032
+ }
2016
2033
  function sitemapLocalesByRoute(site) {
2017
2034
  const byRoute = /* @__PURE__ */ new Map();
2018
2035
  for (const entry of site.discovery?.urls ?? []) {
@@ -2022,8 +2039,9 @@ function sitemapLocalesByRoute(site) {
2022
2039
  } catch {
2023
2040
  continue;
2024
2041
  }
2025
- const { route, locale } = splitRoute(pathname);
2042
+ const { locale } = splitRoute(pathname);
2026
2043
  if (locale === "x-default") continue;
2044
+ const route = rowOf(site, entry.loc, pathname);
2027
2045
  const set = byRoute.get(route) ?? /* @__PURE__ */ new Set();
2028
2046
  set.add(locale.toLowerCase());
2029
2047
  byRoute.set(route, set);
@@ -2087,7 +2105,7 @@ var hreflangSitemapMismatch = {
2087
2105
  } catch {
2088
2106
  continue;
2089
2107
  }
2090
- const { route } = splitRoute(pathname);
2108
+ const route = rowOf(site, page.fetch.finalUrl, pathname);
2091
2109
  const inSitemap = bySitemap.get(route);
2092
2110
  if (!inSitemap || inSitemap.size === 0) continue;
2093
2111
  const onlyInSitemap = sorted(new Set([...inSitemap].filter((l) => !head.has(l))));
@@ -2588,39 +2606,140 @@ function normalise(raw) {
2588
2606
  return `${url.origin}${path}${url.search}`;
2589
2607
  }
2590
2608
  var X_DEFAULT = "x-default";
2609
+ function clusterCount(rows) {
2610
+ return new Set(rows.values()).size;
2611
+ }
2612
+ function indexOf(rows, conflicts, refused) {
2613
+ return {
2614
+ rows,
2615
+ routeOf: (url) => {
2616
+ const key = normalise(url);
2617
+ return key === null ? void 0 : rows.get(key);
2618
+ },
2619
+ size: clusterCount(rows),
2620
+ conflicts,
2621
+ refused
2622
+ };
2623
+ }
2591
2624
  function buildClusterIndex(entries) {
2592
- const routeByUrl = /* @__PURE__ */ new Map();
2625
+ const rows = /* @__PURE__ */ new Map();
2593
2626
  const conflicts = [];
2594
- const anchors = /* @__PURE__ */ new Set();
2627
+ let refused = 0;
2595
2628
  for (const entry of entries) {
2596
2629
  const alternates = entry.alternates;
2597
2630
  if (!alternates || alternates.length === 0) continue;
2598
2631
  const xDefault = alternates.find((a) => a.hreflang.trim().toLowerCase() === X_DEFAULT);
2599
- if (!xDefault) continue;
2632
+ if (!xDefault) {
2633
+ refused++;
2634
+ continue;
2635
+ }
2600
2636
  const anchorUrl = normalise(xDefault.href);
2601
- if (!anchorUrl) continue;
2637
+ if (!anchorUrl) {
2638
+ refused++;
2639
+ continue;
2640
+ }
2602
2641
  const { route: anchorRoute } = splitRoute(new URL(anchorUrl).pathname);
2603
- anchors.add(anchorRoute);
2604
2642
  const members = [entry.loc, ...alternates.map((a) => a.href)];
2605
2643
  for (const member of members) {
2606
2644
  const url = normalise(member);
2607
2645
  if (!url) continue;
2608
- const existing = routeByUrl.get(url);
2646
+ const existing = rows.get(url);
2609
2647
  if (existing === void 0) {
2610
- routeByUrl.set(url, anchorRoute);
2648
+ rows.set(url, anchorRoute);
2611
2649
  continue;
2612
2650
  }
2613
2651
  if (existing !== anchorRoute && !conflicts.includes(url)) conflicts.push(url);
2614
2652
  }
2615
2653
  }
2616
- return {
2617
- routeOf: (url) => {
2618
- const key = normalise(url);
2619
- return key === null ? void 0 : routeByUrl.get(key);
2620
- },
2621
- size: anchors.size,
2622
- conflicts
2654
+ return indexOf(rows, conflicts, refused);
2655
+ }
2656
+ function readHeads(pages) {
2657
+ const seen = /* @__PURE__ */ new Map();
2658
+ for (const page of pages) {
2659
+ const self = normalise(page.fetch.finalUrl);
2660
+ if (self === null || seen.has(self)) continue;
2661
+ const peers = /* @__PURE__ */ new Set();
2662
+ const xDefaults = /* @__PURE__ */ new Set();
2663
+ for (const alt of page.links.alternates) {
2664
+ const href = normalise(alt.href);
2665
+ if (href === null) continue;
2666
+ if (alt.isXDefault) xDefaults.add(href);
2667
+ else if (href !== self) peers.add(href);
2668
+ }
2669
+ seen.set(self, { peers, xDefault: xDefaults.size === 1 ? [...xDefaults][0] : null });
2670
+ }
2671
+ return seen;
2672
+ }
2673
+ function buildHeadClusterIndex(pages) {
2674
+ const seen = readHeads(pages);
2675
+ const parent = /* @__PURE__ */ new Map();
2676
+ for (const url of seen.keys()) parent.set(url, url);
2677
+ const find = (start) => {
2678
+ let root = start;
2679
+ while (parent.get(root) !== root) root = parent.get(root);
2680
+ let cursor = start;
2681
+ while (parent.get(cursor) !== root) {
2682
+ const next = parent.get(cursor);
2683
+ parent.set(cursor, root);
2684
+ cursor = next;
2685
+ }
2686
+ return root;
2687
+ };
2688
+ for (const [url, decl] of seen) {
2689
+ for (const peer of decl.peers) {
2690
+ if (!seen.get(peer)?.peers.has(url)) continue;
2691
+ const [a, b] = [find(url), find(peer)];
2692
+ if (a !== b) parent.set(a, b);
2693
+ }
2694
+ }
2695
+ const components = /* @__PURE__ */ new Map();
2696
+ for (const url of seen.keys()) {
2697
+ const root = find(url);
2698
+ const members = components.get(root);
2699
+ if (members) members.push(url);
2700
+ else components.set(root, [url]);
2701
+ }
2702
+ const rows = /* @__PURE__ */ new Map();
2703
+ let refused = 0;
2704
+ for (const members of components.values()) {
2705
+ if (members.length < 2) continue;
2706
+ const declared = new Set(
2707
+ members.map((m) => seen.get(m)?.xDefault).filter((x) => Boolean(x))
2708
+ );
2709
+ if (declared.size !== 1) {
2710
+ refused++;
2711
+ continue;
2712
+ }
2713
+ const anchor = [...declared][0];
2714
+ if (!members.includes(anchor)) {
2715
+ refused++;
2716
+ continue;
2717
+ }
2718
+ const { route } = splitRoute(new URL(anchor).pathname);
2719
+ for (const member of members) rows.set(member, route);
2720
+ }
2721
+ return indexOf(rows, [], refused);
2722
+ }
2723
+ function combineClusterIndexes(primary, secondary) {
2724
+ const rows = new Map(secondary.rows);
2725
+ const conflicts = [...primary.conflicts];
2726
+ const add = (url) => {
2727
+ if (!conflicts.includes(url)) conflicts.push(url);
2623
2728
  };
2729
+ for (const url of secondary.conflicts) add(url);
2730
+ for (const [url, route] of primary.rows) {
2731
+ const claimed = rows.get(url);
2732
+ if (claimed !== void 0 && claimed !== route) add(url);
2733
+ rows.set(url, route);
2734
+ }
2735
+ return indexOf(rows, conflicts, primary.refused + secondary.refused);
2736
+ }
2737
+ function clustersOnlyFrom(primary, secondary) {
2738
+ const routes = /* @__PURE__ */ new Set();
2739
+ for (const [url, route] of secondary.rows) {
2740
+ if (!primary.rows.has(url)) routes.add(route);
2741
+ }
2742
+ return routes.size;
2624
2743
  }
2625
2744
 
2626
2745
  // src/lib/core/coverage.ts
@@ -3626,7 +3745,7 @@ async function runAudit(inputUrl, options = {}) {
3626
3745
  crawlFallback: false
3627
3746
  }).catch(() => void 0);
3628
3747
  const allSitemapUrls = (discovery?.urls ?? []).map((u) => u.loc);
3629
- const clusters = buildClusterIndex(discovery?.urls ?? []);
3748
+ const sitemapClusters = buildClusterIndex(discovery?.urls ?? []);
3630
3749
  const coverageMode = options.coverage ?? (allSitemapUrls.length > 0 ? "structural" : "all");
3631
3750
  const selection = coverageMode === "structural" && allSitemapUrls.length > 0 ? selectByStructure(allSitemapUrls, { locales: options.locales }) : void 0;
3632
3751
  const sitemapUrls = selection?.urls ?? allSitemapUrls;
@@ -3729,6 +3848,9 @@ async function runAudit(inputUrl, options = {}) {
3729
3848
  );
3730
3849
  }
3731
3850
  if (!ICU_KNOWS_LANGUAGES) warnings.push(ICU_UNAVAILABLE_WARNING);
3851
+ const headClusters = buildHeadClusterIndex(htmlPages);
3852
+ const clusters = combineClusterIndexes(sitemapClusters, headClusters);
3853
+ const clustersFromHead = clustersOnlyFrom(sitemapClusters, headClusters);
3732
3854
  const matrix = buildI18nMatrix(htmlPages, {
3733
3855
  declaredUrls: sitemapUrls,
3734
3856
  locales: localeAxis.locales,
@@ -3736,7 +3858,13 @@ async function runAudit(inputUrl, options = {}) {
3736
3858
  });
3737
3859
  if (clusters.conflicts.length > 0) {
3738
3860
  warnings.push(
3739
- `${clusters.conflicts.length} URL(s) are declared in two different hreflang clusters by the sitemap; the first declaration was kept: ${clusters.conflicts.slice(0, 3).join(", ")}${clusters.conflicts.length > 3 ? `, +${clusters.conflicts.length - 3} more` : ""}.`
3861
+ `${clusters.conflicts.length} URL(s) are declared in two different hreflang clusters by the sitemap and/or the pages' \`<head>\`; the sitemap's declaration was kept: ${clusters.conflicts.slice(0, 3).join(", ")}${clusters.conflicts.length > 3 ? `, +${clusters.conflicts.length - 3} more` : ""}.`
3862
+ );
3863
+ }
3864
+ const unverifiedAlternates = countUnverifiedAlternates(matrix);
3865
+ if (unverifiedAlternates > 0) {
3866
+ warnings.push(
3867
+ `${unverifiedAlternates} translation${unverifiedAlternates === 1 ? "" : "s"} in the matrix ${unverifiedAlternates === 1 ? "is" : "are"} vouched for only by an \`hreflang\` on another page \u2014 not crawled, and not in the sitemap. They are counted as present, so a page advertised but not served reads as translated rather than missing.`
3740
3868
  );
3741
3869
  }
3742
3870
  const holes = localeAxis.multilingual ? deriveTranslationHoles(matrix, options.ignoreHoles) : [];
@@ -3757,7 +3885,11 @@ async function runAudit(inputUrl, options = {}) {
3757
3885
  matrix,
3758
3886
  localeAxis,
3759
3887
  discovery,
3760
- robots
3888
+ robots,
3889
+ // Same index the matrix was built from, so a rule that groups by route
3890
+ // and the grid that reports holes cannot disagree about which URLs are
3891
+ // one page.
3892
+ clusterRouteOf: clusters.routeOf
3761
3893
  };
3762
3894
  const siteOccurrence = /* @__PURE__ */ new Map();
3763
3895
  const siteIssues = lintSite(siteContext).map((draft) => {
@@ -3883,13 +4015,22 @@ async function runAudit(inputUrl, options = {}) {
3883
4015
  },
3884
4016
  ...ignoredHoles > 0 ? { ignoredHoles } : {},
3885
4017
  ...duplicatePages > 0 ? { duplicatePages } : {},
4018
+ ...unverifiedAlternates > 0 ? { unverifiedAlternates } : {},
3886
4019
  // Only when the site declared clusters. A merge that changes which rows
3887
4020
  // exist must be visible: the alternative is a hole count that moved for
3888
4021
  // a reason nothing in the report explains.
3889
- ...clusters.size > 0 ? {
4022
+ ...clusters.size > 0 || clusters.refused > 0 ? {
3890
4023
  declaredClusters: {
3891
4024
  count: clusters.size,
3892
- ...clusters.conflicts.length > 0 ? { conflicts: clusters.conflicts.length } : {}
4025
+ ...clusters.conflicts.length > 0 ? { conflicts: clusters.conflicts.length } : {},
4026
+ // Which source did the work. On a site that declares both ways
4027
+ // the two indexes describe the same clusters, so this counts the
4028
+ // rows only the `<head>` supplied rather than a difference of
4029
+ // totals that would claim a gain that is not there.
4030
+ ...clustersFromHead > 0 ? { fromHead: clustersFromHead } : {},
4031
+ // Declared and not used, for want of an anchor to name the row
4032
+ // after. A declaration we saw and did not act on has to be said.
4033
+ ...clusters.refused > 0 ? { refused: clusters.refused } : {}
3893
4034
  }
3894
4035
  } : {},
3895
4036
  ...discovery ? {
@@ -4566,4 +4707,4 @@ export {
4566
4707
  summarize,
4567
4708
  Logger
4568
4709
  };
4569
- //# sourceMappingURL=chunk-UOKYUKHQ.js.map
4710
+ //# sourceMappingURL=chunk-SKX7UCGC.js.map