jourilo 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Jourilo 1.2.0 - generated by scripts/build.mjs. Do not edit; edit src/ instead.
2
+ // Jourilo 1.2.1 - generated by scripts/build.mjs. Do not edit; edit src/ instead.
3
3
  import { createRequire as __jouriloCreateRequire } from 'node:module';
4
4
  const require = __jouriloCreateRequire(import.meta.url);
5
5
  var __create = Object.create;
@@ -1096,23 +1096,23 @@ var require_axe = __commonJS({
1096
1096
  }
1097
1097
  function publish(promise) {
1098
1098
  var subscribers = promise._subscribers;
1099
- var settled = promise._state;
1099
+ var settled2 = promise._state;
1100
1100
  if (subscribers.length === 0) {
1101
1101
  return;
1102
1102
  }
1103
1103
  var child = void 0, callback = void 0, detail = promise._result;
1104
1104
  for (var i2 = 0; i2 < subscribers.length; i2 += 3) {
1105
1105
  child = subscribers[i2];
1106
- callback = subscribers[i2 + settled];
1106
+ callback = subscribers[i2 + settled2];
1107
1107
  if (child) {
1108
- invokeCallback(settled, child, callback, detail);
1108
+ invokeCallback(settled2, child, callback, detail);
1109
1109
  } else {
1110
1110
  callback(detail);
1111
1111
  }
1112
1112
  }
1113
1113
  promise._subscribers.length = 0;
1114
1114
  }
1115
- function invokeCallback(settled, promise, callback, detail) {
1115
+ function invokeCallback(settled2, promise, callback, detail) {
1116
1116
  var hasCallback = isFunction(callback), value = void 0, error = void 0, succeeded = true;
1117
1117
  if (hasCallback) {
1118
1118
  try {
@@ -1133,9 +1133,9 @@ var require_axe = __commonJS({
1133
1133
  resolve10(promise, value);
1134
1134
  } else if (succeeded === false) {
1135
1135
  reject(promise, error);
1136
- } else if (settled === FULFILLED) {
1136
+ } else if (settled2 === FULFILLED) {
1137
1137
  fulfill(promise, value);
1138
- } else if (settled === REJECTED) {
1138
+ } else if (settled2 === REJECTED) {
1139
1139
  reject(promise, value);
1140
1140
  }
1141
1141
  }
@@ -38823,7 +38823,7 @@ var require_axe = __commonJS({
38823
38823
  init_errors();
38824
38824
 
38825
38825
  // src/core/version.ts
38826
- var VERSION = true ? "1.2.0" : "0.0.0-dev";
38826
+ var VERSION = true ? "1.2.1" : "0.0.0-dev";
38827
38827
 
38828
38828
  // src/cli/framework.ts
38829
38829
  init_errors();
@@ -38877,6 +38877,26 @@ function num(ctx, key, fallback) {
38877
38877
  throw new JouriloError("USAGE", `--${key} must be a non-negative number.`);
38878
38878
  return n;
38879
38879
  }
38880
+ function pagesOption(ctx, fallback) {
38881
+ const v = str(ctx, "pages");
38882
+ if (v === void 0) return fallback;
38883
+ const n = Number(v);
38884
+ if (v.trim() === "" || !Number.isInteger(n) || n < 1)
38885
+ throw new JouriloError("USAGE", `--pages must be a whole number of 1 or more (got "${v}").`);
38886
+ return n;
38887
+ }
38888
+ var MAX_DURATION_LIMIT_S = 86400;
38889
+ function maxDurationOption(ctx) {
38890
+ const v = str(ctx, "max-duration");
38891
+ if (v === void 0) return void 0;
38892
+ const n = Number(v);
38893
+ if (v.trim() === "" || !Number.isFinite(n) || n < 1 || n > MAX_DURATION_LIMIT_S)
38894
+ throw new JouriloError(
38895
+ "USAGE",
38896
+ `--max-duration must be from 1 to ${MAX_DURATION_LIMIT_S} seconds (got "${v}").`
38897
+ );
38898
+ return n;
38899
+ }
38880
38900
  function list(ctx, key) {
38881
38901
  const v = ctx.values[key];
38882
38902
  if (Array.isArray(v))
@@ -38917,6 +38937,29 @@ function parseStateOptions(ctx) {
38917
38937
  function flag(ctx, key) {
38918
38938
  return ctx.values[key] === true;
38919
38939
  }
38940
+ function denyLocalFrom(ctx) {
38941
+ if (flag(ctx, "deny-local")) return true;
38942
+ const raw = process.env.JOURILO_DENY_LOCAL ?? "";
38943
+ const v = raw.trim().toLowerCase();
38944
+ if (!v || ["0", "false", "no", "off"].includes(v)) return false;
38945
+ if (["1", "true", "yes", "on"].includes(v)) return true;
38946
+ if (!warnedDenyLocal) {
38947
+ warnedDenyLocal = true;
38948
+ ctx.log(
38949
+ `JOURILO_DENY_LOCAL=${raw} is not recognised (use 1, true, yes or on to enable, 0, false, no or off to disable); --deny-local is on.`
38950
+ );
38951
+ }
38952
+ return true;
38953
+ }
38954
+ var warnedDenyLocal = false;
38955
+ function refuseDenyLocalWebkit(denyLocal, browser) {
38956
+ if (denyLocal && browser === "webkit")
38957
+ throw new JouriloError(
38958
+ "UNSAFE_URL",
38959
+ "--deny-local cannot be combined with --browser webkit: WebKit sends HTTPS redirects outside the network guard, so a redirect to a local address could not be blocked.",
38960
+ "Use --browser chromium or firefox."
38961
+ );
38962
+ }
38920
38963
  function requirePositional(ctx, index, name, usage2) {
38921
38964
  const v = ctx.positionals[index];
38922
38965
  if (!v) throw new JouriloError("USAGE", `Missing <${name}>.`, `Usage: ${usage2}`);
@@ -39073,7 +39116,7 @@ Re-run with --write to install.`
39073
39116
  };
39074
39117
 
39075
39118
  // src/cli/commands/audit.ts
39076
- import { existsSync as existsSync12, statSync as statSync5 } from "node:fs";
39119
+ import { existsSync as existsSync12, writeSync as writeSync2 } from "node:fs";
39077
39120
  import { resolve as resolve6 } from "node:path";
39078
39121
 
39079
39122
  // src/audit/engine.ts
@@ -39597,8 +39640,16 @@ function lexiconSource(concept, opts = {}) {
39597
39640
  if (opts.exact) return `^\\s*(?:${alt})\\s*[.!\u2026]?\\s*$`;
39598
39641
  return `(?<![\\p{L}\\p{N}])(?:${alt})${STEMS.has(concept) ? "" : "(?![\\p{L}\\p{N}])"}`;
39599
39642
  }
39643
+ function turkishFold(text) {
39644
+ return text.replace(/İ/g, "i").replace(/I/g, "\u0131").toLowerCase();
39645
+ }
39646
+ var LexiconRegExp = class extends RegExp {
39647
+ test(text) {
39648
+ return super.test(text) || super.test(turkishFold(text));
39649
+ }
39650
+ };
39600
39651
  function lexicon(concept, opts = {}) {
39601
- return new RegExp(lexiconSource(concept, opts), "iu");
39652
+ return new LexiconRegExp(lexiconSource(concept, opts), "iu");
39602
39653
  }
39603
39654
  function lexiconSources() {
39604
39655
  return Object.fromEntries(Object.keys(LEX).map((k) => [k, lexiconSource(k)]));
@@ -39613,11 +39664,12 @@ import net, { isIP as isIP2 } from "node:net";
39613
39664
 
39614
39665
  // src/core/url-safety.ts
39615
39666
  init_errors();
39667
+ import { execFile } from "node:child_process";
39616
39668
  import { lookup } from "node:dns/promises";
39617
39669
  import { isIP } from "node:net";
39618
39670
  var LOCAL_SUFFIXES = [".localhost", ".test"];
39619
39671
  function isLocalHostname(host) {
39620
- const h = host.replace(/^\[|\]$/g, "").toLowerCase();
39672
+ const h = host.replace(/^\[|\]$/g, "").toLowerCase().replace(/\.$/, "");
39621
39673
  return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h) || h === "0.0.0.0" || LOCAL_SUFFIXES.some((s) => h.endsWith(s));
39622
39674
  }
39623
39675
  var METADATA_V4 = /* @__PURE__ */ new Set(["169.254.169.254", "169.254.170.2", "169.254.169.123", "100.100.100.200"]);
@@ -39685,7 +39737,7 @@ function classifyIp(ip) {
39685
39737
  if (family === 6) return classifyV6(v);
39686
39738
  return "reserved";
39687
39739
  }
39688
- function addressVerdict(ip, policy) {
39740
+ function addressVerdict(ip, policy, port) {
39689
39741
  const cls = classifyIp(ip);
39690
39742
  switch (cls) {
39691
39743
  case "public":
@@ -39698,6 +39750,8 @@ function addressVerdict(ip, policy) {
39698
39750
  case "reserved":
39699
39751
  return { ok: false, reason: `${ip} is a reserved or special-purpose address.` };
39700
39752
  case "loopback":
39753
+ if (policy.denyLocal && !(port !== void 0 && policy.allowLoopbackPorts?.includes(port) && ip === "127.0.0.1"))
39754
+ return { ok: false, reason: `${ip} is this machine (loopback) and --deny-local is set.` };
39701
39755
  return policy.target === "local" || policy.allowPrivate ? { ok: true } : { ok: false, reason: `${ip} is this machine (loopback); a public target may not reach it.` };
39702
39756
  default:
39703
39757
  return policy.allowPrivate ? { ok: true } : {
@@ -39720,27 +39774,85 @@ function parseTargetUrl(raw) {
39720
39774
  return url;
39721
39775
  }
39722
39776
  var systemResolver = (host) => lookup(host, { all: true, verbatim: true });
39777
+ var RESOLVE_TIMEOUT_MS = 5e3;
39778
+ function withTimeout(work, ms, message) {
39779
+ let timer;
39780
+ const limit = new Promise((_, reject) => {
39781
+ timer = setTimeout(() => reject(new Error(message)), ms);
39782
+ timer.unref();
39783
+ });
39784
+ return Promise.race([work, limit]).finally(() => clearTimeout(timer));
39785
+ }
39786
+ var LookupFailed = class extends Error {
39787
+ };
39788
+ function resolveInChild(host, ms, exec = execFile) {
39789
+ const script = "require('node:dns').lookup(process.env.JOURILO_RESOLVE_HOST,{all:true,verbatim:true},(e,a)=>{if(e)process.exit(2);process.stdout.write(JSON.stringify(a),()=>process.exit(0))})";
39790
+ const env = { ...process.env, NODE_OPTIONS: "", JOURILO_RESOLVE_HOST: host };
39791
+ return new Promise((resolve10, reject) => {
39792
+ try {
39793
+ exec(process.execPath, ["-e", script], { timeout: ms, encoding: "utf8", env }, (err, stdout) => {
39794
+ if (err) {
39795
+ if (err.killed || err.code === 2) return reject(new LookupFailed(`DNS lookup failed for ${host}`));
39796
+ return resolve10([]);
39797
+ }
39798
+ try {
39799
+ resolve10(JSON.parse(stdout));
39800
+ } catch {
39801
+ resolve10([]);
39802
+ }
39803
+ });
39804
+ } catch {
39805
+ resolve10([]);
39806
+ }
39807
+ });
39808
+ }
39809
+ var DENY_LOCAL_HINT = "Unset --deny-local / JOURILO_DENY_LOCAL to audit a local development server, or pass a public URL.";
39723
39810
  var PRIVATE_HINT = "Pass --allow-private if you are authorized to test this host.";
39724
39811
  async function checkTarget(url, opts = {}) {
39812
+ return (await checkTargetDetailed(url, { ...opts, resolveLocalNames: false })).hostClass;
39813
+ }
39814
+ async function checkTargetDetailed(url, opts = {}) {
39725
39815
  const host = url.hostname.replace(/^\[|\]$/g, "");
39726
39816
  const literal = isIP(host) !== 0;
39817
+ if (opts.denyLocal && (isLocalHostname(host) || literal && classifyIp(host) === "loopback"))
39818
+ throw new JouriloError(
39819
+ "UNSAFE_URL",
39820
+ `${host} is a local address and --deny-local is set.`,
39821
+ DENY_LOCAL_HINT
39822
+ );
39727
39823
  if (literal) {
39728
39824
  const cls = classifyIp(host);
39729
39825
  if (cls === "metadata" || cls === "reserved" && host !== "0.0.0.0")
39730
39826
  throw new JouriloError("UNSAFE_URL", `${host} is a ${cls} address; Jourilo does not audit it.`);
39731
- if (cls === "loopback") return "local";
39732
- if (cls === "public") return "public";
39827
+ if (cls === "loopback") return { hostClass: "local" };
39828
+ if (cls === "public") return { hostClass: "public" };
39733
39829
  if (!opts.allowPrivate)
39734
39830
  throw new JouriloError("UNSAFE_URL", `${host} is a private network address.`, PRIVATE_HINT);
39735
- return "private";
39831
+ return { hostClass: "private" };
39736
39832
  }
39737
- if (isLocalHostname(host)) return "local";
39738
- if (opts.resolveDns === false) return "public";
39833
+ if (isLocalHostname(host)) {
39834
+ const h = host.toLowerCase();
39835
+ const needsLookup = LOCAL_SUFFIXES.some((x) => x !== ".localhost" && h.endsWith(x));
39836
+ if (opts.resolveDns === false || opts.resolveLocalNames === false || !needsLookup)
39837
+ return { hostClass: "local" };
39838
+ try {
39839
+ const limit = opts.resolveTimeoutMs ?? RESOLVE_TIMEOUT_MS;
39840
+ await withTimeout(
39841
+ opts.resolver ? opts.resolver(host) : resolveInChild(host, limit),
39842
+ limit,
39843
+ `DNS lookup failed for ${host} (timed out)`
39844
+ );
39845
+ } catch {
39846
+ return { hostClass: "local", unresolved: true };
39847
+ }
39848
+ return { hostClass: "local" };
39849
+ }
39850
+ if (opts.resolveDns === false) return { hostClass: "public" };
39739
39851
  let addrs;
39740
39852
  try {
39741
39853
  addrs = await (opts.resolver ?? systemResolver)(host);
39742
39854
  } catch {
39743
- return "public";
39855
+ return { hostClass: "public" };
39744
39856
  }
39745
39857
  const classes = addrs.map((a) => ({ address: a.address, cls: classifyIp(a.address) }));
39746
39858
  const forbidden = classes.find((c) => c.cls === "metadata" || c.cls === "reserved");
@@ -39749,8 +39861,17 @@ async function checkTarget(url, opts = {}) {
39749
39861
  "UNSAFE_URL",
39750
39862
  `${host} resolves to ${forbidden.address}, a ${forbidden.cls} address; Jourilo does not audit it.`
39751
39863
  );
39864
+ if (opts.denyLocal) {
39865
+ const loop = classes.find((c) => c.cls === "loopback");
39866
+ if (loop)
39867
+ throw new JouriloError(
39868
+ "UNSAFE_URL",
39869
+ `${host} resolves to ${loop.address}, a local address, and --deny-local is set.`,
39870
+ DENY_LOCAL_HINT
39871
+ );
39872
+ }
39752
39873
  const nonPublic = classes.filter((c) => c.cls !== "public");
39753
- if (!nonPublic.length) return "public";
39874
+ if (!nonPublic.length) return { hostClass: "public" };
39754
39875
  if (!opts.allowPrivate) {
39755
39876
  const mixed = nonPublic.length < classes.length;
39756
39877
  throw new JouriloError(
@@ -39759,7 +39880,7 @@ async function checkTarget(url, opts = {}) {
39759
39880
  PRIVATE_HINT
39760
39881
  );
39761
39882
  }
39762
- return "private";
39883
+ return { hostClass: "private" };
39763
39884
  }
39764
39885
  function isCrawlable(candidate, origin) {
39765
39886
  let u;
@@ -39782,6 +39903,10 @@ function normalizeForCrawl(raw) {
39782
39903
  u.hash = "";
39783
39904
  for (const k of [...u.searchParams.keys()])
39784
39905
  if (/^utm_|^(fbclid|gclid|ref)$/i.test(k)) u.searchParams.delete(k);
39906
+ return u.toString();
39907
+ }
39908
+ function crawlKey(url) {
39909
+ const u = new URL(normalizeForCrawl(url));
39785
39910
  if (u.pathname !== "/" && u.pathname.endsWith("/")) u.pathname = u.pathname.replace(/\/+$/, "");
39786
39911
  return u.toString();
39787
39912
  }
@@ -39795,12 +39920,16 @@ var systemResolve = (host) => new Promise(
39795
39920
  );
39796
39921
  async function resolveAllowed(host, port, policy, deps = {}) {
39797
39922
  const h = host.replace(/^\[|\]$/g, "");
39798
- const verdict = deps.verdict ?? ((ip) => addressVerdict(ip, policy));
39923
+ const verdict = deps.verdict ?? ((ip, port2) => addressVerdict(ip, policy, port2));
39799
39924
  let addrs;
39800
39925
  if (isIP2(h)) addrs = [{ address: h, family: isIP2(h) }];
39801
39926
  else {
39802
39927
  try {
39803
- addrs = await (deps.resolve ?? systemResolve)(h);
39928
+ addrs = await withTimeout(
39929
+ (deps.resolve ?? systemResolve)(h),
39930
+ deps.resolveTimeoutMs ?? RESOLVE_TIMEOUT_MS,
39931
+ `DNS lookup failed for ${h} (timed out)`
39932
+ );
39804
39933
  } catch (err) {
39805
39934
  throw Object.assign(new Error(`DNS lookup failed for ${h}`), { cause: err });
39806
39935
  }
@@ -39855,6 +39984,14 @@ function loopbackRedirectVerdict(location, from, policy) {
39855
39984
  } catch {
39856
39985
  return null;
39857
39986
  }
39987
+ if (policy.denyLocal) {
39988
+ const h = to.hostname.replace(/^\[|\]$/g, "").toLowerCase().replace(/\.$/, "");
39989
+ const local = isLocalHostname(h) || isIP2(h) !== 0 && classifyIp(h) === "loopback";
39990
+ const port = Number(to.port || (to.protocol === "https:" ? 443 : 80));
39991
+ const own = policy.allowLoopbackPorts?.includes(port) && h === "127.0.0.1";
39992
+ if (local && !own)
39993
+ return `redirect from ${from.host} to ${to.host} (a local address) refused: --deny-local is set.`;
39994
+ }
39858
39995
  if (!isLoopbackName(to.hostname)) return null;
39859
39996
  if (policy.target === "local" || policy.allowPrivate) return null;
39860
39997
  return `redirect from ${from.host} to ${to.host} (this machine) refused.`;
@@ -39888,8 +40025,9 @@ async function startNetGuard(policy, deps = {}) {
39888
40025
  };
39889
40026
  const sockets = /* @__PURE__ */ new Set();
39890
40027
  const track = (s) => {
40028
+ if (sockets.has(s)) return s;
39891
40029
  sockets.add(s);
39892
- s.on("close", () => sockets.delete(s));
40030
+ s.once("close", () => sockets.delete(s));
39893
40031
  return s;
39894
40032
  };
39895
40033
  const connectUpstream = (host, port2, onReady, onFail) => {
@@ -39966,7 +40104,13 @@ async function startNetGuard(policy, deps = {}) {
39966
40104
  res.writeHead(up.statusCode ?? 502, up.statusMessage, out);
39967
40105
  up.pipe(res);
39968
40106
  });
39969
- upstream.on("error", (err) => fail(502, err.message));
40107
+ upstream.on("socket", (s) => track(s));
40108
+ res.on("close", () => {
40109
+ if (!res.writableFinished) upstream.destroy();
40110
+ });
40111
+ upstream.on("error", (err) => {
40112
+ if (!res.destroyed) fail(502, err.message);
40113
+ });
39970
40114
  req.pipe(upstream);
39971
40115
  },
39972
40116
  (err) => fail(502, err.message)
@@ -40830,12 +40974,15 @@ var probe_default = `// Runs inside the audited page. Plain JavaScript (no impor
40830
40974
  for (const fs of document.querySelectorAll('fieldset')) {
40831
40975
  if (!isVisible(fs)) continue;
40832
40976
  const legend = fs.querySelector(':scope > legend');
40833
- if (!legend || !text(legend))
40834
- structure.fieldsets.push({
40835
- selector: selectorOf(fs),
40836
- rect: rectOf(fs),
40837
- issue: legend ? 'empty legend' : 'no legend',
40838
- });
40977
+ if (legend && text(legend)) continue;
40978
+ if ((fs.getAttribute('aria-label') || '').trim()) continue;
40979
+ const labelledBy = (fs.getAttribute('aria-labelledby') || '').split(/\\s+/).filter(Boolean);
40980
+ if (labelledBy.some((id) => text(document.getElementById(id)))) continue;
40981
+ structure.fieldsets.push({
40982
+ selector: selectorOf(fs),
40983
+ rect: rectOf(fs),
40984
+ issue: legend ? 'empty legend' : 'no name',
40985
+ });
40839
40986
  }
40840
40987
  const radiosByName = new Map();
40841
40988
  for (const r of document.querySelectorAll('input[type="radio"][name]')) {
@@ -41019,6 +41166,7 @@ var probe_default = `// Runs inside the audited page. Plain JavaScript (no impor
41019
41166
  `;
41020
41167
 
41021
41168
  // src/browser/keyboard.ts
41169
+ var DEV_OVERLAY = "nextjs-portal, vite-error-overlay, astro-dev-toolbar, #webpack-dev-server-client-overlay";
41022
41170
  var FOCUS_INFO = `(el) => {
41023
41171
  if (!el || el === document.body || el === document.documentElement) return null;
41024
41172
  const sx = window.scrollX, sy = window.scrollY;
@@ -41078,6 +41226,17 @@ var FOCUS_INFO = `(el) => {
41078
41226
  if (!hit || hit === el || el.contains(hit) || hit.contains(el)) { visiblePts++; continue; }
41079
41227
  if (isCover(hit)) cover = cover || hit;
41080
41228
  }
41229
+ const noBox = (e) => { const b = e.getBoundingClientRect(); return b.width <= 1 || b.height <= 1; };
41230
+ const rootHost = el.getRootNode && el.getRootNode().host;
41231
+ // A stop with text or an accessible name is content a person can reach, so a hidden one is a real focus-visible
41232
+ // failure (a clipped skip link that stays 1 x 1 while focused), not a stop without a box.
41233
+ const labelledBy = (el.getAttribute('aria-labelledby') || '').split(/\\s+/).filter(Boolean)
41234
+ .map((id) => { const n = document.getElementById(id); return n ? (n.textContent || '') : ''; }).join('').trim();
41235
+ const hasName = !!((el.textContent || '').trim() || (el.getAttribute('aria-label') || '').trim() || labelledBy ||
41236
+ (el.getAttribute('title') || '').trim() || (el.getAttribute('alt') || '').trim());
41237
+ const boxless = !!(el.matches(${JSON.stringify(DEV_OVERLAY)}) || (rootHost && rootHost.matches(${JSON.stringify(DEV_OVERLAY)})) ||
41238
+ (noBox(el) && !hasName && !Array.from(el.querySelectorAll('*')).some((d) => !noBox(d)) &&
41239
+ !(el.shadowRoot && Array.from(el.shadowRoot.querySelectorAll('*')).some((d) => !noBox(d)))));
41081
41240
  const name = (el.getAttribute('aria-label') || el.innerText || el.value || el.getAttribute('title') || '').replace(/\\s+/g, ' ').trim().slice(0, 80);
41082
41241
  // The element's identity as markup (like an axe node's html), so a state rule (.tool[aria-pressed]) can be found.
41083
41242
  const attrs = ['id', 'class', 'role', 'aria-pressed', 'aria-expanded', 'aria-selected', 'aria-checked', 'aria-current', 'data-state']
@@ -41090,6 +41249,7 @@ var FOCUS_INFO = `(el) => {
41090
41249
  obscured: !selfHidden && inViewport > 0 && visiblePts === 0 && !!cover,
41091
41250
  coverSelector: cover ? sel(cover) : undefined,
41092
41251
  inModal: !!el.closest('[aria-modal="true"], dialog[open]'),
41252
+ boxless,
41093
41253
  };
41094
41254
  }`;
41095
41255
  var FOCUS_STYLES = `(el) => {
@@ -41448,9 +41608,16 @@ function watchContext(browser, ctx) {
41448
41608
  }
41449
41609
  function networkLimitations(r) {
41450
41610
  const out = [];
41451
- if (r.blocked.length)
41611
+ const list2 = (bs) => `${bs.slice(0, 3).map((b) => `${b.host}:${b.port}`).join(", ")}${bs.length > 3 ? ", \u2026" : ""}`;
41612
+ const denied = r.blocked.filter((b) => b.reason.includes("--deny-local"));
41613
+ const other = r.blocked.filter((b) => !b.reason.includes("--deny-local"));
41614
+ if (denied.length)
41615
+ out.push(
41616
+ `The network guard refused ${denied.length} connection(s) to local addresses because --deny-local is set (${list2(denied)}); content that depends on them was not loaded.`
41617
+ );
41618
+ if (other.length)
41452
41619
  out.push(
41453
- `The network guard refused ${r.blocked.length} connection(s) to non-public addresses (${r.blocked.slice(0, 3).map((b) => `${b.host}:${b.port}`).join(", ")}${r.blocked.length > 3 ? ", \u2026" : ""}); content that depends on them was not loaded.`
41620
+ `The network guard refused ${other.length} connection(s) to non-public addresses (${list2(other)}); content that depends on them was not loaded.`
41454
41621
  );
41455
41622
  if (r.escaped.length)
41456
41623
  out.push(
@@ -41635,7 +41802,11 @@ var STATE = `(controls, mode) => {
41635
41802
  if (mode === 'same') {
41636
41803
  const d = window.__jouriloDialog;
41637
41804
  const ae = document.activeElement;
41638
- return { dialogOpen: visible(d) && (d.tagName !== 'DIALOG' || d.open), unlabelled: false, focusInDialog: !!d && !!ae && d.contains(ae), focusables: 0, controlledVisible: null, activeSelector: ae && ae !== document.body ? (ae.id ? '#' + ae.id : ae.tagName.toLowerCase()) : null };
41805
+ let deep = ae;
41806
+ while (deep && deep.shadowRoot && deep.shadowRoot.activeElement) deep = deep.shadowRoot.activeElement;
41807
+ // A focus guard (Base UI, Radix, React Aria, Headless UI) is an empty sentinel; focus parked there is not an escape.
41808
+ const onSentinel = !!deep && deep !== document.body && !(deep.textContent || '').trim() && (deep.getAttribute('aria-hidden') === 'true' || deep.getAttributeNames().some((n) => /focus-?guard|focus-?trap|data-.*sentinel/i.test(n)));
41809
+ return { dialogOpen: visible(d) && (d.tagName !== 'DIALOG' || d.open), unlabelled: false, focusInDialog: !!d && !!ae && d.contains(ae), onSentinel, focusables: 0, controlledVisible: null, activeSelector: ae && ae !== document.body ? (ae.id ? '#' + ae.id : ae.tagName.toLowerCase()) : null };
41639
41810
  }
41640
41811
  const dialogs = Array.from(document.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"]')).filter(visible);
41641
41812
  let d = dialogs[dialogs.length - 1] || null;
@@ -41665,6 +41836,20 @@ var STATE = `(controls, mode) => {
41665
41836
  activeSelector: ae && ae !== document.body ? (ae.id ? '#' + ae.id : ae.tagName.toLowerCase()) : null,
41666
41837
  };
41667
41838
  }`;
41839
+ var WAIT_FOCUS_STABLE = `new Promise((resolve) => {
41840
+ const deepActive = () => { let a = document.activeElement; while (a && a.shadowRoot && a.shadowRoot.activeElement) a = a.shadowRoot.activeElement; return a; };
41841
+ const start = performance.now();
41842
+ let last = deepActive();
41843
+ let same = 0;
41844
+ const tick = () => {
41845
+ const now = deepActive();
41846
+ same = now === last ? same + 1 : 0;
41847
+ last = now;
41848
+ if (same >= 4 || performance.now() - start >= 250) resolve(true);
41849
+ else requestAnimationFrame(tick);
41850
+ };
41851
+ requestAnimationFrame(tick);
41852
+ })`;
41668
41853
  var COMPOSITES = `(() => {
41669
41854
  const ITEM = { tablist: 'tab', radiogroup: 'radio', listbox: 'option', menubar: 'menuitem, [role="menuitemcheckbox"], [role="menuitemradio"]', tree: 'treeitem' };
41670
41855
  const visible = (e) => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden';
@@ -41779,8 +41964,9 @@ async function checkWidgets(page) {
41779
41964
  );
41780
41965
  } else {
41781
41966
  for (let k = 0; k <= after.focusables; k++) await page.keyboard.press("Tab");
41967
+ await page.evaluate(WAIT_FOCUS_STABLE).catch(() => void 0);
41782
41968
  const tabbed = await state(t.controls, "same");
41783
- if (tabbed.dialogOpen && !tabbed.focusInDialog)
41969
+ if (tabbed.dialogOpen && !tabbed.focusInDialog && !tabbed.onSentinel)
41784
41970
  issue(
41785
41971
  "dialog-focus-escapes",
41786
41972
  `Pressing Tab ${after.focusables + 1} time(s) moved focus out of the open dialog to ${tabbed.activeSelector ?? "the page"}.`
@@ -41913,11 +42099,54 @@ async function settle(page, timeoutMs) {
41913
42099
  await page.waitForTimeout(300);
41914
42100
  }
41915
42101
  var AXE_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"];
42102
+ var HIDDEN_SKIP_RULES = /* @__PURE__ */ new Set(["color-contrast", "color-contrast-enhanced"]);
41916
42103
  async function runAxe(page) {
41917
42104
  await page.addScriptTag({ content: import_axe_core.default.source });
41918
42105
  const raw = await page.evaluate(
41919
42106
  `axe.run(document, { runOnly: { type: 'tag', values: ${JSON.stringify(AXE_TAGS)} }, resultTypes: ['violations', 'incomplete'], elementRef: false, rules: { 'target-size': { enabled: true } } })`
41920
42107
  );
42108
+ const hiddenFlags = await page.evaluate(
42109
+ `(${String(
42110
+ (groups) => groups.map((target) => {
42111
+ try {
42112
+ let root = document;
42113
+ let el = null;
42114
+ const chain = [];
42115
+ for (let i = 0; i < target.length; i++) {
42116
+ const part = target[i];
42117
+ const hops = Array.isArray(part) ? part : [part];
42118
+ for (let h = 0; h < hops.length; h++) {
42119
+ el = root.querySelector(hops[h]);
42120
+ if (!el) return false;
42121
+ chain.push(el);
42122
+ if (h < hops.length - 1) {
42123
+ if (!el.shadowRoot) return false;
42124
+ root = el.shadowRoot;
42125
+ }
42126
+ }
42127
+ if (i < target.length - 1) {
42128
+ const doc = el?.contentDocument;
42129
+ if (!doc) return false;
42130
+ root = doc;
42131
+ }
42132
+ }
42133
+ return chain.some((c) => c.closest('[aria-hidden="true"]') !== null);
42134
+ } catch {
42135
+ return false;
42136
+ }
42137
+ })
42138
+ )})(${JSON.stringify(
42139
+ raw.incomplete.flatMap((r) => HIDDEN_SKIP_RULES.has(r.id) ? r.nodes.map((n) => n.target) : [])
42140
+ )})`
42141
+ ).catch(() => []);
42142
+ {
42143
+ let q = 0;
42144
+ for (const r of raw.incomplete) {
42145
+ if (!HIDDEN_SKIP_RULES.has(r.id)) continue;
42146
+ r.nodes = r.nodes.filter(() => !hiddenFlags[q++]);
42147
+ }
42148
+ raw.incomplete = raw.incomplete.filter((r) => !HIDDEN_SKIP_RULES.has(r.id) || r.nodes.length > 0);
42149
+ }
41921
42150
  const map = (r) => ({
41922
42151
  id: r.id,
41923
42152
  impact: r.impact ?? null,
@@ -41986,6 +42215,10 @@ async function applyThrottle(page) {
41986
42215
  return false;
41987
42216
  }
41988
42217
  }
42218
+ function captureScreenshotFiles(url, vp) {
42219
+ const base2 = `${slug(new URL(url).pathname.replace(/^\/$/, "home"), 40)}-${shortHash(url).slice(0, 6)}-${vp.name}`;
42220
+ return { full: join6("screenshots", `${base2}.jpg`), fold: join6("screenshots", `${base2}-fold.jpg`) };
42221
+ }
41989
42222
  async function capturePage(ctx, url, vp, opts) {
41990
42223
  const timeoutMs = opts.timeoutMs ?? 3e4;
41991
42224
  const page = await ctx.newPage();
@@ -42006,6 +42239,20 @@ async function capturePage(ctx, url, vp, opts) {
42006
42239
  keyboard: null,
42007
42240
  errors: []
42008
42241
  };
42242
+ const redirects = [];
42243
+ page.on("response", (res) => {
42244
+ const status = res.status();
42245
+ if (status < 300 || status >= 400 || redirects.length >= 10) return;
42246
+ if (!res.request().isNavigationRequest() || res.request().frame() !== page.mainFrame()) return;
42247
+ const location = res.headers().location;
42248
+ if (!location) return;
42249
+ let to = location;
42250
+ try {
42251
+ to = new URL(location, res.url()).href;
42252
+ } catch {
42253
+ }
42254
+ redirects.push({ status, from: redactUrl(res.url()), to: redactUrl(to) });
42255
+ });
42009
42256
  try {
42010
42257
  if (opts.throttle && !await applyThrottle(page))
42011
42258
  obs.errors.push("Throttling is only available in Chromium.");
@@ -42015,6 +42262,7 @@ async function capturePage(ctx, url, vp, opts) {
42015
42262
  response = await page.goto(url, { waitUntil: "load", timeout: timeoutMs });
42016
42263
  } catch (err) {
42017
42264
  obs.errors.push(`navigation: ${err.message.split("\n")[0]}`);
42265
+ if (redirects.length) obs.redirects = redirects;
42018
42266
  return obs;
42019
42267
  }
42020
42268
  const signal = response?.headers()[GUARD_UPSTREAM_ERROR_HEADER];
@@ -42045,11 +42293,9 @@ async function capturePage(ctx, url, vp, opts) {
42045
42293
  });
42046
42294
  }
42047
42295
  if (opts.screenshots !== false) {
42048
- const base2 = `${slug(new URL(url).pathname.replace(/^\/$/, "home"), 40)}-${shortHash(url).slice(0, 6)}-${vp.name}`;
42049
42296
  const mask = [...DEFAULT_MASK, ...opts.mask ?? []].map((s) => page.locator(s));
42050
42297
  const mode = opts.evidence ?? "full";
42051
- const full = join6("screenshots", `${base2}.jpg`);
42052
- const fold = join6("screenshots", `${base2}-fold.jpg`);
42298
+ const { full, fold } = captureScreenshotFiles(url, vp);
42053
42299
  try {
42054
42300
  const height = Math.min(obs.probe?.scroll.height ?? vp.height, FULL_PAGE_MAX_HEIGHT[mode]);
42055
42301
  if (mode === "minimal" && height <= vp.height) {
@@ -42181,12 +42427,21 @@ function pathOf2(url) {
42181
42427
  return url;
42182
42428
  }
42183
42429
  }
42430
+ function fingerprintPath(url) {
42431
+ try {
42432
+ const u = new URL(url);
42433
+ const path2 = u.pathname === "/" ? "/" : u.pathname.replace(/\/+$/, "") || "/";
42434
+ return `${path2}${u.search}`;
42435
+ } catch {
42436
+ return url;
42437
+ }
42438
+ }
42184
42439
  function makeFinding(input) {
42185
42440
  const { key, occurrences, reproduction, ...rest } = input;
42186
42441
  const finding2 = {
42187
42442
  ...rest,
42188
42443
  // Viewport is deliberately not part of the fingerprint: the same defect seen at two widths is one finding.
42189
- fingerprint: shortHash(input.ruleId, pathOf2(input.url), key ?? "", input.journey ?? ""),
42444
+ fingerprint: shortHash(input.ruleId, fingerprintPath(input.url), key ?? "", input.journey ?? ""),
42190
42445
  occurrences: occurrences ?? 1,
42191
42446
  reproduction: reproduction ?? defaultReproduction(input)
42192
42447
  };
@@ -42202,6 +42457,11 @@ var SEVERITY_ORDER = ["critical", "serious", "moderate", "minor", "info"];
42202
42457
  function severityRank(s) {
42203
42458
  return SEVERITY_ORDER.indexOf(s);
42204
42459
  }
42460
+ var KIND_STRENGTH = { "needs-review": 0, hypothesis: 1, defect: 2 };
42461
+ var CONFIDENCE_STRENGTH = { low: 0, medium: 1, high: 2 };
42462
+ function strength(f) {
42463
+ return KIND_STRENGTH[f.kind] * 10 + CONFIDENCE_STRENGTH[f.confidence];
42464
+ }
42205
42465
  function mergeFindings(findings) {
42206
42466
  const byFp = /* @__PURE__ */ new Map();
42207
42467
  for (const f of findings) {
@@ -42214,6 +42474,13 @@ function mergeFindings(findings) {
42214
42474
  viewports.delete("");
42215
42475
  prev.viewport = [...viewports].join(", ");
42216
42476
  if (severityRank(f.severity) < severityRank(prev.severity)) prev.severity = f.severity;
42477
+ if (strength(f) > strength(prev)) {
42478
+ prev.kind = f.kind;
42479
+ prev.confidence = f.confidence;
42480
+ prev.title = f.title;
42481
+ prev.description = f.description;
42482
+ prev.recommendation = f.recommendation;
42483
+ }
42217
42484
  prev.occurrences = Math.max(prev.occurrences, f.occurrences);
42218
42485
  for (const e of f.evidence) {
42219
42486
  if (prev.evidence.length >= 12) break;
@@ -42748,6 +43015,20 @@ async function loadRobots(origin, policy, timeoutMs = 8e3) {
42748
43015
  const text = await fetchText(`${origin}/robots.txt`, timeoutMs, policy);
42749
43016
  return text === null ? null : parseRobots(text, USER_AGENT_TOKEN);
42750
43017
  }
43018
+ function robotsOverrideLimitation(url) {
43019
+ return `robots.txt disallows ${url}; it was audited because it was requested explicitly.`;
43020
+ }
43021
+ async function explicitRobotsOverrides(urls, policy, timeoutMs = 8e3) {
43022
+ const out = [];
43023
+ const rules = /* @__PURE__ */ new Map();
43024
+ for (const raw of urls) {
43025
+ const u = new URL(raw);
43026
+ if (!rules.has(u.origin)) rules.set(u.origin, await loadRobots(u.origin, policy, timeoutMs));
43027
+ const r = rules.get(u.origin);
43028
+ if (r && !isAllowed(r, `${u.pathname}${u.search}`)) out.push(redactUrl(u.toString()));
43029
+ }
43030
+ return out;
43031
+ }
42751
43032
  async function sitemapUrls(origin, robots, timeoutMs, policy) {
42752
43033
  const local = policy.target === "local";
42753
43034
  const rebase = (raw) => {
@@ -42783,27 +43064,34 @@ async function crawl(ctx, start, opts) {
42783
43064
  const origin = new URL(start).origin;
42784
43065
  const robots = opts.respectRobots ? await loadRobots(origin, opts.policy) : null;
42785
43066
  const skipped = [];
43067
+ const robotsOverridden = [];
42786
43068
  const seen = /* @__PURE__ */ new Map();
42787
43069
  const queue = [];
42788
- const push = (raw, depth) => {
43070
+ const push = (raw, depth, explicit = false) => {
42789
43071
  let n;
43072
+ let key;
42790
43073
  try {
42791
43074
  n = normalizeForCrawl(raw);
43075
+ key = crawlKey(raw);
42792
43076
  } catch {
42793
43077
  return;
42794
43078
  }
42795
- if (seen.has(n) || !isCrawlable(n, origin)) return;
43079
+ if (seen.has(key) || !isCrawlable(n, origin)) return;
42796
43080
  const u = new URL(n);
42797
43081
  if (robots && !isAllowed(robots, `${u.pathname}${u.search}`)) {
42798
- skipped.push({ url: redactUrl(n), reason: "robots.txt" });
42799
- seen.set(n, depth);
42800
- return;
43082
+ if (explicit) {
43083
+ robotsOverridden.push(redactUrl(n));
43084
+ } else {
43085
+ skipped.push({ url: redactUrl(n), reason: "robots.txt" });
43086
+ seen.set(key, { url: n, depth, blocked: true });
43087
+ return;
43088
+ }
42801
43089
  }
42802
- seen.set(n, depth);
43090
+ seen.set(key, { url: n, depth, blocked: false });
42803
43091
  queue.push({ url: n, depth });
42804
43092
  };
42805
- push(start, 0);
42806
- for (const extra of opts.include ?? []) push(new URL(extra, start).toString(), 0);
43093
+ push(start, 0, true);
43094
+ for (const extra of opts.include ?? []) push(new URL(extra, start).toString(), 0, true);
42807
43095
  const fromSitemap = await sitemapUrls(origin, robots, opts.timeoutMs, opts.policy);
42808
43096
  for (const u of fromSitemap.urls) push(u, 1);
42809
43097
  const visited = /* @__PURE__ */ new Set();
@@ -42832,7 +43120,7 @@ async function crawl(ctx, start, opts) {
42832
43120
  } finally {
42833
43121
  await page.close().catch(() => void 0);
42834
43122
  }
42835
- const ranked = [...seen.entries()].filter(([u]) => !skipped.some((s) => s.url === redactUrl(u) && s.reason === "robots.txt")).map(([url, depth]) => ({ url, depth, role: pageRole(url), score: rankUrl(url, depth) })).sort((a, b) => b.score - a.score);
43123
+ const ranked = [...seen.values()].filter((e) => !e.blocked).map(({ url, depth }) => ({ url, depth, role: pageRole(url), score: rankUrl(url, depth) })).sort((a, b) => b.score - a.score);
42836
43124
  const picked = [];
42837
43125
  const roles = /* @__PURE__ */ new Set();
42838
43126
  for (const p of ranked) {
@@ -42846,9 +43134,11 @@ async function crawl(ctx, start, opts) {
42846
43134
  if (picked.length >= opts.maxPages) break;
42847
43135
  if (!picked.includes(p)) picked.push(p);
42848
43136
  }
43137
+ const auditedOverrides = robotsOverridden.filter((u) => picked.some((p) => redactUrl(p.url) === u));
42849
43138
  return {
42850
43139
  pages: picked,
42851
43140
  skipped,
43141
+ robotsOverridden: auditedOverrides,
42852
43142
  robots,
42853
43143
  sitemapUrls: fromSitemap.urls.length,
42854
43144
  sitemapRebasedFrom: fromSitemap.rebasedFrom
@@ -43120,6 +43410,9 @@ var AFTER = `([sel, beforeText]) => {
43120
43410
  const focusOnProblem = !!ae && ae !== document.body && (fields.includes(ae) || /error|alert/i.test((ae.getAttribute('role') || '') + ' ' + (ae.className || '') + ' ' + (ae.id || '')));
43121
43411
  return { errorish, freshCount: fresh.length, ariaInvalid, described, live, focusOnProblem, fieldCount: fields.length };
43122
43412
  }`;
43413
+ function formScreenshotFile(url, selector, vp) {
43414
+ return toPosix(`screenshots/form-${shortHash(url, selector).slice(0, 8)}-${vp.name}.jpg`);
43415
+ }
43123
43416
  async function probeFormValidation(ctx, url, form, vp, policy, runDir) {
43124
43417
  const page = await ctx.newPage();
43125
43418
  const guard = await installRequestGuard(page, {
@@ -43166,7 +43459,7 @@ async function probeFormValidation(ctx, url, form, vp, policy, runDir) {
43166
43459
  await page.waitForLoadState("networkidle", { timeout: 3e3 }).catch(() => void 0);
43167
43460
  await page.waitForTimeout(600);
43168
43461
  const intercepted = guard.since(submittedAt);
43169
- const shotFile = toPosix(`screenshots/form-${shortHash(url, form.selector).slice(0, 8)}-${vp.name}.jpg`);
43462
+ const shotFile = formScreenshotFile(url, form.selector, vp);
43170
43463
  await page.screenshot({
43171
43464
  path: `${runDir}/${shotFile}`,
43172
43465
  type: "jpeg",
@@ -43689,7 +43982,10 @@ async function runJourney(browser, spec, opts) {
43689
43982
  const startUrl = new URL(interpolate(spec.start, opts.data), opts.base);
43690
43983
  const allSteps = [{ action: "goto", url: startUrl.toString() }, ...spec.steps];
43691
43984
  const shot = async (i) => {
43692
- const file2 = join9("screenshots", `journey-${spec.id}-${vp.name}-${String(i).padStart(2, "0")}.jpg`);
43985
+ const file2 = join9(
43986
+ "screenshots",
43987
+ `journey-${spec.id}-${vp.name}${opts.attempt === void 0 ? "" : `-a${opts.attempt}`}-${String(i).padStart(2, "0")}.jpg`
43988
+ );
43693
43989
  try {
43694
43990
  await page.screenshot({
43695
43991
  path: join9(opts.runDir, file2),
@@ -44080,7 +44376,8 @@ function keyboardFindings(obs) {
44080
44376
  const out = [];
44081
44377
  const vp = obs.viewport.name;
44082
44378
  const shot = obs.screenshot ? { file: obs.screenshot } : {};
44083
- const invisible = kb.stops.filter((s) => s.indicatorChecked && s.indicatorChanges.length === 0);
44379
+ const boxed = kb.stops.filter((s) => !s.boxless);
44380
+ const invisible = boxed.filter((s) => s.indicatorChecked && s.indicatorChanges.length === 0);
44084
44381
  if (invisible.length) {
44085
44382
  out.push(
44086
44383
  makeFinding({
@@ -44093,7 +44390,7 @@ function keyboardFindings(obs) {
44093
44390
  provenance: "measured",
44094
44391
  url: obs.url,
44095
44392
  viewport: vp,
44096
- description: `${invisible.length} of ${kb.stops.length} elements reached with Tab look exactly the same focused and unfocused (outline, box-shadow, border, background, color, underline and the parent's styles were compared).`,
44393
+ description: `${invisible.length} of ${boxed.length} elements reached with Tab look exactly the same focused and unfocused (outline, box-shadow, border, background, color, underline and the parent's styles were compared).`,
44097
44394
  impact: "Keyboard users cannot see where they are on the page, so they cannot operate it reliably.",
44098
44395
  recommendation: "Do not remove the focus outline without a replacement. Add a clearly visible :focus-visible style (e.g. a 2 px outline with 3:1 contrast) to the shared button/link/input components.",
44099
44396
  standards: [wcag("2.4.7")],
@@ -44487,9 +44784,9 @@ function structureFindings(obs) {
44487
44784
  provenance: "measured",
44488
44785
  url: obs.url,
44489
44786
  viewport: vp,
44490
- description: `${st.fieldsets.length} fieldset(s) have no legend or an empty one, so the group of fields has no name.`,
44787
+ description: `${st.fieldsets.length} fieldset(s) have no legend, aria-label or aria-labelledby (or an empty one), so the group of fields has no name.`,
44491
44788
  impact: 'Screen-reader users hear the fields without the question they answer (e.g. "Yes / No" without "Do you have a passport?").',
44492
- recommendation: "Give every fieldset a first-child <legend> with the group question; remove fieldsets used only for styling.",
44789
+ recommendation: "Give every fieldset a first-child <legend> with the group question (or an aria-label / aria-labelledby that names it); remove fieldsets used only for styling.",
44493
44790
  standards: [wcag("1.3.1"), wcag("3.3.2")],
44494
44791
  evidence: ev(st.fieldsets, (i) => st.fieldsets[i].issue),
44495
44792
  occurrences: st.fieldsets.length
@@ -44568,28 +44865,35 @@ function responsiveFindings(obs) {
44568
44865
  if (p.overflow.docWidth > p.overflow.viewportWidth + 1) {
44569
44866
  const narrow = vp.width <= 400;
44570
44867
  const extra = p.overflow.docWidth - p.overflow.viewportWidth;
44868
+ const located = p.overflow.offenders.length > 0;
44571
44869
  out.push(
44572
44870
  makeFinding({
44573
44871
  ruleId: "jourilo/horizontal-overflow",
44574
44872
  title: `Page scrolls sideways at ${vp.width} px`,
44575
44873
  category: "responsive",
44576
- kind: "defect",
44874
+ kind: located ? "defect" : "needs-review",
44577
44875
  severity: narrow ? "serious" : "moderate",
44578
- confidence: "high",
44876
+ confidence: located ? "high" : "low",
44579
44877
  provenance: "measured",
44580
44878
  url: obs.url,
44581
44879
  viewport: vp.name,
44582
- description: `The document is ${p.overflow.docWidth} px wide in a ${p.overflow.viewportWidth} px viewport (${extra} px of horizontal scrolling).${p.overflow.offenders.length ? ` Widest culprit: <${p.overflow.offenders[0].tag}> ending at ${p.overflow.offenders[0].right} px.` : ""}`,
44880
+ description: `The document is ${p.overflow.docWidth} px wide in a ${p.overflow.viewportWidth} px viewport (${extra} px of horizontal scrolling).${located ? ` Widest culprit: <${p.overflow.offenders[0].tag}> ending at ${p.overflow.offenders[0].right} px.` : " The measurement is real, but no element could be located that sticks out; check the page by hand (for example scripted or transformed content)."}`,
44583
44881
  impact: "Content is cut off or requires two-dimensional scrolling; on phones the page wobbles sideways while scrolling.",
44584
44882
  recommendation: "Constrain the offending element (max-width: 100%, flex-wrap, min-width: 0 on flex children, overflow-wrap: anywhere for long strings, responsive tables in a scroll container).",
44585
44883
  standards: vp.width <= 320 ? [wcag("1.4.10")] : [wcag("1.4.10"), nielsen(8)],
44586
- evidence: p.overflow.offenders.map((o) => ({
44884
+ evidence: located ? p.overflow.offenders.map((o) => ({
44587
44885
  type: "element",
44588
44886
  selector: o.selector,
44589
44887
  rect: o.rect,
44590
44888
  data: { right: o.right, width: o.width, viewport: p.overflow.viewportWidth },
44591
44889
  ...shot
44592
- })),
44890
+ })) : [
44891
+ {
44892
+ type: obs.screenshot ? "screenshot" : "metric",
44893
+ data: { docWidth: p.overflow.docWidth, viewport: p.overflow.viewportWidth },
44894
+ ...shot
44895
+ }
44896
+ ],
44593
44897
  occurrences: Math.max(1, p.overflow.offenders.length)
44594
44898
  })
44595
44899
  );
@@ -44650,12 +44954,26 @@ function responsiveFindings(obs) {
44650
44954
  function reliabilityFindings(obs) {
44651
44955
  const out = [];
44652
44956
  const vp = obs.viewport.name;
44653
- const navError = obs.errors.find((e) => e.startsWith("navigation:"));
44957
+ const rawNavError = obs.errors.find((e) => e.startsWith("navigation:"));
44958
+ const navError = rawNavError === void 0 ? void 0 : redactText(rawNavError);
44654
44959
  if (navError) {
44960
+ const hops = obs.redirects ?? [];
44961
+ const froms = hops.map((h) => h.from);
44962
+ const loop = /ERR_TOO_MANY_REDIRECTS/i.test(navError) || new Set(froms).size < froms.length;
44963
+ const short = (u) => {
44964
+ try {
44965
+ const a = new URL(redactUrl(u));
44966
+ return a.origin === new URL(obs.url).origin ? `${a.pathname}${a.search}` : a.toString();
44967
+ } catch {
44968
+ return redactText(u);
44969
+ }
44970
+ };
44971
+ const chain = hops.slice(0, 5).map((h) => `${h.status} ${short(h.from)} \u2192 ${short(h.to)}`);
44972
+ const chainText = chain.join(", ") + (hops.length > chain.length ? `, and ${hops.length - chain.length} more` : "");
44655
44973
  out.push(
44656
44974
  makeFinding({
44657
44975
  ruleId: "jourilo/page-unreachable",
44658
- title: "Page failed to load",
44976
+ title: loop ? "Page redirects to itself in a loop" : "Page failed to load",
44659
44977
  category: "reliability",
44660
44978
  kind: "defect",
44661
44979
  severity: "critical",
@@ -44663,11 +44981,14 @@ function reliabilityFindings(obs) {
44663
44981
  provenance: "observed",
44664
44982
  url: obs.url,
44665
44983
  viewport: vp,
44666
- description: navError,
44984
+ description: loop ? `The browser followed a redirect loop and gave up (${navError})${chainText ? `. Redirects seen: ${chainText}` : ""}.` : navError,
44667
44985
  impact: "Visitors cannot use this page at all.",
44668
- recommendation: "Check the server, DNS and redirects for this URL.",
44986
+ recommendation: loop ? "Fix the server's redirect rules or host configuration (HTTP/HTTPS, www and trailing-slash redirects, or a proxy or CDN redirect) so the URL stops redirecting back to itself." : "Check the server, DNS and redirects for this URL.",
44669
44987
  standards: [],
44670
- evidence: [{ type: "network", snippet: navError }]
44988
+ evidence: [
44989
+ { type: "network", snippet: navError },
44990
+ ...chain.length ? [{ type: "network", snippet: chain.join("\n") }] : []
44991
+ ]
44671
44992
  })
44672
44993
  );
44673
44994
  return out;
@@ -44879,6 +45200,8 @@ var PAGE_RULES = {
44879
45200
  };
44880
45201
  var AuditStopped = class extends Error {
44881
45202
  };
45203
+ var NameUnresolved = class extends Error {
45204
+ };
44882
45205
  function untilStopped(p, signal) {
44883
45206
  if (!signal) return p;
44884
45207
  p.catch(() => void 0);
@@ -44889,6 +45212,43 @@ function untilStopped(p, signal) {
44889
45212
  p.then(resolve10, reject).finally(() => signal.removeEventListener("abort", onAbort));
44890
45213
  });
44891
45214
  }
45215
+ var STOP_GRACE_MS = 3e3;
45216
+ var ABANDONED = /* @__PURE__ */ Symbol("abandoned");
45217
+ async function withinGrace(work, signal, abandon) {
45218
+ if (!signal) return work;
45219
+ work.catch(() => void 0);
45220
+ let timer;
45221
+ let onAbort = () => void 0;
45222
+ const expired = new Promise((resolve10) => {
45223
+ onAbort = () => {
45224
+ timer = setTimeout(() => resolve10(ABANDONED), STOP_GRACE_MS);
45225
+ };
45226
+ if (signal.aborted) onAbort();
45227
+ else signal.addEventListener("abort", onAbort, { once: true });
45228
+ });
45229
+ try {
45230
+ const first = await Promise.race([work, expired]);
45231
+ if (first !== ABANDONED) return first;
45232
+ } finally {
45233
+ clearTimeout(timer);
45234
+ signal.removeEventListener("abort", onAbort);
45235
+ }
45236
+ await abandon().catch(() => void 0);
45237
+ throw new AuditStopped();
45238
+ }
45239
+ async function settled(work, ms) {
45240
+ let timer;
45241
+ await Promise.race([
45242
+ work.then(
45243
+ () => void 0,
45244
+ () => void 0
45245
+ ),
45246
+ new Promise((r) => {
45247
+ timer = setTimeout(r, ms);
45248
+ })
45249
+ ]);
45250
+ clearTimeout(timer);
45251
+ }
44892
45252
  function stopLimitation(stoppedBy, urls, viewports, captured) {
44893
45253
  const by = stoppedBy === "signal" ? "by a stop signal" : "when the --max-duration budget ran out";
44894
45254
  if (!urls) return `The run was stopped early ${by}, before pages were discovered, so no page was audited.`;
@@ -44910,15 +45270,27 @@ function resolveRunId(ws, requested) {
44910
45270
  `Invalid run id "${requested}".`,
44911
45271
  'Use 1-64 letters, digits, "_" or "-", starting with a letter or digit.'
44912
45272
  );
44913
- if (existsSync6(join10(ws.runDir(requested), "run.json")))
45273
+ const dir = ws.runDir(requested);
45274
+ if (existsSync6(join10(dir, "run.json")))
44914
45275
  throw new JouriloError("INVALID_INPUT", `Run ${requested} already exists.`, "Pick another --run-id.");
45276
+ if (existsSync6(dir))
45277
+ throw new JouriloError(
45278
+ "INVALID_INPUT",
45279
+ `The directory of run ${requested} already exists (${dir}) without a finished run, probably left by a run that did not finish.`,
45280
+ "Pick another --run-id, or remove that directory first."
45281
+ );
44915
45282
  return requested;
44916
45283
  }
44917
45284
  async function runAudit(ws, opts) {
44918
45285
  const id = resolveRunId(ws, opts.runId);
44919
45286
  const dismissals = loadDismissals(ws);
44920
45287
  const target = parseTargetUrl(opts.target);
44921
- const hostClass = await checkTarget(target, { allowPrivate: opts.allowPrivate });
45288
+ const check = await checkTargetDetailed(target, {
45289
+ allowPrivate: opts.allowPrivate,
45290
+ // The target of a served directory is Jourilo's own server; the guard still confines the page.
45291
+ ...opts.denyLocal && opts.servedPort === void 0 ? { denyLocal: true } : {}
45292
+ });
45293
+ const hostClass = check.hostClass;
44922
45294
  const log = opts.onProgress ?? (() => void 0);
44923
45295
  const runDir = ws.createRunDir(id);
44924
45296
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -44929,7 +45301,7 @@ async function runAudit(ws, opts) {
44929
45301
  };
44930
45302
  let urls;
44931
45303
  if (opts.urls?.length) {
44932
- urls = opts.urls.map((u) => ({ url: new URL(u, target).toString() }));
45304
+ urls = [...new Set(opts.urls.map((u) => new URL(u, target).toString()))].map((url) => ({ url }));
44933
45305
  for (const u of urls) {
44934
45306
  if (new URL(u.url).origin !== target.origin)
44935
45307
  throw new JouriloError("UNSAFE_URL", `--url ${u.url} is not on the target origin ${target.origin}.`);
@@ -44950,18 +45322,26 @@ async function runAudit(ws, opts) {
44950
45322
  let network = { blocked: [], escaped: [] };
44951
45323
  const endBrowsing = async () => {
44952
45324
  if (session) network = session.report();
44953
- await session?.close();
45325
+ const closing = session;
44954
45326
  session = void 0;
44955
- rmSync2(setupState, { force: true });
45327
+ await closeBrowsing(closing, setupState);
44956
45328
  };
44957
45329
  try {
44958
45330
  try {
45331
+ if (check.unresolved) throw new NameUnresolved();
44959
45332
  log(`Starting ${opts.browser} \u2026`);
44960
45333
  halt();
44961
45334
  session = await openGuardedBrowser(
44962
45335
  opts.browser,
44963
45336
  opts.headed,
44964
- { target: hostClass, allowPrivate: opts.allowPrivate },
45337
+ {
45338
+ target: hostClass,
45339
+ allowPrivate: opts.allowPrivate,
45340
+ ...opts.denyLocal ? {
45341
+ denyLocal: true,
45342
+ ...opts.servedPort === void 0 ? {} : { allowLoopbackPorts: [opts.servedPort] }
45343
+ } : {}
45344
+ },
44965
45345
  signal !== void 0
44966
45346
  );
44967
45347
  const browser = session.browser;
@@ -45026,6 +45406,7 @@ async function runAudit(ws, opts) {
45026
45406
  const robotsSkips = found.skipped.filter((s) => s.reason === "robots.txt").length;
45027
45407
  if (robotsSkips)
45028
45408
  limitations.push(`${robotsSkips} URL(s) were skipped because robots.txt disallows them.`);
45409
+ for (const u of found.robotsOverridden) limitations.push(robotsOverrideLimitation(u));
45029
45410
  if (found.sitemapRebasedFrom)
45030
45411
  limitations.push(
45031
45412
  `The sitemap lists ${found.sitemapRebasedFrom} URLs; their paths were audited on the local server instead.`
@@ -45033,6 +45414,16 @@ async function runAudit(ws, opts) {
45033
45414
  if (!found.robots && opts.respectRobots && hostClass === "public")
45034
45415
  limitations.push("No robots.txt was found; crawling stayed on the target origin.");
45035
45416
  urls = discovered.length ? discovered : [{ url: target.toString() }];
45417
+ } else if (opts.respectRobots) {
45418
+ halt();
45419
+ for (const u of await untilStopped(
45420
+ explicitRobotsOverrides(
45421
+ urls.map((x) => x.url),
45422
+ session.guard.policy
45423
+ ),
45424
+ signal
45425
+ ))
45426
+ limitations.push(robotsOverrideLimitation(u));
45036
45427
  }
45037
45428
  log(
45038
45429
  `Auditing ${urls.length} page(s) \xD7 ${opts.viewports.length} viewport(s) in ${opts.browser} ${browserVersion}`
@@ -45049,7 +45440,7 @@ async function runAudit(ws, opts) {
45049
45440
  for (const { url } of urls) {
45050
45441
  halt();
45051
45442
  log(` ${vp.name.padEnd(8)} ${redactUrl(url)}`);
45052
- const obs = await capturePage(ctx, url, vp, {
45443
+ const capture = capturePage(ctx, url, vp, {
45053
45444
  runDir,
45054
45445
  guardToken: session.guard.token,
45055
45446
  timeoutMs: opts.timeoutMs,
@@ -45060,6 +45451,12 @@ async function runAudit(ws, opts) {
45060
45451
  throttle: opts.throttle,
45061
45452
  ...opts.evidence ? { evidence: opts.evidence } : {}
45062
45453
  });
45454
+ const obs = await withinGrace(capture, signal, async () => {
45455
+ await ctx.close();
45456
+ await settled(capture, 1e3);
45457
+ const { full, fold } = captureScreenshotFiles(url, vp);
45458
+ for (const f of [full, fold]) rmSync2(join10(runDir, f), { force: true });
45459
+ });
45063
45460
  observations.push(obs);
45064
45461
  }
45065
45462
  } finally {
@@ -45073,13 +45470,17 @@ async function runAudit(ws, opts) {
45073
45470
  for (let k = 1; k < runs2; k++) {
45074
45471
  halt();
45075
45472
  log(` metrics ${obs.viewport.name.padEnd(8)} ${obs.url} (run ${k + 1}/${runs2})`);
45076
- const v = await measureVitals(browser, obs.finalUrl, obs.viewport, {
45077
- runDir,
45078
- timeoutMs: opts.timeoutMs,
45079
- throttle: opts.throttle,
45080
- ...storageState ? { storageState } : {},
45081
- ...stateOptions(opts)
45082
- });
45473
+ const v = await withinGrace(
45474
+ measureVitals(browser, obs.finalUrl, obs.viewport, {
45475
+ runDir,
45476
+ timeoutMs: opts.timeoutMs,
45477
+ throttle: opts.throttle,
45478
+ ...storageState ? { storageState } : {},
45479
+ ...stateOptions(opts)
45480
+ }),
45481
+ signal,
45482
+ async () => void 0
45483
+ );
45083
45484
  if (v) samples.push(v);
45084
45485
  }
45085
45486
  const m = medianVitals(samples);
@@ -45106,7 +45507,7 @@ async function runAudit(ws, opts) {
45106
45507
  for (const form of probeable.slice(0, 3)) {
45107
45508
  halt();
45108
45509
  log(` form ${o.url} ${form.selector}`);
45109
- const r = await probeFormValidation(
45510
+ const probe = probeFormValidation(
45110
45511
  ctx,
45111
45512
  o.finalUrl,
45112
45513
  form,
@@ -45114,6 +45515,11 @@ async function runAudit(ws, opts) {
45114
45515
  { hostClass, allowSubmit: opts.allowSubmit ?? false, allowDestructive: false },
45115
45516
  runDir
45116
45517
  );
45518
+ const r = await withinGrace(probe, signal, async () => {
45519
+ await ctx.close();
45520
+ await settled(probe, 1e3);
45521
+ rmSync2(join10(runDir, formScreenshotFile(o.finalUrl, form.selector, vp)), { force: true });
45522
+ });
45117
45523
  formFindings.push(...r.findings);
45118
45524
  if (r.result.outcome === "blocked")
45119
45525
  limitations.push(`Form ${form.selector} on ${o.url} was not submitted: ${r.result.detail}`);
@@ -45133,9 +45539,14 @@ async function runAudit(ws, opts) {
45133
45539
  );
45134
45540
  }
45135
45541
  } catch (err) {
45136
- if (!(err instanceof AuditStopped)) throw err;
45137
- stoppedBy = signal?.reason === "max-duration" ? "max-duration" : "signal";
45138
- log(`Stopped early (${stoppedBy}); writing a partial run \u2026`);
45542
+ if (err instanceof NameUnresolved) {
45543
+ const msg = `No page was loaded: the host name ${target.hostname} could not be resolved (no hosts entry or local DNS answer).`;
45544
+ log(msg);
45545
+ limitations.push(msg);
45546
+ } else if (err instanceof AuditStopped) {
45547
+ stoppedBy = signal?.reason === "max-duration" ? "max-duration" : "signal";
45548
+ log(`Stopped early (${stoppedBy}); writing a partial run \u2026`);
45549
+ } else throw err;
45139
45550
  }
45140
45551
  await endBrowsing();
45141
45552
  if (stoppedBy)
@@ -45201,6 +45612,7 @@ async function runAudit(ws, opts) {
45201
45612
  title: o.title,
45202
45613
  viewport: o.viewport.name,
45203
45614
  ...o.screenshot ? { screenshot: o.screenshot } : {},
45615
+ ...o.foldScreenshot ? { foldScreenshot: o.foldScreenshot } : {},
45204
45616
  loadMs: o.loadMs,
45205
45617
  metrics: o.vitals ? toMetrics(o.vitals, o.vitalsSpread, o.vitalsRuns) : [],
45206
45618
  consoleErrors: o.console.filter((c) => c.type !== "warning").length,
@@ -45261,6 +45673,13 @@ async function runAudit(ws, opts) {
45261
45673
  await endBrowsing();
45262
45674
  }
45263
45675
  }
45676
+ async function closeBrowsing(session, setupState) {
45677
+ try {
45678
+ await session?.close();
45679
+ } finally {
45680
+ rmSync2(setupState, { force: true });
45681
+ }
45682
+ }
45264
45683
  function summarize(findings, pages, journeys2) {
45265
45684
  const sev = { critical: 0, serious: 0, moderate: 0, minor: 0, info: 0 };
45266
45685
  const kind = { defect: 0, hypothesis: 0, "needs-review": 0 };
@@ -45340,6 +45759,12 @@ function stablePort(root) {
45340
45759
  for (const ch of resolve4(root)) h = h * 31 + ch.charCodeAt(0) >>> 0;
45341
45760
  return 41e3 + h % 8e3;
45342
45761
  }
45762
+ function isDirectoryTarget(arg) {
45763
+ return !/^[a-z][a-z0-9+.-]*:\/\//i.test(arg) && existsSync7(arg) && statSync2(arg).isDirectory();
45764
+ }
45765
+ function servedPort(server) {
45766
+ return Number(new URL(server.url).port);
45767
+ }
45343
45768
  async function serveStatic(root, port = 0, opts = {}) {
45344
45769
  if (!existsSync7(root) || !statSync2(root).isDirectory())
45345
45770
  throw new JouriloError("NOT_FOUND", `Not a directory: ${root}`);
@@ -48646,19 +49071,20 @@ async function suggest(ctx) {
48646
49071
  if (!target || ctx.ws.listRuns().some((r) => r.id === target) || target === "latest") {
48647
49072
  run = ctx.ws.loadRun(target ?? "latest").run;
48648
49073
  } else {
48649
- const isDir = existsSync11(target) && !target.includes("://");
49074
+ const isDir = isDirectoryTarget(target);
48650
49075
  const server = isDir ? await serveStatic(resolve5(target)) : void 0;
48651
49076
  try {
48652
49077
  ctx.log("Discovering pages to derive journeys (no audit rules applied) \u2026");
48653
49078
  run = (await runAudit(ctx.ws, {
48654
49079
  target: server?.url ?? target,
48655
49080
  viewports: parseViewports("desktop"),
48656
- maxPages: num(ctx, "pages", 8),
49081
+ maxPages: pagesOption(ctx, 8),
48657
49082
  maxDepth: 3,
48658
49083
  browser: "chromium",
48659
49084
  headed: false,
48660
49085
  respectRobots: true,
48661
49086
  allowPrivate: flag(ctx, "allow-private"),
49087
+ ...denyLocalFrom(ctx) ? { denyLocal: true, ...server ? { servedPort: servedPort(server) } : {} } : {},
48662
49088
  keyboard: false,
48663
49089
  throttle: false,
48664
49090
  timeoutMs: 3e4,
@@ -48748,17 +49174,23 @@ async function runCmd(ctx) {
48748
49174
  );
48749
49175
  const state = parseStateOptions(ctx);
48750
49176
  const evidence = parseEvidence(ctx);
48751
- const isDir = existsSync11(baseArg) && !baseArg.includes("://");
49177
+ const isDir = isDirectoryTarget(baseArg);
48752
49178
  const server = isDir ? await serveStatic(resolve5(baseArg)) : void 0;
48753
49179
  try {
48754
49180
  const base2 = parseTargetUrl(server?.url ?? baseArg);
48755
- const hostClass = await checkTarget(base2, { allowPrivate: flag(ctx, "allow-private") });
49181
+ const denyLocal = denyLocalFrom(ctx);
49182
+ refuseDenyLocalWebkit(denyLocal, str(ctx, "browser") ?? "chromium");
49183
+ const hostClass = await checkTarget(base2, {
49184
+ allowPrivate: flag(ctx, "allow-private"),
49185
+ denyLocal: denyLocal && !server
49186
+ });
48756
49187
  const id = newRunId();
48757
49188
  const runDir = ctx.ws.createRunDir(id);
48758
49189
  const browserName = str(ctx, "browser") ?? "chromium";
48759
49190
  const session = await openGuardedBrowser(browserName, flag(ctx, "headed"), {
48760
49191
  target: hostClass,
48761
- allowPrivate: flag(ctx, "allow-private")
49192
+ allowPrivate: flag(ctx, "allow-private"),
49193
+ ...denyLocal ? { denyLocal: true, ...server ? { allowLoopbackPorts: [servedPort(server)] } : {} } : {}
48762
49194
  });
48763
49195
  const browser = session.browser;
48764
49196
  const browserVersion = browser.version();
@@ -48826,6 +49258,7 @@ async function runCmd(ctx) {
48826
49258
  ...str(ctx, "auth") ? { storageState: str(ctx, "auth") } : {},
48827
49259
  ...state,
48828
49260
  ...evidence ? { evidence } : {},
49261
+ ...repeat > 1 ? { attempt: k + 1 } : {},
48829
49262
  ...saveAuth ? { saveAuth } : {},
48830
49263
  ...allowOrigins.length ? { allowOrigins } : {},
48831
49264
  log: k === 0 ? ctx.log : void 0
@@ -48918,6 +49351,7 @@ var journeys = {
48918
49351
  "allow-submit": { type: "boolean" },
48919
49352
  "allow-destructive": { type: "boolean" },
48920
49353
  "allow-private": { type: "boolean" },
49354
+ "deny-local": { type: "boolean" },
48921
49355
  "allow-origin": { type: "string", multiple: true },
48922
49356
  "fail-on-failure": { type: "boolean" },
48923
49357
  browser: { type: "string" },
@@ -48943,6 +49377,8 @@ Suggest options:
48943
49377
  --pages N Pages to read when deriving journeys from a site (default 8)
48944
49378
  --force Overwrite journey files that already exist (by default existing files are kept)
48945
49379
  --allow-private Allow private-network hosts (10.x, 192.168.x, ...)
49380
+ --deny-local Refuse local targets (localhost, 127.0.0.1, [::1], *.localhost, *.test) (exit 3); a redirect or request to one is blocked by the network guard and listed in the limitations;
49381
+ JOURILO_DENY_LOCAL=1 does the same
48946
49382
 
48947
49383
  Run options:
48948
49384
  --base URL|DIR The site or static build to run against (required)
@@ -48963,6 +49399,8 @@ Run options:
48963
49399
  --evidence MODE full (default) or minimal: keep only the screenshots of the failed step, the step before it and the last step
48964
49400
  --repeat N Run each journey N times (max 10); mixed outcomes are reported as flaky
48965
49401
  --allow-private Allow private-network hosts (10.x, 192.168.x, ...)
49402
+ --deny-local Refuse local targets (localhost, 127.0.0.1, [::1], *.localhost, *.test) (exit 3); a redirect or request to one is blocked by the network guard and listed in the limitations;
49403
+ JOURILO_DENY_LOCAL=1 does the same. Not available with --browser webkit (exit 3)
48966
49404
  --fail-on-failure Exit 1 when a journey fails
48967
49405
 
48968
49406
  Safety: payment, purchase, delete and cancel actions are never performed on non-local hosts; form submission
@@ -48999,6 +49437,7 @@ var AUDIT_OPTIONS = {
48999
49437
  throttle: { type: "boolean" },
49000
49438
  "ignore-robots": { type: "boolean" },
49001
49439
  "allow-private": { type: "boolean" },
49440
+ "deny-local": { type: "boolean" },
49002
49441
  mask: { type: "string", multiple: true },
49003
49442
  auth: { type: "string" },
49004
49443
  locale: { type: "string" },
@@ -49019,20 +49458,35 @@ var AUDIT_OPTIONS = {
49019
49458
  };
49020
49459
  var SEVERITIES = ["critical", "serious", "moderate", "minor"];
49021
49460
  var BROWSERS = ["chromium", "firefox", "webkit", "chrome", "msedge"];
49461
+ function stoppedTwiceLine(json) {
49462
+ const message = "A second stop signal: quitting at once (exit 130). No run was written for this audit.";
49463
+ return json ? `${JSON.stringify({ type: "error", error: { code: "STOPPED_TWICE", message } })}
49464
+ ` : `jourilo: ${message}
49465
+ `;
49466
+ }
49022
49467
  function stopController(ctx, maxDurationS) {
49023
49468
  const controller = new AbortController();
49469
+ const grace = `${STOP_GRACE_MS / 1e3} s`;
49024
49470
  let signals = 0;
49025
49471
  const onSignal = () => {
49026
- if (++signals > 1) process.exit(130);
49472
+ if (++signals > 1) {
49473
+ try {
49474
+ writeSync2(2, stoppedTwiceLine(ctx.json));
49475
+ } catch {
49476
+ }
49477
+ process.exit(130);
49478
+ }
49027
49479
  ctx.log(
49028
- "Stopping: the current page finishes, then a partial run is written (signal again to quit now) \u2026"
49480
+ `Stopping: the page in progress gets up to ${grace} to finish, then a partial run is written (signal again to quit now) \u2026`
49029
49481
  );
49030
49482
  controller.abort("signal");
49031
49483
  };
49032
49484
  process.on("SIGINT", onSignal);
49033
49485
  process.on("SIGTERM", onSignal);
49034
49486
  const timer = maxDurationS ? setTimeout(() => {
49035
- ctx.log(`--max-duration ${maxDurationS}s reached: stopping after the current page \u2026`);
49487
+ ctx.log(
49488
+ `--max-duration ${maxDurationS}s reached: the page in progress gets up to ${grace} to finish, then a partial run is written \u2026`
49489
+ );
49036
49490
  controller.abort("max-duration");
49037
49491
  }, maxDurationS * 1e3) : void 0;
49038
49492
  return {
@@ -49049,32 +49503,34 @@ async function auditCommand(ctx, usage2, categories) {
49049
49503
  const browser = str(ctx, "browser") ?? "chromium";
49050
49504
  if (!BROWSERS.includes(browser))
49051
49505
  throw new JouriloError("USAGE", `--browser must be one of ${BROWSERS.join(", ")}.`);
49506
+ refuseDenyLocalWebkit(denyLocalFrom(ctx), browser);
49052
49507
  const failOn = str(ctx, "fail-on");
49053
49508
  if (failOn && !SEVERITIES.includes(failOn))
49054
49509
  throw new JouriloError("USAGE", `--fail-on must be one of ${SEVERITIES.join(", ")}.`);
49055
49510
  const state = parseStateOptions(ctx);
49056
49511
  const auth = str(ctx, "auth");
49057
49512
  if (auth && !existsSync12(auth)) throw new JouriloError("NOT_FOUND", `Auth state file not found: ${auth}`);
49058
- const maxDuration = str(ctx, "max-duration") === void 0 ? void 0 : num(ctx, "max-duration", 0);
49059
- if (maxDuration === 0) throw new JouriloError("USAGE", "--max-duration must be more than 0 seconds.");
49513
+ const maxDuration = maxDurationOption(ctx);
49514
+ const maxPages = pagesOption(ctx, 6);
49060
49515
  const evidence = parseEvidence(ctx);
49061
49516
  const setupRef = str(ctx, "setup-journey");
49062
49517
  const setupJourney = setupRef ? loadJourneys(ctx.ws, [setupRef])[0] : void 0;
49063
49518
  const stop = stopController(ctx, maxDuration);
49064
49519
  let server;
49065
49520
  try {
49066
- const isDir = !/^[a-z]+:\/\//i.test(targetArg) && existsSync12(targetArg) && statSync5(targetArg).isDirectory();
49521
+ const isDir = isDirectoryTarget(targetArg);
49067
49522
  server = isDir ? await serveStatic(resolve6(targetArg)) : void 0;
49068
49523
  const repo = str(ctx, "repo") ?? (isDir ? resolve6(targetArg) : void 0);
49069
49524
  const opts = {
49070
49525
  target: server ? server.url : targetArg,
49071
49526
  viewports: parseViewports(str(ctx, "viewports")),
49072
- maxPages: num(ctx, "pages", 6),
49527
+ maxPages,
49073
49528
  maxDepth: num(ctx, "depth", 3),
49074
49529
  browser,
49075
49530
  headed: flag(ctx, "headed"),
49076
49531
  respectRobots: !flag(ctx, "ignore-robots"),
49077
49532
  allowPrivate: flag(ctx, "allow-private"),
49533
+ ...denyLocalFrom(ctx) ? { denyLocal: true, ...server ? { servedPort: servedPort(server) } : {} } : {},
49078
49534
  keyboard: !flag(ctx, "no-keyboard"),
49079
49535
  throttle: flag(ctx, "throttle"),
49080
49536
  runs: num(ctx, "runs", 1),
@@ -49174,7 +49630,8 @@ function partialLines(run) {
49174
49630
  function noPageLines(run) {
49175
49631
  if (run.outcome !== "no-page-loaded") return [];
49176
49632
  const first = run.findings.find((f) => f.ruleId === "jourilo/page-unreachable");
49177
- return [`No page could be loaded from ${run.target}.`, ...first ? [` ${first.description}`] : [], ""];
49633
+ const why = first?.description ?? run.limitations.find((l) => l.includes("could not be resolved"));
49634
+ return [`No page could be loaded from ${run.target}.`, ...why ? [` ${why}`] : [], ""];
49178
49635
  }
49179
49636
  var audit = {
49180
49637
  name: "audit",
@@ -49182,7 +49639,7 @@ var audit = {
49182
49639
  usage: "jourilo audit <url|dir> [--pages 6] [--viewports desktop,mobile] [--url /path ...] [--repo .] [--fail-on serious]",
49183
49640
  options: AUDIT_OPTIONS,
49184
49641
  details: `Options:
49185
- --pages N Pages to audit after discovery (default 6; 1 = only the target)
49642
+ --pages N Pages to audit after discovery (default 6; 1 = only the target; 0 is a usage error)
49186
49643
  --depth N Link depth for discovery (default 3)
49187
49644
  --url PATH Audit exactly these paths/URLs on the target origin (repeatable; skips discovery)
49188
49645
  --include PATH Add pages to discovery (repeatable)
@@ -49204,6 +49661,8 @@ var audit = {
49204
49661
  --brand SLUG Markilo brand to check design consistency against (default: master brand)
49205
49662
  --ignore-robots Ignore robots.txt (only for sites you own or are authorized to test)
49206
49663
  --allow-private Allow private-network hosts (10.x, 192.168.x, ...)
49664
+ --deny-local Refuse local targets (localhost, 127.0.0.1, [::1], *.localhost, *.test) (exit 3); a redirect or request to one is blocked by the network guard and listed in the limitations;
49665
+ JOURILO_DENY_LOCAL=1 does the same. Not available with --browser webkit (exit 3). For hosted integrations; a directory target is still served
49207
49666
  --no-forms Skip the form validation probe
49208
49667
  (by default, on local hosts, an empty submission is simulated: nothing that sends data
49209
49668
  leaves the browser, so no database is written; server-side feedback is reported as untested)
@@ -49213,9 +49672,11 @@ var audit = {
49213
49672
  --sarif Also write report.sarif for code scanning
49214
49673
  --fail-on SEVERITY Exit 1 if a defect at or above this severity exists (CI gate)
49215
49674
  --evidence MODE full (default) or minimal: keep first screens and only the full-page screenshots findings need; JPEG quality 55
49216
- --run-id ID Use this run id instead of a generated one (1-64 of A-Z a-z 0-9 _ -); refused if the run exists
49217
- --max-duration SECS Stop after this many seconds and write a partial run (exit 6), as SIGINT/SIGTERM do;
49218
- a second signal quits at once (exit 130)`,
49675
+ --run-id ID Use this run id instead of a generated one (1-64 of A-Z a-z 0-9 _ -); refused (exit 2) if its
49676
+ directory exists, even without a run.json (left by a crashed run)
49677
+ --max-duration SECS Stop after this many seconds (1 to 86400) and write a partial run (exit 6), as SIGINT/SIGTERM do;
49678
+ the page in progress gets up to 3 s to finish, then it is left out; a second signal quits at
49679
+ once (exit 130) after one line on stderr (STOPPED_TWICE with --json)`,
49219
49680
  run: (ctx) => auditCommand(ctx, audit.usage)
49220
49681
  };
49221
49682
  var accessibility = {
@@ -49263,18 +49724,33 @@ async function benchFixture(dir, wsRoot) {
49263
49724
  context: loadContext(dir)
49264
49725
  });
49265
49726
  locateFindings(run, dir);
49266
- const missed = expected.mustFind.filter((e) => !run.findings.some((f) => matches(f, e)));
49727
+ const evidenceOk = (f, e) => {
49728
+ if (!e.evidenceExactly) return true;
49729
+ const texts = f.evidence.map((ev) => `${ev.snippet ?? ""} ${ev.selector ?? ""}`);
49730
+ return texts.length > 0 && texts.every((t) => e.evidenceExactly.some((n) => t.includes(n))) && e.evidenceExactly.every((n) => texts.some((t) => t.includes(n)));
49731
+ };
49732
+ const missed = expected.mustFind.filter(
49733
+ (e) => !run.findings.some((f) => matches(f, e) && evidenceOk(f, e))
49734
+ );
49267
49735
  for (const r of expected.recommendations ?? []) {
49268
49736
  const f = run.findings.find((x) => matches(x, r));
49269
49737
  if (!f?.recommendation.includes(r.includes))
49270
49738
  missed.push({ rule: r.rule, path: r.path, why: `recommendation should include "${r.includes}"` });
49271
49739
  }
49272
49740
  const falsePositives = expected.mustNotFind.filter((e) => run.findings.some((f) => matches(f, e))).map((e) => ({ rule: e.rule, path: e.path, ...e.why ? { why: e.why } : {} }));
49273
- const excessDefects = expected.maxDefects ? run.findings.filter(
49274
- (f) => f.kind === "defect" && severityRank(f.severity) <= severityRank(expected.maxDefects.minSeverity)
49275
- ).filter(
49276
- (f) => !expected.maxDefects.paths || expected.maxDefects.paths.includes(pathOf2(f.url).split("?")[0].replace(/\/$/, "") || "/")
49277
- ).map((f) => ({ rule: f.ruleId, path: pathOf2(f.url), severity: f.severity })) : [];
49741
+ const limits = expected.maxDefects ? [expected.maxDefects].flat() : [];
49742
+ const excessDefects = [];
49743
+ for (const lim of limits) {
49744
+ const over = [];
49745
+ for (const f of run.findings) {
49746
+ if (f.kind !== "defect" || severityRank(f.severity) > severityRank(lim.minSeverity)) continue;
49747
+ if (lim.rule && f.ruleId !== lim.rule) continue;
49748
+ if (lim.paths && !lim.paths.includes(pathOf2(f.url).split("?")[0].replace(/\/$/, "") || "/"))
49749
+ continue;
49750
+ over.push({ rule: f.ruleId, path: pathOf2(f.url), severity: f.severity });
49751
+ }
49752
+ excessDefects.push(...over.slice(0, Math.max(0, over.length - lim.count)));
49753
+ }
49278
49754
  const sourceMisses = [];
49279
49755
  for (const s of expected.sources ?? []) {
49280
49756
  const f = run.findings.find((x) => matches(x, { rule: s.rule, path: s.path }));
@@ -49287,10 +49763,7 @@ async function benchFixture(dir, wsRoot) {
49287
49763
  expected: expected.mustFind.length,
49288
49764
  missed,
49289
49765
  falsePositives,
49290
- excessDefects: excessDefects.slice(
49291
- 0,
49292
- Math.max(0, excessDefects.length - (expected.maxDefects?.count ?? 0))
49293
- ),
49766
+ excessDefects,
49294
49767
  sourceHits: (expected.sources?.length ?? 0) - sourceMisses.length,
49295
49768
  sourceExpected: expected.sources?.length ?? 0,
49296
49769
  sourceMisses,
@@ -49365,21 +49838,21 @@ var bench = {
49365
49838
  };
49366
49839
 
49367
49840
  // src/cli/commands/discover.ts
49368
- import { existsSync as existsSync16, statSync as statSync8, writeFileSync as writeFileSync10 } from "node:fs";
49841
+ import { existsSync as existsSync16, statSync as statSync7, writeFileSync as writeFileSync10 } from "node:fs";
49369
49842
  import { join as join21, resolve as resolve8 } from "node:path";
49370
49843
 
49371
49844
  // src/discovery/repo.ts
49372
49845
  init_fs_safety();
49373
- import { existsSync as existsSync15, readdirSync as readdirSync11, readFileSync as readFileSync14, statSync as statSync7 } from "node:fs";
49846
+ import { existsSync as existsSync15, readdirSync as readdirSync11, readFileSync as readFileSync14, statSync as statSync6 } from "node:fs";
49374
49847
  import { basename as basename5, dirname as dirname8, join as join20, relative as relative5 } from "node:path";
49375
49848
 
49376
49849
  // src/discovery/laravel.ts
49377
49850
  init_fs_safety();
49378
- import { existsSync as existsSync14, readdirSync as readdirSync10, readFileSync as readFileSync13, statSync as statSync6 } from "node:fs";
49851
+ import { existsSync as existsSync14, readdirSync as readdirSync10, readFileSync as readFileSync13, statSync as statSync5 } from "node:fs";
49379
49852
  import { basename as basename4, dirname as dirname7, join as join19, relative as relative4 } from "node:path";
49380
49853
  function read(file2) {
49381
49854
  try {
49382
- return statSync6(file2).size > 4e5 ? "" : readFileSync13(file2, "utf8");
49855
+ return statSync5(file2).size > 4e5 ? "" : readFileSync13(file2, "utf8");
49383
49856
  } catch {
49384
49857
  return "";
49385
49858
  }
@@ -49622,7 +50095,7 @@ function walk(root, max = 2e4) {
49622
50095
  const full = join20(dir, n);
49623
50096
  let st;
49624
50097
  try {
49625
- st = statSync7(full);
50098
+ st = statSync6(full);
49626
50099
  } catch {
49627
50100
  continue;
49628
50101
  }
@@ -49642,7 +50115,7 @@ function readJson2(file2) {
49642
50115
  }
49643
50116
  function read2(file2, max = 4e5) {
49644
50117
  try {
49645
- const st = statSync7(file2);
50118
+ const st = statSync6(file2);
49646
50119
  if (st.size > max) return "";
49647
50120
  return readFileSync14(file2, "utf8");
49648
50121
  } catch {
@@ -50248,20 +50721,23 @@ function siteText(p) {
50248
50721
  var discover = {
50249
50722
  name: "discover",
50250
50723
  summary: "Product intelligence: map a repository (frameworks, routes, design system, i18n, auth, analytics) or a website (pages, conversion points, services).",
50251
- usage: "jourilo discover <path|url> [--pages 10] [--ignore-robots] [--allow-private]",
50724
+ usage: "jourilo discover <path|url> [--pages 10] [--ignore-robots] [--allow-private] [--deny-local]",
50252
50725
  options: {
50253
50726
  pages: { type: "string" },
50254
50727
  "allow-private": { type: "boolean" },
50728
+ "deny-local": { type: "boolean" },
50255
50729
  "ignore-robots": { type: "boolean" }
50256
50730
  },
50257
50731
  details: `Options:
50258
50732
  --pages N Pages to read when mapping a website (default 10)
50259
50733
  --ignore-robots Ignore robots.txt (only for sites you own or are authorized to test)
50260
- --allow-private Allow private-network hosts (10.x, 192.168.x, ...)`,
50734
+ --allow-private Allow private-network hosts (10.x, 192.168.x, ...)
50735
+ --deny-local Refuse local targets (localhost, 127.0.0.1, [::1], *.localhost, *.test) (exit 3); a redirect or request to one is blocked by the network guard and listed in the limitations;
50736
+ JOURILO_DENY_LOCAL=1 does the same`,
50261
50737
  async run(ctx) {
50262
50738
  const target = requirePositional(ctx, 0, "path|url", discover.usage);
50263
50739
  ctx.ws.ensure();
50264
- if (!target.includes("://") && existsSync16(target) && statSync8(target).isDirectory()) {
50740
+ if (!target.includes("://") && existsSync16(target) && statSync7(target).isDirectory()) {
50265
50741
  const profile2 = discoverRepository(resolve8(target));
50266
50742
  const file3 = join21(ctx.ws.root, "product.json");
50267
50743
  writeFileSync10(file3, `${JSON.stringify(profile2, null, 2)}
@@ -50273,12 +50749,13 @@ var discover = {
50273
50749
  const { run, observations } = await runAudit(ctx.ws, {
50274
50750
  target,
50275
50751
  viewports: parseViewports("desktop"),
50276
- maxPages: num(ctx, "pages", 10),
50752
+ maxPages: pagesOption(ctx, 10),
50277
50753
  maxDepth: 3,
50278
50754
  browser: "chromium",
50279
50755
  headed: false,
50280
50756
  respectRobots: !flag(ctx, "ignore-robots"),
50281
50757
  allowPrivate: flag(ctx, "allow-private"),
50758
+ ...denyLocalFrom(ctx) ? { denyLocal: true } : {},
50282
50759
  keyboard: false,
50283
50760
  throttle: false,
50284
50761
  timeoutMs: 3e4,
@@ -51042,14 +51519,7 @@ function metricVerdict(name, a, b) {
51042
51519
  }
51043
51520
  return Math.abs(delta) < (RESOLUTION[name] ?? 0) ? "noise" : "unconfirmed";
51044
51521
  }
51045
- function pathKey(u) {
51046
- try {
51047
- const x = new URL(u);
51048
- return `${x.pathname}${x.search}`;
51049
- } catch {
51050
- return u;
51051
- }
51052
- }
51522
+ var pathKey = fingerprintPath;
51053
51523
  function compareRuns(base2, head) {
51054
51524
  const before = new Map(base2.findings.map((f) => [f.fingerprint, f]));
51055
51525
  const after = new Map(head.findings.map((f) => [f.fingerprint, f]));
@@ -51207,6 +51677,24 @@ function metricSummary(metrics) {
51207
51677
  );
51208
51678
  return lines;
51209
51679
  }
51680
+ function verifyTargetUrl(target) {
51681
+ const hint = "Give a directory to serve, or a URL that starts with http:// or https://.";
51682
+ if (existsSync19(target))
51683
+ throw new JouriloError("INVALID_INPUT", `--target ${target} is a file, not a directory or a URL.`, hint);
51684
+ let url;
51685
+ try {
51686
+ url = new URL(target);
51687
+ } catch {
51688
+ url = void 0;
51689
+ }
51690
+ if (!url || url.protocol !== "http:" && url.protocol !== "https:" || !url.hostname)
51691
+ throw new JouriloError(
51692
+ "INVALID_INPUT",
51693
+ `--target ${target} is neither an existing directory nor an http(s) URL.`,
51694
+ hint
51695
+ );
51696
+ return url;
51697
+ }
51210
51698
  var verify = {
51211
51699
  name: "verify",
51212
51700
  summary: "Repeat a run with the same scope (pages, viewports) after a change and compare: fixed, new, persisting, metric deltas.",
@@ -51214,25 +51702,31 @@ var verify = {
51214
51702
  options: {
51215
51703
  target: { type: "string" },
51216
51704
  "fail-on-regression": { type: "boolean" },
51705
+ "deny-local": { type: "boolean" },
51217
51706
  repo: { type: "string" }
51218
51707
  },
51219
51708
  details: `Options:
51220
51709
  --target URL|DIR Where to repeat the audit (default: the baseline run's target)
51221
51710
  --repo PATH Repository for source mapping (default: the baseline run's repo)
51711
+ --deny-local Refuse a local target (localhost, 127.0.0.1, [::1], *.localhost, *.test) with exit 3; a redirect to one is blocked by the network guard and listed in the limitations;
51712
+ JOURILO_DENY_LOCAL=1 does the same. Not available with --browser webkit (exit 3)
51222
51713
  --fail-on-regression Exit 1 when new serious or critical defects appear`,
51223
51714
  async run(ctx) {
51715
+ const target = str(ctx, "target");
51716
+ const targetDir = target !== void 0 && isDirectoryTarget(target);
51717
+ const targetUrl = target !== void 0 && !targetDir ? verifyTargetUrl(target) : void 0;
51224
51718
  const base2 = ctx.ws.loadRun(ctx.positionals[0] ?? "latest").run;
51719
+ refuseDenyLocalWebkit(denyLocalFrom(ctx), String(base2.browser.name ?? "chromium"));
51225
51720
  const scope = base2.settings.scope;
51226
51721
  if (!scope)
51227
51722
  throw new JouriloError(
51228
51723
  "INVALID_INPUT",
51229
51724
  `Run ${base2.id} has no recorded scope (created by an older version or by journeys).`
51230
51725
  );
51231
- const target = str(ctx, "target");
51232
- const servedDir = target ? existsSync19(target) ? resolve9(target) : void 0 : base2.settings.servedDir;
51726
+ const servedDir = target !== void 0 ? targetDir ? resolve9(target) : void 0 : base2.settings.servedDir;
51233
51727
  const server = servedDir ? await serveStatic(servedDir) : void 0;
51234
51728
  try {
51235
- const origin = server?.url ?? new URL(target ?? scope.urls[0]).origin;
51729
+ const origin = server?.url ?? (targetUrl ?? new URL(scope.urls[0])).origin;
51236
51730
  const urls = scope.urls.map((u) => {
51237
51731
  const x = new URL(u);
51238
51732
  return `${x.pathname}${x.search}`;
@@ -51261,6 +51755,7 @@ var verify = {
51261
51755
  headed: false,
51262
51756
  respectRobots: base2.settings.respectRobots !== false,
51263
51757
  allowPrivate: base2.settings.hostClass === "private",
51758
+ ...denyLocalFrom(ctx) ? { denyLocal: true, ...server ? { servedPort: servedPort(server) } : {} } : {},
51264
51759
  keyboard: base2.settings.keyboard !== false,
51265
51760
  throttle: base2.settings.throttle === true,
51266
51761
  // Same number of visits as the baseline, so medians are compared with medians.