jourilo 0.1.0 → 1.0.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 0.1.0 - generated by scripts/build.mjs. Do not edit; edit src/ instead.
2
+ // Jourilo 1.0.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;
@@ -82,16 +82,26 @@ var init_errors = __esm({
82
82
  // src/core/fs-safety.ts
83
83
  import { chmodSync, existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
84
84
  import { homedir } from "node:os";
85
- import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
85
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
86
86
  function resolveInside(root, child) {
87
87
  if (child.includes("\0")) throw new JouriloError("UNSAFE_PATH", "Path contains a NUL byte.");
88
- const base2 = existsSync(root) ? realpathSync(root) : resolve(root);
89
- const target = resolve(base2, child);
90
- const real = existsSync(target) ? realpathSync(target) : target;
88
+ const base2 = realpathDeep(resolve(root));
89
+ const real = realpathDeep(resolve(base2, child));
91
90
  const rel = relative(base2, real);
92
91
  if (rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)) return real;
93
92
  throw new JouriloError("UNSAFE_PATH", `Path escapes ${root}: ${child}`);
94
93
  }
94
+ function realpathDeep(path2) {
95
+ let existing = path2;
96
+ const rest = [];
97
+ while (!existsSync(existing)) {
98
+ const parent = dirname(existing);
99
+ if (parent === existing) return path2;
100
+ rest.unshift(basename(existing));
101
+ existing = parent;
102
+ }
103
+ return join(realpathSync(existing), ...rest);
104
+ }
95
105
  function writeFileSafe(path2, data, opts = {}) {
96
106
  mkdirSync(dirname(path2), { recursive: true });
97
107
  writeFileSync(path2, data, opts.private ? { mode: 384 } : void 0);
@@ -129,7 +139,7 @@ __export(workspace_exports, {
129
139
  Workspace: () => Workspace
130
140
  });
131
141
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync, writeFileSync as writeFileSync2 } from "node:fs";
132
- import { dirname as dirname2, join, resolve as resolve2 } from "node:path";
142
+ import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
133
143
  var Workspace;
134
144
  var init_workspace = __esm({
135
145
  "src/core/workspace.ts"() {
@@ -143,11 +153,11 @@ var init_workspace = __esm({
143
153
  this.root = resolve2(root ?? process.env.JOURILO_WORKSPACE ?? ".jourilo");
144
154
  }
145
155
  get runsDir() {
146
- return join(this.root, "runs");
156
+ return join2(this.root, "runs");
147
157
  }
148
158
  ensure() {
149
159
  mkdirSync2(this.runsDir, { recursive: true });
150
- const ignore = join(this.root, ".gitignore");
160
+ const ignore = join2(this.root, ".gitignore");
151
161
  if (!existsSync2(ignore)) writeFileSync2(ignore, "*\n");
152
162
  }
153
163
  runDir(id) {
@@ -156,12 +166,12 @@ var init_workspace = __esm({
156
166
  createRunDir(id) {
157
167
  this.ensure();
158
168
  const dir = this.runDir(id);
159
- mkdirSync2(join(dir, "screenshots"), { recursive: true });
169
+ mkdirSync2(join2(dir, "screenshots"), { recursive: true });
160
170
  return dir;
161
171
  }
162
172
  saveRun(run) {
163
173
  const dir = this.createRunDir(run.id);
164
- const file = join(dir, "run.json");
174
+ const file = join2(dir, "run.json");
165
175
  writeFileSync2(file, `${JSON.stringify(run, null, 2)}
166
176
  `);
167
177
  return file;
@@ -170,7 +180,7 @@ var init_workspace = __esm({
170
180
  if (!existsSync2(this.runsDir)) return [];
171
181
  const out = [];
172
182
  for (const id of readdirSync(this.runsDir).sort()) {
173
- const file = join(this.runsDir, id, "run.json");
183
+ const file = join2(this.runsDir, id, "run.json");
174
184
  if (!existsSync2(file)) continue;
175
185
  try {
176
186
  const run = JSON.parse(readFileSync(file, "utf8"));
@@ -196,13 +206,13 @@ var init_workspace = __esm({
196
206
  const runs2 = this.listRuns();
197
207
  const pick = runs2[runs2.length - (ref === "latest" ? 1 : 2)];
198
208
  if (!pick) throw new JouriloError("NOT_FOUND", `No ${ref} run in ${this.runsDir}.`);
199
- file = join(this.runsDir, pick.id, "run.json");
209
+ file = join2(this.runsDir, pick.id, "run.json");
200
210
  } else if (ref.endsWith(".json") && existsSync2(ref)) {
201
211
  file = resolve2(ref);
202
- } else if (existsSync2(join(ref, "run.json"))) {
212
+ } else if (existsSync2(join2(ref, "run.json"))) {
203
213
  file = resolve2(ref, "run.json");
204
214
  } else {
205
- file = join(this.runDir(ref), "run.json");
215
+ file = join2(this.runDir(ref), "run.json");
206
216
  }
207
217
  if (!existsSync2(file)) throw new JouriloError("NOT_FOUND", `Run not found: ${ref}`);
208
218
  const run = JSON.parse(readFileSync(file, "utf8"));
@@ -38811,7 +38821,7 @@ var require_axe = __commonJS({
38811
38821
  init_errors();
38812
38822
 
38813
38823
  // src/core/version.ts
38814
- var VERSION = true ? "0.1.0" : "0.0.0-dev";
38824
+ var VERSION = true ? "1.0.1" : "0.0.0-dev";
38815
38825
 
38816
38826
  // src/cli/framework.ts
38817
38827
  init_errors();
@@ -38884,7 +38894,7 @@ import { fileURLToPath } from "node:url";
38884
38894
 
38885
38895
  // src/agents/adapters.ts
38886
38896
  import { homedir as homedir2 } from "node:os";
38887
- import { join as join2 } from "node:path";
38897
+ import { join as join3 } from "node:path";
38888
38898
  var MARK_START = "<!-- jourilo:start -->";
38889
38899
  var MARK_END = "<!-- jourilo:end -->";
38890
38900
  function instructions(cmd) {
@@ -38922,7 +38932,7 @@ var ADAPTERS = [
38922
38932
  id: "claude-skill",
38923
38933
  name: "Claude Code personal skill (adds a bare /jourilo command)",
38924
38934
  scope: "user",
38925
- path: () => join2(homedir2(), ".claude", "skills", "jourilo", "SKILL.md"),
38935
+ path: () => join3(homedir2(), ".claude", "skills", "jourilo", "SKILL.md"),
38926
38936
  render: (cmd) => `---
38927
38937
  name: jourilo
38928
38938
  description: Jourilo product experience & conversion engineering \u2014 audit UX, accessibility, performance and conversion in a real browser, run user journeys, fix root causes and verify. Use for /jourilo.
@@ -38942,14 +38952,14 @@ ${instructions(cmd)}`
38942
38952
  name: "OpenAI Codex / AGENTS.md-compatible agents",
38943
38953
  scope: "project",
38944
38954
  section: true,
38945
- path: (root) => join2(root, "AGENTS.md"),
38955
+ path: (root) => join3(root, "AGENTS.md"),
38946
38956
  render: (cmd) => instructions(cmd)
38947
38957
  },
38948
38958
  {
38949
38959
  id: "cursor",
38950
38960
  name: "Cursor project rule",
38951
38961
  scope: "project",
38952
- path: (root) => join2(root, ".cursor", "rules", "jourilo.mdc"),
38962
+ path: (root) => join3(root, ".cursor", "rules", "jourilo.mdc"),
38953
38963
  render: (cmd) => `---
38954
38964
  description: Jourilo product experience & conversion engineering (UX, accessibility, journeys, verified fixes)
38955
38965
  alwaysApply: false
@@ -39028,7 +39038,7 @@ import { resolve as resolve5 } from "node:path";
39028
39038
  // src/browser/capture.ts
39029
39039
  var import_axe_core = __toESM(require_axe(), 1);
39030
39040
  init_fs_safety();
39031
- import { join as join3 } from "node:path";
39041
+ import { join as join5 } from "node:path";
39032
39042
 
39033
39043
  // src/core/hash.ts
39034
39044
  import { createHash, randomBytes } from "node:crypto";
@@ -39506,6 +39516,246 @@ function excerpt(input, max = 240) {
39506
39516
  return clean.length > max ? `${clean.slice(0, max - 1)}\u2026` : clean;
39507
39517
  }
39508
39518
 
39519
+ // src/journeys/safety.ts
39520
+ var DESTRUCTIVE = lexicon("destructive");
39521
+ var FINANCIAL = lexicon("financial");
39522
+ function signalWords(raw) {
39523
+ return raw.replace(new RegExp("([\\p{Ll}\\p{N}])(\\p{Lu})", "gu"), "$1 $2").replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
39524
+ }
39525
+ var DESTRUCTIVE_WORDS = new RegExp(
39526
+ `(?<![\\p{L}\\p{N}])(${[
39527
+ "delete",
39528
+ "destroy",
39529
+ "remove",
39530
+ "erase",
39531
+ "purge",
39532
+ "wipe",
39533
+ "trash",
39534
+ "cancel",
39535
+ "unsubscribe",
39536
+ "deactivate",
39537
+ "terminate",
39538
+ "revoke",
39539
+ "close account",
39540
+ "sil",
39541
+ "silme",
39542
+ "kaldir",
39543
+ "kald\u0131r",
39544
+ "iptal",
39545
+ "loeschen",
39546
+ "l\xF6schen",
39547
+ "entfernen",
39548
+ "kuendigen",
39549
+ "k\xFCndigen",
39550
+ "eliminar",
39551
+ "borrar",
39552
+ "cancelar",
39553
+ "dar de baja",
39554
+ "supprimer",
39555
+ "effacer",
39556
+ "resilier",
39557
+ "r\xE9silier",
39558
+ "annuler",
39559
+ "desabonner",
39560
+ "d\xE9sabonner"
39561
+ ].join("|")})(?![\\p{L}\\p{N}])`,
39562
+ "iu"
39563
+ );
39564
+ var FINANCIAL_WORDS = new RegExp(
39565
+ `(?<![\\p{L}\\p{N}])(${[
39566
+ "pay",
39567
+ "payment",
39568
+ "payments",
39569
+ "checkout",
39570
+ "purchase",
39571
+ "charge",
39572
+ "charges",
39573
+ "order",
39574
+ "orders",
39575
+ "billing",
39576
+ "subscription",
39577
+ "transfer",
39578
+ "withdraw",
39579
+ "withdrawal",
39580
+ "donate",
39581
+ "payout",
39582
+ "odeme",
39583
+ "\xF6deme",
39584
+ "satin al",
39585
+ "sat\u0131n al",
39586
+ "siparis",
39587
+ "sipari\u015F",
39588
+ "zahlung",
39589
+ "bezahlen",
39590
+ "kaufen",
39591
+ "bestellung",
39592
+ "pago",
39593
+ "pagar",
39594
+ "comprar",
39595
+ "pedido",
39596
+ "paiement",
39597
+ "payer",
39598
+ "acheter",
39599
+ "commande"
39600
+ ].join("|")})(?![\\p{L}\\p{N}])`,
39601
+ "iu"
39602
+ );
39603
+ var DIALOG_WORDS = /^(cancel|cancelar|annuler|iptal)$/i;
39604
+ function classifyAction(name, signals = "", targets = "") {
39605
+ const attrWords = signalWords(signals).split(" ").filter((w) => !DIALOG_WORDS.test(w)).join(" ");
39606
+ const urlWords = signalWords(targets);
39607
+ if (DESTRUCTIVE.test(name) || attrWords && DESTRUCTIVE_WORDS.test(attrWords) || urlWords && DESTRUCTIVE_WORDS.test(urlWords))
39608
+ return "destructive";
39609
+ if (FINANCIAL.test(name)) return "financial";
39610
+ return null;
39611
+ }
39612
+ function checkAction(ctx, policy) {
39613
+ const safeHost = policy.hostClass === "local";
39614
+ const label = ctx.name || signalWords(`${ctx.signals ?? ""} ${ctx.targets ?? ""}`).slice(0, 60) || "this control";
39615
+ const risky = classifyAction(ctx.name, ctx.signals, ctx.targets) ?? (ctx.paymentForm ? "financial" : null);
39616
+ if (risky) {
39617
+ if (!safeHost)
39618
+ return {
39619
+ allowed: false,
39620
+ reason: `"${label}" looks ${risky === "financial" ? "like a payment/purchase" : "irreversible"}; Jourilo never performs it on a non-local host.`
39621
+ };
39622
+ if (!policy.allowDestructive)
39623
+ return {
39624
+ allowed: false,
39625
+ reason: `"${label}" looks ${risky === "financial" ? "like a payment/purchase" : "irreversible"}; rerun with --allow-destructive on a local/test environment with sandbox data to include it.`
39626
+ };
39627
+ }
39628
+ if (ctx.submitsForm && !safeHost && !policy.allowSubmit) {
39629
+ return {
39630
+ allowed: false,
39631
+ reason: "Submitting a form on a non-local host is blocked; use --allow-submit only on an environment you are authorized to test, with test accounts."
39632
+ };
39633
+ }
39634
+ return { allowed: true };
39635
+ }
39636
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
39637
+ function isGraphqlRead(body) {
39638
+ if (!body) return false;
39639
+ try {
39640
+ const q = JSON.parse(body).query;
39641
+ return typeof q === "string" && /^\s*(query\b|\{)/.test(q) && !/\bmutation\b/.test(q);
39642
+ } catch {
39643
+ return false;
39644
+ }
39645
+ }
39646
+ function checkRequest(r, policy) {
39647
+ let u;
39648
+ try {
39649
+ u = new URL(r.url);
39650
+ } catch {
39651
+ return { allowed: true };
39652
+ }
39653
+ if (u.protocol !== "http:" && u.protocol !== "https:") return { allowed: true };
39654
+ const method = r.method.toUpperCase();
39655
+ const firstParty = u.origin === policy.origin;
39656
+ const safeHost = policy.hostClass === "local";
39657
+ if (r.isNavigation && r.mainFrame && !firstParty && !(policy.allowOrigins ?? []).includes(u.origin))
39658
+ return {
39659
+ allowed: false,
39660
+ kind: "off-origin",
39661
+ reason: `Navigation to ${u.origin} leaves the target origin ${policy.origin}; Jourilo does not act on other sites (allow it with --allow-origin ${u.origin}).`
39662
+ };
39663
+ const words = signalWords(`${u.pathname} ${u.search}`);
39664
+ const mutating = !SAFE_METHODS.has(method);
39665
+ const risky = method === "DELETE" || r.isNavigation && !mutating && DESTRUCTIVE_WORDS.test(words) ? "destructive" : mutating && DESTRUCTIVE_WORDS.test(words) ? "destructive" : mutating && FINANCIAL_WORDS.test(words) ? "financial" : null;
39666
+ const what = `${method} ${u.pathname}`;
39667
+ if (risky && (policy.mode === "read-only" || !safeHost || !policy.allowDestructive))
39668
+ return {
39669
+ allowed: false,
39670
+ kind: risky,
39671
+ reason: `${what} looks ${risky === "financial" ? "like a payment/purchase" : "irreversible"}; ${policy.mode === "read-only" ? "audits never send it" : safeHost ? "rerun with --allow-destructive on a local/test environment with sandbox data to include it" : "Jourilo never sends it to a non-local host"}.`
39672
+ };
39673
+ if (!mutating || isGraphqlRead(r.body)) return { allowed: true };
39674
+ if (policy.mode === "read-only")
39675
+ return {
39676
+ allowed: false,
39677
+ kind: "submit",
39678
+ reason: `${what} would send data; audits only observe (form probes simulate the submission instead).`
39679
+ };
39680
+ if (!safeHost && !policy.allowSubmit && (firstParty || r.isNavigation))
39681
+ return {
39682
+ allowed: false,
39683
+ kind: "submit",
39684
+ reason: `${what} sends data to a non-local host; use --allow-submit only on an environment you are authorized to test, with test accounts.`
39685
+ };
39686
+ return { allowed: true };
39687
+ }
39688
+ function testData(runId, vars = {}) {
39689
+ const tag = runId.replace(/[^a-z0-9]/gi, "").slice(-10).toLowerCase();
39690
+ const base2 = {
39691
+ email: `jourilo+${tag}@example.test`,
39692
+ password: `Jr-${tag}-Q7!x`,
39693
+ name: "Jordan Test",
39694
+ firstName: "Jordan",
39695
+ lastName: "Test",
39696
+ phone: "+1 555 0100",
39697
+ company: "Example Test Co",
39698
+ search: "test",
39699
+ zip: "10001",
39700
+ // Published test card from Stripe's docs; only useful in a provider's test mode.
39701
+ testCard: "4242 4242 4242 4242"
39702
+ };
39703
+ return { ...base2, ...vars };
39704
+ }
39705
+ function interpolate(value, data) {
39706
+ return value.replace(/\{\{\s*([A-Za-z][\w]*)\s*\}\}/g, (m, k) => data[k] ?? m);
39707
+ }
39708
+ function isSecretValue(raw) {
39709
+ return /\{\{\s*(password|testCard|secret|token)\s*\}\}/i.test(raw);
39710
+ }
39711
+
39712
+ // src/journeys/request-guard.ts
39713
+ async function installRequestGuard(target, policy) {
39714
+ const blocked = [];
39715
+ const urls = /* @__PURE__ */ new Set();
39716
+ const handler = async (route) => {
39717
+ const req = route.request();
39718
+ let mainFrame = req.isNavigationRequest();
39719
+ try {
39720
+ const frame = req.frame();
39721
+ mainFrame = frame.parentFrame() === null;
39722
+ } catch {
39723
+ }
39724
+ const verdict = checkRequest(
39725
+ {
39726
+ url: req.url(),
39727
+ method: req.method(),
39728
+ isNavigation: req.isNavigationRequest(),
39729
+ mainFrame,
39730
+ body: req.postData()
39731
+ },
39732
+ policy
39733
+ );
39734
+ if (!verdict.allowed) {
39735
+ if (blocked.length < 200)
39736
+ blocked.push({
39737
+ url: redactUrl(req.url()),
39738
+ method: req.method(),
39739
+ reason: verdict.reason,
39740
+ kind: verdict.kind ?? "submit",
39741
+ navigation: req.isNavigationRequest(),
39742
+ at: Date.now()
39743
+ });
39744
+ urls.add(req.url());
39745
+ await route.abort("blockedbyclient").catch(() => void 0);
39746
+ return;
39747
+ }
39748
+ await route.fallback().catch(() => void 0);
39749
+ };
39750
+ await target.route("**/*", handler);
39751
+ return {
39752
+ blocked,
39753
+ isBlocked: (u) => urls.has(u) || urls.has(u.split("#")[0]),
39754
+ since: (t) => blocked.filter((b) => b.at >= t),
39755
+ detach: () => target.unroute("**/*", handler).catch(() => void 0)
39756
+ };
39757
+ }
39758
+
39509
39759
  // raw:src/browser/inpage/probe.js
39510
39760
  var probe_default = `// Runs inside the audited page. Plain JavaScript (no imports, no bundler helpers) evaluated as
39511
39761
  // \`(probe)(options)\`. It only reads the DOM; it never changes the page.
@@ -40192,6 +40442,28 @@ var ESCAPE_TRAP = `(sels) => {
40192
40442
  next.focus();
40193
40443
  return true;
40194
40444
  }`;
40445
+ var TRAP_ADVICE = `(selectors) => {
40446
+ const text = document.body.innerText || '';
40447
+ const m = text.match(/(press|use|hit)\\s+(ctrl|control|alt|option|shift|cmd|command|esc|escape|f\\d+)[^.\\n]{0,24}?\\s+to\\s+(exit|leave|escape|close|quit|move on|continue)[^.\\n]{0,40}/i);
40448
+ if (m) return m[0].trim();
40449
+ for (const s of selectors) {
40450
+ const el = document.querySelector(s);
40451
+ const name = el ? (el.getAttribute('aria-label') || el.textContent || '').trim() : '';
40452
+ if (/\\b(how to|help|exit|leave|skip out)\\b/i.test(name)) return name.slice(0, 80);
40453
+ }
40454
+ return null;
40455
+ }`;
40456
+ async function shotAround(page, r) {
40457
+ const pad = 24;
40458
+ const clip = {
40459
+ x: Math.max(0, r.x - pad),
40460
+ y: Math.max(0, r.y - pad),
40461
+ width: r.width + pad * 2,
40462
+ height: r.height + pad * 2
40463
+ };
40464
+ if (r.width < 1 || r.height < 1 || clip.width * clip.height > 640 * 480) return null;
40465
+ return page.screenshot({ clip, fullPage: true, animations: "disabled", caret: "hide", type: "png" }).catch(() => null);
40466
+ }
40195
40467
  async function keyboardWalk(page, maxTabs = 60) {
40196
40468
  const focusableCount = await page.evaluate(COUNT_FOCUSABLE);
40197
40469
  const stops = [];
@@ -40202,6 +40474,7 @@ async function keyboardWalk(page, maxTabs = 60) {
40202
40474
  );
40203
40475
  let prevHandle = null;
40204
40476
  let prevFocused = null;
40477
+ let prevShot = null;
40205
40478
  const limit = Math.min(maxTabs, focusableCount + 12);
40206
40479
  const sequence = [];
40207
40480
  const newStop = (d) => ({
@@ -40236,6 +40509,9 @@ async function keyboardWalk(page, maxTabs = 60) {
40236
40509
  for (let i = 0; i < limit; i++) {
40237
40510
  await page.keyboard.press(tabKey);
40238
40511
  walk2.pressed++;
40512
+ await page.evaluate(
40513
+ "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(r, 20))))"
40514
+ ).catch(() => void 0);
40239
40515
  const handle = await page.evaluateHandle("document.activeElement");
40240
40516
  const data = await handle.evaluate(inPage(FOCUS_INFO));
40241
40517
  const prevSelector = sequence[sequence.length - 1];
@@ -40248,12 +40524,24 @@ async function keyboardWalk(page, maxTabs = 60) {
40248
40524
  walk2.trap = { selectors: [data.selector] };
40249
40525
  }
40250
40526
  }
40527
+ const isNewStop = !!data && !noMove && !stops.some((s) => s.selector === data.selector);
40528
+ const curFocused = isNewStop ? await handle.evaluate(inPage(FOCUS_STYLES)).catch(() => null) : null;
40529
+ const curShot = isNewStop ? await shotAround(page, data.rect) : null;
40251
40530
  if (prevHandle && prevFocused && stops.length) {
40252
40531
  const unfocused = await prevHandle.evaluate(inPage(FOCUS_STYLES)).catch(() => null);
40253
40532
  const last = stops[stops.length - 1];
40254
40533
  if (unfocused) {
40255
40534
  last.indicatorChanges = prevFocused.filter((s, k) => s !== unfocused[k]);
40256
40535
  last.indicatorChecked = true;
40536
+ if (!last.indicatorChanges.length && prevShot) {
40537
+ await handle.evaluate(inPage("(el) => el.blur && el.blur()")).catch(() => void 0);
40538
+ const neutral = await shotAround(page, last.rect);
40539
+ await handle.evaluate(inPage("(el) => el.focus && el.focus()")).catch(() => void 0);
40540
+ if (neutral && !neutral.equals(prevShot))
40541
+ last.indicatorChanges = [
40542
+ "the area around the element changes on focus (indicator drawn by another element)"
40543
+ ];
40544
+ }
40257
40545
  if (process.env.JOURILO_DEBUG?.includes("keyboard"))
40258
40546
  process.stderr.write(
40259
40547
  `[keyboard] ${last.selector} changes=${JSON.stringify(last.indicatorChanges)}
@@ -40266,6 +40554,7 @@ async function keyboardWalk(page, maxTabs = 60) {
40266
40554
  break;
40267
40555
  }
40268
40556
  prevFocused = null;
40557
+ prevShot = null;
40269
40558
  if (!data) {
40270
40559
  if (stops.length) walk2.completedCycle = true;
40271
40560
  break;
@@ -40286,7 +40575,8 @@ async function keyboardWalk(page, maxTabs = 60) {
40286
40575
  for (const s of tail) counts.set(s, (counts.get(s) ?? 0) + 1);
40287
40576
  const distinct = new Set([...counts].filter(([, n]) => n >= 2).map(([s]) => s));
40288
40577
  const cycling = distinct.size > 0 && tail.slice(-4).every((s) => distinct.has(s));
40289
- if (cycling && distinct.size <= 3 && focusableCount > distinct.size + 2 && !data.inModal) {
40578
+ const unreached = focusableCount - new Set(sequence).size;
40579
+ if (cycling && distinct.size <= 3 && unreached > 0 && !data.inModal) {
40290
40580
  walk2.trap = { selectors: [...distinct] };
40291
40581
  if (!escaped && await page.evaluate(`(${ESCAPE_TRAP})(${JSON.stringify([...distinct])})`).catch(() => false)) {
40292
40582
  escaped = true;
@@ -40306,12 +40596,653 @@ async function keyboardWalk(page, maxTabs = 60) {
40306
40596
  prevHandle = null;
40307
40597
  continue;
40308
40598
  }
40309
- prevFocused = await handle.evaluate(inPage(FOCUS_STYLES)).catch(() => null);
40599
+ prevFocused = curFocused;
40600
+ prevShot = curShot;
40310
40601
  prevHandle = handle;
40311
40602
  stops.push(newStop(data));
40312
40603
  }
40604
+ if (walk2.trap) {
40605
+ const advice = await page.evaluate(`(${TRAP_ADVICE})(${JSON.stringify(walk2.trap.selectors)})`).catch(() => null);
40606
+ if (advice) {
40607
+ walk2.trap.advice = advice;
40608
+ const key = adviceKey(advice);
40609
+ if (key) {
40610
+ const trapped = walk2.trap.selectors;
40611
+ await page.locator(trapped[0]).first().focus({ timeout: 2e3 }).catch(() => void 0);
40612
+ await page.keyboard.press(key).catch(() => void 0);
40613
+ await page.evaluate("new Promise((r) => requestAnimationFrame(() => setTimeout(r, 30)))").catch(() => void 0);
40614
+ const out = await page.evaluate(
40615
+ `((sel) => { const a = document.activeElement; return !!a && a !== document.body && !sel.some((s) => a.matches(s)); })(${JSON.stringify(trapped)})`
40616
+ ).catch(() => false);
40617
+ walk2.trap.adviceWorks = out;
40618
+ }
40619
+ }
40620
+ }
40313
40621
  return walk2;
40314
40622
  }
40623
+ function adviceKey(advice) {
40624
+ const m = advice.match(/\b(?:press|use|hit)\s+([a-z0-9]+(?:\s*[+-]\s*[a-z0-9]+)*)/i);
40625
+ if (!m) return null;
40626
+ const NAMES = {
40627
+ ctrl: "Control",
40628
+ control: "Control",
40629
+ alt: "Alt",
40630
+ option: "Alt",
40631
+ shift: "Shift",
40632
+ cmd: "Meta",
40633
+ command: "Meta",
40634
+ esc: "Escape",
40635
+ escape: "Escape",
40636
+ tab: "Tab",
40637
+ enter: "Enter"
40638
+ };
40639
+ const parts = m[1].split(/\s*[+-]\s*/).map(
40640
+ (k) => NAMES[k.toLowerCase()] ?? (/^f\d{1,2}$/i.test(k) ? k.toUpperCase() : k.length === 1 ? k.toUpperCase() : null)
40641
+ );
40642
+ if (parts.some((p) => !p) || !parts.some((p) => p !== "Shift" && !/^(Control|Alt|Meta)$/.test(p) || parts.length === 1))
40643
+ return null;
40644
+ return parts.join("+");
40645
+ }
40646
+
40647
+ // src/core/net-guard.ts
40648
+ import { lookup as dnsLookup } from "node:dns";
40649
+ import http from "node:http";
40650
+ import https from "node:https";
40651
+ import net, { isIP as isIP2 } from "node:net";
40652
+
40653
+ // src/core/url-safety.ts
40654
+ init_errors();
40655
+ import { lookup } from "node:dns/promises";
40656
+ import { isIP } from "node:net";
40657
+ var LOCAL_SUFFIXES = [".localhost", ".test"];
40658
+ function isLocalHostname(host) {
40659
+ const h = host.replace(/^\[|\]$/g, "").toLowerCase();
40660
+ return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h) || h === "0.0.0.0" || LOCAL_SUFFIXES.some((s) => h.endsWith(s));
40661
+ }
40662
+ var METADATA_V4 = /* @__PURE__ */ new Set(["169.254.169.254", "169.254.170.2", "169.254.169.123", "100.100.100.200"]);
40663
+ function classifyV4(v) {
40664
+ const [a, b, c, d] = v.split(".").map(Number);
40665
+ if (METADATA_V4.has(`${a}.${b}.${c}.${d}`)) return "metadata";
40666
+ if (a === 0) return b === 0 && c === 0 && d === 0 ? "loopback" : "reserved";
40667
+ if (a === 127) return "loopback";
40668
+ if (a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168) return "private";
40669
+ if (a === 100 && b >= 64 && b <= 127) return "private";
40670
+ if (a === 169 && b === 254) return "link-local";
40671
+ if (a === 192 && b === 0 && (c === 0 || c === 2)) return "reserved";
40672
+ if (a === 192 && b === 88 && c === 99) return "reserved";
40673
+ if (a === 198 && (b === 18 || b === 19)) return "reserved";
40674
+ if (a === 198 && b === 51 && c === 100) return "reserved";
40675
+ if (a === 203 && b === 0 && c === 113) return "reserved";
40676
+ if (a >= 224) return "reserved";
40677
+ return "public";
40678
+ }
40679
+ function v6Groups(v) {
40680
+ let s = v.split("%")[0];
40681
+ const tail = s.match(/(\d+\.\d+\.\d+\.\d+)$/);
40682
+ if (tail) {
40683
+ const p = tail[1].split(".").map(Number);
40684
+ s = `${s.slice(0, -tail[1].length)}${(p[0] << 8 | p[1]).toString(16)}:${(p[2] << 8 | p[3]).toString(16)}`;
40685
+ }
40686
+ const halves = s.split("::");
40687
+ if (halves.length > 2) return null;
40688
+ const parse2 = (h) => h ? h.split(":").map((g) => Number.parseInt(g, 16)) : [];
40689
+ const head = parse2(halves[0]);
40690
+ const rest = halves.length === 2 ? parse2(halves[1]) : [];
40691
+ const fill = 8 - head.length - rest.length;
40692
+ if (fill < 0 || halves.length === 1 && fill !== 0) return null;
40693
+ const groups = [...head, ...Array(fill).fill(0), ...rest];
40694
+ return groups.every((g) => Number.isInteger(g) && g >= 0 && g <= 65535) ? groups : null;
40695
+ }
40696
+ var v4From = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
40697
+ function classifyV6(v) {
40698
+ const g = v6Groups(v);
40699
+ if (!g) return "reserved";
40700
+ const [g0, g1, g2, g3, g4, g5, g6, g7] = g;
40701
+ const zeroTo = (n) => g.slice(0, n).every((x) => x === 0);
40702
+ if (zeroTo(8)) return "loopback";
40703
+ if (zeroTo(7) && g7 === 1) return "loopback";
40704
+ if (zeroTo(5) && g5 === 65535) return classifyV4(v4From(g6, g7));
40705
+ if (zeroTo(6)) return classifyV4(v4From(g6, g7));
40706
+ if (g0 === 100 && g1 === 65435 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0)
40707
+ return classifyV4(v4From(g6, g7));
40708
+ if (g0 === 100 && g1 === 65435 && g2 === 1) return "private";
40709
+ if (g0 === 8194) return classifyV4(v4From(g1, g2));
40710
+ if (g0 === 8193 && g1 === 0) return "reserved";
40711
+ if (g0 === 8193 && g1 === 3512) return "reserved";
40712
+ if (g0 === 256 && g1 === 0 && g2 === 0 && g3 === 0) return "reserved";
40713
+ if (g0 === 64768 && g1 === 3778 && g2 + g3 + g4 + g5 + g6 === 0 && g7 === 596) return "metadata";
40714
+ if ((g0 & 65024) === 64512) return "private";
40715
+ if ((g0 & 65472) === 65152) return "link-local";
40716
+ if ((g0 & 65472) === 65216) return "private";
40717
+ if ((g0 & 65280) === 65280) return "reserved";
40718
+ return "public";
40719
+ }
40720
+ function classifyIp(ip) {
40721
+ const v = ip.replace(/^\[|\]$/g, "").toLowerCase();
40722
+ const family = isIP(v.split("%")[0]);
40723
+ if (family === 4) return classifyV4(v);
40724
+ if (family === 6) return classifyV6(v);
40725
+ return "reserved";
40726
+ }
40727
+ function addressVerdict(ip, policy) {
40728
+ const cls = classifyIp(ip);
40729
+ switch (cls) {
40730
+ case "public":
40731
+ return { ok: true };
40732
+ case "metadata":
40733
+ return {
40734
+ ok: false,
40735
+ reason: `${ip} is a cloud instance-metadata endpoint; Jourilo never connects to it.`
40736
+ };
40737
+ case "reserved":
40738
+ return { ok: false, reason: `${ip} is a reserved or special-purpose address.` };
40739
+ case "loopback":
40740
+ return policy.target === "local" || policy.allowPrivate ? { ok: true } : { ok: false, reason: `${ip} is this machine (loopback); a public target may not reach it.` };
40741
+ default:
40742
+ return policy.allowPrivate ? { ok: true } : {
40743
+ ok: false,
40744
+ reason: `${ip} is a ${cls} network address; pass --allow-private if you are authorized.`
40745
+ };
40746
+ }
40747
+ }
40748
+ function parseTargetUrl(raw) {
40749
+ let url;
40750
+ try {
40751
+ url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
40752
+ } catch {
40753
+ throw new JouriloError("INVALID_INPUT", `Not a valid URL: ${raw}`);
40754
+ }
40755
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
40756
+ throw new JouriloError("UNSAFE_URL", `Only http and https URLs are supported (got ${url.protocol}).`);
40757
+ }
40758
+ url.hash = "";
40759
+ return url;
40760
+ }
40761
+ var systemResolver = (host) => lookup(host, { all: true, verbatim: true });
40762
+ var PRIVATE_HINT = "Pass --allow-private if you are authorized to test this host.";
40763
+ async function checkTarget(url, opts = {}) {
40764
+ const host = url.hostname.replace(/^\[|\]$/g, "");
40765
+ const literal = isIP(host) !== 0;
40766
+ if (literal) {
40767
+ const cls = classifyIp(host);
40768
+ if (cls === "metadata" || cls === "reserved" && host !== "0.0.0.0")
40769
+ throw new JouriloError("UNSAFE_URL", `${host} is a ${cls} address; Jourilo does not audit it.`);
40770
+ if (cls === "loopback") return "local";
40771
+ if (cls === "public") return "public";
40772
+ if (!opts.allowPrivate)
40773
+ throw new JouriloError("UNSAFE_URL", `${host} is a private network address.`, PRIVATE_HINT);
40774
+ return "private";
40775
+ }
40776
+ if (isLocalHostname(host)) return "local";
40777
+ if (opts.resolveDns === false) return "public";
40778
+ let addrs;
40779
+ try {
40780
+ addrs = await (opts.resolver ?? systemResolver)(host);
40781
+ } catch {
40782
+ return "public";
40783
+ }
40784
+ const classes = addrs.map((a) => ({ address: a.address, cls: classifyIp(a.address) }));
40785
+ const forbidden = classes.find((c) => c.cls === "metadata" || c.cls === "reserved");
40786
+ if (forbidden)
40787
+ throw new JouriloError(
40788
+ "UNSAFE_URL",
40789
+ `${host} resolves to ${forbidden.address}, a ${forbidden.cls} address; Jourilo does not audit it.`
40790
+ );
40791
+ const nonPublic = classes.filter((c) => c.cls !== "public");
40792
+ if (!nonPublic.length) return "public";
40793
+ if (!opts.allowPrivate) {
40794
+ const mixed = nonPublic.length < classes.length;
40795
+ throw new JouriloError(
40796
+ "UNSAFE_URL",
40797
+ mixed ? `${host} resolves to both public and private addresses (${nonPublic.map((c) => c.address).join(", ")}); this is refused because the browser could connect to either (DNS rebinding).` : `${host} resolves to a private network address (${nonPublic[0].address}).`,
40798
+ PRIVATE_HINT
40799
+ );
40800
+ }
40801
+ return "private";
40802
+ }
40803
+ function isCrawlable(candidate, origin) {
40804
+ let u;
40805
+ try {
40806
+ u = new URL(candidate);
40807
+ } catch {
40808
+ return false;
40809
+ }
40810
+ if (u.origin !== origin) return false;
40811
+ if (/\/(log-?out|sign-?out|logoff|delete|destroy|remove|unsubscribe|cancel)(\/|$|\?)/i.test(u.pathname))
40812
+ return false;
40813
+ if (/\.(pdf|zip|gz|dmg|exe|msi|png|jpe?g|gif|webp|svg|mp4|mp3|woff2?|ttf|ico|xml|json|txt|csv)$/i.test(
40814
+ u.pathname
40815
+ ))
40816
+ return false;
40817
+ return true;
40818
+ }
40819
+ function normalizeForCrawl(raw) {
40820
+ const u = new URL(raw);
40821
+ u.hash = "";
40822
+ for (const k of [...u.searchParams.keys()])
40823
+ if (/^utm_|^(fbclid|gclid|ref)$/i.test(k)) u.searchParams.delete(k);
40824
+ if (u.pathname !== "/" && u.pathname.endsWith("/")) u.pathname = u.pathname.replace(/\/+$/, "");
40825
+ return u.toString();
40826
+ }
40827
+
40828
+ // src/core/net-guard.ts
40829
+ var GuardBlockedError = class extends Error {
40830
+ code = "EJOURILOBLOCKED";
40831
+ };
40832
+ var systemResolve = (host) => new Promise(
40833
+ (resolve10, reject) => dnsLookup(host, { all: true, verbatim: true }, (err, addrs) => err ? reject(err) : resolve10(addrs))
40834
+ );
40835
+ async function resolveAllowed(host, port, policy, deps = {}) {
40836
+ const h = host.replace(/^\[|\]$/g, "");
40837
+ const verdict = deps.verdict ?? ((ip) => addressVerdict(ip, policy));
40838
+ let addrs;
40839
+ if (isIP2(h)) addrs = [{ address: h, family: isIP2(h) }];
40840
+ else {
40841
+ try {
40842
+ addrs = await (deps.resolve ?? systemResolve)(h);
40843
+ } catch (err) {
40844
+ throw Object.assign(new Error(`DNS lookup failed for ${h}`), { cause: err });
40845
+ }
40846
+ }
40847
+ let reason = "";
40848
+ for (const a of addrs) {
40849
+ const v = verdict(a.address, port);
40850
+ if (v.ok) return { address: a.address, family: a.family };
40851
+ reason ||= v.reason;
40852
+ }
40853
+ return { blocked: reason || `${h} did not resolve to an allowed address.` };
40854
+ }
40855
+ function guardedLookup(policy, port, deps = {}) {
40856
+ return (hostname, options, callback) => {
40857
+ const wantAll = typeof options === "object" && options?.all === true;
40858
+ resolveAllowed(hostname, port, policy, deps).then(
40859
+ (r) => {
40860
+ if ("blocked" in r) return callback(new GuardBlockedError(r.blocked), wantAll ? [] : "", 0);
40861
+ if (wantAll) return callback(null, [{ address: r.address, family: r.family }]);
40862
+ callback(null, r.address, r.family);
40863
+ },
40864
+ (err) => callback(err, wantAll ? [] : "", 0)
40865
+ );
40866
+ };
40867
+ }
40868
+ function splitHostPort(authority, defaultPort) {
40869
+ const m = authority.match(/^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/);
40870
+ if (!m) return null;
40871
+ const port = m[2] ? Number(m[2]) : defaultPort;
40872
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
40873
+ return { host: m[1].replace(/^\[|\]$/g, ""), port };
40874
+ }
40875
+ var HOP_BY_HOP = /* @__PURE__ */ new Set([
40876
+ "proxy-connection",
40877
+ "proxy-authorization",
40878
+ "proxy-authenticate",
40879
+ "connection",
40880
+ "keep-alive",
40881
+ "te",
40882
+ "trailer",
40883
+ "transfer-encoding",
40884
+ "upgrade"
40885
+ ]);
40886
+ function isLoopbackName(host) {
40887
+ const h = host.replace(/^\[|\]$/g, "").toLowerCase();
40888
+ return h === "localhost" || h.endsWith(".localhost") || isIP2(h) !== 0 && classifyIp(h) === "loopback";
40889
+ }
40890
+ function loopbackRedirectVerdict(location, from, policy) {
40891
+ let to;
40892
+ try {
40893
+ to = new URL(location, from);
40894
+ } catch {
40895
+ return null;
40896
+ }
40897
+ if (!isLoopbackName(to.hostname)) return null;
40898
+ if (policy.target === "local" || policy.allowPrivate) return null;
40899
+ return `redirect from ${from.host} to ${to.host} (this machine) refused.`;
40900
+ }
40901
+ function redirectEscape(url, redirectedFrom, policy) {
40902
+ if (!redirectedFrom) return null;
40903
+ try {
40904
+ return loopbackRedirectVerdict(url, new URL(redirectedFrom), policy);
40905
+ } catch {
40906
+ return null;
40907
+ }
40908
+ }
40909
+ async function startNetGuard(policy, deps = {}) {
40910
+ const blocked = [];
40911
+ const seen = /* @__PURE__ */ new Set();
40912
+ const record = (host, port2, reason) => {
40913
+ const key = `${host}:${port2}`;
40914
+ if (seen.has(key) || blocked.length >= 200) return;
40915
+ seen.add(key);
40916
+ blocked.push({ host, port: port2, reason });
40917
+ };
40918
+ const sockets = /* @__PURE__ */ new Set();
40919
+ const track = (s) => {
40920
+ sockets.add(s);
40921
+ s.on("close", () => sockets.delete(s));
40922
+ return s;
40923
+ };
40924
+ const connectUpstream = (host, port2, onReady, onFail) => {
40925
+ resolveAllowed(host, port2, policy, deps).then(
40926
+ (r) => {
40927
+ if ("blocked" in r) {
40928
+ record(host, port2, r.blocked);
40929
+ return onFail(403, r.blocked);
40930
+ }
40931
+ const upstream = track(net.connect({ host: r.address, port: port2 }));
40932
+ upstream.once("connect", () => onReady(upstream));
40933
+ upstream.once("error", (err) => onFail(502, err.message));
40934
+ },
40935
+ (err) => onFail(502, err.message)
40936
+ );
40937
+ };
40938
+ const pipeBoth = (client, upstream, head) => {
40939
+ if (head.length) upstream.write(head);
40940
+ upstream.pipe(client);
40941
+ client.pipe(upstream);
40942
+ upstream.on("error", () => client.destroy());
40943
+ client.on("error", () => upstream.destroy());
40944
+ client.on("close", () => upstream.destroy());
40945
+ upstream.on("close", () => client.destroy());
40946
+ };
40947
+ const server = http.createServer((req, res) => {
40948
+ let target;
40949
+ try {
40950
+ target = new URL(req.url ?? "");
40951
+ if (target.protocol !== "http:") throw new Error("not http");
40952
+ } catch {
40953
+ res.writeHead(400).end("Jourilo network guard: absolute http:// URL expected.");
40954
+ return;
40955
+ }
40956
+ const host = target.hostname.replace(/^\[|\]$/g, "");
40957
+ const port2 = Number(target.port || 80);
40958
+ const headers = {};
40959
+ for (const [k, v] of Object.entries(req.headers))
40960
+ if (v !== void 0 && !HOP_BY_HOP.has(k.toLowerCase())) headers[k] = v;
40961
+ const fail = (status, reason) => {
40962
+ if (res.headersSent) return void res.destroy();
40963
+ res.writeHead(status, { "content-type": "text/plain" }).end(status === 403 ? `Blocked by Jourilo network guard: ${reason}` : "Bad gateway");
40964
+ };
40965
+ resolveAllowed(host, port2, policy, deps).then(
40966
+ (r) => {
40967
+ if ("blocked" in r) {
40968
+ record(host, port2, r.blocked);
40969
+ req.resume();
40970
+ return fail(403, r.blocked);
40971
+ }
40972
+ const upstream = http.request({
40973
+ host: r.address,
40974
+ port: port2,
40975
+ method: req.method,
40976
+ path: `${target.pathname}${target.search}`,
40977
+ headers,
40978
+ setHost: false
40979
+ });
40980
+ upstream.on("response", (up) => {
40981
+ const loc = (up.statusCode ?? 0) >= 300 && (up.statusCode ?? 0) < 400 ? up.headers.location : void 0;
40982
+ const refused = loc ? loopbackRedirectVerdict(loc, target, policy) : null;
40983
+ if (refused) {
40984
+ up.resume();
40985
+ record(host, port2, refused);
40986
+ return fail(403, refused);
40987
+ }
40988
+ const out = {};
40989
+ for (const [k, v] of Object.entries(up.headers))
40990
+ if (v !== void 0 && !HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
40991
+ res.writeHead(up.statusCode ?? 502, up.statusMessage, out);
40992
+ up.pipe(res);
40993
+ });
40994
+ upstream.on("error", (err) => fail(502, err.message));
40995
+ req.pipe(upstream);
40996
+ },
40997
+ (err) => fail(502, err.message)
40998
+ );
40999
+ });
41000
+ server.on("connect", (req, client, head) => {
41001
+ track(client);
41002
+ client.on("error", () => void 0);
41003
+ const hp = splitHostPort(req.url ?? "", 443);
41004
+ if (!hp) {
41005
+ client.end("HTTP/1.1 400 Bad Request\r\n\r\n");
41006
+ return;
41007
+ }
41008
+ connectUpstream(
41009
+ hp.host,
41010
+ hp.port,
41011
+ (upstream) => {
41012
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
41013
+ pipeBoth(client, upstream, head);
41014
+ },
41015
+ (status, reason) => client.end(
41016
+ `HTTP/1.1 ${status} ${status === 403 ? "Forbidden" : "Bad Gateway"}\r
41017
+ content-type: text/plain\r
41018
+ \r
41019
+ ${status === 403 ? `Blocked by Jourilo network guard: ${reason}` : ""}`
41020
+ )
41021
+ );
41022
+ });
41023
+ server.on("upgrade", (req, client, head) => {
41024
+ track(client);
41025
+ client.on("error", () => void 0);
41026
+ let target;
41027
+ try {
41028
+ target = new URL(req.url ?? "");
41029
+ } catch {
41030
+ client.end("HTTP/1.1 400 Bad Request\r\n\r\n");
41031
+ return;
41032
+ }
41033
+ connectUpstream(
41034
+ target.hostname,
41035
+ Number(target.port || 80),
41036
+ (upstream) => {
41037
+ const lines = [`${req.method} ${target.pathname}${target.search} HTTP/1.1`];
41038
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
41039
+ const k = req.rawHeaders[i];
41040
+ if (/^proxy-/i.test(k)) continue;
41041
+ lines.push(`${k}: ${req.rawHeaders[i + 1]}`);
41042
+ }
41043
+ upstream.write(`${lines.join("\r\n")}\r
41044
+ \r
41045
+ `);
41046
+ pipeBoth(client, upstream, head);
41047
+ },
41048
+ (status) => client.end(`HTTP/1.1 ${status} ${status === 403 ? "Forbidden" : "Bad Gateway"}\r
41049
+ \r
41050
+ `)
41051
+ );
41052
+ });
41053
+ server.on("connection", (s) => track(s));
41054
+ server.on("clientError", (_err, socket) => socket.destroy());
41055
+ await new Promise((resolve10, reject) => {
41056
+ server.once("error", reject);
41057
+ server.listen(0, "127.0.0.1", () => resolve10());
41058
+ });
41059
+ const { port } = server.address();
41060
+ return {
41061
+ url: `http://127.0.0.1:${port}`,
41062
+ policy,
41063
+ blocked,
41064
+ close: () => new Promise((resolve10) => {
41065
+ for (const s of sockets) s.destroy();
41066
+ server.close(() => resolve10());
41067
+ })
41068
+ };
41069
+ }
41070
+ async function guardedFetchText(url, policy, opts) {
41071
+ const origin = new URL(url).origin;
41072
+ let current = new URL(url);
41073
+ for (let hop = 0; hop < 6; hop++) {
41074
+ if (current.origin !== origin) return null;
41075
+ const port = Number(current.port || (current.protocol === "https:" ? 443 : 80));
41076
+ const pre = await resolveAllowed(current.hostname, port, policy, opts.deps).catch(() => null);
41077
+ if (!pre || "blocked" in pre) return null;
41078
+ const res = await new Promise((resolve10) => {
41079
+ const mod = current.protocol === "https:" ? https : http;
41080
+ const req = mod.get(
41081
+ current,
41082
+ {
41083
+ headers: { "user-agent": opts.userAgent, accept: "text/plain, application/xml, text/xml, */*" },
41084
+ lookup: guardedLookup(policy, port, opts.deps),
41085
+ timeout: opts.timeoutMs,
41086
+ agent: false
41087
+ },
41088
+ (r) => {
41089
+ const status = r.statusCode ?? 0;
41090
+ if (status >= 300 && status < 400 && r.headers.location) {
41091
+ r.resume();
41092
+ return resolve10({ status, location: r.headers.location });
41093
+ }
41094
+ if (status < 200 || status >= 300) {
41095
+ r.resume();
41096
+ return resolve10({ status });
41097
+ }
41098
+ const max = opts.maxBytes ?? 2e6;
41099
+ let size = 0;
41100
+ const chunks = [];
41101
+ r.on("data", (c) => {
41102
+ size += c.length;
41103
+ if (size <= max) chunks.push(c);
41104
+ else r.destroy();
41105
+ });
41106
+ const done = () => resolve10({ status, body: Buffer.concat(chunks).toString("utf8") });
41107
+ r.on("end", done);
41108
+ r.on("close", done);
41109
+ r.on("error", () => resolve10(null));
41110
+ }
41111
+ );
41112
+ req.on("timeout", () => req.destroy());
41113
+ req.on("error", () => resolve10(null));
41114
+ });
41115
+ if (!res) return null;
41116
+ if (res.location) {
41117
+ try {
41118
+ current = new URL(res.location, current);
41119
+ } catch {
41120
+ return null;
41121
+ }
41122
+ continue;
41123
+ }
41124
+ return res.body ?? null;
41125
+ }
41126
+ return null;
41127
+ }
41128
+
41129
+ // src/browser/runtime.ts
41130
+ init_errors();
41131
+ import { spawnSync } from "node:child_process";
41132
+ import { createRequire } from "node:module";
41133
+ import { dirname as dirname4, join as join4 } from "node:path";
41134
+ var cached;
41135
+ async function loadPlaywright() {
41136
+ if (cached) return cached;
41137
+ try {
41138
+ cached = await import("playwright-core");
41139
+ return cached;
41140
+ } catch {
41141
+ throw new JouriloError(
41142
+ "BROWSER_UNAVAILABLE",
41143
+ "playwright-core is not installed.",
41144
+ "Run `npm install` in the Jourilo directory (or `npx jourilo setup`)."
41145
+ );
41146
+ }
41147
+ }
41148
+ async function launchBrowser(name = "chromium", headed = false, guard) {
41149
+ const pw = await loadPlaywright();
41150
+ const type = name === "firefox" ? pw.firefox : name === "webkit" ? pw.webkit : pw.chromium;
41151
+ const opts = { headless: !headed };
41152
+ if (name === "chrome" || name === "msedge") opts.channel = name;
41153
+ if (guard) {
41154
+ if (name === "firefox") {
41155
+ opts.proxy = { server: guard.url };
41156
+ opts.firefoxUserPrefs = {
41157
+ "network.proxy.allow_hijacking_localhost": true,
41158
+ "network.proxy.no_proxies_on": "",
41159
+ "media.peerconnection.enabled": false,
41160
+ "network.dns.disablePrefetch": true
41161
+ };
41162
+ } else if (name === "webkit") {
41163
+ opts.proxy = { server: guard.url };
41164
+ } else {
41165
+ opts.proxy = { server: guard.url, bypass: "<-loopback>" };
41166
+ opts.args = [
41167
+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
41168
+ "--webrtc-ip-handling-policy=disable_non_proxied_udp",
41169
+ "--disable-quic"
41170
+ ];
41171
+ }
41172
+ }
41173
+ try {
41174
+ return await type.launch(opts);
41175
+ } catch (err) {
41176
+ const msg = err instanceof Error ? err.message : String(err);
41177
+ if (/Executable doesn't exist|browserType\.launch: .*not found|install/i.test(msg)) {
41178
+ throw new JouriloError(
41179
+ "BROWSER_UNAVAILABLE",
41180
+ `The ${name} browser is not installed for Playwright.`,
41181
+ name === "chrome" || name === "msedge" ? `Install ${name === "chrome" ? "Google Chrome" : "Microsoft Edge"}, or use --browser chromium.` : `Run \`jourilo setup --browser ${name}\` (downloads the browser build Playwright needs), or use --browser chrome to drive an installed Google Chrome.`
41182
+ );
41183
+ }
41184
+ throw new JouriloError("BROWSER_UNAVAILABLE", `Could not launch ${name}: ${msg.split("\n")[0]}`);
41185
+ }
41186
+ }
41187
+ function installBrowser(name) {
41188
+ const require2 = createRequire(import.meta.url);
41189
+ let cli;
41190
+ try {
41191
+ cli = join4(dirname4(require2.resolve("playwright-core/package.json")), "cli.js");
41192
+ } catch {
41193
+ throw new JouriloError(
41194
+ "BROWSER_UNAVAILABLE",
41195
+ "playwright-core is not installed; cannot install browsers."
41196
+ );
41197
+ }
41198
+ const res = spawnSync(process.execPath, [cli, "install", name], { encoding: "utf8" });
41199
+ return { ok: res.status === 0, output: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
41200
+ }
41201
+
41202
+ // src/browser/session.ts
41203
+ var sessions = /* @__PURE__ */ new WeakMap();
41204
+ async function openGuardedBrowser(name, headed, policy) {
41205
+ const guard = await startNetGuard(policy);
41206
+ let browser;
41207
+ try {
41208
+ browser = await launchBrowser(name, headed, guard);
41209
+ } catch (err) {
41210
+ await guard.close();
41211
+ throw err;
41212
+ }
41213
+ const escaped = [];
41214
+ sessions.set(browser, { policy, escaped });
41215
+ return {
41216
+ browser,
41217
+ guard,
41218
+ report: () => ({ blocked: [...guard.blocked], escaped: [...escaped] }),
41219
+ close: async () => {
41220
+ await browser.close().catch(() => void 0);
41221
+ await guard.close();
41222
+ }
41223
+ };
41224
+ }
41225
+ function watchContext(browser, ctx) {
41226
+ const s = sessions.get(browser);
41227
+ if (!s) return;
41228
+ ctx.on("request", (req) => {
41229
+ const from = req.redirectedFrom()?.url() ?? null;
41230
+ const reason = redirectEscape(req.url(), from, s.policy);
41231
+ if (reason && s.escaped.length < 50) s.escaped.push({ url: redactUrl(req.url()), reason });
41232
+ });
41233
+ }
41234
+ function networkLimitations(r) {
41235
+ const out = [];
41236
+ if (r.blocked.length)
41237
+ out.push(
41238
+ `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.`
41239
+ );
41240
+ if (r.escaped.length)
41241
+ out.push(
41242
+ `SECURITY: ${r.escaped.length} redirect(s) from the target reached this machine outside the network guard (${r.escaped[0].url}). This browser follows HTTPS redirects to loopback addresses directly; use Chromium or Firefox for untrusted sites.`
41243
+ );
41244
+ return out;
41245
+ }
40315
41246
 
40316
41247
  // src/browser/vitals.ts
40317
41248
  var VITALS_INIT_SCRIPT = `(() => {
@@ -40409,6 +41340,11 @@ function toMetrics(v, spread, runs2 = 1) {
40409
41340
  }
40410
41341
  const rounded = unit === "score" ? Math.round(value * 1e3) / 1e3 : Math.round(value);
40411
41342
  const m = { name, value: rounded, unit, source: "lab" };
41343
+ if (runs2 > 1 && range) {
41344
+ const r2 = (x) => unit === "score" ? Math.round(x * 1e3) / 1e3 : Math.round(x);
41345
+ m.runs = runs2;
41346
+ m.range = [r2(range[0]), r2(range[1])];
41347
+ }
40412
41348
  const r = rate(name, rounded);
40413
41349
  if (r) m.rating = r;
40414
41350
  if (note) m.note = note;
@@ -40428,56 +41364,6 @@ function toMetrics(v, spread, runs2 = 1) {
40428
41364
  return out;
40429
41365
  }
40430
41366
 
40431
- // src/journeys/safety.ts
40432
- var DESTRUCTIVE = lexicon("destructive");
40433
- var FINANCIAL = lexicon("financial");
40434
- function checkAction(ctx, policy) {
40435
- const safeHost = policy.hostClass === "local";
40436
- const risky = DESTRUCTIVE.test(ctx.name) ? "destructive" : FINANCIAL.test(ctx.name) || ctx.paymentForm ? "financial" : null;
40437
- if (risky) {
40438
- if (!safeHost)
40439
- return {
40440
- allowed: false,
40441
- reason: `"${ctx.name}" looks ${risky === "financial" ? "like a payment/purchase" : "irreversible"}; Jourilo never performs it on a non-local host.`
40442
- };
40443
- if (!policy.allowDestructive)
40444
- return {
40445
- allowed: false,
40446
- reason: `"${ctx.name}" looks ${risky === "financial" ? "like a payment/purchase" : "irreversible"}; rerun with --allow-destructive on a local/test environment with sandbox data to include it.`
40447
- };
40448
- }
40449
- if (ctx.submitsForm && !safeHost && !policy.allowSubmit) {
40450
- return {
40451
- allowed: false,
40452
- reason: "Submitting a form on a non-local host is blocked; use --allow-submit only on an environment you are authorized to test, with test accounts."
40453
- };
40454
- }
40455
- return { allowed: true };
40456
- }
40457
- function testData(runId, vars = {}) {
40458
- const tag = runId.replace(/[^a-z0-9]/gi, "").slice(-10).toLowerCase();
40459
- const base2 = {
40460
- email: `jourilo+${tag}@example.test`,
40461
- password: `Jr-${tag}-Q7!x`,
40462
- name: "Jordan Test",
40463
- firstName: "Jordan",
40464
- lastName: "Test",
40465
- phone: "+1 555 0100",
40466
- company: "Example Test Co",
40467
- search: "test",
40468
- zip: "10001",
40469
- // Published test card from Stripe's docs; only useful in a provider's test mode.
40470
- testCard: "4242 4242 4242 4242"
40471
- };
40472
- return { ...base2, ...vars };
40473
- }
40474
- function interpolate(value, data) {
40475
- return value.replace(/\{\{\s*([A-Za-z][\w]*)\s*\}\}/g, (m, k) => data[k] ?? m);
40476
- }
40477
- function isSecretValue(raw) {
40478
- return /\{\{\s*(password|testCard|secret|token)\s*\}\}/i.test(raw);
40479
- }
40480
-
40481
41367
  // src/browser/widgets.ts
40482
41368
  var AVOID = /(?<![\p{L}])(add to (cart|basket|bag)|sepete ekle|in den warenkorb|añadir al carrito|ajouter au panier|like|beğen|follow|takip et|vote|oy ver|log ?out|sign ?out|çıkış yap|abmelden|cerrar sesión|déconnexion)(?![\p{L}])/iu;
40483
41369
  var TRIGGERS = `(() => {
@@ -40494,6 +41380,16 @@ var TRIGGERS = `(() => {
40494
41380
  };
40495
41381
  return Array.from(document.querySelectorAll('[aria-haspopup]:not([aria-haspopup="false"]), [aria-expanded], [aria-controls], a[href="#"], a[href^="#"]:not([href="#main"]):not([href^="#content"]), a[href^="javascript:"]'))
40496
41382
  .filter((e) => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden' && !e.disabled && /^(BUTTON|A|SUMMARY)$|^button$/i.test(e.tagName === 'DIV' || e.tagName === 'SPAN' ? (e.getAttribute('role') || '') : e.tagName))
41383
+ // A link to a section that is already on screen is in-page navigation (skip links, table of contents), not a
41384
+ // popup trigger; following it only scrolls.
41385
+ .filter((e) => {
41386
+ const href = e.tagName === 'A' ? e.getAttribute('href') || '' : '';
41387
+ if (!/^#./.test(href) || e.hasAttribute('aria-haspopup') || e.hasAttribute('aria-expanded') || e.hasAttribute('aria-controls')) return true;
41388
+ let id = href.slice(1);
41389
+ try { id = decodeURIComponent(id); } catch {}
41390
+ const target = document.getElementById(id) || document.getElementsByName(id)[0];
41391
+ return !(target && target.getClientRects().length > 0 && getComputedStyle(target).visibility !== 'hidden');
41392
+ })
40497
41393
  .slice(0, 8)
40498
41394
  .map((e) => {
40499
41395
  const r = e.getBoundingClientRect();
@@ -40515,11 +41411,17 @@ var SNAPSHOT_LAYERS = `(() => {
40515
41411
  return (cs.position === 'fixed' || cs.position === 'absolute') && e.getClientRects().length > 0 && cs.visibility !== 'hidden' && big(e);
40516
41412
  });
40517
41413
  // Signatures, not node identity: frameworks that re-render replace nodes without anything new appearing.
40518
- const sig = (e) => { const r = e.getBoundingClientRect(); return e.tagName + '.' + String(e.className) + '|' + Math.round(r.left) + ',' + Math.round(r.top) + ',' + Math.round(r.width) + ',' + Math.round(r.height); };
41414
+ // Page coordinates for absolutely positioned layers, so scrolling does not make an existing layer look new.
41415
+ const sig = (e) => { const r = e.getBoundingClientRect(); const fixed = getComputedStyle(e).position === 'fixed'; return e.tagName + '.' + String(e.className) + '|' + Math.round(r.left + (fixed ? 0 : scrollX)) + ',' + Math.round(r.top + (fixed ? 0 : scrollY)) + ',' + Math.round(r.width) + ',' + Math.round(r.height); };
40519
41416
  window.__jouriloLayers = new Set(layers.map(sig));
40520
41417
  })()`;
40521
- var STATE = `(controls) => {
40522
- const visible = (e) => !!e && e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden';
41418
+ var STATE = `(controls, mode) => {
41419
+ const visible = (e) => !!e && e.isConnected && e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden';
41420
+ if (mode === 'same') {
41421
+ const d = window.__jouriloDialog;
41422
+ const ae = document.activeElement;
41423
+ 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 };
41424
+ }
40523
41425
  const dialogs = Array.from(document.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"]')).filter(visible);
40524
41426
  let d = dialogs[dialogs.length - 1] || null;
40525
41427
  let unlabelled = false;
@@ -40528,12 +41430,14 @@ var STATE = `(controls) => {
40528
41430
  const fresh = Array.from(document.querySelectorAll('body *')).filter((e) => {
40529
41431
  const cs = getComputedStyle(e);
40530
41432
  const r = e.getBoundingClientRect();
40531
- const sig = e.tagName + '.' + String(e.className) + '|' + Math.round(r.left) + ',' + Math.round(r.top) + ',' + Math.round(r.width) + ',' + Math.round(r.height);
41433
+ const fixed = cs.position === 'fixed';
41434
+ const sig = e.tagName + '.' + String(e.className) + '|' + Math.round(r.left + (fixed ? 0 : scrollX)) + ',' + Math.round(r.top + (fixed ? 0 : scrollY)) + ',' + Math.round(r.width) + ',' + Math.round(r.height);
40532
41435
  return (cs.position === 'fixed' || cs.position === 'absolute') && visible(e) && big(e) && !window.__jouriloLayers.has(sig);
40533
41436
  });
40534
41437
  const outer = fresh.filter((e) => !fresh.some((o) => o !== e && o.contains(e)));
40535
41438
  if (outer.length) { d = outer[outer.length - 1]; unlabelled = true; }
40536
41439
  }
41440
+ window.__jouriloDialog = d;
40537
41441
  const ae = document.activeElement;
40538
41442
  const controlled = controls ? controls.split(/\\s+/).map((id) => document.getElementById(id)).filter(Boolean) : [];
40539
41443
  const focusables = d ? Array.from(d.querySelectorAll('a[href], button, input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])')).filter(visible).length : 0;
@@ -40546,10 +41450,89 @@ var STATE = `(controls) => {
40546
41450
  activeSelector: ae && ae !== document.body ? (ae.id ? '#' + ae.id : ae.tagName.toLowerCase()) : null,
40547
41451
  };
40548
41452
  }`;
41453
+ var COMPOSITES = `(() => {
41454
+ const ITEM = { tablist: 'tab', radiogroup: 'radio', listbox: 'option', menubar: 'menuitem, [role="menuitemcheckbox"], [role="menuitemradio"]', tree: 'treeitem' };
41455
+ const visible = (e) => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden';
41456
+ const tabbable = (e) => e.tabIndex >= 0 && !e.disabled && e.getAttribute('aria-disabled') !== 'true' && visible(e);
41457
+ const out = [];
41458
+ for (const [role, item] of Object.entries(ITEM)) {
41459
+ for (const c of document.querySelectorAll('[role="' + role + '"]')) {
41460
+ if (!visible(c) || out.length >= 6) continue;
41461
+ const items = Array.from(c.querySelectorAll(item.split(',').map((s) => s.trim().startsWith('[') ? s : '[role="' + s.trim() + '"]').join(','))).filter(visible);
41462
+ if (items.length < 2) continue;
41463
+ const stops = items.filter(tabbable);
41464
+ const containerStop = tabbable(c) && c.hasAttribute('aria-activedescendant');
41465
+ if (stops.length > 1 || (!stops.length && !containerStop)) continue;
41466
+ const id = 'jourilo-composite-' + out.length;
41467
+ c.setAttribute('data-jourilo-composite', id);
41468
+ if (stops[0]) stops[0].setAttribute('data-jourilo-composite-stop', id);
41469
+ const r = c.getBoundingClientRect();
41470
+ const orientation = c.getAttribute('aria-orientation') || (role === 'listbox' || role === 'tree' ? 'vertical' : 'horizontal');
41471
+ out.push({ id, role, orientation, items: items.length, name: (c.getAttribute('aria-label') || items.map((i) => i.innerText.trim()).filter(Boolean).slice(0, 3).join(' / ')).replace(/\\s+/g, ' ').slice(0, 80), rect: { x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), width: Math.round(r.width), height: Math.round(r.height) } });
41472
+ }
41473
+ }
41474
+ return out;
41475
+ })()`;
41476
+ var COMPOSITE_STATE = `(id) => {
41477
+ const c = document.querySelector('[data-jourilo-composite="' + id + '"]');
41478
+ if (!c) return null;
41479
+ const ae = document.activeElement;
41480
+ const marks = Array.from(c.querySelectorAll('[aria-selected], [aria-checked]')).map((e) => (e.getAttribute('aria-selected') || '') + (e.getAttribute('aria-checked') || '')).join('');
41481
+ return { focusIn: !!ae && c.contains(ae), active: ae ? (ae.id || ae.textContent || '').trim().slice(0, 40) + '#' + Array.from(c.querySelectorAll('*')).indexOf(ae) : '', descendant: c.getAttribute('aria-activedescendant') || '', marks };
41482
+ }`;
41483
+ async function checkComposites(page, out) {
41484
+ const found = await page.evaluate(COMPOSITES).catch(() => []);
41485
+ for (const c of found) {
41486
+ const stop = page.locator(`[data-jourilo-composite-stop="${c.id}"]`);
41487
+ const target = await stop.count() ? stop.first() : page.locator(`[data-jourilo-composite="${c.id}"]`);
41488
+ const read3 = () => page.evaluate(`(${COMPOSITE_STATE})(${JSON.stringify(c.id)})`);
41489
+ try {
41490
+ await target.focus({ timeout: 2e3 });
41491
+ } catch {
41492
+ continue;
41493
+ }
41494
+ const before = await read3();
41495
+ if (!before?.focusIn) continue;
41496
+ out.composites++;
41497
+ const keys = c.orientation === "vertical" ? ["ArrowDown", "ArrowRight"] : ["ArrowRight", "ArrowDown"];
41498
+ let moved = false;
41499
+ for (const key of keys) {
41500
+ await page.keyboard.press(key);
41501
+ await page.waitForTimeout(120);
41502
+ const after = await read3();
41503
+ if (after && (after.active !== before.active || after.descendant !== before.descendant || after.marks !== before.marks)) {
41504
+ moved = true;
41505
+ break;
41506
+ }
41507
+ }
41508
+ if (!moved)
41509
+ out.issues.push({
41510
+ kind: "composite-unreachable",
41511
+ trigger: `[role="${c.role}"]`,
41512
+ name: c.name || c.role,
41513
+ rect: c.rect,
41514
+ detail: `Only one of the ${c.items} items of this ${c.role} is in the Tab order, and the arrow keys do not move to the others, so ${c.items - 1} item(s) cannot be reached with a keyboard.`,
41515
+ steps: [
41516
+ `Press Tab until the ${c.role} "${c.name || c.role}" has focus.`,
41517
+ `Press ${keys.join(" and ")}: focus and selection stay where they are.`
41518
+ ]
41519
+ });
41520
+ }
41521
+ await page.evaluate(
41522
+ `document.querySelectorAll('[data-jourilo-composite],[data-jourilo-composite-stop]').forEach((e) => { e.removeAttribute('data-jourilo-composite'); e.removeAttribute('data-jourilo-composite-stop'); })`
41523
+ ).catch(() => void 0);
41524
+ }
40549
41525
  async function checkWidgets(page) {
40550
41526
  const triggers = await page.evaluate(TRIGGERS);
40551
- const out = { triggers: triggers.length, dialogs: 0, disclosures: 0, issues: [] };
40552
- const state = (controls) => page.evaluate(`(${STATE})(${JSON.stringify(controls)})`);
41527
+ const out = {
41528
+ triggers: triggers.length,
41529
+ dialogs: 0,
41530
+ disclosures: 0,
41531
+ composites: 0,
41532
+ issues: []
41533
+ };
41534
+ await checkComposites(page, out);
41535
+ const state = (controls, mode = "open") => page.evaluate(`(${STATE})(${JSON.stringify(controls)}, ${JSON.stringify(mode)})`);
40553
41536
  for (const t of triggers) {
40554
41537
  if (DESTRUCTIVE.test(t.name) || FINANCIAL.test(t.name) || AVOID.test(t.name)) continue;
40555
41538
  const loc = page.locator(t.selector).first();
@@ -40580,7 +41563,7 @@ async function checkWidgets(page) {
40580
41563
  );
40581
41564
  } else {
40582
41565
  for (let k = 0; k <= after.focusables; k++) await page.keyboard.press("Tab");
40583
- const tabbed = await state(t.controls);
41566
+ const tabbed = await state(t.controls, "same");
40584
41567
  if (tabbed.dialogOpen && !tabbed.focusInDialog)
40585
41568
  issue(
40586
41569
  "dialog-focus-escapes",
@@ -40589,7 +41572,7 @@ async function checkWidgets(page) {
40589
41572
  }
40590
41573
  await page.keyboard.press("Escape");
40591
41574
  await page.waitForTimeout(300);
40592
- const closed = await state(t.controls);
41575
+ const closed = await state(t.controls, "same");
40593
41576
  if (closed.dialogOpen) {
40594
41577
  issue("dialog-escape", "Pressing Escape did not close the dialog.");
40595
41578
  await page.reload({ waitUntil: "load" }).catch(() => void 0);
@@ -40645,6 +41628,7 @@ async function newContext(browser, vp, opts) {
40645
41628
  serviceWorkers: "block",
40646
41629
  ...opts.storageState ? { storageState: opts.storageState } : {}
40647
41630
  });
41631
+ watchContext(browser, ctx);
40648
41632
  await ctx.addInitScript({ content: NAME_SHIM });
40649
41633
  await ctx.addInitScript({ content: VITALS_INIT_SCRIPT });
40650
41634
  return ctx;
@@ -40652,6 +41636,7 @@ async function newContext(browser, vp, opts) {
40652
41636
  function observe(page, origin) {
40653
41637
  const consoleEntries = [];
40654
41638
  const network = [];
41639
+ let ignored = () => false;
40655
41640
  page.on("console", (msg) => {
40656
41641
  const t = msg.type();
40657
41642
  if (t !== "error" && t !== "warning") return;
@@ -40674,6 +41659,7 @@ function observe(page, origin) {
40674
41659
  }
40675
41660
  };
40676
41661
  page.on("requestfailed", (req) => {
41662
+ if (ignored(req.url())) return;
40677
41663
  const failure = req.failure()?.errorText ?? "failed";
40678
41664
  if (/ERR_ABORTED|NS_BINDING_ABORTED|cancelled/i.test(failure)) return;
40679
41665
  if (network.length >= 100) return;
@@ -40695,7 +41681,13 @@ function observe(page, origin) {
40695
41681
  firstParty: firstParty(res.url())
40696
41682
  });
40697
41683
  });
40698
- return { console: consoleEntries, network };
41684
+ return {
41685
+ console: consoleEntries,
41686
+ network,
41687
+ ignore: (fn) => {
41688
+ ignored = fn;
41689
+ }
41690
+ };
40699
41691
  }
40700
41692
  async function settle(page, timeoutMs) {
40701
41693
  await page.waitForLoadState("networkidle", { timeout: Math.min(timeoutMs, 5e3) }).catch(() => void 0);
@@ -40832,12 +41824,12 @@ async function capturePage(ctx, url, vp, opts) {
40832
41824
  if (opts.screenshots !== false) {
40833
41825
  const base2 = `${slug(new URL(url).pathname.replace(/^\/$/, "home"), 40)}-${shortHash(url).slice(0, 6)}-${vp.name}`;
40834
41826
  const mask = [...DEFAULT_MASK, ...opts.mask ?? []].map((s) => page.locator(s));
40835
- const full = join3("screenshots", `${base2}.jpg`);
40836
- const fold = join3("screenshots", `${base2}-fold.jpg`);
41827
+ const full = join5("screenshots", `${base2}.jpg`);
41828
+ const fold = join5("screenshots", `${base2}-fold.jpg`);
40837
41829
  try {
40838
41830
  const height = Math.min(obs.probe?.scroll.height ?? vp.height, 12e3);
40839
41831
  await page.screenshot({
40840
- path: join3(opts.runDir, full),
41832
+ path: join5(opts.runDir, full),
40841
41833
  type: "jpeg",
40842
41834
  quality: 80,
40843
41835
  fullPage: true,
@@ -40849,7 +41841,7 @@ async function capturePage(ctx, url, vp, opts) {
40849
41841
  });
40850
41842
  obs.screenshot = toPosix(full);
40851
41843
  await page.screenshot({
40852
- path: join3(opts.runDir, fold),
41844
+ path: join5(opts.runDir, fold),
40853
41845
  type: "jpeg",
40854
41846
  quality: 80,
40855
41847
  animations: "disabled",
@@ -40863,6 +41855,15 @@ async function capturePage(ctx, url, vp, opts) {
40863
41855
  }
40864
41856
  }
40865
41857
  if (opts.keyboard !== false) {
41858
+ const guard = await installRequestGuard(page, {
41859
+ origin,
41860
+ hostClass: "public",
41861
+ allowSubmit: false,
41862
+ allowDestructive: false,
41863
+ mode: "read-only"
41864
+ });
41865
+ live.ignore(guard.isBlocked);
41866
+ obs.blockedActions = guard.blocked;
40866
41867
  obs.keyboard = await keyboardWalk(page).catch((e) => {
40867
41868
  obs.errors.push(`keyboard: ${e.message.split("\n")[0]}`);
40868
41869
  return null;
@@ -40933,59 +41934,6 @@ function sanitizeInventory(inv) {
40933
41934
  }
40934
41935
  }
40935
41936
 
40936
- // src/browser/runtime.ts
40937
- init_errors();
40938
- import { spawnSync } from "node:child_process";
40939
- import { createRequire } from "node:module";
40940
- import { dirname as dirname4, join as join4 } from "node:path";
40941
- var cached;
40942
- async function loadPlaywright() {
40943
- if (cached) return cached;
40944
- try {
40945
- cached = await import("playwright-core");
40946
- return cached;
40947
- } catch {
40948
- throw new JouriloError(
40949
- "BROWSER_UNAVAILABLE",
40950
- "playwright-core is not installed.",
40951
- "Run `npm install` in the Jourilo directory (or `npx jourilo setup`)."
40952
- );
40953
- }
40954
- }
40955
- async function launchBrowser(name = "chromium", headed = false) {
40956
- const pw = await loadPlaywright();
40957
- const type = name === "firefox" ? pw.firefox : name === "webkit" ? pw.webkit : pw.chromium;
40958
- const opts = { headless: !headed };
40959
- if (name === "chrome" || name === "msedge") opts.channel = name;
40960
- try {
40961
- return await type.launch(opts);
40962
- } catch (err) {
40963
- const msg = err instanceof Error ? err.message : String(err);
40964
- if (/Executable doesn't exist|browserType\.launch: .*not found|install/i.test(msg)) {
40965
- throw new JouriloError(
40966
- "BROWSER_UNAVAILABLE",
40967
- `The ${name} browser is not installed for Playwright.`,
40968
- name === "chrome" || name === "msedge" ? `Install ${name === "chrome" ? "Google Chrome" : "Microsoft Edge"}, or use --browser chromium.` : `Run \`jourilo setup --browser ${name}\` (downloads the browser build Playwright needs), or use --browser chrome to drive an installed Google Chrome.`
40969
- );
40970
- }
40971
- throw new JouriloError("BROWSER_UNAVAILABLE", `Could not launch ${name}: ${msg.split("\n")[0]}`);
40972
- }
40973
- }
40974
- function installBrowser(name) {
40975
- const require2 = createRequire(import.meta.url);
40976
- let cli;
40977
- try {
40978
- cli = join4(dirname4(require2.resolve("playwright-core/package.json")), "cli.js");
40979
- } catch {
40980
- throw new JouriloError(
40981
- "BROWSER_UNAVAILABLE",
40982
- "playwright-core is not installed; cannot install browsers."
40983
- );
40984
- }
40985
- const res = spawnSync(process.execPath, [cli, "install", name], { encoding: "utf8" });
40986
- return { ok: res.status === 0, output: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
40987
- }
40988
-
40989
41937
  // src/audit/finding.ts
40990
41938
  function pathOf(url) {
40991
41939
  try {
@@ -41179,6 +42127,16 @@ function nielsen(n) {
41179
42127
  url: "https://www.nngroup.com/articles/ten-usability-heuristics/"
41180
42128
  };
41181
42129
  }
42130
+ var MANUAL_REVIEW = [
42131
+ "1.1.1 Non-text content: is each text alternative accurate and useful (presence is checked, meaning is not)?",
42132
+ "1.2.x Time-based media: are captions, audio description and transcripts correct and complete?",
42133
+ "1.3.2 Meaningful sequence and 1.3.3 Sensory characteristics: does the reading order make sense; do instructions rely only on shape, position or colour?",
42134
+ "1.4.1 Use of colour and 1.4.11 Non-text contrast (where not measured): is colour the only cue anywhere?",
42135
+ "2.4.4 / 2.4.6 Link purpose, headings and labels: are they descriptive in context?",
42136
+ "2.5.7 Dragging movements: does every drag have a single-pointer alternative?",
42137
+ "3.1.2 Language of parts, 3.2.6 Consistent help, 3.3.7 Redundant entry, 3.3.8 Accessible authentication (beyond paste blocking).",
42138
+ "4.1.3 Status messages and the overall experience with a screen reader and with voice control."
42139
+ ];
41182
42140
 
41183
42141
  // src/conversion/rules.ts
41184
42142
  var VAGUE = lexicon("vagueCta", { exact: true });
@@ -41399,97 +42357,6 @@ function formFriction(obs, form) {
41399
42357
  init_errors();
41400
42358
  init_types();
41401
42359
 
41402
- // src/core/url-safety.ts
41403
- init_errors();
41404
- import { lookup } from "node:dns/promises";
41405
- import { isIP } from "node:net";
41406
- var LOCAL_SUFFIXES = [".localhost", ".test"];
41407
- function isLocalHostname(host) {
41408
- const h = host.replace(/^\[|\]$/g, "").toLowerCase();
41409
- return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h) || h === "0.0.0.0" || LOCAL_SUFFIXES.some((s) => h.endsWith(s));
41410
- }
41411
- function isPrivateIp(ip) {
41412
- const v = ip.replace(/^\[|\]$/g, "").toLowerCase();
41413
- if (isIP(v) === 4) {
41414
- const [a, b] = v.split(".").map(Number);
41415
- return a === 10 || a === 127 || a === 0 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127;
41416
- }
41417
- if (isIP(v) === 6) {
41418
- if (v === "::1" || v === "::") return true;
41419
- if (v.startsWith("fc") || v.startsWith("fd") || v.startsWith("fe80")) return true;
41420
- const mapped = v.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
41421
- return mapped ? isPrivateIp(mapped[1]) : false;
41422
- }
41423
- return false;
41424
- }
41425
- function parseTargetUrl(raw) {
41426
- let url;
41427
- try {
41428
- url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
41429
- } catch {
41430
- throw new JouriloError("INVALID_INPUT", `Not a valid URL: ${raw}`);
41431
- }
41432
- if (url.protocol !== "http:" && url.protocol !== "https:") {
41433
- throw new JouriloError("UNSAFE_URL", `Only http and https URLs are supported (got ${url.protocol}).`);
41434
- }
41435
- url.hash = "";
41436
- return url;
41437
- }
41438
- async function checkTarget(url, opts = {}) {
41439
- const host = url.hostname;
41440
- if (isLocalHostname(host)) return "local";
41441
- if (isPrivateIp(host)) {
41442
- if (!opts.allowPrivate) {
41443
- throw new JouriloError(
41444
- "UNSAFE_URL",
41445
- `${host} is a private network address.`,
41446
- "Pass --allow-private if you are authorized to test this host."
41447
- );
41448
- }
41449
- return "private";
41450
- }
41451
- if (opts.resolveDns !== false && isIP(host) === 0) {
41452
- try {
41453
- const addrs = await lookup(host, { all: true });
41454
- if (addrs.length && addrs.every((a) => isPrivateIp(a.address)) && !opts.allowPrivate) {
41455
- throw new JouriloError(
41456
- "UNSAFE_URL",
41457
- `${host} resolves to a private network address.`,
41458
- "Pass --allow-private if you are authorized to test this host."
41459
- );
41460
- }
41461
- if (addrs.some((a) => isPrivateIp(a.address))) return "private";
41462
- } catch (err) {
41463
- if (err instanceof JouriloError) throw err;
41464
- }
41465
- }
41466
- return "public";
41467
- }
41468
- function isCrawlable(candidate, origin) {
41469
- let u;
41470
- try {
41471
- u = new URL(candidate);
41472
- } catch {
41473
- return false;
41474
- }
41475
- if (u.origin !== origin) return false;
41476
- if (/\/(log-?out|sign-?out|logoff|delete|destroy|remove|unsubscribe|cancel)(\/|$|\?)/i.test(u.pathname))
41477
- return false;
41478
- if (/\.(pdf|zip|gz|dmg|exe|msi|png|jpe?g|gif|webp|svg|mp4|mp3|woff2?|ttf|ico|xml|json|txt|csv)$/i.test(
41479
- u.pathname
41480
- ))
41481
- return false;
41482
- return true;
41483
- }
41484
- function normalizeForCrawl(raw) {
41485
- const u = new URL(raw);
41486
- u.hash = "";
41487
- for (const k of [...u.searchParams.keys()])
41488
- if (/^utm_|^(fbclid|gclid|ref)$/i.test(k)) u.searchParams.delete(k);
41489
- if (u.pathname !== "/" && u.pathname.endsWith("/")) u.pathname = u.pathname.replace(/\/+$/, "");
41490
- return u.toString();
41491
- }
41492
-
41493
42360
  // src/discovery/robots.ts
41494
42361
  function parseRobots(text, agent2 = "jourilo") {
41495
42362
  const groups = [];
@@ -41569,49 +42436,50 @@ function rankUrl(url, depth) {
41569
42436
  for (const [re, w] of IMPORTANT) if (re.test(path2)) score += w;
41570
42437
  return score;
41571
42438
  }
41572
- async function fetchText(url, timeoutMs) {
41573
- try {
41574
- const res = await fetch(url, {
41575
- signal: AbortSignal.timeout(timeoutMs),
41576
- redirect: "follow",
41577
- headers: { "user-agent": `${USER_AGENT_TOKEN} (+https://github.com/cihantopac/jourilo)` }
41578
- });
41579
- if (!res.ok) return null;
41580
- if (new URL(res.url).origin !== new URL(url).origin) return null;
41581
- const text = await res.text();
41582
- return text.slice(0, 2e6);
41583
- } catch {
41584
- return null;
41585
- }
42439
+ async function fetchText(url, timeoutMs, policy) {
42440
+ return guardedFetchText(url, policy, {
42441
+ timeoutMs,
42442
+ userAgent: `${USER_AGENT_TOKEN} (+https://github.com/cihantopac/jourilo)`
42443
+ }).catch(() => null);
41586
42444
  }
41587
- async function loadRobots(origin, timeoutMs = 8e3) {
41588
- const text = await fetchText(`${origin}/robots.txt`, timeoutMs);
42445
+ async function loadRobots(origin, policy, timeoutMs = 8e3) {
42446
+ const text = await fetchText(`${origin}/robots.txt`, timeoutMs, policy);
41589
42447
  return text === null ? null : parseRobots(text, USER_AGENT_TOKEN);
41590
42448
  }
41591
- async function sitemapUrls(origin, robots, timeoutMs) {
41592
- const candidates = [...robots?.sitemaps ?? [], `${origin}/sitemap.xml`];
41593
- const urls = [];
41594
- for (const sm of candidates.slice(0, 3)) {
41595
- let smUrl;
42449
+ async function sitemapUrls(origin, robots, timeoutMs, policy) {
42450
+ const local = policy.target === "local";
42451
+ const rebase = (raw) => {
41596
42452
  try {
41597
- smUrl = new URL(sm);
42453
+ const u = new URL(raw, origin);
42454
+ if (u.origin === origin) return u.toString();
42455
+ return local ? new URL(`${u.pathname}${u.search}`, origin).toString() : null;
41598
42456
  } catch {
41599
- continue;
42457
+ return null;
41600
42458
  }
41601
- if (smUrl.origin !== origin) continue;
41602
- const xml = await fetchText(smUrl.toString(), timeoutMs);
42459
+ };
42460
+ const candidates = [...robots?.sitemaps ?? [], `${origin}/sitemap.xml`];
42461
+ const urls = [];
42462
+ let rebasedFrom = null;
42463
+ for (const sm of candidates.slice(0, 3)) {
42464
+ const smUrl = rebase(sm);
42465
+ if (!smUrl) continue;
42466
+ const xml = await fetchText(smUrl, timeoutMs, policy);
41603
42467
  if (!xml) continue;
41604
42468
  for (const m of xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)) {
41605
- urls.push(m[1].replace(/&amp;/g, "&"));
42469
+ const loc = m[1].replace(/&amp;/g, "&");
42470
+ const mapped = rebase(loc);
42471
+ if (!mapped) continue;
42472
+ if (mapped !== loc) rebasedFrom ??= new URL(loc).origin;
42473
+ urls.push(mapped);
41606
42474
  if (urls.length >= 500) break;
41607
42475
  }
41608
42476
  if (urls.length) break;
41609
42477
  }
41610
- return urls;
42478
+ return { urls, rebasedFrom };
41611
42479
  }
41612
42480
  async function crawl(ctx, start, opts) {
41613
42481
  const origin = new URL(start).origin;
41614
- const robots = opts.respectRobots ? await loadRobots(origin) : null;
42482
+ const robots = opts.respectRobots ? await loadRobots(origin, opts.policy) : null;
41615
42483
  const skipped = [];
41616
42484
  const seen = /* @__PURE__ */ new Map();
41617
42485
  const queue = [];
@@ -41634,8 +42502,8 @@ async function crawl(ctx, start, opts) {
41634
42502
  };
41635
42503
  push(start, 0);
41636
42504
  for (const extra of opts.include ?? []) push(new URL(extra, start).toString(), 0);
41637
- const fromSitemap = await sitemapUrls(origin, robots, opts.timeoutMs);
41638
- for (const u of fromSitemap) push(u, 1);
42505
+ const fromSitemap = await sitemapUrls(origin, robots, opts.timeoutMs, opts.policy);
42506
+ for (const u of fromSitemap.urls) push(u, 1);
41639
42507
  const visited = /* @__PURE__ */ new Set();
41640
42508
  const page = await ctx.newPage();
41641
42509
  try {
@@ -41676,13 +42544,19 @@ async function crawl(ctx, start, opts) {
41676
42544
  if (picked.length >= opts.maxPages) break;
41677
42545
  if (!picked.includes(p)) picked.push(p);
41678
42546
  }
41679
- return { pages: picked, skipped, robots, sitemapUrls: fromSitemap.length };
42547
+ return {
42548
+ pages: picked,
42549
+ skipped,
42550
+ robots,
42551
+ sitemapUrls: fromSitemap.urls.length,
42552
+ sitemapRebasedFrom: fromSitemap.rebasedFrom
42553
+ };
41680
42554
  }
41681
42555
 
41682
42556
  // src/integrations/context.ts
41683
42557
  init_fs_safety();
41684
42558
  import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync } from "node:fs";
41685
- import { join as join5, relative as relative2 } from "node:path";
42559
+ import { join as join6, relative as relative2 } from "node:path";
41686
42560
  var CONTEXT_SCHEMA = "jourilo.context/v1";
41687
42561
  function readJson(file) {
41688
42562
  try {
@@ -41716,11 +42590,11 @@ function flattenTokens(tokens) {
41716
42590
  }
41717
42591
  function readMarkilo(root, brandSlug) {
41718
42592
  for (const dir of ["branding", "brand"]) {
41719
- const manifestFile = join5(root, dir, "markilo.json");
42593
+ const manifestFile = join6(root, dir, "markilo.json");
41720
42594
  const manifest = readJson(manifestFile);
41721
42595
  if (!manifest?.brands?.length) continue;
41722
42596
  const brand = manifest.brands.find((b) => b.slug === brandSlug) ?? manifest.brands.find((b) => b.role === "master") ?? manifest.brands[0];
41723
- const tokensFile = join5(root, dir, "brands", brand.slug, "exports", "tokens", "tokens.json");
42597
+ const tokensFile = join6(root, dir, "brands", brand.slug, "exports", "tokens", "tokens.json");
41724
42598
  if (!existsSync4(tokensFile)) continue;
41725
42599
  const { colors, fonts } = flattenTokens(readJson(tokensFile));
41726
42600
  return {
@@ -41735,13 +42609,13 @@ function readMarkilo(root, brandSlug) {
41735
42609
  return null;
41736
42610
  }
41737
42611
  function readSearvex(root) {
41738
- const runs2 = join5(root, ".searvex", "runs");
42612
+ const runs2 = join6(root, ".searvex", "runs");
41739
42613
  if (!existsSync4(runs2)) return null;
41740
- const dirs = readdirSync2(runs2).filter((d) => existsSync4(join5(runs2, d, "report.json"))).sort(
41741
- (a, b) => statSync(join5(runs2, b, "report.json")).mtimeMs - statSync(join5(runs2, a, "report.json")).mtimeMs
42614
+ const dirs = readdirSync2(runs2).filter((d) => existsSync4(join6(runs2, d, "report.json"))).sort(
42615
+ (a, b) => statSync(join6(runs2, b, "report.json")).mtimeMs - statSync(join6(runs2, a, "report.json")).mtimeMs
41742
42616
  );
41743
42617
  if (!dirs.length) return null;
41744
- const file = join5(runs2, dirs[0], "report.json");
42618
+ const file = join6(runs2, dirs[0], "report.json");
41745
42619
  const report2 = readJson(file);
41746
42620
  if (!report2?.findings) return null;
41747
42621
  return {
@@ -41757,7 +42631,7 @@ function readSearvex(root) {
41757
42631
  };
41758
42632
  }
41759
42633
  function readExplicit(root) {
41760
- const file = join5(root, "jourilo.context.json");
42634
+ const file = join6(root, "jourilo.context.json");
41761
42635
  const data = readJson(file);
41762
42636
  if (!data) return null;
41763
42637
  if (data.schema !== CONTEXT_SCHEMA)
@@ -41946,6 +42820,12 @@ var AFTER = `([sel, beforeText]) => {
41946
42820
  }`;
41947
42821
  async function probeFormValidation(ctx, url, form, vp, policy, runDir) {
41948
42822
  const page = await ctx.newPage();
42823
+ const guard = await installRequestGuard(page, {
42824
+ ...policy,
42825
+ allowDestructive: false,
42826
+ origin: new URL(url).origin,
42827
+ mode: policy.allowSubmit ? "journey" : "read-only"
42828
+ });
41949
42829
  const base2 = {
41950
42830
  url: redactUrl(url),
41951
42831
  form: form.selector,
@@ -41978,10 +42858,12 @@ async function probeFormValidation(ctx, url, form, vp, policy, runDir) {
41978
42858
  );
41979
42859
  const startUrl = page.url();
41980
42860
  const submit = page.locator(form.selector).locator('button[type="submit"], button:not([type]), input[type="submit"]').first();
42861
+ const submittedAt = Date.now();
41981
42862
  if (await submit.count()) await submit.click({ timeout: 1e4 });
41982
42863
  else await page.locator(form.selector).locator("input").first().press("Enter");
41983
42864
  await page.waitForLoadState("networkidle", { timeout: 3e3 }).catch(() => void 0);
41984
42865
  await page.waitForTimeout(600);
42866
+ const intercepted = guard.since(submittedAt);
41985
42867
  const shotFile = toPosix(`screenshots/form-${shortHash(url, form.selector).slice(0, 8)}-${vp.name}.jpg`);
41986
42868
  await page.screenshot({
41987
42869
  path: `${runDir}/${shotFile}`,
@@ -42000,6 +42882,16 @@ async function probeFormValidation(ctx, url, form, vp, policy, runDir) {
42000
42882
  const evidence = [
42001
42883
  { type: "screenshot", file: shotFile, note: "After submitting the empty form" }
42002
42884
  ];
42885
+ if (after && !after.errorish.length && !after.ariaInvalid && intercepted.length) {
42886
+ return {
42887
+ result: {
42888
+ ...base2,
42889
+ outcome: "simulated",
42890
+ detail: `No client-side validation; the empty submission (${intercepted[0].method} ${intercepted[0].url}) was intercepted, so server-side feedback was not tested.`
42891
+ },
42892
+ findings
42893
+ };
42894
+ }
42003
42895
  if (!after || !after.errorish.length && !after.ariaInvalid) {
42004
42896
  const accepted = navigated && after !== null;
42005
42897
  findings.push(
@@ -42093,6 +42985,7 @@ async function probeFormValidation(ctx, url, form, vp, policy, runDir) {
42093
42985
  } catch (err) {
42094
42986
  return { result: { ...base2, outcome: "error", detail: excerpt(err.message, 200) }, findings };
42095
42987
  } finally {
42988
+ await guard.detach();
42096
42989
  await page.close().catch(() => void 0);
42097
42990
  }
42098
42991
  }
@@ -42304,18 +43197,20 @@ function keyboardFindings(obs) {
42304
43197
  );
42305
43198
  }
42306
43199
  if (kb.trap) {
43200
+ const advised = !!kb.trap.advice && kb.trap.adviceWorks !== false;
43201
+ const broken = !!kb.trap.advice && kb.trap.adviceWorks === false;
42307
43202
  out.push(
42308
43203
  makeFinding({
42309
43204
  ruleId: "jourilo/keyboard-trap",
42310
- title: "Keyboard focus is trapped",
43205
+ title: advised ? "Keyboard focus is held; the page explains a way out" : "Keyboard focus is trapped",
42311
43206
  category: "accessibility",
42312
- kind: "defect",
42313
- severity: "critical",
43207
+ kind: advised ? "needs-review" : "defect",
43208
+ severity: advised ? "moderate" : "critical",
42314
43209
  confidence: "medium",
42315
43210
  provenance: "observed",
42316
43211
  url: obs.url,
42317
43212
  viewport: vp,
42318
- description: `Pressing Tab cycled between ${kb.trap.selectors.length} element(s) outside any modal dialog while the page has ${kb.focusableCount} focusable elements.`,
43213
+ description: `Pressing Tab cycled between ${kb.trap.selectors.length} element(s) outside any modal dialog while the page has ${kb.focusableCount} focusable elements.${advised ? ` The page explains how to leave ("${kb.trap.advice}")${kb.trap.adviceWorks ? ", and that key let focus out" : ""}; check that the instruction is announced before focus is held.` : ""}${broken ? ` The page says "${kb.trap.advice}", but pressing it did not let focus out.` : ""}`,
42319
43214
  impact: "Keyboard users cannot reach the rest of the page and may have to reload it.",
42320
43215
  recommendation: 'Remove custom keydown handlers that intercept Tab outside dialogs; if this is a dialog, mark it aria-modal="true" and provide Escape to close.',
42321
43216
  standards: [wcag("2.1.2")],
@@ -42510,6 +43405,22 @@ var WIDGET_RULES = {
42510
43405
  }
42511
43406
  ]
42512
43407
  },
43408
+ "composite-unreachable": {
43409
+ ruleId: "jourilo/composite-keyboard",
43410
+ title: "Items of a tab list, radio group, list box or menu bar cannot be reached with the keyboard",
43411
+ severity: "serious",
43412
+ impact: "Keyboard and screen-reader users can reach only the current item; the other tabs, options or choices are out of reach.",
43413
+ recommendation: "Implement the arrow-key pattern for this widget (move focus or aria-activedescendant to the next item with the arrow keys), or put every item in the Tab order.",
43414
+ standards: [
43415
+ wcag("2.1.1"),
43416
+ {
43417
+ standard: "WAI-ARIA APG",
43418
+ id: "keyboard-interface",
43419
+ title: "Developing a Keyboard Interface",
43420
+ url: "https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/"
43421
+ }
43422
+ ]
43423
+ },
42513
43424
  "dialog-escape": {
42514
43425
  ruleId: "jourilo/dialog-escape",
42515
43426
  title: "Escape does not close the dialog",
@@ -42600,8 +43511,7 @@ function widgetFindings(obs) {
42600
43511
  occurrences: issues.length,
42601
43512
  reproduction: [
42602
43513
  `Open ${obs.url} at the ${obs.viewport.name} viewport.`,
42603
- `Focus "${issues[0].name}" with Tab and press Enter.`,
42604
- issues[0].detail
43514
+ ...issues[0].steps ?? [`Focus "${issues[0].name}" with Tab and press Enter.`, issues[0].detail]
42605
43515
  ]
42606
43516
  });
42607
43517
  });
@@ -43029,9 +43939,13 @@ async function runAudit(ws, opts) {
43029
43939
  const runDir = ws.createRunDir(id);
43030
43940
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
43031
43941
  const limitations = [];
43032
- let browser;
43942
+ let session;
43033
43943
  try {
43034
- browser = await launchBrowser(opts.browser, opts.headed);
43944
+ session = await openGuardedBrowser(opts.browser, opts.headed, {
43945
+ target: hostClass,
43946
+ allowPrivate: opts.allowPrivate
43947
+ });
43948
+ const browser = session.browser;
43035
43949
  const browserVersion = browser.version();
43036
43950
  let urls;
43037
43951
  if (opts.urls?.length) {
@@ -43056,6 +43970,7 @@ async function runAudit(ws, opts) {
43056
43970
  maxDepth: opts.maxDepth,
43057
43971
  respectRobots: opts.respectRobots,
43058
43972
  timeoutMs: opts.timeoutMs,
43973
+ policy: session.guard.policy,
43059
43974
  ...opts.include ? { include: opts.include } : {}
43060
43975
  });
43061
43976
  await ctx.close();
@@ -43063,6 +43978,10 @@ async function runAudit(ws, opts) {
43063
43978
  const robotsSkips = found.skipped.filter((s) => s.reason === "robots.txt").length;
43064
43979
  if (robotsSkips)
43065
43980
  limitations.push(`${robotsSkips} URL(s) were skipped because robots.txt disallows them.`);
43981
+ if (found.sitemapRebasedFrom)
43982
+ limitations.push(
43983
+ `The sitemap lists ${found.sitemapRebasedFrom} URLs; their paths were audited on the local server instead.`
43984
+ );
43066
43985
  if (!found.robots && opts.respectRobots && hostClass === "public")
43067
43986
  limitations.push("No robots.txt was found; crawling stayed on the target origin.");
43068
43987
  if (!urls.length) urls = [{ url: target.toString() }];
@@ -43095,6 +44014,16 @@ async function runAudit(ws, opts) {
43095
44014
  await ctx.close().catch(() => void 0);
43096
44015
  }
43097
44016
  }
44017
+ if (hostClass === "local" && !opts.urls?.length) {
44018
+ const startPath = target.pathname;
44019
+ const missing = observations.filter((o) => o.status === 404 && new URL(o.url).pathname === startPath);
44020
+ if (missing.length && observations.some((o) => (o.status ?? 200) < 400)) {
44021
+ for (const m of missing) observations.splice(observations.indexOf(m), 1);
44022
+ limitations.push(
44023
+ `${startPath} returned 404 on this local server (no start page in the build); it was not audited. The other pages were found through the sitemap and links.`
44024
+ );
44025
+ }
44026
+ }
43098
44027
  const runs2 = Math.max(1, Math.min(9, Math.round(opts.runs ?? 1)));
43099
44028
  if (runs2 > 1) {
43100
44029
  for (const obs of observations) {
@@ -43117,6 +44046,7 @@ async function runAudit(ws, opts) {
43117
44046
  }
43118
44047
  }
43119
44048
  const formFindings = [];
44049
+ const simulated = [];
43120
44050
  const formsAllowed = hostClass === "local" || opts.allowSubmit === true;
43121
44051
  if (opts.forms !== false && !opts.inventoryOnly && formsAllowed) {
43122
44052
  const vp = opts.viewports.find((v) => !v.hasTouch) ?? opts.viewports[0];
@@ -43142,11 +44072,16 @@ async function runAudit(ws, opts) {
43142
44072
  formFindings.push(...r.findings);
43143
44073
  if (r.result.outcome === "blocked")
43144
44074
  limitations.push(`Form ${form.selector} on ${o.url} was not submitted: ${r.result.detail}`);
44075
+ if (r.result.outcome === "simulated") simulated.push(`${form.selector} on ${o.url}`);
43145
44076
  }
43146
44077
  }
43147
44078
  } finally {
43148
44079
  await ctx.close().catch(() => void 0);
43149
44080
  }
44081
+ if (simulated.length)
44082
+ limitations.push(
44083
+ `Form validation was simulated: empty submissions were intercepted before reaching the server, so nothing was written. ${simulated.length} form(s) rely on the server for validation feedback (${simulated.slice(0, 3).join("; ")}${simulated.length > 3 ? "; \u2026" : ""}); that feedback was not tested. Rerun with --allow-submit on a disposable environment to test it.`
44084
+ );
43150
44085
  } else if (opts.forms !== false && observations.some((o) => o.probe?.inventory.forms.length)) {
43151
44086
  limitations.push(
43152
44087
  "Form validation was not tested: submitting forms on a public host requires --allow-submit (authorized test environments only)."
@@ -43188,6 +44123,13 @@ async function runAudit(ws, opts) {
43188
44123
  "Throttling simulated a mid-range mobile network and 4\xD7 CPU slowdown (Chromium only)."
43189
44124
  );
43190
44125
  else limitations.push("Measured without network/CPU throttling: real mobile devices are usually slower.");
44126
+ const network = session.report();
44127
+ limitations.push(...networkLimitations(network));
44128
+ const stopped = observations.flatMap((o) => o.blockedActions ?? []);
44129
+ if (stopped.length)
44130
+ limitations.push(
44131
+ `While keys were pressed and widgets operated, ${stopped.length} request(s) that would have changed data or left the site were stopped (e.g. ${stopped[0].method} ${stopped[0].url}); audits only observe.`
44132
+ );
43191
44133
  const pages = observations.map((o) => ({
43192
44134
  url: o.url,
43193
44135
  finalUrl: o.finalUrl,
@@ -43228,12 +44170,13 @@ async function runAudit(ws, opts) {
43228
44170
  findings,
43229
44171
  summary: summarize(findings, pages.length, 0),
43230
44172
  limitations: [...new Set(limitations)],
43231
- ...opts.context && (opts.context.brand || opts.context.search) ? { context: opts.context } : {}
44173
+ ...opts.context && (opts.context.brand || opts.context.search) ? { context: opts.context } : {},
44174
+ ...network.blocked.length || network.escaped.length ? { network } : {}
43232
44175
  };
43233
44176
  ws.saveRun(run);
43234
44177
  return { run, runDir, observations };
43235
44178
  } finally {
43236
- await browser?.close().catch(() => void 0);
44179
+ await session?.close();
43237
44180
  }
43238
44181
  }
43239
44182
  function summarize(findings, pages, journeys2) {
@@ -43323,7 +44266,7 @@ init_errors();
43323
44266
  init_fs_safety();
43324
44267
  import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as statSync2 } from "node:fs";
43325
44268
  import { createServer } from "node:http";
43326
- import { extname, join as join6, resolve as resolve4 } from "node:path";
44269
+ import { extname, join as join7, resolve as resolve4 } from "node:path";
43327
44270
  var TYPES = {
43328
44271
  ".html": "text/html; charset=utf-8",
43329
44272
  ".css": "text/css; charset=utf-8",
@@ -43356,24 +44299,34 @@ function stablePort(root) {
43356
44299
  async function serveStatic(root, port = 0, opts = {}) {
43357
44300
  if (!existsSync5(root) || !statSync2(root).isDirectory())
43358
44301
  throw new JouriloError("NOT_FOUND", `Not a directory: ${root}`);
43359
- const cfgFile = join6(root, "jourilo-fixture.json");
44302
+ const cfgFile = join7(root, "jourilo-fixture.json");
43360
44303
  const cfg = existsSync5(cfgFile) ? JSON.parse(readFileSync4(cfgFile, "utf8")) : {};
43361
44304
  const spa = opts.spa ?? cfg.spa ?? looksLikeSpa(root);
43362
44305
  const server = createServer((req, res) => {
43363
44306
  const url = new URL(req.url ?? "/", "http://localhost");
43364
- let pathname2 = decodeURIComponent(url.pathname);
44307
+ let pathname2;
44308
+ try {
44309
+ pathname2 = decodeURIComponent(url.pathname);
44310
+ } catch {
44311
+ res.writeHead(400).end("Bad request");
44312
+ return;
44313
+ }
44314
+ if (pathname2.split("/").some((seg) => seg.startsWith(".") && seg !== ".well-known")) {
44315
+ res.writeHead(404, { "content-type": "text/plain" }).end("Not found");
44316
+ return;
44317
+ }
43365
44318
  const delay = cfg.delays?.[pathname2] ?? 0;
43366
44319
  const send = () => {
43367
44320
  try {
43368
44321
  let file = resolveInside(root, `.${pathname2}`);
43369
- if (existsSync5(file) && statSync2(file).isDirectory()) file = join6(file, "index.html");
44322
+ if (existsSync5(file) && statSync2(file).isDirectory()) file = join7(file, "index.html");
43370
44323
  if (!existsSync5(file) && !extname(pathname2)) {
43371
44324
  const html = resolveInside(root, `.${pathname2}.html`);
43372
44325
  if (existsSync5(html)) file = html;
43373
44326
  }
43374
- if (!existsSync5(file) && spa && !extname(pathname2)) file = join6(root, "index.html");
44327
+ if (!existsSync5(file) && spa && !extname(pathname2)) file = join7(root, "index.html");
43375
44328
  if (!existsSync5(file)) {
43376
- const notFound = join6(root, "404.html");
44329
+ const notFound = join7(root, "404.html");
43377
44330
  res.writeHead(404, { "content-type": TYPES[".html"] });
43378
44331
  res.end(existsSync5(notFound) ? readFileSync4(notFound) : "Not found");
43379
44332
  return;
@@ -43419,7 +44372,7 @@ async function serveStatic(root, port = 0, opts = {}) {
43419
44372
  // src/repair/locate.ts
43420
44373
  init_fs_safety();
43421
44374
  import { readdirSync as readdirSync4, readFileSync as readFileSync5, statSync as statSync3 } from "node:fs";
43422
- import { extname as extname2, join as join7, relative as relative3 } from "node:path";
44375
+ import { extname as extname2, join as join8, relative as relative3 } from "node:path";
43423
44376
  var SOURCE_EXT = /* @__PURE__ */ new Set([
43424
44377
  ".html",
43425
44378
  ".htm",
@@ -43478,7 +44431,7 @@ function indexRepository(root, maxFiles = 6e3) {
43478
44431
  for (const name of entries) {
43479
44432
  if (out.length >= maxFiles) return;
43480
44433
  if (name.startsWith(".") && name !== ".vitepress") continue;
43481
- const full = join7(dir, name);
44434
+ const full = join8(dir, name);
43482
44435
  let st;
43483
44436
  try {
43484
44437
  st = statSync3(full);
@@ -43761,7 +44714,26 @@ function locateFindings(run, repoRoot) {
43761
44714
 
43762
44715
  // src/report/index.ts
43763
44716
  import { writeFileSync as writeFileSync4 } from "node:fs";
43764
- import { join as join8 } from "node:path";
44717
+ import { join as join9 } from "node:path";
44718
+
44719
+ // src/report/brand.ts
44720
+ var WORDMARK_VIEWBOX = "-18 -74 320 107";
44721
+ var WORDMARK_PATHS = [
44722
+ "m-15.5-64.4 3.2-3.2h55.5v2.6l-8 2.65L35.3-2C35.3 18 25 30.5 10 30.5c-6.5 0-12-3-15-9.5l-6.5-8L-5 5l6.5 8-3 3.7c2 4.8 6 7.8 11.5 7.8 9 0 14.3-6.5 14.3-18.5v-65.5q-.7-1.3-2.8-1.7h-33.8Z",
44723
+ "M70.15-3.85q3.65 0 6.4-1.75t4.3-5.45 1.55-9.6q0-6.5-1.5-10.87-1.5-4.38-4.25-6.6-2.75-2.23-6.7-2.23-3.6 0-6.35 1.73-2.75 1.72-4.3 5.44-1.55 3.73-1.55 9.63 0 6.4 1.48 10.8 1.47 4.4 4.27 6.65t6.65 2.25M69.8 1.1q-6.85 0-11.97-2.87-5.13-2.88-7.93-8-2.8-5.13-2.8-11.88 0-6.95 2.98-12.3 2.97-5.35 8.2-8.37 5.22-3.03 12.07-3.03 6.9 0 11.98 2.88 5.07 2.87 7.9 7.97 2.82 5.1 2.82 11.9 0 6.95-2.97 12.3-2.98 5.35-8.2 8.38Q76.65 1.1 69.8 1.1",
44724
+ "M115.1-14.95q0 2.8.8 4.45t2.73 2.4q1.92.75 5.22.75 3.1 0 6.1-1.05 2.1-.75 3.5-1.8v-28.25l-6.65-3.05v-2.55l17.2-.9-.65 6.3V-8.3q.5.45 1.3 1t1.78 1.05q.97.5 1.95.9.97.4 1.82.6v1.8L136.2.9h-1.35l-1.1-7.45q-1.55 1.5-2.9 2.6Q128.45-2 126.4-.9T122.3.65t-4.45.45q-6.45 0-9.52-3.88-3.08-3.87-3.08-11.57v-23.9l-7.2-3.2v-2.6L115.7-45l-.6 6.25Z",
44725
+ "m186-44 6.5 8-6.5 8-6.5-8q-3.5.6-7.5 2v29.1l7.75 2.5V0h-24.9v-2.4l7.3-2.4v-29.25q-.7-.75-1.77-1.42-1.08-.68-2.48-1.38t-3.15-1.4v-1.65l14.65-5.45h1.4l1 8.15Q178-43.5 186-44",
44726
+ "m207.8-72.4 6.5 7.5-6.5 7.5-6.5-7.5Zm5.85 27.05-.45 9.45v31.1l7.3 2.4V0h-24.45v-2.4l7.25-2.4v-29.95q-.55-.65-1.75-1.43-1.2-.77-2.67-1.54-1.48-.78-2.98-1.33V-41l16.05-4.35Z",
44727
+ "m241.2-4.8 7.3 2.4V0h-24.45v-2.4l7.25-2.4v-55.5q-.7-.8-1.85-1.6t-2.75-1.65-3.55-1.65v-1.9l17.25-4.5h1.5l-.7 9.25Z",
44728
+ "M277-3.85q3.65 0 6.4-1.75t4.3-5.45 1.55-9.6q0-6.5-1.5-10.87-1.5-4.38-4.25-6.6-2.75-2.23-6.7-2.23-3.6 0-6.35 1.73-2.75 1.72-4.3 5.44-1.55 3.73-1.55 9.63 0 6.4 1.48 10.8 1.47 4.4 4.27 6.65T277-3.85m-.35 4.95q-6.85 0-11.97-2.87-5.13-2.88-7.93-8-2.8-5.13-2.8-11.88 0-6.95 2.98-12.3 2.97-5.35 8.2-8.37 5.22-3.03 12.07-3.03 6.9 0 11.98 2.88 5.07 2.87 7.9 7.97 2.82 5.1 2.82 11.9 0 6.95-2.97 12.3-2.98 5.35-8.2 8.38-5.23 3.02-12.08 3.02"
44729
+ ];
44730
+ var SYMBOL_PATH = "m150 122 42-42h670v70q-75 10-120 50v400c0 220-130 345-310 345-90 0-150-30-175-75l-85-100 85-100 85 100-18 21c23 39 58 61 113 61 115 0 175-82 175-252V205q-12-35-52-41H192Z";
44731
+ function wordmarkSvg(height) {
44732
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${WORDMARK_VIEWBOX}" height="${height}" role="img" aria-label="Jourilo" fill="currentColor">${WORDMARK_PATHS.map((d) => `<path d="${d}"/>`).join("")}</svg>`;
44733
+ }
44734
+ var FAVICON_URL = `data:image/svg+xml,${encodeURIComponent(
44735
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><style>path{fill:#1c1a17}@media (prefers-color-scheme:dark){path{fill:#f4efe4}}</style><path d="${SYMBOL_PATH}"/></svg>`
44736
+ )}`;
43765
44737
 
43766
44738
  // src/report/html.ts
43767
44739
  function esc(s) {
@@ -43881,7 +44853,9 @@ function journeysHtml(journeys2) {
43881
44853
  if (!journeys2.length)
43882
44854
  return '<p class="muted">No journeys were run. Use <code>jourilo journeys suggest</code> and <code>jourilo journeys run</code>.</p>';
43883
44855
  return journeys2.map(
43884
- (j) => `<section class="journey ${j.status}"><h3>${esc(j.name)} <span class="pill st-${j.status}">${j.status}</span> <span class="muted">${esc(j.viewport)} \xB7 ${(j.durationMs / 1e3).toFixed(1)} s</span></h3>
44856
+ (j) => `<section class="journey ${j.status}"><h3>${esc(j.name)} <span class="pill st-${j.status}">${j.status}</span>${j.stability ? ` <span class="pill ${j.stability === "flaky" ? "st-failed" : "st-passed"}">${j.stability}</span>` : ""} <span class="muted">${esc(j.viewport)} \xB7 ${(j.durationMs / 1e3).toFixed(1)} s</span></h3>
44857
+ ${j.attempts ? `<p class="muted">${j.attempts.length} attempts: ${j.attempts.map((a) => esc(a.status)).join(", ")}${j.stability === "flaky" ? " \u2014 the steps below are from a failing attempt." : ""}</p>` : ""}
44858
+ ${j.blockedRequests?.length ? `<details><summary>${j.blockedRequests.length} request(s) stopped by the safety policy</summary><ul>${j.blockedRequests.map((b) => `<li><code>${esc(b.method)} ${esc(b.url)}</code> \u2014 ${esc(b.reason)}</li>`).join("")}</ul></details>` : ""}
43885
44859
  <ol class="steps">${j.steps.map(
43886
44860
  (s) => `<li class="${s.status}"><div>${esc(s.description)} <span class="pill st-${s.status}">${s.status}</span> <span class="muted">${s.durationMs} ms</span>${s.error ? `<pre>${esc(s.error)}</pre>` : ""}</div>${s.screenshot ? `<a href="${safeHref(s.screenshot)}" target="_blank" rel="noopener"><img class="stepshot" loading="lazy" alt="" src="${safeHref(s.screenshot)}"></a>` : ""}</li>`
43887
44861
  ).join("")}</ol>
@@ -43889,11 +44863,18 @@ function journeysHtml(journeys2) {
43889
44863
  ${j.trace ? `<p>Trace: <code>${esc(j.trace)}</code> (open with <code>npx playwright show-trace</code>)</p>` : ""}</section>`
43890
44864
  ).join("");
43891
44865
  }
44866
+ function networkHtml(run) {
44867
+ const n = run.network;
44868
+ if (!n || !n.blocked.length && !n.escaped.length) return "";
44869
+ return `<h2>Network safety</h2>
44870
+ ${n.escaped.length ? `<p class="poor"><strong>Security event:</strong> ${n.escaped.length} redirect(s) reached this machine outside the network guard. Rerun untrusted sites in Chromium or Firefox.</p><ul>${n.escaped.map((e) => `<li><code>${esc(e.url)}</code> \u2014 ${esc(e.reason)}</li>`).join("")}</ul>` : ""}
44871
+ ${n.blocked.length ? `<p class="muted">Connections the page tried to open and the guard refused (content depending on them did not load):</p><table><thead><tr><th>Host</th><th>Port</th><th>Reason</th></tr></thead><tbody>${n.blocked.map((b) => `<tr><td><code>${esc(b.host)}</code></td><td>${b.port}</td><td>${esc(b.reason)}</td></tr>`).join("")}</tbody></table>` : ""}`;
44872
+ }
43892
44873
  function compareHtml(cmp) {
43893
44874
  const row = (label, n, cls) => `<div class="card ${cls}"><div class="num">${n}</div><div>${label}</div></div>`;
43894
44875
  return `<div class="cards">${row("Fixed", cmp.fixed.length, "good")}${row("New", cmp.introduced.length, "poor")}${row("Still present", cmp.persisting.length, "")}${row("Changed severity", cmp.changed.length, "")}</div>
43895
44876
  <p class="muted">Baseline run <code>${esc(cmp.base.id)}</code> (${esc(cmp.base.startedAt)}) vs <code>${esc(cmp.head.id)}</code> (${esc(cmp.head.startedAt)}).</p>
43896
- ${cmp.metrics.length ? `<table><thead><tr><th>Page</th><th>Viewport</th><th>Metric</th><th>Before</th><th>After</th><th>\u0394</th></tr></thead><tbody>${cmp.metrics.map((m) => `<tr><td>${esc(pathname(m.url))}</td><td>${esc(m.viewport)}</td><td>${esc(m.name)}</td><td>${m.before}</td><td>${m.after}</td><td class="${m.delta < 0 ? "good" : m.delta > 0 ? "poor" : ""}">${m.delta > 0 ? "+" : ""}${m.delta}</td></tr>`).join("")}</tbody></table><p class="muted">Lab deltas from single visits can be noise; repeat runs before drawing conclusions.</p>` : ""}`;
44877
+ ${cmp.metrics.length ? `<table><thead><tr><th>Page</th><th>Viewport</th><th>Metric</th><th>Before</th><th>After</th><th>\u0394</th><th>Verdict</th></tr></thead><tbody>${cmp.metrics.map((m) => `<tr><td>${esc(pathname(m.url))}</td><td>${esc(m.viewport)}</td><td>${esc(m.name)}</td><td>${m.before}</td><td>${m.after}</td><td class="${m.verdict === "improved" ? "good" : m.verdict === "regressed" ? "poor" : ""}">${m.delta > 0 ? "+" : ""}${m.delta}</td><td>${esc(m.verdict)}</td></tr>`).join("")}</tbody></table><p class="muted">Lab metrics. "Improved"/"regressed" need repeated visits whose ranges do not overlap (<code>--runs 5</code>); "unconfirmed" is a large change seen in single visits; "noise" is within measurement variation.</p>` : ""}`;
43897
44878
  }
43898
44879
  function contextHtml(run) {
43899
44880
  const c = run.context;
@@ -43983,9 +44964,10 @@ function renderHtml(run, cmp, history = []) {
43983
44964
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
43984
44965
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
43985
44966
  <title>Jourilo report \xB7 ${esc(run.target)}</title>
44967
+ <link rel="icon" href="${FAVICON_URL}">
43986
44968
  <style>${CSS2}</style></head>
43987
44969
  <body>
43988
- <header class="top"><div class="brand">Jourilo <span class="muted">Experience Observatory</span></div>
44970
+ <header class="top"><div class="brand">${wordmarkSvg(34)}<span class="muted">Experience Observatory</span></div>
43989
44971
  <div class="meta"><b>${esc(run.target)}</b><br><span class="muted">${esc(run.mode)} mode \xB7 ${esc(new Date(run.startedAt).toUTCString())} \xB7 ${esc(run.browser.name)} ${esc(run.browser.version)} \xB7 ${run.viewports.map((v) => `${esc(v.name)} ${v.width}\xD7${v.height}`).join(", ")} \xB7 Jourilo ${esc(run.tool.version)}</span></div></header>
43990
44972
  <nav class="tabs" role="tablist">
43991
44973
  <button role="tab" aria-selected="true" data-tab="overview">Overview</button>
@@ -44006,6 +44988,8 @@ ${roadmapHtml(findings)}
44006
44988
  ${historyHtml(run, history)}
44007
44989
  ${contextHtml(run)}
44008
44990
  <h2>Limitations of this run</h2><ul class="limits">${run.limitations.map((l) => `<li>${esc(l)}</li>`).join("")}</ul>
44991
+ ${networkHtml(run)}
44992
+ <h2>Still needs a person</h2><p class="muted">Automated checks cannot judge these WCAG 2.2 criteria; no run, including this one, shows conformance.</p><ul class="limits">${MANUAL_REVIEW.map((m) => `<li>${esc(m)}</li>`).join("")}</ul>
44009
44993
  </section>
44010
44994
  <section id="findings" class="panel" hidden>
44011
44995
  <div class="filters">
@@ -44026,12 +45010,12 @@ ${cmp ? `<section id="compare" class="panel" hidden>${compareHtml(cmp)}</section
44026
45010
  </body></html>`;
44027
45011
  }
44028
45012
  var CSS2 = `
44029
- :root{--bg:#f7f7f8;--fg:#18181b;--muted:#6b6b76;--card:#fff;--line:#e4e4e7;--accent:#4338ca;--crit:#b91c1c;--ser:#c2410c;--mod:#a16207;--min:#4b5563;--good:#15803d;--poor:#b91c1c;--ni:#a16207}
44030
- @media (prefers-color-scheme:dark){:root{--bg:#0f0f12;--fg:#ececf1;--muted:#a1a1aa;--card:#18181d;--line:#2e2e35;--accent:#a5b4fc;--crit:#f87171;--ser:#fb923c;--mod:#facc15;--min:#cbd5e1;--good:#4ade80;--poor:#f87171;--ni:#facc15}}
45013
+ :root{--bg:#f4efe4;--fg:#1c1a17;--muted:#65635f;--card:#fffdf8;--line:#e2d9c7;--accent:#875802;--crit:#b91c1c;--ser:#c2410c;--mod:#955d05;--min:#4b5563;--good:#14713a;--poor:#b91c1c;--ni:#955d05}
45014
+ @media (prefers-color-scheme:dark){:root{--bg:#100d08;--fg:#f4efe4;--muted:#b9b7b4;--card:#1c1a17;--line:#393835;--accent:#d9af79;--crit:#f87171;--ser:#fb923c;--mod:#facc15;--min:#cbd5e1;--good:#4ade80;--poor:#f87171;--ni:#facc15}}
44031
45015
  *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}
44032
45016
  a{color:var(--accent)}code,pre{font:12.5px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}pre{white-space:pre-wrap;word-break:break-word;background:var(--bg);border:1px solid var(--line);padding:6px 8px;border-radius:6px;margin:4px 0}
44033
45017
  .top{display:flex;gap:24px;align-items:center;justify-content:space-between;flex-wrap:wrap;padding:16px 24px;border-bottom:1px solid var(--line);background:var(--card)}
44034
- .brand{font-weight:700;font-size:18px}.muted{color:var(--muted)}.meta{text-align:right;font-size:13px;overflow-wrap:anywhere}
45018
+ .brand{display:flex;align-items:center;gap:20px;font-size:15px}.brand svg{display:block;flex:none}.muted{color:var(--muted)}.meta{text-align:right;font-size:13px;overflow-wrap:anywhere}
44035
45019
  .tabs{display:flex;gap:4px;padding:0 24px;background:var(--card);border-bottom:1px solid var(--line);overflow-x:auto}
44036
45020
  .tabs button{font:inherit;background:none;border:0;border-bottom:3px solid transparent;padding:12px 12px;color:var(--muted);cursor:pointer;white-space:nowrap}
44037
45021
  .tabs button[aria-selected=true]{color:var(--fg);border-color:var(--accent);font-weight:600}.tabs button:focus-visible{outline:2px solid var(--accent)}
@@ -44111,6 +45095,18 @@ function renderMarkdown(run, cmp, limit = 25) {
44111
45095
  lines.push(`- \u{1F195} ${f.severity}: ${md(f.title)} \u2014 \`${path(f.url)}\``);
44112
45096
  for (const f of cmp.fixed.slice(0, 10)) lines.push(`- \u2705 fixed: ${md(f.title)} \u2014 \`${path(f.url)}\``);
44113
45097
  lines.push("");
45098
+ if (cmp.metrics.length) {
45099
+ lines.push(
45100
+ "| Page | Viewport | Metric | Before | After | Verdict |",
45101
+ "| ---- | -------- | ------ | ------ | ----- | ------- |",
45102
+ ...cmp.metrics.map(
45103
+ (m) => `| \`${path(m.url)}\` | ${m.viewport} | ${m.name} | ${m.before} | ${m.after} | ${m.verdict} |`
45104
+ ),
45105
+ "",
45106
+ "Lab metrics. Improved/regressed requires repeated visits whose ranges do not overlap (`--runs 5`).",
45107
+ ""
45108
+ );
45109
+ }
44114
45110
  }
44115
45111
  const sorted = [...run.findings].sort(
44116
45112
  (a, b) => severityRank(a.severity) - severityRank(b.severity) || b.occurrences - a.occurrences
@@ -44151,6 +45147,22 @@ function renderMarkdown(run, cmp, limit = 25) {
44151
45147
  lines.push("");
44152
45148
  }
44153
45149
  lines.push("## Limitations", "", ...run.limitations.map((l) => `- ${md(l)}`), "");
45150
+ if (run.network?.blocked.length || run.network?.escaped.length)
45151
+ lines.push(
45152
+ "## Network safety",
45153
+ "",
45154
+ ...run.network.escaped.map((e) => `- \u26A0\uFE0F security event: \`${md(e.url)}\` \u2014 ${md(e.reason)}`) ?? [],
45155
+ ...run.network.blocked.map((b) => `- refused \`${md(b.host)}:${b.port}\` \u2014 ${md(b.reason)}`),
45156
+ ""
45157
+ );
45158
+ lines.push(
45159
+ "## Still needs a person",
45160
+ "",
45161
+ "Automated checks cannot judge these WCAG 2.2 criteria; no run shows conformance.",
45162
+ "",
45163
+ ...MANUAL_REVIEW.map((m) => `- ${md(m)}`),
45164
+ ""
45165
+ );
44154
45166
  return lines.join("\n");
44155
45167
  }
44156
45168
  function renderSarif(run) {
@@ -44203,13 +45215,13 @@ function renderSarif(run) {
44203
45215
 
44204
45216
  // src/report/index.ts
44205
45217
  function writeReports(dir, run, cmp, opts = {}) {
44206
- const html = join8(dir, "report.html");
44207
- const markdown = join8(dir, "report.md");
45218
+ const html = join9(dir, "report.html");
45219
+ const markdown = join9(dir, "report.md");
44208
45220
  writeFileSync4(html, renderHtml(run, cmp, opts.history));
44209
45221
  writeFileSync4(markdown, renderMarkdown(run, cmp));
44210
45222
  const out = { html, markdown };
44211
45223
  if (opts.sarif) {
44212
- out.sarif = join8(dir, "report.sarif");
45224
+ out.sarif = join9(dir, "report.sarif");
44213
45225
  writeFileSync4(out.sarif, `${JSON.stringify(renderSarif(run), null, 2)}
44214
45226
  `);
44215
45227
  }
@@ -44367,8 +45379,11 @@ var audit = {
44367
45379
  --brand SLUG Markilo brand to check design consistency against (default: master brand)
44368
45380
  --ignore-robots Ignore robots.txt (only for sites you own or are authorized to test)
44369
45381
  --allow-private Allow private-network hosts (10.x, 192.168.x, ...)
44370
- --no-forms Do not submit forms (by default, forms on local hosts are submitted empty to test validation)
44371
- --allow-submit Allow submitting forms on non-local hosts (authorized test environments only)
45382
+ --no-forms Skip the form validation probe
45383
+ (by default, on local hosts, an empty submission is simulated: nothing that sends data
45384
+ leaves the browser, so no database is written; server-side feedback is reported as untested)
45385
+ --allow-submit Really send empty submissions to test server-side validation, on local or other hosts
45386
+ (disposable/test environments you are authorized to use only; never to delete/payment URLs)
44372
45387
  --sarif Also write report.sarif for code scanning
44373
45388
  --fail-on SEVERITY Exit 1 if a defect at or above this severity exists (CI gate)`,
44374
45389
  run: (ctx) => auditCommand(ctx, audit.usage)
@@ -44390,11 +45405,11 @@ var conversion = {
44390
45405
 
44391
45406
  // src/cli/commands/bench.ts
44392
45407
  import { existsSync as existsSync7, readdirSync as readdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
44393
- import { join as join9, resolve as resolve6 } from "node:path";
45408
+ import { join as join10, resolve as resolve6 } from "node:path";
44394
45409
  init_errors();
44395
- var matches = (f, e) => f.ruleId === e.rule && pathOf(f.url).split("?")[0].replace(/\/$/, "") === e.path.replace(/\/$/, "") && (!e.viewport || (f.viewport ?? "").split(", ").includes(e.viewport));
45410
+ var matches = (f, e) => f.ruleId === e.rule && (!e.kind || f.kind === e.kind) && pathOf(f.url).split("?")[0].replace(/\/$/, "") === e.path.replace(/\/$/, "") && (!e.viewport || (f.viewport ?? "").split(", ").includes(e.viewport));
44396
45411
  async function benchFixture(dir, wsRoot) {
44397
- const expected = JSON.parse(readFileSync6(join9(dir, "expected.json"), "utf8"));
45412
+ const expected = JSON.parse(readFileSync6(join10(dir, "expected.json"), "utf8"));
44398
45413
  const server = await serveStatic(dir);
44399
45414
  const t0 = Date.now();
44400
45415
  try {
@@ -44409,6 +45424,7 @@ async function benchFixture(dir, wsRoot) {
44409
45424
  headed: false,
44410
45425
  respectRobots: true,
44411
45426
  allowPrivate: false,
45427
+ allowSubmit: expected.audit?.allowSubmit === true,
44412
45428
  keyboard: true,
44413
45429
  throttle: false,
44414
45430
  timeoutMs: 3e4,
@@ -44464,12 +45480,12 @@ var bench = {
44464
45480
  const root = resolve6(ctx.positionals[0] ?? "fixtures/sites");
44465
45481
  if (!existsSync7(root)) throw new JouriloError("NOT_FOUND", `No fixtures at ${root}`);
44466
45482
  const only = str(ctx, "only");
44467
- const dirs = readdirSync5(root).filter((d) => existsSync7(join9(root, d, "expected.json"))).filter((d) => !only || d === only);
45483
+ const dirs = readdirSync5(root).filter((d) => existsSync7(join10(root, d, "expected.json"))).filter((d) => !only || d === only);
44468
45484
  if (!dirs.length) throw new JouriloError("NOT_FOUND", `No fixtures with expected.json in ${root}`);
44469
45485
  const results = [];
44470
45486
  for (const d of dirs) {
44471
45487
  ctx.log(`bench: ${d}`);
44472
- results.push(await benchFixture(join9(root, d), join9(ctx.ws.root, "bench")));
45488
+ results.push(await benchFixture(join10(root, d), join10(ctx.ws.root, "bench")));
44473
45489
  }
44474
45490
  const found = results.reduce((a, r) => a + r.found, 0);
44475
45491
  const expected = results.reduce((a, r) => a + r.expected, 0);
@@ -44519,13 +45535,230 @@ var bench = {
44519
45535
  };
44520
45536
 
44521
45537
  // src/cli/commands/discover.ts
44522
- import { existsSync as existsSync9, statSync as statSync6, writeFileSync as writeFileSync6 } from "node:fs";
44523
- import { join as join11, resolve as resolve7 } from "node:path";
45538
+ import { existsSync as existsSync10, statSync as statSync7, writeFileSync as writeFileSync6 } from "node:fs";
45539
+ import { join as join13, resolve as resolve7 } from "node:path";
44524
45540
 
44525
45541
  // src/discovery/repo.ts
44526
45542
  init_fs_safety();
45543
+ import { existsSync as existsSync9, readdirSync as readdirSync7, readFileSync as readFileSync8, statSync as statSync6 } from "node:fs";
45544
+ import { basename as basename3, dirname as dirname6, join as join12, relative as relative5 } from "node:path";
45545
+
45546
+ // src/discovery/laravel.ts
45547
+ init_fs_safety();
44527
45548
  import { existsSync as existsSync8, readdirSync as readdirSync6, readFileSync as readFileSync7, statSync as statSync5 } from "node:fs";
44528
- import { basename, dirname as dirname5, join as join10, relative as relative4 } from "node:path";
45549
+ import { basename as basename2, dirname as dirname5, join as join11, relative as relative4 } from "node:path";
45550
+ function read(file) {
45551
+ try {
45552
+ return statSync5(file).size > 4e5 ? "" : readFileSync7(file, "utf8");
45553
+ } catch {
45554
+ return "";
45555
+ }
45556
+ }
45557
+ function stripComments(php) {
45558
+ return php.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).replace(/(^|[^:'"\\])\/\/[^\n]*/g, (m, p) => p + " ".repeat(m.length - p.length));
45559
+ }
45560
+ function argValue(arg) {
45561
+ const lit = arg.match(/^\s*(['"])(.*?)\1\s*$/);
45562
+ if (lit) return lit[2];
45563
+ const ternary = arg.match(/\?\s*(['"])(.*?)\1\s*:\s*(['"])(.*?)\3/);
45564
+ if (ternary) return ternary[2] || ternary[4] || "";
45565
+ return null;
45566
+ }
45567
+ function joinPath(prefix, path2) {
45568
+ const p = `/${[prefix, path2].map((s) => s.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/")}`;
45569
+ return p.replace(/\{(\w+)\??\}/g, ":$1");
45570
+ }
45571
+ var AUTH_MW = /^(auth(:[\w,]+)?|verified|password\.confirm|can:.+|[\w.]*\.auth|[\w.]*\bauthenticated)$/;
45572
+ function middlewares(chain) {
45573
+ const out = [];
45574
+ for (const m of chain.matchAll(/middleware\(\s*(\[[^\]]*\]|['"][^'"]+['"])/g))
45575
+ for (const s of m[1].matchAll(/['"]([^'"]+)['"]/g)) out.push(s[1]);
45576
+ return out;
45577
+ }
45578
+ function entryFiles(appRoot) {
45579
+ const out = /* @__PURE__ */ new Set();
45580
+ const add = (rel) => {
45581
+ const abs = join11(appRoot, rel);
45582
+ if (existsSync8(abs)) out.add(toPosix(relative4(appRoot, abs)));
45583
+ };
45584
+ add("routes/web.php");
45585
+ for (const f of ["bootstrap/app.php", "app/Providers/RouteServiceProvider.php"]) {
45586
+ const t = read(join11(appRoot, f));
45587
+ for (const m of t.matchAll(/web\s*:\s*__DIR__\s*\.\s*['"]([^'"]+)['"]/g)) add(join11(dirname5(f), m[1]));
45588
+ for (const m of t.matchAll(/base_path\(\s*['"](routes\/[^'"]+\.php)['"]\s*\)/g))
45589
+ if (!/routes\/(api|console|channels)\.php$/.test(m[1])) add(m[1]);
45590
+ }
45591
+ return [...out];
45592
+ }
45593
+ function scanFile(appRoot, rel, inherited, routes, seen) {
45594
+ if (seen.has(rel)) return;
45595
+ seen.add(rel);
45596
+ const text = stripComments(read(join11(appRoot, rel)));
45597
+ if (!text) return;
45598
+ const stack = [{ prefix: inherited.prefix, auth: inherited.auth, depth: -1 }];
45599
+ let depth = 0;
45600
+ let stmtStart = 0;
45601
+ let pendingGroup = null;
45602
+ const top = () => stack[stack.length - 1];
45603
+ for (let i = 0; i < text.length; i++) {
45604
+ const ch = text[i];
45605
+ if (ch === "'" || ch === '"') {
45606
+ const q = ch;
45607
+ for (i++; i < text.length && text[i] !== q; i++) if (text[i] === "\\") i++;
45608
+ continue;
45609
+ }
45610
+ if (ch === "{") {
45611
+ depth++;
45612
+ if (pendingGroup) {
45613
+ stack.push({ ...pendingGroup, depth });
45614
+ pendingGroup = null;
45615
+ }
45616
+ stmtStart = i + 1;
45617
+ continue;
45618
+ }
45619
+ if (ch === "}") {
45620
+ if (top().depth === depth && stack.length > 1) stack.pop();
45621
+ depth--;
45622
+ stmtStart = i + 1;
45623
+ continue;
45624
+ }
45625
+ if (ch === ";") {
45626
+ const stmt = text.slice(stmtStart, i);
45627
+ stmtStart = i + 1;
45628
+ handleStatement(stmt);
45629
+ continue;
45630
+ }
45631
+ if (text.startsWith("group(", i) && /->|::/.test(text.slice(Math.max(0, i - 2), i))) {
45632
+ const chain = text.slice(stmtStart, i);
45633
+ const rest = text.slice(i, i + 200);
45634
+ const included = rest.match(
45635
+ /^group\(\s*(?:__DIR__\s*\.\s*['"]([^'"]+)['"]|base_path\(\s*['"]([^'"]+)['"]\s*\))/
45636
+ );
45637
+ const g = groupOf(chain);
45638
+ if (included) {
45639
+ const target = included[1] ? join11(dirname5(rel), included[1]) : included[2];
45640
+ scanFile(appRoot, toPosix(target), g, routes, seen);
45641
+ } else if (/^group\(\s*(?:static\s+)?function/.test(rest)) pendingGroup = g;
45642
+ }
45643
+ }
45644
+ function groupOf(chain) {
45645
+ const pre = chain.match(/prefix\(([^)]*\)?)\)/);
45646
+ const value = pre ? argValue(pre[1]) : null;
45647
+ const auth = middlewares(chain).filter((m) => AUTH_MW.test(m));
45648
+ return {
45649
+ prefix: joinPath(top().prefix, value ?? "").replace(/^\/$/, ""),
45650
+ auth: [...top().auth, ...auth]
45651
+ };
45652
+ }
45653
+ function handleStatement(stmt) {
45654
+ const req = stmt.match(/^\s*(?:require|include)(?:_once)?\s*\(?\s*__DIR__\s*\.\s*['"]([^'"]+\.php)['"]/);
45655
+ if (req) {
45656
+ scanFile(appRoot, toPosix(join11(dirname5(rel), req[1])), top(), routes, seen);
45657
+ return;
45658
+ }
45659
+ const m = stmt.match(
45660
+ /\b(?:Route|Volt)::(get|view|any|match|livewire|inertia|redirect|permanentRedirect|route)\(\s*(?:\[[^\]]*\]\s*,\s*)?(['"])([^'"]*)\2/
45661
+ );
45662
+ if (!m) return;
45663
+ const verb = m[1];
45664
+ if (verb === "match" && !/['"]get['"]/i.test(stmt)) return;
45665
+ const via = verb === "livewire" ? "Livewire" : verb === "route" && /Volt::/.test(stmt) ? "Volt" : verb === "inertia" ? "Inertia" : verb === "redirect" || verb === "permanentRedirect" ? "redirect" : "Laravel";
45666
+ if (via === "redirect") return;
45667
+ const path2 = joinPath(top().prefix, m[3]);
45668
+ const own = middlewares(stmt).filter((x) => AUTH_MW.test(x));
45669
+ routes.push({ path: path2, file: rel, via, dynamic: /[:{]/.test(path2), auth: [...top().auth, ...own] });
45670
+ }
45671
+ }
45672
+ function kebab(name) {
45673
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
45674
+ }
45675
+ function plural(word) {
45676
+ if (/(s|x|z|ch|sh)$/.test(word)) return `${word}es`;
45677
+ if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
45678
+ return `${word}s`;
45679
+ }
45680
+ function phpFiles(dir, depth) {
45681
+ if (!existsSync8(dir)) return [];
45682
+ const out = [];
45683
+ for (const e of readdirSync6(dir, { withFileTypes: true })) {
45684
+ const full = join11(dir, e.name);
45685
+ if (e.isDirectory() && depth > 1) out.push(...phpFiles(full, depth - 1));
45686
+ else if (e.isFile() && e.name.endsWith(".php")) out.push(full);
45687
+ }
45688
+ return out.sort();
45689
+ }
45690
+ function filamentPanels(appRoot, routes) {
45691
+ const dir = join11(appRoot, "app/Providers/Filament");
45692
+ const files = existsSync8(dir) ? readdirSync6(dir).filter((f) => f.endsWith("PanelProvider.php")) : [];
45693
+ const panels = [];
45694
+ for (const f of files) {
45695
+ const rel = `app/Providers/Filament/${f}`;
45696
+ const t = stripComments(read(join11(appRoot, rel)));
45697
+ const id = t.match(/->id\(\s*['"]([^'"]+)['"]/)?.[1] ?? kebab(f.replace(/PanelProvider\.php$/, ""));
45698
+ const pathArg = t.match(/->path\(([^)]*\)?)\)/)?.[1];
45699
+ const path2 = joinPath("", (pathArg !== void 0 ? argValue(pathArg) : null) ?? id);
45700
+ const login = /->login\(/.test(t);
45701
+ panels.push({ id, path: path2, login, file: rel });
45702
+ const auth = ["filament"];
45703
+ routes.push({ path: path2, file: rel, via: "Filament", dynamic: false, auth });
45704
+ if (login)
45705
+ routes.push({ path: joinPath(path2, "login"), file: rel, via: "Filament", dynamic: false, auth: [] });
45706
+ for (const [sub, isResource] of [
45707
+ ["app/Filament/Resources", true],
45708
+ ["app/Filament/Pages", false]
45709
+ ]) {
45710
+ for (const file of phpFiles(join11(appRoot, sub), 2)) {
45711
+ const name = basename2(file, ".php");
45712
+ if (isResource ? !name.endsWith("Resource") : name === "Dashboard") continue;
45713
+ const relFile = toPosix(join11(sub, relative4(join11(appRoot, sub), file)));
45714
+ if (!isResource && /\/(Concerns|Widgets)\//.test(relFile)) continue;
45715
+ const own = read(file).match(/static\s+(?:\?string\s+)?\$slug\s*=\s*['"]([^'"]+)['"]/)?.[1];
45716
+ const slug2 = own ?? (isResource ? plural(kebab(name.replace(/Resource$/, ""))) : kebab(name));
45717
+ routes.push({ path: joinPath(path2, slug2), file: relFile, via: "Filament", dynamic: false, auth });
45718
+ }
45719
+ }
45720
+ }
45721
+ return panels;
45722
+ }
45723
+ function topLevelKeys(php, name) {
45724
+ const start = php.search(new RegExp(`['"]${name}['"]\\s*=>\\s*\\[`));
45725
+ if (start < 0) return [];
45726
+ const open = php.indexOf("[", start);
45727
+ const keys = [];
45728
+ let depth = 0;
45729
+ for (let i = open; i < php.length; i++) {
45730
+ const ch = php[i];
45731
+ if (ch === "'" || ch === '"') {
45732
+ let j = i + 1;
45733
+ while (j < php.length && php[j] !== ch) j += php[j] === "\\" ? 2 : 1;
45734
+ if (depth === 1 && /^\s*=>/.test(php.slice(j + 1, j + 8))) keys.push(php.slice(i + 1, j));
45735
+ i = j;
45736
+ continue;
45737
+ }
45738
+ if (ch === "[") depth++;
45739
+ else if (ch === "]" && --depth === 0) break;
45740
+ }
45741
+ return keys;
45742
+ }
45743
+ function laravelProfile(appRoot) {
45744
+ const routes = [];
45745
+ const seen = /* @__PURE__ */ new Set();
45746
+ for (const f of entryFiles(appRoot)) scanFile(appRoot, f, { prefix: "", auth: [] }, routes, seen);
45747
+ const routesDir = join11(appRoot, "routes");
45748
+ if (existsSync8(routesDir)) {
45749
+ for (const f of readdirSync6(routesDir))
45750
+ if (f.endsWith(".php") && !/^(api|console|channels)\.php$/.test(f))
45751
+ scanFile(appRoot, `routes/${f}`, { prefix: "", auth: [] }, routes, seen);
45752
+ }
45753
+ const panels = filamentPanels(appRoot, routes);
45754
+ const authConfig = stripComments(read(join11(appRoot, "config/auth.php")));
45755
+ const guards = topLevelKeys(authConfig, "guards");
45756
+ const unique = /* @__PURE__ */ new Map();
45757
+ for (const r of routes) if (!unique.has(r.path)) unique.set(r.path, r);
45758
+ return { routes: [...unique.values()], guards, panels };
45759
+ }
45760
+
45761
+ // src/discovery/repo.ts
44529
45762
  var SKIP = /* @__PURE__ */ new Set([
44530
45763
  "node_modules",
44531
45764
  ".git",
@@ -44550,21 +45783,21 @@ function walk(root, max = 2e4) {
44550
45783
  if (out.length >= max || depth > 14) return;
44551
45784
  let entries;
44552
45785
  try {
44553
- entries = readdirSync6(dir);
45786
+ entries = readdirSync7(dir);
44554
45787
  } catch {
44555
45788
  return;
44556
45789
  }
44557
45790
  for (const n of entries) {
44558
45791
  if (SKIP.has(n) || n.startsWith(".") && n !== ".github") continue;
44559
- const full = join10(dir, n);
45792
+ const full = join12(dir, n);
44560
45793
  let st;
44561
45794
  try {
44562
- st = statSync5(full);
45795
+ st = statSync6(full);
44563
45796
  } catch {
44564
45797
  continue;
44565
45798
  }
44566
45799
  if (st.isDirectory()) rec(full, depth + 1);
44567
- else out.push(toPosix(relative4(root, full)));
45800
+ else out.push(toPosix(relative5(root, full)));
44568
45801
  }
44569
45802
  };
44570
45803
  rec(root, 0);
@@ -44572,16 +45805,16 @@ function walk(root, max = 2e4) {
44572
45805
  }
44573
45806
  function readJson2(file) {
44574
45807
  try {
44575
- return JSON.parse(readFileSync7(file, "utf8"));
45808
+ return JSON.parse(readFileSync8(file, "utf8"));
44576
45809
  } catch {
44577
45810
  return null;
44578
45811
  }
44579
45812
  }
44580
- function read(file, max = 4e5) {
45813
+ function read2(file, max = 4e5) {
44581
45814
  try {
44582
- const st = statSync5(file);
45815
+ const st = statSync6(file);
44583
45816
  if (st.size > max) return "";
44584
- return readFileSync7(file, "utf8");
45817
+ return readFileSync8(file, "utf8");
44585
45818
  } catch {
44586
45819
  return "";
44587
45820
  }
@@ -44689,7 +45922,7 @@ function fileRoutes(appRoot, files, frameworks) {
44689
45922
  [
44690
45923
  "Next.js",
44691
45924
  /^(?:src\/)?pages\/(.+)\.(tsx|jsx|ts|js|mdx|md)$/,
44692
- (m) => /^_(app|document|error)/.test(basename(m[1])) ? null : { segs: m[1].split("/"), kind: m[1].startsWith("api/") ? "api" : "page" }
45925
+ (m) => /^_(app|document|error)/.test(basename3(m[1])) ? null : { segs: m[1].split("/"), kind: m[1].startsWith("api/") ? "api" : "page" }
44693
45926
  ],
44694
45927
  ["Nuxt", /^(?:app\/)?pages\/(.+)\.vue$/, (m) => ({ segs: m[1].split("/"), kind: "page" })],
44695
45928
  [
@@ -44737,7 +45970,6 @@ function fileRoutes(appRoot, files, frameworks) {
44737
45970
  const codeRoute = [
44738
45971
  [/\.(t|j)sx?$/, /<Route\s[^>]*path=["'`]([^"'`]+)["'`]/g, "React Router"],
44739
45972
  [/\.(t|j)sx?$|\.vue$/, /\bpath:\s*["'`](\/[^"'`]*)["'`]/g, "Router config"],
44740
- [/routes\/web\.php$/, /Route::(?:get|view|match|any)\(\s*['"]([^'"]+)['"]/g, "Laravel"],
44741
45973
  [
44742
45974
  /\.dart$/,
44743
45975
  /GoRoute\(\s*(?:name:\s*['"][^'"]*['"]\s*,\s*)?path:\s*['"]([^'"]+)['"]/g,
@@ -44748,7 +45980,7 @@ function fileRoutes(appRoot, files, frameworks) {
44748
45980
  for (const f of files) {
44749
45981
  for (const [fileRe, re, label] of codeRoute) {
44750
45982
  if (!fileRe.test(f)) continue;
44751
- const text = read(join10(appRoot, f));
45983
+ const text = read2(join12(appRoot, f));
44752
45984
  if (!text) continue;
44753
45985
  for (const m of text.matchAll(re)) {
44754
45986
  const raw = m[1].trim();
@@ -44759,6 +45991,9 @@ function fileRoutes(appRoot, files, frameworks) {
44759
45991
  }
44760
45992
  }
44761
45993
  }
45994
+ if (has("Laravel"))
45995
+ for (const r of laravelProfile(appRoot).routes)
45996
+ add(r.path, r.file, "page", r.via === "Laravel" ? "Laravel" : `Laravel (${r.via})`, r.dynamic);
44762
45997
  return routes.sort((a, b) => a.path.localeCompare(b.path));
44763
45998
  }
44764
45999
  function devCommand(appRoot, pkg, frameworks, rel) {
@@ -44780,7 +46015,7 @@ function devCommand(appRoot, pkg, frameworks, rel) {
44780
46015
  };
44781
46016
  const def = frameworks.map((f) => defaults[f]).find(Boolean) ?? null;
44782
46017
  if (script) {
44783
- const lock = existsSync8(join10(appRoot, "pnpm-lock.yaml")) ? "pnpm" : existsSync8(join10(appRoot, "yarn.lock")) ? "yarn" : existsSync8(join10(appRoot, "bun.lockb")) || existsSync8(join10(appRoot, "bun.lock")) ? "bun" : "npm";
46018
+ const lock = existsSync9(join12(appRoot, "pnpm-lock.yaml")) ? "pnpm" : existsSync9(join12(appRoot, "yarn.lock")) ? "yarn" : existsSync9(join12(appRoot, "bun.lockb")) || existsSync9(join12(appRoot, "bun.lock")) ? "bun" : "npm";
44784
46019
  return {
44785
46020
  command: lock,
44786
46021
  args: ["run", script],
@@ -44788,9 +46023,9 @@ function devCommand(appRoot, pkg, frameworks, rel) {
44788
46023
  source: `${rel || "."}/package.json`
44789
46024
  };
44790
46025
  }
44791
- if (existsSync8(join10(appRoot, "artisan")))
46026
+ if (existsSync9(join12(appRoot, "artisan")))
44792
46027
  return { command: "php", args: ["artisan", "serve"], port: 8e3, source: `${rel || "."}/artisan` };
44793
- if (existsSync8(join10(appRoot, "pubspec.yaml")) && existsSync8(join10(appRoot, "web")))
46028
+ if (existsSync9(join12(appRoot, "pubspec.yaml")) && existsSync9(join12(appRoot, "web")))
44794
46029
  return {
44795
46030
  command: "flutter",
44796
46031
  args: ["run", "-d", "web-server", "--web-port", "8080"],
@@ -44800,9 +46035,9 @@ function devCommand(appRoot, pkg, frameworks, rel) {
44800
46035
  return null;
44801
46036
  }
44802
46037
  function profileApp(repoRoot, appRoot, files) {
44803
- const rel = toPosix(relative4(repoRoot, appRoot));
44804
- const pkgFile = join10(appRoot, "package.json");
44805
- const pkg = existsSync8(pkgFile) ? readJson2(pkgFile) : null;
46038
+ const rel = toPosix(relative5(repoRoot, appRoot));
46039
+ const pkgFile = join12(appRoot, "package.json");
46040
+ const pkg = existsSync9(pkgFile) ? readJson2(pkgFile) : null;
44806
46041
  const deps = {
44807
46042
  ...pkg?.dependencies ?? {},
44808
46043
  ...pkg?.devDependencies ?? {}
@@ -44813,14 +46048,14 @@ function profileApp(repoRoot, appRoot, files) {
44813
46048
  if (s.group === "framework" && s.dep.test(d) && !frameworks.some((f) => f.value === s.label))
44814
46049
  frameworks.push(fact(s.label, `${rel || "."}/package.json`));
44815
46050
  }
44816
- const composer = existsSync8(join10(appRoot, "composer.json")) ? readJson2(join10(appRoot, "composer.json")) : null;
46051
+ const composer = existsSync9(join12(appRoot, "composer.json")) ? readJson2(join12(appRoot, "composer.json")) : null;
44817
46052
  if (composer && JSON.stringify(composer.require ?? {}).includes("laravel/framework"))
44818
46053
  frameworks.push(fact("Laravel", `${rel || "."}/composer.json`));
44819
- const pubspec = read(join10(appRoot, "pubspec.yaml"));
46054
+ const pubspec = read2(join12(appRoot, "pubspec.yaml"));
44820
46055
  if (/\bflutter:\s*\n\s+sdk:\s*flutter/.test(pubspec))
44821
46056
  frameworks.push(
44822
46057
  fact(
44823
- existsSync8(join10(appRoot, "web")) ? "Flutter (web enabled)" : "Flutter",
46058
+ existsSync9(join12(appRoot, "web")) ? "Flutter (web enabled)" : "Flutter",
44824
46059
  `${rel || "."}/pubspec.yaml`
44825
46060
  )
44826
46061
  );
@@ -44836,48 +46071,49 @@ function profileApp(repoRoot, appRoot, files) {
44836
46071
  );
44837
46072
  return {
44838
46073
  root: rel || ".",
44839
- name: pkg?.name ?? basename(appRoot),
46074
+ name: pkg?.name ?? basename3(appRoot),
44840
46075
  frameworks,
44841
46076
  routes,
44842
46077
  devCommand: devCommand(appRoot, pkg, names, rel),
44843
46078
  components: {
44844
46079
  count: componentFiles.length,
44845
- dirs: [...new Set(componentFiles.map((f) => dirname5(f)))].slice(0, 12)
46080
+ dirs: [...new Set(componentFiles.map((f) => dirname6(f)))].slice(0, 12)
44846
46081
  }
44847
46082
  };
44848
46083
  }
44849
46084
  function discoverRepository(root) {
44850
46085
  const files = walk(root);
44851
46086
  const gaps = [];
44852
- const rootPkg = existsSync8(join10(root, "package.json")) ? readJson2(join10(root, "package.json")) : null;
46087
+ const rootPkg = existsSync9(join12(root, "package.json")) ? readJson2(join12(root, "package.json")) : null;
44853
46088
  let monorepo = null;
44854
46089
  const appRoots = [];
44855
46090
  const ws = rootPkg?.workspaces;
44856
46091
  const wsGlobs = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : [];
44857
- const pnpm = read(join10(root, "pnpm-workspace.yaml"));
46092
+ const pnpm = read2(join12(root, "pnpm-workspace.yaml"));
44858
46093
  for (const m of pnpm.matchAll(/^\s*-\s*['"]?([^'"\n]+)['"]?\s*$/gm)) wsGlobs.push(m[1]);
44859
46094
  if (wsGlobs.length)
44860
46095
  monorepo = fact(
44861
46096
  `workspaces: ${wsGlobs.join(", ")}`,
44862
- existsSync8(join10(root, "pnpm-workspace.yaml")) ? "pnpm-workspace.yaml" : "package.json"
46097
+ existsSync9(join12(root, "pnpm-workspace.yaml")) ? "pnpm-workspace.yaml" : "package.json"
44863
46098
  );
44864
46099
  for (const g of wsGlobs) {
44865
46100
  const base2 = g.replace(/\/\*\*?$/, "");
44866
46101
  if (g.endsWith("*")) {
44867
- const dir = join10(root, base2);
44868
- if (!existsSync8(dir)) continue;
44869
- for (const n of readdirSync6(dir))
44870
- if (existsSync8(join10(dir, n, "package.json"))) appRoots.push(join10(dir, n));
44871
- } else if (existsSync8(join10(root, base2, "package.json"))) appRoots.push(join10(root, base2));
46102
+ const dir = join12(root, base2);
46103
+ if (!existsSync9(dir)) continue;
46104
+ for (const n of readdirSync7(dir))
46105
+ if (existsSync9(join12(dir, n, "package.json"))) appRoots.push(join12(dir, n));
46106
+ } else if (existsSync9(join12(root, base2, "package.json"))) appRoots.push(join12(root, base2));
44872
46107
  }
44873
46108
  const apps = (appRoots.length ? appRoots : [root]).map((r) => profileApp(root, r, files)).filter(
44874
46109
  (a, _, all) => all.length === 1 || a.routes.length > 0 || a.frameworks.some((f) => !["React", "Vue", "Svelte", "Vite"].includes(f.value))
44875
46110
  );
44876
46111
  if (!apps.length) apps.push(profileApp(root, root, files));
46112
+ const laravelRoles = [];
44877
46113
  const allPkgs = files.filter((f) => f.endsWith("package.json")).slice(0, 60);
44878
46114
  const signals = /* @__PURE__ */ new Map();
44879
46115
  for (const p of allPkgs) {
44880
- const j = readJson2(join10(root, p));
46116
+ const j = readJson2(join12(root, p));
44881
46117
  const deps = Object.keys({
44882
46118
  ...j?.dependencies ?? {},
44883
46119
  ...j?.devDependencies ?? {}
@@ -44890,11 +46126,31 @@ function discoverRepository(root) {
44890
46126
  signals.set(s.group, list2);
44891
46127
  }
44892
46128
  }
44893
- const composer = existsSync8(join10(root, "composer.json")) ? JSON.stringify(readJson2(join10(root, "composer.json"))?.require ?? {}) : "";
46129
+ const composer = existsSync9(join12(root, "composer.json")) ? JSON.stringify(readJson2(join12(root, "composer.json"))?.require ?? {}) : "";
44894
46130
  if (/laravel\/(breeze|fortify|jetstream|sanctum)/.test(composer))
44895
46131
  (signals.get("auth") ?? signals.set("auth", []).get("auth")).push(
44896
46132
  fact(composer.match(/laravel\/(breeze|fortify|jetstream|sanctum)/)[0], "composer.json")
44897
46133
  );
46134
+ for (const app of apps.filter((a) => a.frameworks.some((f) => f.value === "Laravel"))) {
46135
+ const lp = laravelProfile(join12(root, app.root));
46136
+ const at = (f) => app.root === "." ? f : `${app.root}/${f}`;
46137
+ const authList = signals.get("auth") ?? signals.set("auth", []).get("auth");
46138
+ if (lp.guards.length)
46139
+ authList.push(fact(`Laravel guards: ${lp.guards.join(", ")}`, at("config/auth.php")));
46140
+ for (const p of lp.panels.filter((x) => x.login))
46141
+ authList.push(fact(`Filament panel "${p.id}" with login at ${p.path}/login`, at(p.file)));
46142
+ const protectedRoutes = lp.routes.filter((r) => r.auth.length && r.via !== "Filament");
46143
+ if (protectedRoutes.length)
46144
+ authList.push(
46145
+ fact(
46146
+ `${protectedRoutes.length} route(s) behind ${[...new Set(protectedRoutes.flatMap((r) => r.auth))].join(", ")}`,
46147
+ at(protectedRoutes[0].file)
46148
+ )
46149
+ );
46150
+ for (const g of lp.guards.filter((x) => !["web", "api", "sanctum"].includes(x)))
46151
+ laravelRoles.push(fact(g, at("config/auth.php")));
46152
+ for (const p of lp.panels) laravelRoles.push(fact(`${p.id} panel user`, at(p.file)));
46153
+ }
44898
46154
  if (/laravel\/cashier/.test(composer))
44899
46155
  (signals.get("payments") ?? signals.set("payments", []).get("payments")).push(
44900
46156
  fact("laravel/cashier", "composer.json")
@@ -44904,9 +46160,9 @@ function discoverRepository(root) {
44904
46160
  let cssVariables = 0;
44905
46161
  for (const f of files) {
44906
46162
  if (/(^|\/)(tokens?|design-tokens|theme)\.(json|ts|js|css)$/i.test(f) || /\.tokens\.json$/.test(f))
44907
- tokens.push(fact(basename(f), f));
46163
+ tokens.push(fact(basename3(f), f));
44908
46164
  if (/\.(css|scss)$/.test(f)) {
44909
- const t = read(join10(root, f), 3e5);
46165
+ const t = read2(join12(root, f), 3e5);
44910
46166
  cssVariables += (t.match(/--[a-z0-9-]+\s*:/gi) ?? []).length;
44911
46167
  for (const m of t.matchAll(/font-family\s*:\s*["']?([A-Za-z][\w -]+?)["']?\s*[,;]/g)) {
44912
46168
  const name = m[1].trim();
@@ -44915,7 +46171,7 @@ function discoverRepository(root) {
44915
46171
  }
44916
46172
  }
44917
46173
  if (/\.(tsx|jsx|ts|js)$/.test(f) && /layout|_app|_document/.test(f)) {
44918
- const t = read(join10(root, f));
46174
+ const t = read2(join12(root, f));
44919
46175
  for (const m of t.matchAll(/import\s*\{\s*([A-Z][\w,\s]+)\}\s*from\s*['"]next\/font\/google['"]/g))
44920
46176
  for (const n of m[1].split(",").map((s) => s.trim()).filter(Boolean))
44921
46177
  if (!fonts.some((x) => x.value === n)) fonts.push(fact(n.replace(/_/g, " "), f));
@@ -44939,8 +46195,9 @@ function discoverRepository(root) {
44939
46195
  fact("signed-in user", allRoutes.find((r) => r.role === "dashboard" || r.role === "settings").file)
44940
46196
  );
44941
46197
  roles.unshift(fact("visitor", "public routes"));
46198
+ for (const r of laravelRoles) if (!roles.some((x) => x.value === r.value)) roles.push(r);
44942
46199
  for (const f of files.filter((x) => /\.(ts|tsx|js|php|dart|rb|py)$/.test(x)).slice(0, 3e3)) {
44943
- const t = read(join10(root, f), 12e4);
46200
+ const t = read2(join12(root, f), 12e4);
44944
46201
  for (const m of t.matchAll(
44945
46202
  /\brole\s*(?:===?|==|:)\s*['"](owner|admin|editor|viewer|member|manager|customer|seller|vendor|teacher|student)['"]/gi
44946
46203
  )) {
@@ -44953,11 +46210,12 @@ function discoverRepository(root) {
44953
46210
  const cj = (name, rs, why) => {
44954
46211
  if (rs.length) candidateJourneys.push({ name, routes: [...new Set(rs)].slice(0, 6), why });
44955
46212
  };
44956
- cj(
44957
- "Sign up and reach the product",
44958
- [...byRole("signup"), ...byRole("dashboard")],
44959
- "signup and dashboard routes exist"
44960
- );
46213
+ if (byRole("signup").length)
46214
+ cj(
46215
+ "Sign up and reach the product",
46216
+ [...byRole("signup"), ...byRole("dashboard")],
46217
+ byRole("dashboard").length ? "signup and dashboard routes exist" : "signup route exists"
46218
+ );
44961
46219
  cj("Sign in", byRole("login"), "login route exists");
44962
46220
  cj("Recover access", byRole("recovery"), "password recovery route exists");
44963
46221
  cj(
@@ -44972,7 +46230,7 @@ function discoverRepository(root) {
44972
46230
  let readmeSummary = null;
44973
46231
  const readmeFile = files.find((f) => /^readme\.md$/i.test(f));
44974
46232
  if (readmeFile) {
44975
- const para = read(join10(root, readmeFile)).split(/\n\s*\n/).map((p) => p.replace(/<[^>]+>|!\[[^\]]*\]\([^)]*\)|\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)/g, "").trim()).find((p) => p.length > 60 && !p.startsWith("#") && !p.startsWith("```") && !p.startsWith("|"));
46233
+ const para = read2(join12(root, readmeFile)).split(/\n\s*\n/).map((p) => p.replace(/<[^>]+>|!\[[^\]]*\]\([^)]*\)|\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)/g, "").trim()).find((p) => p.length > 60 && !p.startsWith("#") && !p.startsWith("```") && !p.startsWith("|"));
44976
46234
  if (para) readmeSummary = fact(para.replace(/\s+/g, " ").slice(0, 400), readmeFile);
44977
46235
  }
44978
46236
  const description = typeof rootPkg?.description === "string" ? fact(rootPkg.description, "package.json") : null;
@@ -44996,7 +46254,7 @@ function discoverRepository(root) {
44996
46254
  schemaVersion: 1,
44997
46255
  kind: "repository",
44998
46256
  root,
44999
- name: rootPkg?.name ?? basename(root),
46257
+ name: rootPkg?.name ?? basename3(root),
45000
46258
  description,
45001
46259
  readmeSummary,
45002
46260
  monorepo,
@@ -45169,9 +46427,9 @@ var discover = {
45169
46427
  async run(ctx) {
45170
46428
  const target = requirePositional(ctx, 0, "path|url", discover.usage);
45171
46429
  ctx.ws.ensure();
45172
- if (!target.includes("://") && existsSync9(target) && statSync6(target).isDirectory()) {
46430
+ if (!target.includes("://") && existsSync10(target) && statSync7(target).isDirectory()) {
45173
46431
  const profile2 = discoverRepository(resolve7(target));
45174
- const file2 = join11(ctx.ws.root, "product.json");
46432
+ const file2 = join13(ctx.ws.root, "product.json");
45175
46433
  writeFileSync6(file2, `${JSON.stringify(profile2, null, 2)}
45176
46434
  `);
45177
46435
  return { data: { file: file2, profile: profile2 }, text: `${repoText(profile2)}
@@ -45196,7 +46454,7 @@ var discover = {
45196
46454
  onProgress: ctx.log
45197
46455
  });
45198
46456
  const profile = buildSiteProfile(run, observations);
45199
- const file = join11(ctx.ws.root, "site.json");
46457
+ const file = join13(ctx.ws.root, "site.json");
45200
46458
  writeFileSync6(file, `${JSON.stringify(profile, null, 2)}
45201
46459
  `);
45202
46460
  return { data: { file, profile }, text: `${siteText(profile)}
@@ -45207,8 +46465,8 @@ var discover = {
45207
46465
 
45208
46466
  // src/cli/commands/experiment.ts
45209
46467
  init_errors();
45210
- import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "node:fs";
45211
- import { join as join12 } from "node:path";
46468
+ import { existsSync as existsSync11, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync7 } from "node:fs";
46469
+ import { join as join14 } from "node:path";
45212
46470
 
45213
46471
  // src/experiment/stats.ts
45214
46472
  function normCdf(z) {
@@ -45630,19 +46888,19 @@ Jourilo never invents traffic, conversions or effects: every number comes from y
45630
46888
  if (!f) throw new JouriloError("NOT_FOUND", `Finding ${fp} is not in run ${run.id}.`);
45631
46889
  if (f.kind !== "hypothesis")
45632
46890
  ctx.log(`Note: ${f.ruleId} is a ${f.kind}; defects should usually be fixed, not A/B tested.`);
45633
- const productFile = join12(ctx.ws.root, "product.json");
45634
- const product = existsSync10(productFile) ? JSON.parse(readFileSync8(productFile, "utf8")) : null;
46891
+ const productFile = join14(ctx.ws.root, "product.json");
46892
+ const product = existsSync11(productFile) ? JSON.parse(readFileSync9(productFile, "utf8")) : null;
45635
46893
  const plan = planExperiment(run.id, f, {
45636
46894
  ...numOpt(ctx, "baseline") !== void 0 ? { baseline: numOpt(ctx, "baseline") } : {},
45637
46895
  ...numOpt(ctx, "mde") !== void 0 ? { mde: numOpt(ctx, "mde") } : {},
45638
46896
  ...numOpt(ctx, "daily-traffic") !== void 0 ? { dailyTraffic: numOpt(ctx, "daily-traffic") } : {},
45639
46897
  product
45640
46898
  });
45641
- const outDir = join12(ctx.ws.root, "experiments");
46899
+ const outDir = join14(ctx.ws.root, "experiments");
45642
46900
  mkdirSync4(outDir, { recursive: true });
45643
- const md2 = join12(outDir, `${plan.id}.md`);
46901
+ const md2 = join14(outDir, `${plan.id}.md`);
45644
46902
  writeFileSync7(md2, planMarkdown(plan));
45645
- writeFileSync7(join12(outDir, `${plan.id}.json`), `${JSON.stringify(plan, null, 2)}
46903
+ writeFileSync7(join14(outDir, `${plan.id}.json`), `${JSON.stringify(plan, null, 2)}
45646
46904
  `);
45647
46905
  return { data: { plan, file: md2 }, text: `${planMarkdown(plan)}
45648
46906
  Saved: ${md2}` };
@@ -45665,8 +46923,8 @@ Saved: ${md2}` };
45665
46923
  };
45666
46924
 
45667
46925
  // src/cli/commands/finding.ts
45668
- import { existsSync as existsSync11 } from "node:fs";
45669
- import { join as join13 } from "node:path";
46926
+ import { existsSync as existsSync12 } from "node:fs";
46927
+ import { join as join15 } from "node:path";
45670
46928
  init_errors();
45671
46929
  init_fs_safety();
45672
46930
  var CATEGORIES = [
@@ -45724,7 +46982,7 @@ var finding = {
45724
46982
  "At least one --evidence file from the run (e.g. screenshots/home-\u2026-desktop.jpg) is required: judgments must point at what they judge."
45725
46983
  );
45726
46984
  for (const e of evidenceFiles)
45727
- if (!existsSync11(resolveInside(dir, e)))
46985
+ if (!existsSync12(resolveInside(dir, e)))
45728
46986
  throw new JouriloError("NOT_FOUND", `Evidence ${e} is not in run ${run.id}.`);
45729
46987
  const url = str(ctx, "url") ?? run.pages[0]?.url ?? run.target;
45730
46988
  const f = makeFinding({
@@ -45761,20 +47019,20 @@ var finding = {
45761
47019
  writeReports(dir, run, void 0, { history: ctx.ws.listRuns() });
45762
47020
  return {
45763
47021
  data: f,
45764
- text: `Added ${f.provenance} finding ${f.fingerprint} to run ${run.id} (${join13(dir, "report.html")}).`
47022
+ text: `Added ${f.provenance} finding ${f.fingerprint} to run ${run.id} (${join15(dir, "report.html")}).`
45765
47023
  };
45766
47024
  }
45767
47025
  };
45768
47026
 
45769
47027
  // src/cli/commands/journeys.ts
45770
- import { existsSync as existsSync12, mkdirSync as mkdirSync5, readdirSync as readdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "node:fs";
45771
- import { basename as basename2, join as join15, resolve as resolve8 } from "node:path";
47028
+ import { existsSync as existsSync15, mkdirSync as mkdirSync7, readdirSync as readdirSync10, readFileSync as readFileSync12, writeFileSync as writeFileSync9 } from "node:fs";
47029
+ import { basename as basename5, join as join19, resolve as resolve8 } from "node:path";
45772
47030
  init_errors();
45773
47031
  init_fs_safety();
45774
47032
  init_types();
45775
47033
 
45776
47034
  // src/journeys/runner.ts
45777
- import { join as join14 } from "node:path";
47035
+ import { join as join16 } from "node:path";
45778
47036
  init_fs_safety();
45779
47037
 
45780
47038
  // src/journeys/schema.ts
@@ -45887,15 +47145,61 @@ function locatorFor(page, t) {
45887
47145
  return page.locator(t.css);
45888
47146
  }
45889
47147
  var ELEMENT_FACTS = `(el) => {
45890
- const form = el.form || el.closest('form');
45891
- const tag = el.tagName;
45892
- const type = (el.getAttribute('type') || '').toLowerCase();
47148
+ if (!el || el === document.body || el === document.documentElement) return null;
47149
+ const act = el.closest('a[href],button,input,select,textarea,summary,[role=button],[role=link],[role=menuitem],[role=tab],[role=option],[role=checkbox],[role=switch]') || el;
47150
+ const form = act.form || act.closest('form');
47151
+ const tag = act.tagName;
47152
+ const type = (act.getAttribute('type') || '').toLowerCase();
47153
+ const role = (act.getAttribute('role') || '').toLowerCase();
45893
47154
  const submits = !!form && ((tag === 'BUTTON' && (type === '' || type === 'submit')) || (tag === 'INPUT' && (type === 'submit' || type === 'image')));
45894
- const inField = !!form && (tag === 'INPUT' || tag === 'SELECT');
45895
- const payment = !!form && !!form.querySelector('input[autocomplete^="cc-"], input[name*="card" i], input[name*="cvc" i], input[name*="cvv" i]');
45896
- const name = (el.getAttribute('aria-label') || el.innerText || el.value || el.getAttribute('title') || '').replace(/\\s+/g, ' ').trim().slice(0, 120);
45897
- return { submits, inField, payment, name };
47155
+ const inField = !!form && ((tag === 'INPUT' && !['button','submit','reset','checkbox','radio','image','file','range','color'].includes(type)) || tag === 'SELECT');
47156
+ const textarea = tag === 'TEXTAREA' || act.isContentEditable;
47157
+ const buttonLike = tag === 'BUTTON' || tag === 'SUMMARY' || (tag === 'INPUT' && ['button','submit','reset','image'].includes(type)) || ['button','menuitem','tab','option','switch','checkbox'].includes(role);
47158
+ const linkLike = (tag === 'A' && act.hasAttribute('href')) || role === 'link';
47159
+ const payment = !!form && !!form.querySelector('input[autocomplete^="cc-"], input[name*="card" i], input[name*="cvc" i], input[name*="cvv" i], input[data-stripe], iframe[src*="stripe"], iframe[name*="card" i], iframe[title*="card" i]');
47160
+ const byIds = (attr) => (act.getAttribute(attr) || '').split(/\\s+/).map((id) => id && document.getElementById(id)).filter(Boolean).map((n) => n.innerText || n.textContent || '').join(' ');
47161
+ const inner = Array.from(act.querySelectorAll('img[alt], [aria-label], svg title')).map((n) => n.getAttribute('alt') || n.getAttribute('aria-label') || n.textContent || '').join(' ');
47162
+ const name = (act.getAttribute('aria-label') || byIds('aria-labelledby') || act.innerText || act.value || act.getAttribute('title') || act.getAttribute('alt') || inner || '').replace(/\\s+/g, ' ').trim().slice(0, 120);
47163
+ const attrs = [];
47164
+ for (const n of [act, ...Array.from(act.querySelectorAll('*')).slice(0, 20)])
47165
+ for (const a of Array.from(n.attributes))
47166
+ if (/^(id|class|name|title|data-[\\w-]+)$/.test(a.name) || ((a.name === 'href' || a.name === 'xlink:href') && n.tagName.toLowerCase() === 'use'))
47167
+ attrs.push(a.value);
47168
+ const targets = [];
47169
+ if (linkLike && act.getAttribute('href')) targets.push(act.href || act.getAttribute('href'));
47170
+ if (act.getAttribute('formaction')) targets.push(act.formAction || act.getAttribute('formaction'));
47171
+ if (form && (submits || inField)) targets.push(form.getAttribute('action') || '');
47172
+ return { submits, inField, textarea, buttonLike, linkLike, payment, name, signals: attrs.join(' ').slice(0, 1500), targets: targets.join(' ') };
45898
47173
  }`;
47174
+ function keyEffect(key, f) {
47175
+ if (!f) return "none";
47176
+ const k = key.split("+").pop();
47177
+ const mods = key.includes("+");
47178
+ const enter = k === "Enter" || k === "NumpadEnter";
47179
+ const space = k === " " || k === "Space";
47180
+ if (enter) {
47181
+ if (f.textarea) return mods ? "submit" : "none";
47182
+ if (f.inField) return "submit";
47183
+ if (f.buttonLike || f.linkLike) return f.submits ? "submit" : "activate";
47184
+ return "activate";
47185
+ }
47186
+ if (space && f.buttonLike) return f.submits ? "submit" : "activate";
47187
+ return "none";
47188
+ }
47189
+ function verdictFor(f, effect, action, policy) {
47190
+ const submitsForm = effect === "submit" || f.submits;
47191
+ return checkAction(
47192
+ {
47193
+ action,
47194
+ name: f.name,
47195
+ signals: f.signals,
47196
+ targets: f.targets,
47197
+ submitsForm,
47198
+ paymentForm: f.payment && submitsForm
47199
+ },
47200
+ policy
47201
+ );
47202
+ }
45899
47203
  var facts = (src) => new Function(`return (${src})(arguments[0])`);
45900
47204
  function matchesUrl(actual, expected) {
45901
47205
  if (expected.startsWith("re:")) {
@@ -45925,34 +47229,31 @@ async function act(page, step, opts) {
45925
47229
  switch (step.action) {
45926
47230
  case "goto": {
45927
47231
  const url = new URL(value(step.url), opts.base);
45928
- if (url.origin !== opts.base.origin)
45929
- throw new Error(`goto ${url.origin} leaves the target origin ${opts.base.origin}`);
47232
+ if (url.origin !== opts.base.origin && !(opts.allowOrigins ?? []).includes(url.origin))
47233
+ throw new Blocked(`goto ${url.origin} leaves the target origin ${opts.base.origin}`);
45930
47234
  await page.goto(url.toString(), { waitUntil: "load", timeout: t * 3 });
45931
47235
  await page.waitForLoadState("networkidle", { timeout: 3e3 }).catch(() => void 0);
45932
47236
  return;
45933
47237
  }
45934
47238
  case "click":
45935
47239
  case "press": {
45936
- const loc = step.action === "click" ? locatorFor(page, step.target).first() : step.target ? locatorFor(page, step.target).first() : null;
47240
+ const loc = step.target ? locatorFor(page, step.target).first() : null;
47241
+ let handle = null;
45937
47242
  if (loc) {
45938
47243
  await loc.waitFor({ state: "visible", timeout: t });
45939
- const f = await loc.evaluate(facts(ELEMENT_FACTS));
45940
- const submitsForm = step.action === "click" ? f.submits : step.key === "Enter" && f.inField;
45941
- const verdict = checkAction(
45942
- {
45943
- action: step.action,
45944
- name: step.action === "click" ? f.name : "",
45945
- submitsForm,
45946
- paymentForm: f.payment && submitsForm
45947
- },
45948
- opts.policy
45949
- );
47244
+ handle = await loc.elementHandle({ timeout: t });
47245
+ } else {
47246
+ handle = (await page.evaluateHandle("document.activeElement")).asElement();
47247
+ }
47248
+ const f = handle ? await handle.evaluate(facts(ELEMENT_FACTS)) : null;
47249
+ const effect = step.action === "click" ? f?.submits ? "submit" : "activate" : keyEffect(step.key, f);
47250
+ if (f && effect !== "none") {
47251
+ const verdict = verdictFor(f, effect, step.action, opts.policy);
45950
47252
  if (!verdict.allowed) throw new Blocked(verdict.reason);
45951
- if (step.action === "click") await loc.click({ timeout: t });
45952
- else await loc.press(step.key, { timeout: t });
45953
- } else if (step.action === "press") {
45954
- await page.keyboard.press(step.key);
45955
47253
  }
47254
+ if (step.action === "click") await handle.click({ timeout: t });
47255
+ else if (loc && handle) await handle.press(step.key, { timeout: t });
47256
+ else await page.keyboard.press(step.key);
45956
47257
  await page.waitForLoadState("networkidle", { timeout: 3e3 }).catch(() => void 0);
45957
47258
  return;
45958
47259
  }
@@ -46004,10 +47305,17 @@ async function runJourney(browser, spec, opts) {
46004
47305
  runDir: opts.runDir,
46005
47306
  ...opts.storageState ? { storageState: opts.storageState } : {}
46006
47307
  });
46007
- const tracePath = opts.trace ? join14("traces", `${spec.id}-${vp.name}.zip`) : void 0;
47308
+ const tracePath = opts.trace ? join16("traces", `${spec.id}-${vp.name}.zip`) : void 0;
46008
47309
  if (tracePath) await ctx.tracing.start({ screenshots: true, snapshots: true, title: spec.name });
47310
+ const guard = await installRequestGuard(ctx, {
47311
+ ...opts.policy,
47312
+ origin: opts.base.origin,
47313
+ ...opts.allowOrigins ? { allowOrigins: opts.allowOrigins } : {},
47314
+ mode: "journey"
47315
+ });
46009
47316
  const page = await ctx.newPage();
46010
47317
  const live = observe(page, opts.base.origin);
47318
+ live.ignore(guard.isBlocked);
46011
47319
  const steps = [];
46012
47320
  const findings = [];
46013
47321
  const t0 = Date.now();
@@ -46015,10 +47323,10 @@ async function runJourney(browser, spec, opts) {
46015
47323
  const startUrl = new URL(interpolate(spec.start, opts.data), opts.base);
46016
47324
  const allSteps = [{ action: "goto", url: startUrl.toString() }, ...spec.steps];
46017
47325
  const shot = async (i) => {
46018
- const file = join14("screenshots", `journey-${spec.id}-${vp.name}-${String(i).padStart(2, "0")}.jpg`);
47326
+ const file = join16("screenshots", `journey-${spec.id}-${vp.name}-${String(i).padStart(2, "0")}.jpg`);
46019
47327
  try {
46020
47328
  await page.screenshot({
46021
- path: join14(opts.runDir, file),
47329
+ path: join16(opts.runDir, file),
46022
47330
  type: "jpeg",
46023
47331
  quality: 70,
46024
47332
  animations: "disabled",
@@ -46057,6 +47365,14 @@ async function runJourney(browser, spec, opts) {
46057
47365
  res.status = err instanceof Blocked ? "blocked" : "failed";
46058
47366
  res.error = excerpt(err.message.split("\n")[0], 300);
46059
47367
  }
47368
+ const stopped = guard.since(s0).filter((b) => b.navigation || b.kind !== "submit");
47369
+ if (stopped.length && res.status !== "blocked") {
47370
+ res.status = "blocked";
47371
+ res.error = excerpt(
47372
+ `${stopped[0].reason}${stopped.length > 1 ? ` (+${stopped.length - 1} more)` : ""}`,
47373
+ 300
47374
+ );
47375
+ }
46060
47376
  res.durationMs = Date.now() - s0;
46061
47377
  res.url = redactUrl(page.url());
46062
47378
  const s = await shot(i);
@@ -46115,7 +47431,7 @@ async function runJourney(browser, spec, opts) {
46115
47431
  const vitals = await page.evaluate(READ_VITALS_SCRIPT).catch(() => null);
46116
47432
  const durationMs = Date.now() - t0;
46117
47433
  if (tracePath) {
46118
- await ctx.tracing.stop({ path: join14(opts.runDir, tracePath) });
47434
+ await ctx.tracing.stop({ path: join16(opts.runDir, tracePath) });
46119
47435
  }
46120
47436
  if (opts.saveAuth && status === "passed") {
46121
47437
  writeFileSafe(opts.saveAuth, JSON.stringify(await ctx.storageState(), null, 2), { private: true });
@@ -46128,8 +47444,10 @@ async function runJourney(browser, spec, opts) {
46128
47444
  steps,
46129
47445
  successCriteria: criteria,
46130
47446
  durationMs,
46131
- ...tracePath ? { trace: toPosix(tracePath) } : {}
47447
+ ...tracePath ? { trace: toPosix(tracePath) } : {},
47448
+ ...guard.blocked.length ? { blockedRequests: guard.blocked.map(({ method, url: url2, reason }) => ({ method, url: url2, reason })) } : {}
46132
47449
  };
47450
+ const blockedNote = guard.blocked.length ? ` The safety policy stopped ${guard.blocked.length} request(s) during this journey (e.g. ${guard.blocked[0].method} ${guard.blocked[0].url}), which may be why.` : "";
46133
47451
  const reproduction = steps.filter((s) => s.status !== "skipped").map((s) => s.description);
46134
47452
  const failedStep = steps.find((s) => s.status === "failed");
46135
47453
  const blockedStep = steps.find((s) => s.status === "blocked");
@@ -46149,7 +47467,9 @@ async function runJourney(browser, spec, opts) {
46149
47467
  provenance: "observed",
46150
47468
  url,
46151
47469
  viewport: vp.name,
46152
- description: failedStep ? `Step ${failedStep.index + 1} (${failedStep.description}) failed: ${failedStep.error}` : `All steps ran, but ${unmet.length} success criterion/criteria were not met: ${unmet.map((c) => `${c.description}${c.detail ? ` (${c.detail})` : ""}`).join("; ")}.`,
47470
+ description: `${failedStep ? `Step ${failedStep.index + 1} (${failedStep.description}) failed: ${failedStep.error}` : `All steps ran, but ${unmet.length} success criterion/criteria were not met: ${unmet.map((c) => `${c.description}${c.detail ? ` (${c.detail})` : ""}`).join("; ")}.`}${blockedNote}`,
47471
+ // A goal missed because Jourilo itself stopped a request is not evidence that the site is broken.
47472
+ ...blockedNote ? { confidence: "low" } : {},
46153
47473
  impact: spec.goal ? `People trying to ${spec.goal} cannot complete it.` : "People cannot complete this task.",
46154
47474
  recommendation: spec.source === "suggested" ? "Check whether the journey definition matches the intended flow (it was generated); if it does, fix the step that failed." : "Fix the step that failed, then rerun the journey.",
46155
47475
  standards: [nielsen(9)],
@@ -46402,33 +47722,382 @@ function suggestJourneys(run) {
46402
47722
  return out;
46403
47723
  }
46404
47724
 
47725
+ // src/repair/git.ts
47726
+ init_errors();
47727
+ init_fs_safety();
47728
+ import { spawnSync as spawnSync2 } from "node:child_process";
47729
+ import { existsSync as existsSync14, mkdirSync as mkdirSync6, readdirSync as readdirSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "node:fs";
47730
+ import { dirname as dirname8, join as join18 } from "node:path";
47731
+
47732
+ // src/repair/transaction.ts
47733
+ init_errors();
47734
+ init_fs_safety();
47735
+ import { createHash as createHash2, randomBytes as randomBytes2 } from "node:crypto";
47736
+ import {
47737
+ closeSync,
47738
+ existsSync as existsSync13,
47739
+ fsyncSync,
47740
+ lstatSync,
47741
+ mkdirSync as mkdirSync5,
47742
+ openSync,
47743
+ readdirSync as readdirSync8,
47744
+ readFileSync as readFileSync10,
47745
+ renameSync,
47746
+ rmSync,
47747
+ statSync as statSync8,
47748
+ unlinkSync,
47749
+ writeSync
47750
+ } from "node:fs";
47751
+ import { basename as basename4, dirname as dirname7, join as join17 } from "node:path";
47752
+ var sha256 = (b) => createHash2("sha256").update(b).digest("hex");
47753
+ var recordsDir = (wsRoot) => join17(wsRoot, "checkpoints");
47754
+ var txDir = (wsRoot, id) => join17(recordsDir(wsRoot), id);
47755
+ function atomicWrite(path2, data, mode) {
47756
+ mkdirSync5(dirname7(path2), { recursive: true });
47757
+ const tmp = join17(dirname7(path2), `.${basename4(path2)}.jourilo-${randomBytes2(4).toString("hex")}.tmp`);
47758
+ const fd = openSync(tmp, "wx", mode ?? 420);
47759
+ try {
47760
+ let off = 0;
47761
+ while (off < data.length) off += writeSync(fd, data, off, data.length - off);
47762
+ fsyncSync(fd);
47763
+ } finally {
47764
+ closeSync(fd);
47765
+ }
47766
+ for (let attempt = 0; ; attempt++) {
47767
+ try {
47768
+ renameSync(tmp, path2);
47769
+ return;
47770
+ } catch (err) {
47771
+ if (attempt >= 5 || !["EPERM", "EBUSY", "EACCES"].includes(err.code ?? "")) {
47772
+ rmSync(tmp, { force: true });
47773
+ throw err;
47774
+ }
47775
+ const until = Date.now() + 50 * (attempt + 1);
47776
+ while (Date.now() < until) ;
47777
+ }
47778
+ }
47779
+ }
47780
+ function saveRecord(wsRoot, tx) {
47781
+ mkdirSync5(recordsDir(wsRoot), { recursive: true });
47782
+ atomicWrite(join17(recordsDir(wsRoot), `${tx.id}.json`), Buffer.from(`${JSON.stringify(tx, null, 2)}
47783
+ `));
47784
+ }
47785
+ function writablePath(root, file) {
47786
+ const abs = resolveInside(root, file);
47787
+ const lexical = join17(root, file);
47788
+ if (existsSync13(lexical) && lstatSync(lexical).isSymbolicLink())
47789
+ throw new JouriloError(
47790
+ "UNSAFE_PATH",
47791
+ `${file} is a symbolic link; Jourilo does not write through links. Make this change by hand.`
47792
+ );
47793
+ return abs;
47794
+ }
47795
+ function readOrNull(path2) {
47796
+ return existsSync13(path2) ? readFileSync10(path2) : null;
47797
+ }
47798
+ function applyTransaction(root, wsRoot, edits, opts = {}) {
47799
+ const pending = activeTransactions(wsRoot).filter((t) => t.state === "pending");
47800
+ if (pending.length)
47801
+ throw new JouriloError(
47802
+ "INVALID_INPUT",
47803
+ `An earlier change (${pending[0].id}) was interrupted before it finished.`,
47804
+ `Run \`jourilo rollback ${pending[0].id} --yes\` to restore those files first.`
47805
+ );
47806
+ const names = edits.map((e) => e.file);
47807
+ const dup = names.find((n, i) => names.indexOf(n) !== i);
47808
+ if (dup) throw new JouriloError("INTERNAL", `${dup} appears twice in one change; combine its edits first.`);
47809
+ const prepared = edits.map((e) => {
47810
+ const abs = writablePath(root, e.file);
47811
+ const current = readOrNull(abs);
47812
+ const same = e.before === null ? current === null : current?.equals(e.before);
47813
+ if (!same)
47814
+ throw new JouriloError(
47815
+ "INVALID_INPUT",
47816
+ `${e.file} changed after the fix was planned; nothing was written.`,
47817
+ "Run the command again so the plan is computed from the current file."
47818
+ );
47819
+ return { ...e, abs, mode: current ? statSync8(abs).mode & 511 : null };
47820
+ });
47821
+ const tx = {
47822
+ kind: "fix-transaction",
47823
+ id: newRunId(),
47824
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
47825
+ root,
47826
+ note: opts.note ?? "",
47827
+ ...opts.undoes ? { undoes: opts.undoes } : {},
47828
+ state: "pending",
47829
+ files: prepared.map((p) => ({
47830
+ file: p.file,
47831
+ beforeSha: p.before ? sha256(p.before) : null,
47832
+ afterSha: p.after ? sha256(p.after) : null,
47833
+ mode: p.mode,
47834
+ state: "pending"
47835
+ }))
47836
+ };
47837
+ for (const p of prepared)
47838
+ if (p.before) atomicWrite(resolveInside(join17(txDir(wsRoot, tx.id), "before"), p.file), p.before);
47839
+ saveRecord(wsRoot, tx);
47840
+ try {
47841
+ prepared.forEach((p, i) => {
47842
+ opts.beforeWrite?.(p.file, i);
47843
+ if (p.after) atomicWrite(p.abs, p.after, p.mode);
47844
+ else if (existsSync13(p.abs)) unlinkSync(p.abs);
47845
+ tx.files[i].state = "written";
47846
+ saveRecord(wsRoot, tx);
47847
+ });
47848
+ } catch (err) {
47849
+ const undo = rollbackTransaction(wsRoot, tx, { force: false });
47850
+ tx.state = "failed";
47851
+ saveRecord(wsRoot, tx);
47852
+ const msg = err instanceof Error ? err.message : String(err);
47853
+ throw new JouriloError(
47854
+ "INTERNAL",
47855
+ `Writing the changes failed (${msg}); ${undo.restored.length} file(s) already written were restored.${undo.conflicts.length ? ` Not restored: ${undo.conflicts.map((c) => c.file).join(", ")}.` : ""}`
47856
+ );
47857
+ }
47858
+ tx.state = "applied";
47859
+ saveRecord(wsRoot, tx);
47860
+ return tx;
47861
+ }
47862
+ function rollbackTransaction(wsRoot, tx, opts = {}) {
47863
+ const result = { restored: [], alreadyRestored: [], conflicts: [] };
47864
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
47865
+ for (const f of tx.files) {
47866
+ if (opts.files?.length && !opts.files.includes(f.file)) continue;
47867
+ if (f.state === "restored") {
47868
+ result.alreadyRestored.push(f.file);
47869
+ continue;
47870
+ }
47871
+ const abs = writablePath(tx.root, f.file);
47872
+ const current = readOrNull(abs);
47873
+ const currentSha = current ? sha256(current) : null;
47874
+ if (currentSha === f.beforeSha) {
47875
+ f.state = "restored";
47876
+ delete f.conflict;
47877
+ result.alreadyRestored.push(f.file);
47878
+ continue;
47879
+ }
47880
+ if (currentSha !== f.afterSha) {
47881
+ const reason = current === null ? "deleted after the change was applied" : "modified after the change was applied (not by Jourilo)";
47882
+ if (!opts.force) {
47883
+ f.state = "conflict";
47884
+ f.conflict = reason;
47885
+ result.conflicts.push({ file: f.file, reason });
47886
+ continue;
47887
+ }
47888
+ let savedCopy;
47889
+ if (current) {
47890
+ savedCopy = resolveInside(join17(txDir(wsRoot, tx.id), "conflicts", stamp), f.file);
47891
+ atomicWrite(savedCopy, current);
47892
+ }
47893
+ result.conflicts.push({ file: f.file, reason, ...savedCopy ? { savedCopy } : {} });
47894
+ }
47895
+ if (f.beforeSha === null) {
47896
+ if (existsSync13(abs)) unlinkSync(abs);
47897
+ } else {
47898
+ const backup = resolveInside(join17(txDir(wsRoot, tx.id), "before"), f.file);
47899
+ if (!existsSync13(backup)) {
47900
+ f.state = "conflict";
47901
+ f.conflict = "backup missing";
47902
+ result.conflicts.push({ file: f.file, reason: "backup missing; cannot restore" });
47903
+ continue;
47904
+ }
47905
+ const bytes = readFileSync10(backup);
47906
+ if (sha256(bytes) !== f.beforeSha) {
47907
+ result.conflicts.push({ file: f.file, reason: "backup does not match its recorded hash" });
47908
+ continue;
47909
+ }
47910
+ atomicWrite(abs, bytes, f.mode);
47911
+ }
47912
+ f.state = "restored";
47913
+ delete f.conflict;
47914
+ result.restored.push(f.file);
47915
+ }
47916
+ const allBack = tx.files.every((f) => f.state === "restored");
47917
+ if (tx.state !== "pending" || allBack) tx.state = allBack ? "rolled-back" : "partially-rolled-back";
47918
+ saveRecord(wsRoot, tx);
47919
+ return result;
47920
+ }
47921
+ function isTransaction(x) {
47922
+ return !!x && typeof x === "object" && x.kind === "fix-transaction";
47923
+ }
47924
+ function activeTransactions(wsRoot) {
47925
+ const dir = recordsDir(wsRoot);
47926
+ if (!existsSync13(dir)) return [];
47927
+ const out = [];
47928
+ for (const f of readdirSync8(dir).filter((n) => n.endsWith(".json")).sort()) {
47929
+ try {
47930
+ const rec = JSON.parse(readFileSync10(join17(dir, f), "utf8"));
47931
+ if (isTransaction(rec) && ["pending", "applied", "partially-rolled-back"].includes(rec.state))
47932
+ out.push(rec);
47933
+ } catch {
47934
+ }
47935
+ }
47936
+ return out;
47937
+ }
47938
+ function previewRollback(tx) {
47939
+ return tx.files.map((f) => {
47940
+ if (f.state === "restored") return { file: f.file, action: "unchanged" };
47941
+ const abs = writablePath(tx.root, f.file);
47942
+ const current = readOrNull(abs);
47943
+ const sha = current ? sha256(current) : null;
47944
+ if (sha === f.beforeSha) return { file: f.file, action: "unchanged" };
47945
+ if (sha === f.afterSha) return { file: f.file, action: "restore" };
47946
+ return {
47947
+ file: f.file,
47948
+ action: "conflict",
47949
+ reason: current === null ? "deleted after the change" : "modified after the change"
47950
+ };
47951
+ });
47952
+ }
47953
+
47954
+ // src/repair/git.ts
47955
+ function git(cwd, args) {
47956
+ const r = spawnSync2("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
47957
+ return { ok: r.status === 0, out: (r.stdout ?? "").trim(), err: (r.stderr ?? "").trim() };
47958
+ }
47959
+ function isGitRepo(root) {
47960
+ return git(root, ["rev-parse", "--is-inside-work-tree"]).out === "true";
47961
+ }
47962
+ function untrackedFiles(root) {
47963
+ return git(root, ["ls-files", "--others", "--exclude-standard"]).out.split("\n").filter(Boolean);
47964
+ }
47965
+ function createCheckpoint(root, wsRoot, note = "") {
47966
+ if (!isGitRepo(root))
47967
+ throw new JouriloError(
47968
+ "INVALID_INPUT",
47969
+ `${root} is not a git repository; checkpoints need git.`,
47970
+ "Run `git init` and commit, or keep your own backup before applying fixes."
47971
+ );
47972
+ const head = git(root, ["rev-parse", "HEAD"]);
47973
+ if (!head.ok)
47974
+ throw new JouriloError(
47975
+ "INVALID_INPUT",
47976
+ "The repository has no commits yet; commit once before using checkpoints."
47977
+ );
47978
+ const stash = git(root, ["stash", "create", `jourilo checkpoint ${note}`.trim()]);
47979
+ const cp = {
47980
+ id: newRunId(),
47981
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
47982
+ head: head.out,
47983
+ snapshot: stash.out || head.out,
47984
+ untracked: untrackedFiles(root),
47985
+ note
47986
+ };
47987
+ git(root, ["update-ref", `refs/jourilo/checkpoints/${cp.id}`, cp.snapshot]);
47988
+ const dir = join18(wsRoot, "checkpoints");
47989
+ mkdirSync6(dir, { recursive: true });
47990
+ writeFileSync8(join18(dir, `${cp.id}.json`), `${JSON.stringify(cp, null, 2)}
47991
+ `);
47992
+ return cp;
47993
+ }
47994
+ function loadCheckpoint(wsRoot, id) {
47995
+ const dir = join18(wsRoot, "checkpoints");
47996
+ const read3 = (name) => JSON.parse(readFileSync11(join18(dir, `${name}.json`), "utf8"));
47997
+ let pick = id;
47998
+ if (id === "latest") {
47999
+ const names = existsSync14(dir) ? readdirSync9(dir).filter((f) => f.endsWith(".json")).map((f) => f.replace(/\.json$/, "")).sort().reverse() : [];
48000
+ pick = names.find((n) => {
48001
+ try {
48002
+ const rec = read3(n);
48003
+ if (!isTransaction(rec)) return !rec.rolledBack;
48004
+ return !rec.undoes && ["applied", "pending", "partially-rolled-back"].includes(rec.state);
48005
+ } catch {
48006
+ return false;
48007
+ }
48008
+ });
48009
+ }
48010
+ if (!pick || !/^[\w-]+$/.test(pick) || !existsSync14(join18(dir, `${pick}.json`)))
48011
+ throw new JouriloError("NOT_FOUND", `Checkpoint not found: ${id}`);
48012
+ return read3(pick);
48013
+ }
48014
+ function diffSince(root, cp) {
48015
+ const stat = git(root, ["diff", "--stat", cp.snapshot]).out;
48016
+ const patch = git(root, ["diff", cp.snapshot]).out;
48017
+ const newFiles = untrackedFiles(root).filter((f) => !cp.untracked.includes(f));
48018
+ return { stat, patch, newFiles };
48019
+ }
48020
+ function backupDir(wsRoot, cp) {
48021
+ return join18(wsRoot, "checkpoints", cp.id);
48022
+ }
48023
+ function blob(root, rev, file) {
48024
+ const r = spawnSync2("git", ["cat-file", "blob", `${rev}:${file}`], {
48025
+ cwd: root,
48026
+ maxBuffer: 64 * 1024 * 1024
48027
+ });
48028
+ return r.status === 0 ? r.stdout : null;
48029
+ }
48030
+ function rollbackCheckpoint(root, cp, wsRoot, opts = {}) {
48031
+ const changed = git(root, ["diff", "--name-only", "--no-renames", cp.snapshot]).out.split("\n").filter(Boolean).filter((f) => !opts.files?.length || opts.files.includes(f));
48032
+ const edits = [];
48033
+ const notRestorable = [];
48034
+ for (const f of changed) {
48035
+ const target = resolveInside(root, f);
48036
+ const current = existsSync14(target) ? readFileSync11(target) : null;
48037
+ const saved = resolveInside(backupDir(wsRoot, cp), f);
48038
+ let wanted = existsSync14(saved) ? readFileSync11(saved) : null;
48039
+ if (!wanted) {
48040
+ const content = blob(root, cp.snapshot, f);
48041
+ if (!content) {
48042
+ notRestorable.push(f);
48043
+ continue;
48044
+ }
48045
+ wanted = current?.includes("\r\n") && !content.includes("\r\n") ? Buffer.from(content.toString("utf8").replace(/\n/g, "\r\n"), "utf8") : content;
48046
+ }
48047
+ if (current?.equals(wanted)) continue;
48048
+ edits.push({ file: f, before: current, after: wanted });
48049
+ }
48050
+ const transaction = edits.length ? applyTransaction(root, wsRoot, edits, { note: `rollback to checkpoint ${cp.id}`, undoes: cp.id }) : null;
48051
+ return {
48052
+ transaction,
48053
+ restored: edits.map((e) => e.file),
48054
+ // Files that did not exist at checkpoint time are listed, never deleted.
48055
+ leftUntracked: [
48056
+ ...notRestorable,
48057
+ ...untrackedFiles(root).filter((f) => !cp.untracked.includes(f) && !notRestorable.includes(f))
48058
+ ]
48059
+ };
48060
+ }
48061
+ function markRolledBack(wsRoot, cp, transactionId) {
48062
+ const file = join18(wsRoot, "checkpoints", `${cp.id}.json`);
48063
+ writeFileSync8(file, `${JSON.stringify({ ...cp, rolledBack: transactionId }, null, 2)}
48064
+ `);
48065
+ }
48066
+ function wouldBeCommitted(file) {
48067
+ let dir = dirname8(file);
48068
+ while (!existsSync14(dir) && dirname8(dir) !== dir) dir = dirname8(dir);
48069
+ if (!isGitRepo(dir)) return false;
48070
+ const r = spawnSync2("git", ["check-ignore", "-q", "--no-index", file], { cwd: dir });
48071
+ return r.status === 1;
48072
+ }
48073
+
46405
48074
  // src/cli/commands/journeys.ts
46406
48075
  function journeysDir(ws) {
46407
- return join15(ws.root, "journeys");
48076
+ return join19(ws.root, "journeys");
46408
48077
  }
46409
48078
  function loadJourneys(ws, refs) {
46410
48079
  const dir = journeysDir(ws);
46411
48080
  const files = [];
46412
48081
  if (!refs.length) {
46413
- if (!existsSync12(dir)) return [];
48082
+ if (!existsSync15(dir)) return [];
46414
48083
  files.push(
46415
- ...readdirSync7(dir).filter((f) => f.endsWith(".json")).sort().map((f) => join15(dir, f))
48084
+ ...readdirSync10(dir).filter((f) => f.endsWith(".json")).sort().map((f) => join19(dir, f))
46416
48085
  );
46417
48086
  } else {
46418
48087
  for (const r of refs) {
46419
- if (r.endsWith(".json") && existsSync12(r)) files.push(resolve8(r));
46420
- else if (existsSync12(join15(dir, `${r}.json`))) files.push(join15(dir, `${r}.json`));
48088
+ if (r.endsWith(".json") && existsSync15(r)) files.push(resolve8(r));
48089
+ else if (existsSync15(join19(dir, `${r}.json`))) files.push(join19(dir, `${r}.json`));
46421
48090
  else throw new JouriloError("NOT_FOUND", `Journey not found: ${r} (looked in ${dir})`);
46422
48091
  }
46423
48092
  }
46424
48093
  return files.map((f) => {
46425
48094
  let raw;
46426
48095
  try {
46427
- raw = JSON.parse(readFileSync9(f, "utf8"));
48096
+ raw = JSON.parse(readFileSync12(f, "utf8"));
46428
48097
  } catch (err) {
46429
- throw new JouriloError("INVALID_INPUT", `${basename2(f)} is not valid JSON: ${err.message}`);
48098
+ throw new JouriloError("INVALID_INPUT", `${basename5(f)} is not valid JSON: ${err.message}`);
46430
48099
  }
46431
- return validateJourney(raw, basename2(f));
48100
+ return validateJourney(raw, basename5(f));
46432
48101
  });
46433
48102
  }
46434
48103
  function parseVars(ctx) {
@@ -46448,7 +48117,7 @@ async function suggest(ctx) {
46448
48117
  if (!target || ctx.ws.listRuns().some((r) => r.id === target) || target === "latest") {
46449
48118
  run = ctx.ws.loadRun(target ?? "latest").run;
46450
48119
  } else {
46451
- const isDir = existsSync12(target) && !target.includes("://");
48120
+ const isDir = existsSync15(target) && !target.includes("://");
46452
48121
  const server = isDir ? await serveStatic(resolve8(target)) : void 0;
46453
48122
  try {
46454
48123
  ctx.log("Discovering pages to derive journeys (no audit rules applied) \u2026");
@@ -46475,16 +48144,16 @@ async function suggest(ctx) {
46475
48144
  }
46476
48145
  const specs = suggestJourneys(run);
46477
48146
  const dir = journeysDir(ctx.ws);
46478
- mkdirSync5(dir, { recursive: true });
48147
+ mkdirSync7(dir, { recursive: true });
46479
48148
  const written = [];
46480
48149
  const kept = [];
46481
48150
  for (const s of specs) {
46482
- const file = join15(dir, `${s.id}.json`);
46483
- if (existsSync12(file) && !flag(ctx, "force")) {
48151
+ const file = join19(dir, `${s.id}.json`);
48152
+ if (existsSync15(file) && !flag(ctx, "force")) {
46484
48153
  kept.push(s.id);
46485
48154
  continue;
46486
48155
  }
46487
- writeFileSync8(file, `${JSON.stringify(s, null, 2)}
48156
+ writeFileSync9(file, `${JSON.stringify(s, null, 2)}
46488
48157
  `);
46489
48158
  written.push(file);
46490
48159
  }
@@ -46500,6 +48169,42 @@ async function suggest(ctx) {
46500
48169
  ].filter(Boolean).join("\n")
46501
48170
  };
46502
48171
  }
48172
+ function summarizeAttempts(spec, attempts) {
48173
+ if (attempts.length === 1) return attempts[0];
48174
+ const passed = attempts.filter((a) => a.result.status === "passed").length;
48175
+ const rep = attempts.find((a) => a.result.status !== "passed") ?? attempts[0];
48176
+ const result = {
48177
+ ...rep.result,
48178
+ attempts: attempts.map((a) => ({ status: a.result.status, durationMs: a.result.durationMs })),
48179
+ stability: passed === 0 || passed === attempts.length ? "stable" : "flaky"
48180
+ };
48181
+ if (result.stability === "stable") return { result, findings: rep.findings };
48182
+ const findings = [
48183
+ ...rep.findings.map((f) => ({ ...f, confidence: "low" })),
48184
+ makeFinding({
48185
+ ruleId: "journey/flaky",
48186
+ key: spec.id,
48187
+ journey: spec.id,
48188
+ title: `Journey "${spec.name}" passed ${passed} of ${attempts.length} times`,
48189
+ category: "reliability",
48190
+ kind: "needs-review",
48191
+ severity: "moderate",
48192
+ confidence: "high",
48193
+ provenance: "observed",
48194
+ url: rep.result.steps.at(-1)?.url || spec.start,
48195
+ viewport: rep.result.viewport,
48196
+ description: `The same steps gave different outcomes across ${attempts.length} runs (${attempts.map((a) => a.result.status).join(", ")}). Either the product behaves inconsistently (race conditions, timing, data dependence) or the journey depends on timing; the failing run's evidence is attached to the journey.`,
48197
+ impact: "Intermittent failures reach real users and make before/after comparisons unreliable.",
48198
+ recommendation: "Compare the failing attempt with a passing one: look for requests that finish in a different order, elements that render late, or data created by an earlier run. Fix the product if it is a race; otherwise make the journey wait for the right state.",
48199
+ standards: [],
48200
+ evidence: [{ type: "step", snippet: rep.result.steps.find((s) => s.status !== "passed")?.error ?? "" }],
48201
+ reproduction: [
48202
+ `Run the journey ${attempts.length} times: jourilo journeys run ${spec.id} --repeat ${attempts.length}`
48203
+ ]
48204
+ })
48205
+ ];
48206
+ return { result, findings };
48207
+ }
46503
48208
  async function runCmd(ctx) {
46504
48209
  const baseArg = str(ctx, "base");
46505
48210
  if (!baseArg)
@@ -46511,7 +48216,7 @@ async function runCmd(ctx) {
46511
48216
  "No journeys to run.",
46512
48217
  "Create some with `jourilo journeys suggest <url>`."
46513
48218
  );
46514
- const isDir = existsSync12(baseArg) && !baseArg.includes("://");
48219
+ const isDir = existsSync15(baseArg) && !baseArg.includes("://");
46515
48220
  const server = isDir ? await serveStatic(resolve8(baseArg)) : void 0;
46516
48221
  try {
46517
48222
  const base2 = parseTargetUrl(server?.url ?? baseArg);
@@ -46519,7 +48224,12 @@ async function runCmd(ctx) {
46519
48224
  const id = newRunId();
46520
48225
  const runDir = ctx.ws.createRunDir(id);
46521
48226
  const browserName = str(ctx, "browser") ?? "chromium";
46522
- const browser = await launchBrowser(browserName, flag(ctx, "headed"));
48227
+ const session = await openGuardedBrowser(browserName, flag(ctx, "headed"), {
48228
+ target: hostClass,
48229
+ allowPrivate: flag(ctx, "allow-private")
48230
+ });
48231
+ const browser = session.browser;
48232
+ const browserVersion = browser.version();
46523
48233
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
46524
48234
  const journeys2 = [];
46525
48235
  let findings = [];
@@ -46534,16 +48244,29 @@ async function runCmd(ctx) {
46534
48244
  limitations.push(
46535
48245
  "--allow-destructive is ignored on non-local hosts: irreversible and payment actions were blocked."
46536
48246
  );
48247
+ const allowOrigins = list(ctx, "allow-origin").map((o) => {
48248
+ const u = parseTargetUrl(o);
48249
+ return u.origin;
48250
+ });
48251
+ const repeat = Math.max(1, Math.min(10, num(ctx, "repeat", 1)));
46537
48252
  const saveAuthArg = str(ctx, "save-auth");
48253
+ if (saveAuthArg && repeat > 1)
48254
+ throw new JouriloError("USAGE", "--save-auth cannot be combined with --repeat.");
46538
48255
  if (saveAuthArg && specs.length !== 1)
46539
48256
  throw new JouriloError("USAGE", "--save-auth needs exactly one journey (the sign-in journey).");
46540
48257
  const saveAuth = saveAuthArg ? resolve8(saveAuthArg) : void 0;
46541
- if (saveAuth)
48258
+ if (saveAuth) {
46542
48259
  limitations.push(
46543
48260
  `Signed-in state saved to ${displayPath(saveAuth)} (owner-only permissions). Treat it like a password; it is a test-account session.`
46544
48261
  );
48262
+ if (wouldBeCommitted(saveAuth)) {
48263
+ const warning = `WARNING: ${displayPath(saveAuth)} is inside a git repository and not ignored, so it could be committed with its session cookies. Add it to .gitignore or save it under .jourilo/.`;
48264
+ limitations.push(warning);
48265
+ ctx.log(warning);
48266
+ }
48267
+ }
46545
48268
  if (flag(ctx, "trace")) {
46546
- mkdirSync5(join15(runDir, "traces"), { recursive: true });
48269
+ mkdirSync7(join19(runDir, "traces"), { recursive: true });
46547
48270
  limitations.push(
46548
48271
  "Playwright traces contain DOM snapshots and typed values: keep them private (they are excluded from git)."
46549
48272
  );
@@ -46553,27 +48276,38 @@ async function runCmd(ctx) {
46553
48276
  for (const spec of specs) {
46554
48277
  for (const vp of viewports) {
46555
48278
  ctx.log(`journey ${spec.id}${vp ? ` (${vp})` : ""}`);
46556
- const { result, findings: jf } = await runJourney(browser, spec, {
46557
- base: base2,
46558
- runDir,
46559
- hostClass,
46560
- policy,
46561
- data,
46562
- trace: flag(ctx, "trace"),
46563
- stepTimeoutMs: num(ctx, "timeout", 10) * 1e3,
46564
- ...vp ? { viewportOverride: vp } : {},
46565
- ...str(ctx, "auth") ? { storageState: str(ctx, "auth") } : {},
46566
- ...saveAuth ? { saveAuth } : {},
46567
- log: ctx.log
46568
- });
46569
- ctx.log(` \u2192 ${result.status} (${(result.durationMs / 1e3).toFixed(1)} s)`);
48279
+ const attempts = [];
48280
+ for (let k = 0; k < repeat; k++) {
48281
+ const r = await runJourney(browser, spec, {
48282
+ base: base2,
48283
+ runDir,
48284
+ hostClass,
48285
+ policy,
48286
+ data: repeat > 1 ? testData(`${id}${k}`, parseVars(ctx)) : data,
48287
+ trace: flag(ctx, "trace") && k === 0,
48288
+ stepTimeoutMs: num(ctx, "timeout", 10) * 1e3,
48289
+ ...vp ? { viewportOverride: vp } : {},
48290
+ ...str(ctx, "auth") ? { storageState: str(ctx, "auth") } : {},
48291
+ ...saveAuth ? { saveAuth } : {},
48292
+ ...allowOrigins.length ? { allowOrigins } : {},
48293
+ log: k === 0 ? ctx.log : void 0
48294
+ });
48295
+ if (repeat > 1) ctx.log(` attempt ${k + 1}/${repeat}: ${r.result.status}`);
48296
+ attempts.push(r);
48297
+ }
48298
+ const { result, findings: jf } = summarizeAttempts(spec, attempts);
48299
+ ctx.log(
48300
+ ` \u2192 ${result.status}${result.stability ? ` (${result.stability})` : ""} (${(result.durationMs / 1e3).toFixed(1)} s)`
48301
+ );
46570
48302
  journeys2.push(result);
46571
48303
  findings.push(...jf);
46572
48304
  }
46573
48305
  }
46574
48306
  } finally {
46575
- await browser.close().catch(() => void 0);
48307
+ await session.close();
46576
48308
  }
48309
+ const network = session.report();
48310
+ limitations.push(...networkLimitations(network));
46577
48311
  findings = mergeFindings(findings);
46578
48312
  const run = {
46579
48313
  schemaVersion: RUN_SCHEMA_VERSION,
@@ -46583,7 +48317,7 @@ async function runCmd(ctx) {
46583
48317
  target: server ? `${displayPath(resolve8(baseArg))} (served at ${server.url})` : base2.toString(),
46584
48318
  startedAt,
46585
48319
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
46586
- browser: { name: browserName, version: "" },
48320
+ browser: { name: browserName, version: browserVersion },
46587
48321
  viewports: parseViewports([...new Set(journeys2.map((j) => j.viewport))].join(",") || "desktop"),
46588
48322
  settings: {
46589
48323
  journeys: specs.map((s) => s.id),
@@ -46598,7 +48332,8 @@ async function runCmd(ctx) {
46598
48332
  limitations: [
46599
48333
  ...limitations,
46600
48334
  "Journeys prove that a scripted path works (or not) in one browser; they do not measure how real people choose paths."
46601
- ]
48335
+ ],
48336
+ ...network.blocked.length || network.escaped.length ? { network } : {}
46602
48337
  };
46603
48338
  const repo = str(ctx, "repo") ?? (isDir ? resolve8(baseArg) : void 0);
46604
48339
  if (repo) locateFindings(run, repo);
@@ -46634,6 +48369,7 @@ var journeys = {
46634
48369
  "allow-submit": { type: "boolean" },
46635
48370
  "allow-destructive": { type: "boolean" },
46636
48371
  "allow-private": { type: "boolean" },
48372
+ "allow-origin": { type: "string", multiple: true },
46637
48373
  "fail-on-failure": { type: "boolean" },
46638
48374
  browser: { type: "string" },
46639
48375
  headed: { type: "boolean" },
@@ -46642,7 +48378,8 @@ var journeys = {
46642
48378
  timeout: { type: "string" },
46643
48379
  pages: { type: "string" },
46644
48380
  force: { type: "boolean" },
46645
- "save-auth": { type: "string" }
48381
+ "save-auth": { type: "string" },
48382
+ repeat: { type: "string" }
46646
48383
  },
46647
48384
  details: `Subcommands:
46648
48385
  suggest [url|dir|run] Derive journeys from a site (or an existing run) into .jourilo/journeys/*.json
@@ -46655,13 +48392,18 @@ Run options:
46655
48392
  --var key=value Test data for {{key}} placeholders (also JOURILO_VAR_KEY env vars)
46656
48393
  --allow-submit Allow form submission on non-local hosts (authorized test environments only)
46657
48394
  --allow-destructive Allow irreversible/payment actions \u2014 local hosts with sandbox data only
48395
+ --allow-origin ORIGIN Let the page navigate to another origin you are authorized to test (e.g. a test IdP)
46658
48396
  --trace Record a Playwright trace per journey (contains typed values; keep private)
46659
48397
  --auth FILE Playwright storage state for signed-in journeys
46660
48398
  --save-auth FILE After a passing sign-in journey (exactly one), save its session for --auth (test accounts only)
48399
+ --repeat N Run each journey N times (max 10); mixed outcomes are reported as flaky
46661
48400
  --fail-on-failure Exit 1 when a journey fails
46662
48401
 
46663
48402
  Safety: payment, purchase, delete and cancel actions are never performed on non-local hosts; form submission
46664
- on non-local hosts needs --allow-submit. Blocked steps are reported, not skipped silently.`,
48403
+ on non-local hosts needs --allow-submit. The policy is checked twice: on the element about to be clicked or
48404
+ pressed (its name, link target, form action and identifiers \u2014 icon-only buttons included), and on every request
48405
+ the page then sends (form posts, DELETE, requests to delete/cancel/payment endpoints, navigation to other
48406
+ origins). Blocked steps are reported, not skipped silently.`,
46665
48407
  async run(ctx) {
46666
48408
  const sub = ctx.positionals[0] ?? "list";
46667
48409
  if (sub === "suggest") return suggest(ctx);
@@ -46679,8 +48421,8 @@ ${specs.map((s) => ` ${s.id.padEnd(32)} ${s.steps.length} steps, ${s.success.le
46679
48421
  };
46680
48422
 
46681
48423
  // src/cli/commands/optimize.ts
46682
- import { existsSync as existsSync15, mkdirSync as mkdirSync7, writeFileSync as writeFileSync10 } from "node:fs";
46683
- import { join as join17, resolve as resolve9 } from "node:path";
48424
+ import { existsSync as existsSync17, mkdirSync as mkdirSync8, writeFileSync as writeFileSync10 } from "node:fs";
48425
+ import { join as join20, resolve as resolve9 } from "node:path";
46684
48426
  init_errors();
46685
48427
  init_fs_safety();
46686
48428
 
@@ -46751,17 +48493,17 @@ function unifiedDiff(file, before, after, context = 3) {
46751
48493
  }
46752
48494
 
46753
48495
  // src/repair/fixes.ts
46754
- import { existsSync as existsSync13, readFileSync as readFileSync10 } from "node:fs";
48496
+ import { existsSync as existsSync16, readFileSync as readFileSync13 } from "node:fs";
46755
48497
  import { extname as extname3 } from "node:path";
46756
48498
  init_fs_safety();
46757
48499
  function base(f, title, explanation, expectedEffect) {
46758
48500
  return { fingerprint: f.fingerprint, ruleId: f.ruleId, title, explanation, expectedEffect };
46759
48501
  }
46760
48502
  var isJsx = (file) => /\.(jsx|tsx)$/.test(file);
46761
- var htmlLang = (f, _root, read2) => {
48503
+ var htmlLang = (f, _root, read3) => {
46762
48504
  const src = f.sources?.find((s) => s.reason.includes("<html>"));
46763
48505
  if (!src) return null;
46764
- const text = read2(src.file);
48506
+ const text = read3(src.file);
46765
48507
  if (!text) return null;
46766
48508
  const path2 = (() => {
46767
48509
  try {
@@ -46787,10 +48529,10 @@ var htmlLang = (f, _root, read2) => {
46787
48529
  ]
46788
48530
  };
46789
48531
  };
46790
- var viewportMeta = (f, _root, read2) => {
48532
+ var viewportMeta = (f, _root, read3) => {
46791
48533
  const src = f.sources?.find((s) => /\.html?$/.test(s.file) && s.reason.includes("viewport"));
46792
48534
  if (!src) return null;
46793
- const text = read2(src.file);
48535
+ const text = read3(src.file);
46794
48536
  if (!text || /name=["']viewport["']/i.test(text)) return null;
46795
48537
  const anchor = /<meta\s+charset=[^>]*>/i.test(text) ? /(<meta\s+charset=[^>]*>)(\r?\n?)(\s*)/i : /(<head\b[^>]*>)(\r?\n?)(\s*)/i;
46796
48538
  const after = text.replace(
@@ -46812,7 +48554,7 @@ ${indent}`
46812
48554
  ]
46813
48555
  };
46814
48556
  };
46815
- var autocomplete = (f, root, read2) => {
48557
+ var autocomplete = (f, root, read3) => {
46816
48558
  const edits = /* @__PURE__ */ new Map();
46817
48559
  const tokens = [];
46818
48560
  for (const e of f.evidence) {
@@ -46824,7 +48566,7 @@ var autocomplete = (f, root, read2) => {
46824
48566
  const token = suggestAutocomplete(type, name, label === "\u2205" ? null : label, purpose);
46825
48567
  if (token.startsWith("on (")) continue;
46826
48568
  for (const s of f.sources ?? []) {
46827
- const cur = edits.get(s.file)?.after ?? read2(s.file);
48569
+ const cur = edits.get(s.file)?.after ?? read3(s.file);
46828
48570
  if (!cur) continue;
46829
48571
  const attr = isJsx(s.file) ? "autoComplete" : "autocomplete";
46830
48572
  const re = new RegExp(
@@ -46832,7 +48574,7 @@ var autocomplete = (f, root, read2) => {
46832
48574
  );
46833
48575
  const next = cur.replace(re, `$1 ${attr}="${token}"$2`);
46834
48576
  if (next !== cur) {
46835
- edits.set(s.file, { before: edits.get(s.file)?.before ?? read2(s.file), after: next });
48577
+ edits.set(s.file, { before: edits.get(s.file)?.before ?? read3(s.file), after: next });
46836
48578
  tokens.push(`${name} \u2192 ${token}`);
46837
48579
  break;
46838
48580
  }
@@ -46853,10 +48595,10 @@ var autocomplete = (f, root, read2) => {
46853
48595
  ]
46854
48596
  };
46855
48597
  };
46856
- var focusVisible = (f, _root, read2) => {
48598
+ var focusVisible = (f, _root, read3) => {
46857
48599
  const src = f.sources?.find((s) => /\.(css|scss|less)$/.test(s.file) && s.reason.includes("focus outline"));
46858
48600
  if (!src) return null;
46859
- const text = read2(src.file);
48601
+ const text = read3(src.file);
46860
48602
  if (!text) return null;
46861
48603
  const lines = text.split("\n");
46862
48604
  let start = src.line - 1;
@@ -46886,10 +48628,10 @@ ${sels.join(",\n")} {
46886
48628
  ]
46887
48629
  };
46888
48630
  };
46889
- var pasteAllowed = (f, _root, read2) => {
48631
+ var pasteAllowed = (f, _root, read3) => {
46890
48632
  const src = f.sources?.find((s) => s.reason.includes("paste"));
46891
48633
  if (!src) return null;
46892
- const text = read2(src.file);
48634
+ const text = read3(src.file);
46893
48635
  if (!text) return null;
46894
48636
  const after = text.replace(/(<input\b[^>]*?)\s+on[pP]aste=(?:"[^"]*"|'[^']*'|\{[^}]*\})([^>]*>)/g, "$1$2");
46895
48637
  if (after === text) return null;
@@ -46907,7 +48649,7 @@ var pasteAllowed = (f, _root, read2) => {
46907
48649
  };
46908
48650
  };
46909
48651
  var escRe = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46910
- var imageDimensions = (f, _root, read2) => {
48652
+ var imageDimensions = (f, _root, read3) => {
46911
48653
  const edits = /* @__PURE__ */ new Map();
46912
48654
  const done = [];
46913
48655
  const notes = [];
@@ -46917,7 +48659,7 @@ var imageDimensions = (f, _root, read2) => {
46917
48659
  const h = Number(e.data?.naturalHeight);
46918
48660
  if (!src || !(w > 0 && h > 0)) continue;
46919
48661
  for (const s of f.sources ?? []) {
46920
- const cur = edits.get(s.file)?.after ?? read2(s.file);
48662
+ const cur = edits.get(s.file)?.after ?? read3(s.file);
46921
48663
  if (!cur) continue;
46922
48664
  const re = new RegExp(
46923
48665
  `<img\\b(?=[^>]*\\bsrc=["'{]?["']?${escRe(src)}["'])(?![^>]*\\bwidth=)(?![^>]*\\bheight=)[^>]*?(\\s*/?>)`
@@ -46935,7 +48677,7 @@ var imageDimensions = (f, _root, read2) => {
46935
48677
  }
46936
48678
  tag = tag.replace(/(\s*\/?>)$/, ` width="${w}" height="${h}"$1`);
46937
48679
  edits.set(s.file, {
46938
- before: edits.get(s.file)?.before ?? read2(s.file),
48680
+ before: edits.get(s.file)?.before ?? read3(s.file),
46939
48681
  after: cur.replace(m[0], tag)
46940
48682
  });
46941
48683
  done.push(`${src} \u2192 ${w}\xD7${h}`);
@@ -46957,14 +48699,14 @@ var imageDimensions = (f, _root, read2) => {
46957
48699
  ]
46958
48700
  };
46959
48701
  };
46960
- var placeholderLabel = (f, _root, read2) => {
48702
+ var placeholderLabel = (f, _root, read3) => {
46961
48703
  const edits = /* @__PURE__ */ new Map();
46962
48704
  const done = [];
46963
48705
  for (const e of f.evidence) {
46964
48706
  const name = e.snippet?.match(/name=(\S+)/)?.[1];
46965
48707
  if (!name || name === "\u2205") continue;
46966
48708
  for (const s of f.sources ?? []) {
46967
- const cur = edits.get(s.file)?.after ?? read2(s.file);
48709
+ const cur = edits.get(s.file)?.after ?? read3(s.file);
46968
48710
  if (!cur) continue;
46969
48711
  const jsx = isJsx(s.file);
46970
48712
  const re = new RegExp(`<(input|textarea|select)\\b(?=[^>]*\\bname=["']${escRe(name)}["'])[^>]*>`);
@@ -46982,7 +48724,7 @@ var placeholderLabel = (f, _root, read2) => {
46982
48724
  }
46983
48725
  const label = `<label ${jsx ? "htmlFor" : "for"}="${id}">${placeholder}</label> `;
46984
48726
  edits.set(s.file, {
46985
- before: edits.get(s.file)?.before ?? read2(s.file),
48727
+ before: edits.get(s.file)?.before ?? read3(s.file),
46986
48728
  after: cur.replace(tag, `${label}${newTag}`)
46987
48729
  });
46988
48730
  done.push(`${name} \u2192 "${placeholder}"`);
@@ -47012,15 +48754,28 @@ var FIXERS = {
47012
48754
  "jourilo/unsized-images": imageDimensions,
47013
48755
  "jourilo/placeholder-label": placeholderLabel
47014
48756
  };
48757
+ function utf8OrNull(buf) {
48758
+ const text = buf.toString("utf8");
48759
+ return Buffer.from(text, "utf8").equals(buf) ? text : null;
48760
+ }
48761
+ function composeEdits(plans) {
48762
+ const byFile = /* @__PURE__ */ new Map();
48763
+ for (const p of plans)
48764
+ for (const e of p.edits) {
48765
+ const prev = byFile.get(e.file);
48766
+ byFile.set(e.file, { file: e.file, before: prev?.before ?? e.before, after: e.after });
48767
+ }
48768
+ return [...byFile.values()];
48769
+ }
47015
48770
  function planFixes(findings, root) {
47016
48771
  const cache = /* @__PURE__ */ new Map();
47017
- const read2 = (file) => {
48772
+ const read3 = (file) => {
47018
48773
  if (!cache.has(file)) {
47019
48774
  try {
47020
48775
  const abs = resolveInside(root, file);
47021
48776
  cache.set(
47022
48777
  file,
47023
- existsSync13(abs) && [
48778
+ existsSync16(abs) && [
47024
48779
  ".html",
47025
48780
  ".htm",
47026
48781
  ".css",
@@ -47034,7 +48789,7 @@ function planFixes(findings, root) {
47034
48789
  ".svelte",
47035
48790
  ".astro",
47036
48791
  ".php"
47037
- ].includes(extname3(abs).toLowerCase()) ? readFileSync10(abs, "utf8") : null
48792
+ ].includes(extname3(abs).toLowerCase()) ? utf8OrNull(readFileSync13(abs)) : null
47038
48793
  );
47039
48794
  } catch {
47040
48795
  cache.set(file, null);
@@ -47047,10 +48802,10 @@ function planFixes(findings, root) {
47047
48802
  for (const f of findings) {
47048
48803
  const fixer = FIXERS[f.ruleId];
47049
48804
  if (!fixer || !f.sources?.length) continue;
47050
- const plan = fixer(f, root, (file) => touched.get(file) ?? read2(file));
48805
+ const plan = fixer(f, root, (file) => touched.get(file) ?? read3(file));
47051
48806
  if (!plan) continue;
47052
48807
  for (const e of plan.edits) {
47053
- e.before = touched.get(e.file) ?? read2(e.file) ?? e.before;
48808
+ e.before = touched.get(e.file) ?? read3(e.file) ?? e.before;
47054
48809
  touched.set(e.file, e.after);
47055
48810
  }
47056
48811
  if (plans.some(
@@ -47062,119 +48817,21 @@ function planFixes(findings, root) {
47062
48817
  return plans;
47063
48818
  }
47064
48819
 
47065
- // src/repair/git.ts
47066
- init_errors();
47067
- init_fs_safety();
47068
- import { spawnSync as spawnSync2 } from "node:child_process";
47069
- import { existsSync as existsSync14, mkdirSync as mkdirSync6, readdirSync as readdirSync8, readFileSync as readFileSync11, writeFileSync as writeFileSync9 } from "node:fs";
47070
- import { dirname as dirname6, join as join16 } from "node:path";
47071
- function git(cwd, args) {
47072
- const r = spawnSync2("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
47073
- return { ok: r.status === 0, out: (r.stdout ?? "").trim(), err: (r.stderr ?? "").trim() };
47074
- }
47075
- function isGitRepo(root) {
47076
- return git(root, ["rev-parse", "--is-inside-work-tree"]).out === "true";
47077
- }
47078
- function untrackedFiles(root) {
47079
- return git(root, ["ls-files", "--others", "--exclude-standard"]).out.split("\n").filter(Boolean);
47080
- }
47081
- function createCheckpoint(root, wsRoot, note = "") {
47082
- if (!isGitRepo(root))
47083
- throw new JouriloError(
47084
- "INVALID_INPUT",
47085
- `${root} is not a git repository; checkpoints need git.`,
47086
- "Run `git init` and commit, or keep your own backup before applying fixes."
47087
- );
47088
- const head = git(root, ["rev-parse", "HEAD"]);
47089
- if (!head.ok)
47090
- throw new JouriloError(
47091
- "INVALID_INPUT",
47092
- "The repository has no commits yet; commit once before using checkpoints."
47093
- );
47094
- const stash = git(root, ["stash", "create", `jourilo checkpoint ${note}`.trim()]);
47095
- const cp = {
47096
- id: newRunId(),
47097
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
47098
- head: head.out,
47099
- snapshot: stash.out || head.out,
47100
- untracked: untrackedFiles(root),
47101
- note
47102
- };
47103
- git(root, ["update-ref", `refs/jourilo/checkpoints/${cp.id}`, cp.snapshot]);
47104
- const dir = join16(wsRoot, "checkpoints");
47105
- mkdirSync6(dir, { recursive: true });
47106
- writeFileSync9(join16(dir, `${cp.id}.json`), `${JSON.stringify(cp, null, 2)}
47107
- `);
47108
- return cp;
47109
- }
47110
- function loadCheckpoint(wsRoot, id) {
47111
- const dir = join16(wsRoot, "checkpoints");
47112
- const pick = id === "latest" ? existsSync14(dir) ? readdirSync8(dir).filter((f) => f.endsWith(".json")).sort().pop()?.replace(/\.json$/, "") : void 0 : id;
47113
- if (!pick || !/^[\w-]+$/.test(pick) || !existsSync14(join16(dir, `${pick}.json`)))
47114
- throw new JouriloError("NOT_FOUND", `Checkpoint not found: ${id}`);
47115
- return JSON.parse(readFileSync11(join16(dir, `${pick}.json`), "utf8"));
47116
- }
47117
- function diffSince(root, cp) {
47118
- const stat = git(root, ["diff", "--stat", cp.snapshot]).out;
47119
- const patch = git(root, ["diff", cp.snapshot]).out;
47120
- const newFiles = untrackedFiles(root).filter((f) => !cp.untracked.includes(f));
47121
- return { stat, patch, newFiles };
47122
- }
47123
- function backupDir(wsRoot, cp) {
47124
- return join16(wsRoot, "checkpoints", cp.id);
47125
- }
47126
- function backupFiles(root, wsRoot, cp, files) {
47127
- for (const f of files) {
47128
- const src = resolveInside(root, f);
47129
- if (!existsSync14(src)) continue;
47130
- const dest = resolveInside(backupDir(wsRoot, cp), f);
47131
- mkdirSync6(dirname6(dest), { recursive: true });
47132
- writeFileSync9(dest, readFileSync11(src));
48820
+ // src/report/compare.ts
48821
+ var RESOLUTION = { LCP: 150, FCP: 150, TBT: 100, CLS: 0.02, Transfer: 0 };
48822
+ function metricVerdict(name, a, b) {
48823
+ const delta = b.value - a.value;
48824
+ if (name === "Transfer") {
48825
+ if (Math.abs(delta) <= Math.max(2048, a.value * 0.02)) return "noise";
48826
+ return delta < 0 ? "improved" : "regressed";
47133
48827
  }
47134
- }
47135
- function blob(root, rev, file) {
47136
- const r = spawnSync2("git", ["cat-file", "blob", `${rev}:${file}`], {
47137
- cwd: root,
47138
- maxBuffer: 64 * 1024 * 1024
47139
- });
47140
- return r.status === 0 ? r.stdout : null;
47141
- }
47142
- function rollback(root, cp, wsRoot) {
47143
- const changed = git(root, ["diff", "--name-only", cp.snapshot]).out.split("\n").filter(Boolean);
47144
- const restored = [];
47145
- const notRestorable = [];
47146
- for (const f of changed) {
47147
- const target = resolveInside(root, f);
47148
- const saved = wsRoot ? resolveInside(backupDir(wsRoot, cp), f) : null;
47149
- if (saved && existsSync14(saved)) {
47150
- writeFileSync9(target, readFileSync11(saved));
47151
- restored.push(f);
47152
- continue;
47153
- }
47154
- const content = blob(root, cp.snapshot, f);
47155
- if (!content) {
47156
- notRestorable.push(f);
47157
- continue;
47158
- }
47159
- let out = content;
47160
- const current = existsSync14(target) ? readFileSync11(target) : null;
47161
- if (current?.includes("\r\n") && !content.includes("\r\n"))
47162
- out = Buffer.from(content.toString("utf8").replace(/\n/g, "\r\n"), "utf8");
47163
- mkdirSync6(dirname6(target), { recursive: true });
47164
- writeFileSync9(target, out);
47165
- restored.push(f);
48828
+ if (a.range && b.range) {
48829
+ const overlap = a.range[0] <= b.range[1] && b.range[0] <= a.range[1];
48830
+ if (overlap || Math.abs(delta) < (RESOLUTION[name] ?? 0) / 2) return "noise";
48831
+ return delta < 0 ? "improved" : "regressed";
47166
48832
  }
47167
- return {
47168
- restored,
47169
- // Files that did not exist at checkpoint time are listed, never deleted.
47170
- leftUntracked: [
47171
- ...notRestorable,
47172
- ...untrackedFiles(root).filter((f) => !cp.untracked.includes(f) && !notRestorable.includes(f))
47173
- ]
47174
- };
48833
+ return Math.abs(delta) < (RESOLUTION[name] ?? 0) ? "noise" : "unconfirmed";
47175
48834
  }
47176
-
47177
- // src/report/compare.ts
47178
48835
  function pathKey(u) {
47179
48836
  try {
47180
48837
  const x = new URL(u);
@@ -47200,7 +48857,15 @@ function compareRuns(base2, head) {
47200
48857
  const b = hp.metrics.find((m) => m.name === name);
47201
48858
  if (!a || !b) continue;
47202
48859
  const delta = Math.round((b.value - a.value) * 1e3) / 1e3;
47203
- metrics.push({ url: hp.url, viewport: hp.viewport, name, before: a.value, after: b.value, delta });
48860
+ metrics.push({
48861
+ url: hp.url,
48862
+ viewport: hp.viewport,
48863
+ name,
48864
+ before: a.value,
48865
+ after: b.value,
48866
+ delta,
48867
+ verdict: metricVerdict(name, a, b)
48868
+ });
47204
48869
  }
47205
48870
  }
47206
48871
  const regressionFree = !introduced.some(
@@ -47212,7 +48877,7 @@ function compareRuns(base2, head) {
47212
48877
  // src/cli/commands/optimize.ts
47213
48878
  var optimize = {
47214
48879
  name: "optimize",
47215
- summary: "Plan (and with --apply, make) minimal source fixes for located findings; shows diffs, checkpoints first.",
48880
+ summary: "Plan (and with --apply, make) minimal source fixes for located findings; shows diffs, writes one reversible change.",
47216
48881
  usage: "jourilo optimize [run=latest] --repo <path> [--rule id ...] [--apply]",
47217
48882
  options: {
47218
48883
  repo: { type: "string" },
@@ -47221,13 +48886,16 @@ var optimize = {
47221
48886
  apply: { type: "boolean" }
47222
48887
  },
47223
48888
  details: `Deterministic fixers (others are reported with source candidates for a person or agent to fix):
47224
- axe/html-has-lang, jourilo/missing-viewport-meta, jourilo/input-purpose, jourilo/focus-visible, jourilo/paste-blocked
47225
- Without --apply nothing is written. With --apply, a git checkpoint is created first (jourilo rollback undoes it).
48889
+ axe/html-has-lang, jourilo/missing-viewport-meta, jourilo/input-purpose, jourilo/focus-visible, jourilo/paste-blocked,
48890
+ jourilo/unsized-images, jourilo/placeholder-label
48891
+ Without --apply nothing is written. With --apply, all files are written as one change: each file must be unchanged
48892
+ since the plan, writes are atomic, an interrupted apply is undone, and \`jourilo rollback <id>\` restores the files
48893
+ (git is not required).
47226
48894
  Afterwards run \`jourilo verify <run>\` to repeat the audit and compare.`,
47227
48895
  async run(ctx) {
47228
48896
  const { run, dir } = ctx.ws.loadRun(ctx.positionals[0] ?? "latest");
47229
48897
  const repo = resolve9(str(ctx, "repo") ?? run.settings.repo ?? ".");
47230
- if (!existsSync15(repo)) throw new JouriloError("NOT_FOUND", `Repository not found: ${repo}`);
48898
+ if (!existsSync17(repo)) throw new JouriloError("NOT_FOUND", `Repository not found: ${repo}`);
47231
48899
  if (run.findings.every((f) => !f.sources)) locateFindings(run, repo);
47232
48900
  const rules = list(ctx, "rule");
47233
48901
  const only = list(ctx, "finding");
@@ -47254,23 +48922,29 @@ Afterwards run \`jourilo verify <run>\` to repeat the audit and compare.`,
47254
48922
  );
47255
48923
  let checkpoint2 = null;
47256
48924
  if (flag(ctx, "apply") && plans.length) {
47257
- const finalContent = /* @__PURE__ */ new Map();
47258
- for (const p of plans) for (const e of p.edits) finalContent.set(e.file, e.after);
47259
- if (isGitRepo(repo)) {
47260
- const cp = createCheckpoint(repo, ctx.ws.root, `before optimize of run ${run.id}`);
47261
- backupFiles(repo, ctx.ws.root, cp, [...finalContent.keys()]);
47262
- checkpoint2 = cp.id;
47263
- } else ctx.log("Warning: not a git repository, so no checkpoint/rollback is available.");
47264
- for (const [file, content] of finalContent) writeFileSync10(resolveInside(repo, file), content);
47265
- const outDir = join17(ctx.ws.root, "fixes");
47266
- mkdirSync7(outDir, { recursive: true });
48925
+ const edits = composeEdits(plans);
48926
+ const tx = applyTransaction(
48927
+ repo,
48928
+ ctx.ws.root,
48929
+ edits.map((e) => ({
48930
+ file: e.file,
48931
+ before: Buffer.from(e.before, "utf8"),
48932
+ after: Buffer.from(e.after, "utf8")
48933
+ })),
48934
+ {
48935
+ note: `optimize run ${run.id}`
48936
+ }
48937
+ );
48938
+ checkpoint2 = tx.id;
48939
+ const outDir = join20(ctx.ws.root, "fixes");
48940
+ mkdirSync8(outDir, { recursive: true });
47267
48941
  writeFileSync10(
47268
- join17(outDir, `${run.id}.json`),
48942
+ join20(outDir, `${run.id}.json`),
47269
48943
  `${JSON.stringify({ run: run.id, checkpoint: checkpoint2, applied: plans.map((p) => ({ fingerprint: p.fingerprint, ruleId: p.ruleId, title: p.title, files: p.edits.map((e) => e.file) })) }, null, 2)}
47270
48944
  `
47271
48945
  );
47272
48946
  lines.push(
47273
- `Applied ${plans.length} fix(es) to ${finalContent.size} file(s).${checkpoint2 ? ` Checkpoint ${checkpoint2} (jourilo rollback ${checkpoint2}).` : ""}`,
48947
+ `Applied ${plans.length} fix(es) to ${edits.length} file(s) as change ${checkpoint2} (undo: jourilo rollback ${checkpoint2} --yes).`,
47274
48948
  `Next: jourilo verify ${run.id}`
47275
48949
  );
47276
48950
  } else if (plans.length) {
@@ -47278,7 +48952,7 @@ Afterwards run \`jourilo verify <run>\` to repeat the audit and compare.`,
47278
48952
  }
47279
48953
  lines.push(
47280
48954
  "",
47281
- `${manual.length} other defect(s) need a person or agent; source candidates are in the report (${join17(dir, "report.html")}).`
48955
+ `${manual.length} other defect(s) need a person or agent; source candidates are in the report (${join20(dir, "report.html")}).`
47282
48956
  );
47283
48957
  return {
47284
48958
  data: {
@@ -47299,6 +48973,21 @@ Afterwards run \`jourilo verify <run>\` to repeat the audit and compare.`,
47299
48973
  };
47300
48974
  }
47301
48975
  };
48976
+ function metricSummary(metrics) {
48977
+ if (!metrics.length) return [];
48978
+ const n = (v) => metrics.filter((m) => m.verdict === v);
48979
+ const lines = [
48980
+ ` lab metrics: ${n("improved").length} improved \xB7 ${n("regressed").length} regressed \xB7 ${n("noise").length} within noise \xB7 ${n("unconfirmed").length} unconfirmed`,
48981
+ ...[...n("improved"), ...n("regressed")].map(
48982
+ (m) => ` ${m.verdict === "improved" ? "\u2193" : "\u2191"} ${m.name} ${new URL(m.url).pathname} (${m.viewport}): ${m.before} \u2192 ${m.after}`
48983
+ )
48984
+ ];
48985
+ if (n("unconfirmed").length)
48986
+ lines.push(
48987
+ " Unconfirmed changes come from single visits; repeat with `jourilo audit \u2026 --runs 5` to confirm."
48988
+ );
48989
+ return lines;
48990
+ }
47302
48991
  var verify = {
47303
48992
  name: "verify",
47304
48993
  summary: "Repeat a run with the same scope (pages, viewports) after a change and compare: fixed, new, persisting, metric deltas.",
@@ -47316,7 +49005,7 @@ var verify = {
47316
49005
  "INVALID_INPUT",
47317
49006
  `Run ${base2.id} has no recorded scope (created by an older version or by journeys).`
47318
49007
  );
47319
- const servedDir = (str(ctx, "target") && existsSync15(str(ctx, "target")) ? resolve9(str(ctx, "target")) : void 0) ?? base2.settings.servedDir;
49008
+ const servedDir = (str(ctx, "target") && existsSync17(str(ctx, "target")) ? resolve9(str(ctx, "target")) : void 0) ?? base2.settings.servedDir;
47320
49009
  const server = servedDir ? await serveStatic(servedDir) : void 0;
47321
49010
  try {
47322
49011
  const origin = server?.url ?? new URL(str(ctx, "target") ?? scope.urls[0]).origin;
@@ -47337,6 +49026,8 @@ var verify = {
47337
49026
  allowPrivate: base2.settings.hostClass === "private",
47338
49027
  keyboard: base2.settings.keyboard !== false,
47339
49028
  throttle: base2.settings.throttle === true,
49029
+ // Same number of visits as the baseline, so medians are compared with medians.
49030
+ runs: typeof base2.settings.runs === "number" ? base2.settings.runs : 1,
47340
49031
  timeoutMs: 3e4,
47341
49032
  mask: [],
47342
49033
  mode: base2.mode,
@@ -47372,6 +49063,7 @@ var verify = {
47372
49063
  ` fixed ${cmp.fixed.length} \xB7 new ${cmp.introduced.length} \xB7 still present ${cmp.persisting.length}`,
47373
49064
  ...cmp.fixed.map((f) => ` \u2713 fixed ${f.title} (${new URL(f.url).pathname})`),
47374
49065
  ...cmp.introduced.map((f) => ` \u2717 new ${f.severity} ${f.title} (${new URL(f.url).pathname})`),
49066
+ ...metricSummary(cmp.metrics),
47375
49067
  cmp.regressionFree ? " No new serious/critical defects." : " \u26A0 Regression: new serious/critical defects.",
47376
49068
  ` Report: ${reports.html}`
47377
49069
  ].join("\n"),
@@ -47391,6 +49083,11 @@ var checkpoint = {
47391
49083
  const repo = resolve9(str(ctx, "repo") ?? ".");
47392
49084
  if (ctx.positionals[0] === "diff") {
47393
49085
  const cp2 = loadCheckpoint(ctx.ws.root, ctx.positionals[1] ?? "latest");
49086
+ if (isTransaction(cp2))
49087
+ return {
49088
+ data: { transaction: cp2, files: previewRollback(cp2) },
49089
+ text: previewRollback(cp2).map((f) => `${f.action.padEnd(9)} ${f.file}${f.reason ? ` (${f.reason})` : ""}`).join("\n")
49090
+ };
47394
49091
  const d = diffSince(repo, cp2);
47395
49092
  return {
47396
49093
  data: { checkpoint: cp2, ...d },
@@ -47409,26 +49106,78 @@ ${d.patch}`
47409
49106
  };
47410
49107
  var rollbackCmd = {
47411
49108
  name: "rollback",
47412
- summary: "Restore tracked files to a checkpoint (requires --yes). Untracked files created since are listed, not deleted.",
47413
- usage: "jourilo rollback <checkpoint-id|latest> --yes [--repo .]",
47414
- options: { repo: { type: "string" }, yes: { type: "boolean" } },
49109
+ summary: "Undo an applied fix (or restore a checkpoint) without overwriting later edits; requires --yes. The rollback can itself be undone.",
49110
+ usage: "jourilo rollback <id|latest> --yes [--repo .] [--file path ...] [--force]",
49111
+ options: {
49112
+ repo: { type: "string" },
49113
+ yes: { type: "boolean" },
49114
+ file: { type: "string", multiple: true },
49115
+ force: { type: "boolean" }
49116
+ },
49117
+ details: `For a change made by \`optimize --apply\` (or a previous rollback), a file is restored only while it still
49118
+ holds exactly what Jourilo wrote. A file edited afterwards is reported as a conflict and left alone; --force restores
49119
+ it anyway after saving the current bytes under .jourilo/checkpoints/<id>/conflicts/.
49120
+ For a checkpoint made with \`jourilo checkpoint\`, every tracked file changed since then is restored (everything you
49121
+ or an agent changed in the meantime); the current bytes are kept, and the printed id undoes the rollback.
49122
+ Untracked files created after a checkpoint are listed, never deleted.`,
47415
49123
  async run(ctx) {
49124
+ const rec = loadCheckpoint(ctx.ws.root, ctx.positionals[0] ?? "latest");
49125
+ const files = list(ctx, "file");
49126
+ if (isTransaction(rec)) {
49127
+ const preview = previewRollback(rec).filter((f) => !files.length || files.includes(f.file));
49128
+ if (!flag(ctx, "yes"))
49129
+ return {
49130
+ data: { transaction: rec.id, preview },
49131
+ text: [
49132
+ `Change ${rec.id} (${rec.note || "no note"}), ${rec.state}:`,
49133
+ ...preview.map((f) => ` ${f.action.padEnd(9)} ${f.file}${f.reason ? ` \u2014 ${f.reason}` : ""}`),
49134
+ preview.some((f) => f.action === "conflict") ? "Conflicting files are left alone unless you add --force (their current bytes are saved first)." : "",
49135
+ "Re-run with --yes to roll back."
49136
+ ].filter(Boolean).join("\n")
49137
+ };
49138
+ const r2 = rollbackTransaction(ctx.ws.root, rec, {
49139
+ force: flag(ctx, "force"),
49140
+ ...files.length ? { files } : {}
49141
+ });
49142
+ const conflicted = r2.conflicts.filter((c) => !c.savedCopy && !flag(ctx, "force"));
49143
+ return {
49144
+ data: { transaction: rec.id, ...r2 },
49145
+ text: [
49146
+ `Restored ${r2.restored.length} file(s) from change ${rec.id}.`,
49147
+ ...r2.alreadyRestored.map((f) => ` unchanged ${f} (already original)`),
49148
+ ...r2.conflicts.map(
49149
+ (c) => ` ${c.savedCopy ? "forced " : "conflict "} ${c.file} \u2014 ${c.reason}${c.savedCopy ? ` (your version saved to ${displayPath(c.savedCopy)})` : ""}`
49150
+ ),
49151
+ conflicted.length ? "Conflicting files were not touched. Use --force to restore them anyway." : ""
49152
+ ].filter(Boolean).join("\n"),
49153
+ exitCode: conflicted.length ? 1 : 0
49154
+ };
49155
+ }
49156
+ const cp = rec;
47416
49157
  const repo = resolve9(str(ctx, "repo") ?? ".");
47417
- const cp = loadCheckpoint(ctx.ws.root, ctx.positionals[0] ?? "latest");
47418
49158
  const d = diffSince(repo, cp);
47419
49159
  if (!flag(ctx, "yes"))
47420
49160
  return {
47421
49161
  data: { checkpoint: cp, wouldRestore: d.stat, newFiles: d.newFiles },
47422
- text: `Would restore:
49162
+ text: `Would restore (every change since the checkpoint, including your own edits):
47423
49163
  ${d.stat || "(nothing)"}
47424
- Re-run with --yes to roll back.`,
49164
+ The current versions are kept and the rollback can be undone. Re-run with --yes to roll back.`,
47425
49165
  exitCode: 0
47426
49166
  };
47427
- const r = rollback(repo, cp, ctx.ws.root);
49167
+ const r = rollbackCheckpoint(repo, cp, ctx.ws.root, files.length ? { files } : {});
49168
+ if (r.transaction && !files.length) markRolledBack(ctx.ws.root, cp, r.transaction.id);
47428
49169
  return {
47429
- data: r,
47430
- text: `Restored ${r.restored.length} file(s) to checkpoint ${cp.id}.${r.leftUntracked.length ? `
47431
- Not removed (created after the checkpoint): ${r.leftUntracked.join(", ")}` : ""}`
49170
+ data: {
49171
+ checkpoint: cp.id,
49172
+ transaction: r.transaction?.id ?? null,
49173
+ restored: r.restored,
49174
+ leftUntracked: r.leftUntracked
49175
+ },
49176
+ text: [
49177
+ `Restored ${r.restored.length} file(s) to checkpoint ${cp.id}.`,
49178
+ r.transaction ? `Undo this rollback with: jourilo rollback ${r.transaction.id} --yes` : "",
49179
+ r.leftUntracked.length ? `Not removed (created after the checkpoint): ${r.leftUntracked.join(", ")}` : ""
49180
+ ].filter(Boolean).join("\n")
47432
49181
  };
47433
49182
  }
47434
49183
  };
@@ -47675,7 +49424,18 @@ function usage() {
47675
49424
  lines.push("Global options: --json (machine-readable stdout), --workspace DIR (default .jourilo), --quiet");
47676
49425
  return lines.join("\n");
47677
49426
  }
49427
+ function nodeSupported(version) {
49428
+ const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
49429
+ return major > 20 || major === 20 && minor >= 10;
49430
+ }
47678
49431
  async function main(argv) {
49432
+ if (!nodeSupported(process.versions.node)) {
49433
+ process.stderr.write(
49434
+ `Jourilo needs Node.js 20.10 or newer (this is ${process.versions.node}). Install a current LTS from https://nodejs.org.
49435
+ `
49436
+ );
49437
+ return 2;
49438
+ }
47679
49439
  const [name, ...rest] = argv;
47680
49440
  if (!name || name === "--help" || name === "-h" || name === "help") {
47681
49441
  process.stdout.write(`${usage()}
@@ -47736,7 +49496,8 @@ main(process.argv.slice(2)).then((code) => {
47736
49496
  process.exitCode = code;
47737
49497
  });
47738
49498
  export {
47739
- main
49499
+ main,
49500
+ nodeSupported
47740
49501
  };
47741
49502
  /*! Bundled license information:
47742
49503