@olomonhelpdesk2/helpdesk 0.1.5 → 0.1.9

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.
@@ -51,6 +51,18 @@ function unusablePath(path3) {
51
51
  if (/\[|\]/.test(path3)) return "the path still holds a dynamic segment rather than a record";
52
52
  return void 0;
53
53
  }
54
+ var PLAIN_QUERY = /^[\w.~%:+,-]+=[\w.~%:+,-]*(?:&[\w.~%:+,-]+=[\w.~%:+,-]*)*$/;
55
+ function queryFor(route, named) {
56
+ const mark = named.indexOf("?");
57
+ const path3 = mark < 0 ? named : named.slice(0, mark);
58
+ const query = mark < 0 ? "" : named.slice(mark + 1);
59
+ if (!query) return { because: "it has no dynamic segment, so the only thing to name for it is the query it opens with" };
60
+ if (path3 !== route) return { because: `${named} is not ${route} with a query` };
61
+ const unusable = unusablePath(route);
62
+ if (unusable) return { because: unusable };
63
+ if (named.length > MAX_PATH || !PLAIN_QUERY.test(query)) return { because: "the query is not a plain list of names and values" };
64
+ return { visit: named };
65
+ }
54
66
  function readExampleRoutes(raw) {
55
67
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
56
68
  return { ok: false, because: "the file is not a JSON object" };
@@ -68,14 +80,20 @@ function readExampleRoutes(raw) {
68
80
  const paths = /* @__PURE__ */ new Map();
69
81
  const refused = [];
70
82
  for (const [route, value] of entries) {
71
- if (!route.startsWith("/") || dynamicSegments(route).length === 0) {
72
- refused.push({ route, because: "it is not a route with a dynamic segment" });
83
+ if (!route.startsWith("/")) {
84
+ refused.push({ route, because: "it is not a route" });
73
85
  continue;
74
86
  }
75
87
  if (typeof value !== "string") {
76
88
  refused.push({ route, because: "its example is not a path" });
77
89
  continue;
78
90
  }
91
+ if (dynamicSegments(route).length === 0) {
92
+ const opened = queryFor(route, value.trim());
93
+ if ("because" in opened) refused.push({ route, because: opened.because });
94
+ else paths.set(route, opened.visit);
95
+ continue;
96
+ }
79
97
  const path3 = value.trim();
80
98
  const unusable = unusablePath(path3);
81
99
  if (unusable) {
@@ -92,8 +110,8 @@ function readExampleRoutes(raw) {
92
110
  }
93
111
  function resolveVisit(route, examples) {
94
112
  const needs = dynamicSegments(route);
95
- if (needs.length === 0) return { visit: route };
96
113
  const path3 = examples.paths.get(route);
114
+ if (needs.length === 0) return { visit: path3 ?? route };
97
115
  return path3 ? { visit: path3 } : { needs };
98
116
  }
99
117
  function describeMissingExample(route, needs) {
@@ -165,7 +183,7 @@ function actionFor(outcome) {
165
183
  return void 0;
166
184
  case "not-found": {
167
185
  if ((!outcome.missingEndpoint || outcome.missingEndpoint === outcome.route) && !outcome.route.includes("[")) {
168
- return `${outcome.route} answered Not Found here, and its address names no record to create. If it is behind a feature flag, turn the flag on where the run happens; if it is gone, it will drop out of the plan.`;
186
+ return `${outcome.route} answered Not Found here, and its address names no record to create. If it is behind a feature flag, turn the flag on where the run happens. If it opens only from a record, as a form started from one does, name the address it opens at in the file the seed command writes at ${CI_EXAMPLES_OUT_ENV}. If it is gone, it will drop out of the plan.`;
169
187
  }
170
188
  const asked = outcome.missingEndpoint ?? outcome.route;
171
189
  const noun = subjectOf(fillsPattern(outcome.route, asked) ? outcome.route : asked);
@@ -340,40 +358,40 @@ function preflight(facts) {
340
358
  name: "target",
341
359
  ok: true,
342
360
  blocking: false,
343
- detail: `${facts.baseUrl}, reading from ${facts.replaying} \u2014 no live backend is reachable from this run, so nothing on these screens can be a real person's`
361
+ detail: `${facts.baseUrl}, reading from ${facts.replaying}. No live backend is reachable from this run, so nothing on these screens can be a real person's`
344
362
  });
345
363
  } else if (facts.session && live && !facts.productionGranted) {
346
364
  checks.push({
347
365
  name: "target",
348
366
  ok: false,
349
367
  blocking: true,
350
- detail: `${facts.baseUrl} does not look like a demo or staging address, and this run has a session. A help article shows the typical state of a screen, not somebody's data \u2014 point this at a demo tenant, or pass --allow-production if that address really is the seeded one.`
368
+ detail: `${facts.baseUrl} does not look like a demo or staging address, and this run has a session. A help article shows the typical state of a screen, not somebody's data. Point this at a demo tenant, or pass --allow-production if that address really is the seeded one.`
351
369
  });
352
370
  } else {
353
371
  checks.push({
354
372
  name: "target",
355
373
  ok: true,
356
374
  blocking: false,
357
- detail: facts.session ? `${facts.baseUrl}, signed in${live ? " \u2014 accepted as seeded by --allow-production" : " as a demo tenant"}` : `${facts.baseUrl}, signed in as nobody`
375
+ detail: facts.session ? `${facts.baseUrl}, signed in${live ? ", accepted as seeded by --allow-production" : " as a demo tenant"}` : `${facts.baseUrl}, signed in as nobody`
358
376
  });
359
377
  }
360
378
  checks.push({
361
379
  name: "browser",
362
380
  ok: Boolean(facts.browser),
363
381
  blocking: false,
364
- detail: facts.browser ? `${facts.browser}, recorded on every frame` : "not identified \u2014 a browser upgrade will look like the product changing"
382
+ detail: facts.browser ? `${facts.browser}, recorded on every frame` : "not identified. A browser upgrade will look like the product changing"
365
383
  });
366
384
  checks.push({
367
385
  name: "clock",
368
386
  ok: facts.clockPinned,
369
387
  blocking: false,
370
- detail: facts.clockPinned ? "pinned, with randomness and generated ids seeded from it" : "running live (--live-clock) \u2014 screens showing a date will differ every run, so nothing here can tell you whether they changed"
388
+ detail: facts.clockPinned ? "pinned, with randomness and generated ids seeded from it" : "running live (--live-clock). Screens showing a date will differ every run, so nothing here can tell you whether they changed"
371
389
  });
372
390
  checks.push({
373
391
  name: "locale",
374
392
  ok: true,
375
393
  blocking: false,
376
- detail: `${facts.locale}, ${facts.timezone} \u2014 set explicitly rather than inherited from this machine`
394
+ detail: `${facts.locale}, ${facts.timezone}, set explicitly rather than inherited from this machine`
377
395
  });
378
396
  checks.push({
379
397
  name: "isolation",
@@ -398,7 +416,7 @@ function preflight(facts) {
398
416
  name: "rebaseline",
399
417
  ok: true,
400
418
  blocking: false,
401
- detail: `replacing every stored frame \u2014 "${facts.rebaselineReason}"`
419
+ detail: `replacing every stored frame: "${facts.rebaselineReason}"`
402
420
  });
403
421
  }
404
422
  const blocked = checks.filter((check) => check.blocking && !check.ok);
@@ -425,11 +443,30 @@ function attestation(run) {
425
443
  ].filter(Boolean).join("\n");
426
444
  }
427
445
 
446
+ // core/capture/run-record.ts
447
+ function runIdentityFromEnv(env) {
448
+ if (env["GITHUB_ACTIONS"] !== "true" || !env["GITHUB_RUN_NUMBER"]) return void 0;
449
+ const said = { number: env["GITHUB_RUN_NUMBER"] };
450
+ for (const [key, name] of [
451
+ ["id", "GITHUB_RUN_ID"],
452
+ ["attempt", "GITHUB_RUN_ATTEMPT"],
453
+ ["repository", "GITHUB_REPOSITORY"],
454
+ ["server", "GITHUB_SERVER_URL"]
455
+ ]) {
456
+ const value = env[name];
457
+ if (value) said[key] = value;
458
+ }
459
+ return said;
460
+ }
461
+
428
462
  // core/capture/run-verdict.ts
463
+ function runCounts(run) {
464
+ return `${run.captured} photographed, ${run.unchanged} unchanged, ${run.skipped} skipped, ${run.refused} refused, ${run.failed} failed`;
465
+ }
429
466
  function runVerdict(run) {
430
467
  const delivered = run.captured + run.unchanged;
431
468
  const missed = run.skipped + run.refused + run.failed;
432
- const counts = `${run.captured} photographed, ${run.unchanged} unchanged, ${run.skipped} skipped, ${run.refused} refused, ${run.failed} failed`;
469
+ const counts = runCounts(run);
433
470
  if (run.screens > 0 && delivered === 0) {
434
471
  return {
435
472
  exit: 1,
@@ -580,6 +617,8 @@ var ALREADY_SAFE = [
580
617
  function alreadySafe(kind, value) {
581
618
  return ALREADY_SAFE.some((entry) => entry.kind === kind && entry.regex.test(value));
582
619
  }
620
+ var BIRTH_LABEL = /(?:\bdate[\s_-]*of[\s_-]*birth|\bbirth[\s_-]*(?:date|day)|\bdob\b|\bd\.o\.b\b|\bborn\b)/i;
621
+ var LABEL_REACH = 48;
583
622
  var PATTERNS = [
584
623
  {
585
624
  kind: "email",
@@ -629,8 +668,9 @@ var PATTERNS = [
629
668
  {
630
669
  kind: "date-of-birth",
631
670
  regex: /\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/g,
632
- because: "a full date \u2014 if it is a birth date it identifies somebody",
633
- replace: () => "1970-01-01"
671
+ because: "a date labeled as a birth date, which identifies somebody",
672
+ replace: () => "1970-01-01",
673
+ labelled: BIRTH_LABEL
634
674
  },
635
675
  {
636
676
  kind: "passport",
@@ -651,8 +691,13 @@ function scrub(text2, options = {}) {
651
691
  let output = text2;
652
692
  for (const pattern of PATTERNS) {
653
693
  const seen = /* @__PURE__ */ new Map();
654
- output = output.replace(pattern.regex, (match) => {
694
+ output = output.replace(pattern.regex, (...args) => {
695
+ const match = args[0];
696
+ const named = typeof args[args.length - 1] === "object";
697
+ const offset = args[args.length - (named ? 3 : 2)];
698
+ const whole = args[args.length - (named ? 2 : 1)];
655
699
  if (alreadySafe(pattern.kind, match)) return match;
700
+ if (pattern.labelled && !pattern.labelled.test(whole.slice(Math.max(0, offset - LABEL_REACH), offset))) return match;
656
701
  const already = seen.get(match);
657
702
  if (already) return already;
658
703
  const replacement = pattern.replace(match, seen.size);
@@ -669,7 +714,7 @@ function scrub(text2, options = {}) {
669
714
  found: brand,
670
715
  replacement: brand,
671
716
  where,
672
- because: "a real institution in a published screenshot implies a partnership \u2014 a legal review item, not a design nit"
717
+ because: "a real institution in a published screenshot implies a partnership: a legal review item, not a design nit"
673
718
  });
674
719
  }
675
720
  }
@@ -697,7 +742,7 @@ function gateFrame(pageText, options = {}) {
697
742
  return {
698
743
  safe: false,
699
744
  findings,
700
- reason: onlyBrands ? `Not photographed${where}: the screen names ${named.join(", ")}. A real company in a published screenshot implies a partnership, which is a legal review item rather than a design nit \u2014 so somebody has to say whether it belongs there. If these names are on this screen on purpose, list them on the capture approval and this screen is photographed as it is.` : `Not photographed${where}: the screen showed ${kinds.join(", ")} that looks real. A screenshot cannot be redacted afterwards, so it is refused rather than cleaned \u2014 point the capture at a demo tenant, or fix the seed that produced this.`
745
+ reason: onlyBrands ? `Not photographed${where}: the screen names ${named.join(", ")}. A real company in a published screenshot implies a partnership, which is a legal review item rather than a design nit, so somebody has to say whether it belongs there. If these names are on this screen on purpose, list them on the capture approval and this screen is photographed as it is.` : `Not photographed${where}: the screen showed ${kinds.join(", ")} that looks real. A screenshot cannot be redacted afterwards, so it is refused rather than cleaned. Point the capture at a demo tenant, or fix the seed that produced this.`
701
746
  };
702
747
  }
703
748
 
@@ -1037,11 +1082,22 @@ var LIMITS = {
1037
1082
  fields: 24,
1038
1083
  notices: 20,
1039
1084
  warnings: 12,
1085
+ disabled: 24,
1086
+ empty: 12,
1087
+ gatedBy: 8,
1040
1088
  choices: 12,
1041
1089
  /** Longer than this is a paragraph of copy, not a label. */
1042
1090
  label: 120,
1043
1091
  sentence: 300
1044
1092
  };
1093
+ var EMPTY_STATE = /^(?:no\s+(?!(?:longer|need|problem|worries|matter|one|way|thanks)\b)[a-z]|nothing\b|none\s|you (?:have|haven't|don't have|do not have)\s+(?:no|any|not)\b|there(?:'s| (?:are|is|were))\s+(?:no|nothing)\b|there aren't any\b|(?:you|we) haven't\b.*\byet\b)/i;
1094
+ function isEmptyState(text2) {
1095
+ const sentence = text2.replace(/[\u2018\u2019]/g, "'").replace(/\s+/g, " ").trim();
1096
+ if (!sentence || sentence.length > LIMITS.sentence || sentence.includes("?")) return false;
1097
+ if (/\b(?:until|unless)\b/i.test(sentence)) return false;
1098
+ if (sentence.split(" ").length < 2) return false;
1099
+ return EMPTY_STATE.test(sentence);
1100
+ }
1045
1101
 
1046
1102
  // core/discovery/aria-facts.ts
1047
1103
  var LANDMARK = {
@@ -1092,6 +1148,12 @@ function readAriaFactRecords(snapshot, options = {}) {
1092
1148
  const state = controlState(node);
1093
1149
  if (node.role === "heading") {
1094
1150
  add("heading", node.name || node.text, node);
1151
+ if (isEmptyState(node.name || node.text)) add("empty", node.name || node.text, node);
1152
+ continue;
1153
+ }
1154
+ if (node.role === "paragraph" || node.role === "text") {
1155
+ const said = node.text || node.name;
1156
+ if (isEmptyState(said)) add("empty", trim(said, LIMITS.sentence), node);
1095
1157
  continue;
1096
1158
  }
1097
1159
  if (node.role === "link") {
@@ -1115,7 +1177,9 @@ function readAriaFactRecords(snapshot, options = {}) {
1115
1177
  continue;
1116
1178
  }
1117
1179
  if (NOTICE.has(node.role)) {
1118
- add("notice", trim(node.text || node.name, LIMITS.sentence), node);
1180
+ const said = trim(node.text || node.name, LIMITS.sentence);
1181
+ add("notice", said, node);
1182
+ if (isEmptyState(said)) add("empty", said, node);
1119
1183
  }
1120
1184
  }
1121
1185
  return facts;
@@ -1152,7 +1216,7 @@ function parse(snapshot) {
1152
1216
  if (node.name || node.text) parent.choices.push(node.name || node.text);
1153
1217
  continue;
1154
1218
  }
1155
- if (role !== "text") out.push(node);
1219
+ out.push(node);
1156
1220
  stack.push({ depth: depth2, node });
1157
1221
  }
1158
1222
  return out;
@@ -1235,6 +1299,7 @@ function readRenderedFactRecords(html, options = {}) {
1235
1299
  const label = accessibleName(element);
1236
1300
  if (HEADING.test(tag) || role === "heading") {
1237
1301
  add("heading", label, landmark);
1302
+ if (isEmptyState(label)) add("empty", label, landmark);
1238
1303
  continue;
1239
1304
  }
1240
1305
  if (tag === "button" || PRESSABLE.has(role) || submitLike(element)) {
@@ -1260,11 +1325,20 @@ function readRenderedFactRecords(html, options = {}) {
1260
1325
  continue;
1261
1326
  }
1262
1327
  if (ANNOUNCEMENT.has(role) || attributes["aria-live"]) {
1263
- add("notice", trim2(text(inner), LIMITS.sentence), landmark);
1328
+ const said = trim2(text(inner), LIMITS.sentence);
1329
+ add("notice", said, landmark);
1330
+ if (isEmptyState(said)) add("empty", said, landmark);
1331
+ continue;
1332
+ }
1333
+ if (TEXT_HOLDER.has(tag) && !BLOCK_INSIDE.test(inner)) {
1334
+ const said = trim2(text(inner).replace(/\s+([.,;:!])/g, "$1"), LIMITS.sentence);
1335
+ if (isEmptyState(said)) add("empty", said, landmark);
1264
1336
  }
1265
1337
  }
1266
1338
  return facts;
1267
1339
  }
1340
+ var TEXT_HOLDER = /* @__PURE__ */ new Set(["p", "span", "small", "td", "li", "div"]);
1341
+ var BLOCK_INSIDE = /<(?!\/?(?:strong|em|b|i|a|code|br|span|small)\b)[a-z]/i;
1268
1342
  var OVERLAY_ROLE = {
1269
1343
  dialog: "dialog",
1270
1344
  alertdialog: "dialog",
@@ -1616,7 +1690,7 @@ function requireDeployment(producer, input) {
1616
1690
  const value = deployment.value.trim();
1617
1691
  if (BROWSER_SHAPED.test(value) || value === input.captureEnvironment?.trim()) {
1618
1692
  throw new UnstampedObservation(
1619
- `A ${producer} observation gives "${value}" as the deployment that answered it. That names the browser that did the reading, which belongs in captureEnvironment. What is wanted here is what the application was running \u2014 a deployment id, a build id, a release SHA \u2014 or unknownDeployment("why not").`
1693
+ `A ${producer} observation gives "${value}" as the deployment that answered it. That names the browser that did the reading, which belongs in captureEnvironment. What is wanted here is what the application was running (a deployment id, a build id, a release SHA) or unknownDeployment("why not").`
1620
1694
  );
1621
1695
  }
1622
1696
  }
@@ -1665,7 +1739,7 @@ async function captureOne(options, shot, build) {
1665
1739
  status: "skipped",
1666
1740
  result: "bounced",
1667
1741
  needsAccount: true,
1668
- reason: "a signed-out visitor is shown a sign-in here, so this screen needs a demo tenant \u2014 never a real account"
1742
+ reason: "a signed-out visitor is shown a sign-in here, so this screen needs a demo tenant, never a real account"
1669
1743
  },
1670
1744
  observed: { route: shot.route, result: "bounced" }
1671
1745
  };
@@ -1853,7 +1927,7 @@ async function captureOne(options, shot, build) {
1853
1927
  sha256,
1854
1928
  settled: frame.settled,
1855
1929
  ...frame.settled ? observed.landed ? { reason: `followed its redirect to ${observed.landed}` } : {} : {
1856
- reason: "photographed, but this screen never stopped moving \u2014 its hash will differ every run, so drift on it means nothing"
1930
+ reason: "photographed, but this screen never stopped moving. Its hash will differ every run, so drift on it means nothing"
1857
1931
  }
1858
1932
  } : {
1859
1933
  shotId: shot.id,
@@ -2546,7 +2620,7 @@ async function stageAgainstRecording(options) {
2546
2620
  died = code ?? 1;
2547
2621
  });
2548
2622
  } else {
2549
- options.onLog?.(`The recording is at ${replay.url} \u2014 start your product with ${variable} set to it.`);
2623
+ options.onLog?.(`The recording is at ${replay.url}. Start your product with ${variable} set to it.`);
2550
2624
  }
2551
2625
  const up = await waitForApp(options.appUrl, Date.now() + (options.waitMs ?? 6e4), options.onLog, () => died !== void 0);
2552
2626
  if (app) await new Promise((resolve) => setTimeout(resolve, 250));
@@ -2557,7 +2631,7 @@ async function stageAgainstRecording(options) {
2557
2631
  throw new Error(
2558
2632
  `The start command exited (${died}) before the product was ready. Its output is above.` + (up ? `
2559
2633
 
2560
- Something else is already answering on ${options.appUrl}. That is almost always a server left behind by an earlier run \u2014 this will not photograph it, because it is not the product this run started.` : "")
2634
+ Something else is already answering on ${options.appUrl}. That is almost always a server left behind by an earlier run. This will not photograph it, because it is not the product this run started.` : "")
2561
2635
  );
2562
2636
  }
2563
2637
  if (!up) {
@@ -2748,7 +2822,7 @@ function gapReport(gaps) {
2748
2822
  ""
2749
2823
  ];
2750
2824
  for (const gap of gaps) {
2751
- const broke = gap.broke.length ? ` \u2014 breaks ${gap.broke.join(", ")}` : "";
2825
+ const broke = gap.broke.length ? ` (breaks ${gap.broke.join(", ")})` : "";
2752
2826
  lines.push(` ${gap.method} ${gap.path}${broke}`);
2753
2827
  if (gap.path !== gap.example) lines.push(` for example ${gap.example}`);
2754
2828
  }
@@ -2795,7 +2869,7 @@ function coverageReport(found, total) {
2795
2869
  ...found.missing.map((route) => ` ${route}`),
2796
2870
  "",
2797
2871
  "They will not be photographed. Record again and visit them, or they stay out of the",
2798
- "plan \u2014 an empty screen photographed from a gap in the recording looks exactly like an",
2872
+ "plan: an empty screen photographed from a gap in the recording looks exactly like an",
2799
2873
  "empty screen belonging to a customer with nothing, which is how a help article ends up",
2800
2874
  "showing a product with no content in it."
2801
2875
  ];
@@ -2991,7 +3065,7 @@ function score(route, signals) {
2991
3065
  }
2992
3066
  if (hasSegment(route.path, MONEY_OR_ACCESS)) {
2993
3067
  total += 18;
2994
- because.push("money or access \u2014 asked about out of proportion to its traffic");
3068
+ because.push("money or access: asked about out of proportion to its traffic");
2995
3069
  }
2996
3070
  if (hasSegment(route.path, ENTRY_POINT)) {
2997
3071
  total += 16;
@@ -3087,6 +3161,16 @@ function buildArticlePlan(options) {
3087
3161
  function repositoryFiles(cwd) {
3088
3162
  return execFileSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).split("\n").filter(Boolean);
3089
3163
  }
3164
+ var WORKFLOW_ACTIONS = {
3165
+ checkout: "actions/checkout@v7",
3166
+ setupNode: "actions/setup-node@v7"
3167
+ };
3168
+ var SETUP_STEPS = ` - uses: ${WORKFLOW_ACTIONS.checkout}
3169
+ - uses: ${WORKFLOW_ACTIONS.setupNode}
3170
+ with:
3171
+ node-version: "22"
3172
+ package-manager-cache: false
3173
+ `;
3090
3174
 
3091
3175
  // core/discovery/storyboard.ts
3092
3176
  function shotIdFor(route) {
@@ -3322,6 +3406,10 @@ Approve one on the plan page, then run this again.`
3322
3406
  `
3323
3407
  );
3324
3408
  const runId = local ? void 0 : await openRun(parsed.to, headers, shots.length);
3409
+ let told = Promise.resolve();
3410
+ const tell = (body) => {
3411
+ told = told.then(() => tellHelpDesk(parsed.to, headers, body));
3412
+ };
3325
3413
  try {
3326
3414
  const report = await runCapture({
3327
3415
  baseUrl,
@@ -3354,13 +3442,16 @@ Approve one on the plan page, then run this again.`
3354
3442
  role: parsed.session ? unknownRole() : noRole("this run was signed out"),
3355
3443
  onProgress: (outcome, index, total) => {
3356
3444
  const mark = { captured: "\u2713", unchanged: "=", skipped: "\u2013", refused: "\u2717", failed: "!" }[outcome.status];
3357
- console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ? ` \u2014 ${outcome.reason}` : ""}`);
3445
+ console.log(`${mark} ${index}/${total} ${outcome.route}${outcome.reason ? `: ${outcome.reason}` : ""}`);
3358
3446
  if (runId) {
3359
- void tellHelpDesk(parsed.to, headers, {
3447
+ tell({
3360
3448
  action: "progress",
3361
3449
  jobId: runId,
3362
3450
  item: outcome.route,
3451
+ // Kept for a help desk that predates `outcome`, which folds the
3452
+ // five into three and cannot tell a refusal from a skip.
3363
3453
  status: outcome.status === "captured" || outcome.status === "unchanged" ? "ok" : outcome.status === "failed" ? "failed" : "skipped",
3454
+ outcome: outcome.status,
3364
3455
  ...outcome.reason ? { note: outcome.reason } : {},
3365
3456
  // So Setup can ask about them, once each. See `core/capture/unclassified.ts`.
3366
3457
  ...outcome.unaccounted?.length ? { unaccounted: outcome.unaccounted } : {}
@@ -3413,10 +3504,8 @@ Approve one on the plan page, then run this again.`
3413
3504
  return { ok: false, error: body.error ?? `the help desk answered ${response.status}` };
3414
3505
  }
3415
3506
  });
3416
- console.log(
3417
- `
3418
- ${report.captured} photographed, ${report.unchanged} unchanged, ${report.skipped} skipped, ${report.refused} refused, ${report.failed} failed.`
3419
- );
3507
+ console.log(`
3508
+ ${runCounts(report)}.`);
3420
3509
  const unclassified = unclassifiedLines(
3421
3510
  groupUnclassified(
3422
3511
  report.outcomes.flatMap((outcome) => outcome.unaccounted?.length ? [{ route: outcome.route, values: outcome.unaccounted }] : [])
@@ -3481,7 +3570,7 @@ whether they changed. Usually a spinner that never resolves or a looping animati
3481
3570
  const gated = report.outcomes.filter((outcome) => outcome.needsAccount);
3482
3571
  if (gated.length) {
3483
3572
  console.log(
3484
- stage ? "\nSome screens still showed a sign-in. A recording answers the backend, but it cannot\ngive the product a session \u2014 record the sign-in itself, or save one against the replayed\nproduct and pass it with --session." : parsed.session ? "\nSome screens still showed a sign-in. The saved session may have expired \u2014 run `sign-in` again." : `
3573
+ stage ? "\nSome screens still showed a sign-in. A recording answers the backend, but it cannot\ngive the product a session: record the sign-in itself, or save one against the replayed\nproduct and pass it with --session." : parsed.session ? "\nSome screens still showed a sign-in. The saved session may have expired. Run `sign-in` again." : `
3485
3574
  Some screens are behind your sign-in. To photograph them, save a session against a
3486
3575
  DEMO tenant and pass it back:
3487
3576
 
@@ -3508,27 +3597,32 @@ ${attestation({
3508
3597
  console.log(`
3509
3598
  ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
3510
3599
  }
3600
+ const counts = {
3601
+ screens: shots.length,
3602
+ captured: report.captured,
3603
+ unchanged: report.unchanged,
3604
+ skipped: report.skipped,
3605
+ refused: report.refused,
3606
+ failed: report.failed
3607
+ };
3511
3608
  if (runId) {
3609
+ await told;
3512
3610
  await tellHelpDesk(parsed.to, headers, {
3513
3611
  action: "finish",
3514
3612
  jobId: runId,
3613
+ // What the verdict below prints, so Setup can say what this log says.
3614
+ counts,
3515
3615
  ...report.survey.length ? { survey: report.survey } : {}
3516
3616
  });
3517
3617
  }
3518
- const verdict = runVerdict({
3519
- screens: shots.length,
3520
- captured: report.captured,
3521
- unchanged: report.unchanged,
3522
- skipped: report.skipped,
3523
- refused: report.refused,
3524
- failed: report.failed
3525
- });
3618
+ const verdict = runVerdict(counts);
3526
3619
  if (verdict.sentence) console.log(`
3527
3620
  ${verdict.sentence}`);
3528
3621
  if (verdict.annotation && process.env["GITHUB_ACTIONS"] === "true") console.log(verdict.annotation);
3529
3622
  return verdict.exit;
3530
3623
  } catch (error) {
3531
3624
  if (runId) {
3625
+ await told;
3532
3626
  await tellHelpDesk(parsed.to, headers, {
3533
3627
  action: "finish",
3534
3628
  jobId: runId,
@@ -3543,11 +3637,12 @@ ${verdict.sentence}`);
3543
3637
  }
3544
3638
  }
3545
3639
  async function openRun(to, headers, total) {
3640
+ const run = runIdentityFromEnv(process.env);
3546
3641
  try {
3547
3642
  const response = await fetch(`${to}/api/helpdesk/captures`, {
3548
3643
  method: "POST",
3549
3644
  headers: { ...headers, "content-type": "application/json" },
3550
- body: JSON.stringify({ action: "start", total })
3645
+ body: JSON.stringify({ action: "start", total, ...run ? { run } : {} })
3551
3646
  });
3552
3647
  if (!response.ok) return void 0;
3553
3648
  const body = await response.json();
@@ -3577,7 +3672,7 @@ async function signIn(argv) {
3577
3672
  return 2;
3578
3673
  }
3579
3674
  console.log(
3580
- `Opening ${baseUrl}. Sign in as your DEMO tenant \u2014 never a real customer.
3675
+ `Opening ${baseUrl}. Sign in as your DEMO tenant, never as a real customer.
3581
3676
  This window is yours: use SSO, a one-time code, whatever you normally do.
3582
3677
  The session will be saved to ${sessionPath} and sent nowhere.
3583
3678
  `
package/helpdesk-seed.mjs CHANGED
@@ -219,7 +219,7 @@ function gapReport(gaps2) {
219
219
  ""
220
220
  ];
221
221
  for (const gap of gaps2) {
222
- const broke = gap.broke.length ? ` \u2014 breaks ${gap.broke.join(", ")}` : "";
222
+ const broke = gap.broke.length ? ` (breaks ${gap.broke.join(", ")})` : "";
223
223
  lines.push(` ${gap.method} ${gap.path}${broke}`);
224
224
  if (gap.path !== gap.example) lines.push(` for example ${gap.example}`);
225
225
  }
@@ -733,6 +733,8 @@ var ALREADY_SAFE = [
733
733
  function alreadySafe(kind, value) {
734
734
  return ALREADY_SAFE.some((entry) => entry.kind === kind && entry.regex.test(value));
735
735
  }
736
+ var BIRTH_LABEL = /(?:\bdate[\s_-]*of[\s_-]*birth|\bbirth[\s_-]*(?:date|day)|\bdob\b|\bd\.o\.b\b|\bborn\b)/i;
737
+ var LABEL_REACH = 48;
736
738
  var PATTERNS = [
737
739
  {
738
740
  kind: "email",
@@ -782,8 +784,9 @@ var PATTERNS = [
782
784
  {
783
785
  kind: "date-of-birth",
784
786
  regex: /\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/g,
785
- because: "a full date \u2014 if it is a birth date it identifies somebody",
786
- replace: () => "1970-01-01"
787
+ because: "a date labeled as a birth date, which identifies somebody",
788
+ replace: () => "1970-01-01",
789
+ labelled: BIRTH_LABEL
787
790
  },
788
791
  {
789
792
  kind: "passport",
@@ -804,8 +807,13 @@ function scrub(text, options = {}) {
804
807
  let output = text;
805
808
  for (const pattern of PATTERNS) {
806
809
  const seen = /* @__PURE__ */ new Map();
807
- output = output.replace(pattern.regex, (match) => {
810
+ output = output.replace(pattern.regex, (...args) => {
811
+ const match = args[0];
812
+ const named = typeof args[args.length - 1] === "object";
813
+ const offset = args[args.length - (named ? 3 : 2)];
814
+ const whole = args[args.length - (named ? 2 : 1)];
808
815
  if (alreadySafe(pattern.kind, match)) return match;
816
+ if (pattern.labelled && !pattern.labelled.test(whole.slice(Math.max(0, offset - LABEL_REACH), offset))) return match;
809
817
  const already = seen.get(match);
810
818
  if (already) return already;
811
819
  const replacement = pattern.replace(match, seen.size);
@@ -822,7 +830,7 @@ function scrub(text, options = {}) {
822
830
  found: brand,
823
831
  replacement: brand,
824
832
  where,
825
- because: "a real institution in a published screenshot implies a partnership \u2014 a legal review item, not a design nit"
833
+ because: "a real institution in a published screenshot implies a partnership: a legal review item, not a design nit"
826
834
  });
827
835
  }
828
836
  }
@@ -838,7 +846,7 @@ function describeScrub(result) {
838
846
  }
839
847
  const lines = [`${result.findings.length} thing(s) found. Nothing publishes until you confirm.`, ""];
840
848
  for (const [kind, group] of byKind) {
841
- lines.push(` ${kind.toUpperCase().replace(/-/g, " ")} \u2014 ${group[0].because}`);
849
+ lines.push(` ${kind.toUpperCase().replace(/-/g, " ")}: ${group[0].because}`);
842
850
  for (const finding of group.slice(0, 5)) {
843
851
  lines.push(
844
852
  kind === "brand" ? ` ${finding.found} (left as-is: decide whether this frame can ship)` : ` ${finding.found} \u2192 ${finding.replacement}`
@@ -1217,7 +1225,7 @@ async function record(args) {
1217
1225
  [
1218
1226
  `Recording everything sent to ${args.upstream}.`,
1219
1227
  "",
1220
- "Point your product's backend address at this and use it \u2014 sign in, open the screens",
1228
+ "Point your product's backend address at this and use it: sign in, open the screens",
1221
1229
  "you want documented, and make the errors happen if you can. What you visit is what",
1222
1230
  "the recording will be able to replay.",
1223
1231
  "",
@@ -1289,7 +1297,7 @@ function carriedReport(carried, cassette) {
1289
1297
  (field) => ` ${field.key.padEnd(width)} ${String(field.count).padStart(4)}\xD7 ${field.example}`
1290
1298
  ),
1291
1299
  "",
1292
- "That is not automatically wrong \u2014 an id, a status or a currency is meant to survive.",
1300
+ "That is not automatically wrong: an id, a status or a currency is meant to survive.",
1293
1301
  "But this package only knows the words every product shares: people, their contact",
1294
1302
  "details, their address, and the role each one is playing. Whatever your product holds",
1295
1303
  "that no other product does is on the list above, and it was not touched.",
@@ -1304,7 +1312,7 @@ function carriedReport(carried, cassette) {
1304
1312
  " company, note, amount",
1305
1313
  "",
1306
1314
  " note invents a sentence of about the same length, for anything free-text.",
1307
- " amount invents a different figure of the same size, for a salary or a balance \u2014",
1315
+ " amount invents a different figure of the same size, for a salary or a balance:",
1308
1316
  " a number is never detectable by shape, so naming it is the only way it is caught."
1309
1317
  ] : []
1310
1318
  ];
package/helpdesk.mjs CHANGED
@@ -188,7 +188,7 @@ function score(route, signals) {
188
188
  }
189
189
  if (hasSegment(route.path, MONEY_OR_ACCESS)) {
190
190
  total += 18;
191
- because.push("money or access \u2014 asked about out of proportion to its traffic");
191
+ because.push("money or access: asked about out of proportion to its traffic");
192
192
  }
193
193
  if (hasSegment(route.path, ENTRY_POINT)) {
194
194
  total += 16;
@@ -538,11 +538,22 @@ var LIMITS = {
538
538
  fields: 24,
539
539
  notices: 20,
540
540
  warnings: 12,
541
+ disabled: 24,
542
+ empty: 12,
543
+ gatedBy: 8,
541
544
  choices: 12,
542
545
  /** Longer than this is a paragraph of copy, not a label. */
543
546
  label: 120,
544
547
  sentence: 300
545
548
  };
549
+ var EMPTY_STATE = /^(?:no\s+(?!(?:longer|need|problem|worries|matter|one|way|thanks)\b)[a-z]|nothing\b|none\s|you (?:have|haven't|don't have|do not have)\s+(?:no|any|not)\b|there(?:'s| (?:are|is|were))\s+(?:no|nothing)\b|there aren't any\b|(?:you|we) haven't\b.*\byet\b)/i;
550
+ function isEmptyState(text) {
551
+ const sentence = text.replace(/[\u2018\u2019]/g, "'").replace(/\s+/g, " ").trim();
552
+ if (!sentence || sentence.length > LIMITS.sentence || sentence.includes("?")) return false;
553
+ if (/\b(?:until|unless)\b/i.test(sentence)) return false;
554
+ if (sentence.split(" ").length < 2) return false;
555
+ return EMPTY_STATE.test(sentence);
556
+ }
546
557
  var IRREVERSIBLE = /\b(cannot be undone|can't be undone|permanently|permanent|irreversible|no way back|cannot be recovered|unrecoverable|this cannot be reversed)\b/i;
547
558
  var DESTRUCTIVE = /\b(delete|deletes|deleting|remove|removes|revoke|revokes|erase|wipe|discard|destroy|overwrite|overwrites|reset|disconnect|unpublish|cancel subscription)\b/i;
548
559
  var PERMISSION = /\b(only (?:an?\s+)?(?:owner|admin|administrator)s?\b|your role|not allowed|do(?:es)? not have permission|don't have permission|permission to|requires? (?:an? )?(?:owner|admin|editing|editor)|admins? only|read[- ]only)\b/i;
@@ -553,18 +564,24 @@ function readScreenFacts(files, options = {}) {
553
564
  const links = [];
554
565
  const fields = [];
555
566
  const notices = [];
556
- const warnings = [];
567
+ const disabled = [];
568
+ const empty = [];
557
569
  const messageDepth = options.messageDepth ?? 2;
570
+ const read = files.filter((file) => !options.skip?.(file.path));
571
+ const gatedBy = flagsGating(read, messageDepth);
558
572
  for (const file of files) {
559
573
  if (options.skip?.(file.path)) continue;
560
574
  const source = withoutComments(file.contents);
561
- const before = headings.length + actions.length + links.length + fields.length + notices.length;
562
- readElements(source, { headings, actions, links, fields });
563
- if ((file.depth ?? 0) <= messageDepth) readMessages(source, notices);
564
- const after = headings.length + actions.length + links.length + fields.length + notices.length;
575
+ const before = headings.length + actions.length + links.length + fields.length + notices.length + empty.length;
576
+ readElements(source, { headings, actions, links, fields, disabled, empty });
577
+ if ((file.depth ?? 0) <= messageDepth) {
578
+ readMessages(source, notices);
579
+ readEmptyMessages(source, empty);
580
+ }
581
+ const after = headings.length + actions.length + links.length + fields.length + notices.length + empty.length;
565
582
  if (after > before) facts.readFrom.push(file.path);
566
583
  }
567
- return finishScreenFacts({ headings, actions, links, fields, notices, readFrom: facts.readFrom });
584
+ return finishScreenFacts({ headings, actions, links, fields, notices, readFrom: facts.readFrom, disabled, empty, gatedBy });
568
585
  }
569
586
  function finishScreenFacts(parts) {
570
587
  const warnings = [];
@@ -581,23 +598,48 @@ function finishScreenFacts(parts) {
581
598
  fields: cappedBy(parts.fields, (field) => `${field.label}|${field.kind}`, LIMITS.fields),
582
599
  notices: capped(parts.notices, LIMITS.notices),
583
600
  warnings: capped(warnings, LIMITS.warnings),
584
- readFrom: parts.readFrom
601
+ readFrom: parts.readFrom,
602
+ ...states({ disabled: parts.disabled, empty: parts.empty, gatedBy: parts.gatedBy })
603
+ };
604
+ }
605
+ function states(parts) {
606
+ const disabled = capped(parts.disabled ?? [], LIMITS.disabled);
607
+ const empty = capped(parts.empty ?? [], LIMITS.empty);
608
+ const gatedBy = capped(parts.gatedBy ?? [], LIMITS.gatedBy);
609
+ return {
610
+ ...disabled.length ? { disabled } : {},
611
+ ...empty.length ? { empty } : {},
612
+ ...gatedBy.length ? { gatedBy } : {}
585
613
  };
586
614
  }
587
615
  var HEADING_TAG = /^h[1-4]$/;
588
616
  var BUTTON_TAG = /^(button|Button|MenuItem|Tab)$/;
589
617
  var LINK_TAG = /^(a|Link|NavLink)$/;
590
618
  var FIELD_TAG = /^(input|Input|textarea|Textarea|select|Select|Checkbox|Radio|Switch|Toggle)$/;
619
+ var TEXT_TAG = /^(p|span|small|td|li|div)$/;
620
+ var NOT_INLINE = /<(?!\/?(?:strong|em|b|i|code|br)\b)[A-Za-z]/;
621
+ var EMPTY_TAG = /^(EmptyState|Empty|EmptyMessage|EmptyPanel|NoResults|ZeroState|BlankState)$/;
622
+ var EMPTY_PROPS = ["emptyState", "emptyText", "emptyMessage", "emptyLabel", "emptyTitle", "emptyDescription"];
623
+ var DISABLED_PROP = /(?:^|\s)(?:disabled|aria-disabled)(?=\s|=|\/|$)(?!\s*=\s*\{\s*false\s*\})(?!\s*=\s*["']false["'])/;
591
624
  function readElements(source, into) {
592
625
  for (const tag of tagsIn(source)) {
593
626
  const attributes = readAttributes(tag.attributes);
594
627
  if (HEADING_TAG.test(tag.name)) {
595
- push(into.headings, visibleText(childrenOf(source, tag)));
628
+ const children = childrenOf(source, tag);
629
+ const heading = visibleText(children);
630
+ push(into.headings, heading);
631
+ for (const said of [heading, ...chosenIn(children)]) if (isEmptyState(said)) push(into.empty, said, LIMITS.sentence);
596
632
  continue;
597
633
  }
598
634
  push(into.headings, attributes["heading"]);
635
+ for (const prop of EMPTY_PROPS) pushWords(into.empty, attributes[prop]);
636
+ if (EMPTY_TAG.test(tag.name)) {
637
+ for (const prop of ["title", "description", "message"]) pushWords(into.empty, attributes[prop]);
638
+ }
599
639
  if (BUTTON_TAG.test(tag.name)) {
600
- push(into.actions, visibleText(childrenOf(source, tag)) || attributes["label"] || attributes["aria-label"]);
640
+ const label = visibleText(childrenOf(source, tag)) || attributes["label"] || attributes["aria-label"];
641
+ push(into.actions, label);
642
+ if (DISABLED_PROP.test(tag.attributes)) push(into.disabled, label);
601
643
  continue;
602
644
  }
603
645
  if (LINK_TAG.test(tag.name)) {
@@ -611,6 +653,16 @@ function readElements(source, into) {
611
653
  if (FIELD_TAG.test(tag.name)) {
612
654
  const field = fieldFrom(tag.name, attributes, source, tag);
613
655
  if (field) into.fields.push(field);
656
+ if (field && DISABLED_PROP.test(tag.attributes)) push(into.disabled, field.label);
657
+ continue;
658
+ }
659
+ if (TEXT_TAG.test(tag.name)) {
660
+ const children = childrenOf(source, tag);
661
+ if (NOT_INLINE.test(children)) continue;
662
+ const written = visibleText(children, LIMITS.sentence).replace(/\s+([.,;:!])/g, "$1");
663
+ for (const said of [written, ...chosenIn(children)]) {
664
+ if (isEmptyState(said)) push(into.empty, said, LIMITS.sentence);
665
+ }
614
666
  }
615
667
  }
616
668
  for (const tag of tagsIn(source)) {
@@ -723,7 +775,7 @@ function childrenOf(source, tag) {
723
775
  function escapeName(name) {
724
776
  return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
725
777
  }
726
- function visibleText(fragment) {
778
+ function visibleText(fragment, limit = LIMITS.label) {
727
779
  if (!fragment) return "";
728
780
  let text = fragment;
729
781
  let previous = "";
@@ -732,7 +784,26 @@ function visibleText(fragment) {
732
784
  text = text.replace(/\{[^{}]*\}/g, " ");
733
785
  }
734
786
  text = text.replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&mdash;/g, "\u2014").replace(/&ndash;/g, "\u2013").replace(/&(?:l|r)squo;/g, "'").replace(/&(?:l|r)dquo;/g, '"').replace(/&#\d+;/g, " ").replace(/\s+/g, " ").trim();
735
- return /[A-Za-z]/.test(text) ? trim(text, LIMITS.label) : "";
787
+ return /[A-Za-z]/.test(text) ? trim(text, limit) : "";
788
+ }
789
+ function chosenIn(fragment) {
790
+ const chosen = [];
791
+ let depth2 = 0;
792
+ let start = 0;
793
+ for (let index = 0; index < fragment.length; index += 1) {
794
+ if (fragment[index] === "{") {
795
+ if (depth2 === 0) start = index + 1;
796
+ depth2 += 1;
797
+ } else if (fragment[index] === "}" && depth2 > 0) {
798
+ depth2 -= 1;
799
+ if (depth2 > 0) continue;
800
+ for (const literal of fragment.slice(start, index).matchAll(/"([^"\\]*)"|'([^'\\]*)'|`([^`\\$]*)`/g)) {
801
+ const text = (literal[1] ?? literal[2] ?? literal[3] ?? "").replace(/\s+/g, " ").trim();
802
+ if (text) chosen.push(text);
803
+ }
804
+ }
805
+ }
806
+ return chosen;
736
807
  }
737
808
  function readAttributes(raw) {
738
809
  const attributes = {};
@@ -752,11 +823,80 @@ function humanise(name) {
752
823
  function scrubExpressions(value) {
753
824
  return value.replace(/\$\{[^}]*\}/g, "\u2026").replace(/\s+/g, " ").trim();
754
825
  }
755
- function push(into, value) {
826
+ function push(into, value, limit = LIMITS.label) {
756
827
  const text = value === void 0 ? "" : scrubExpressions(value);
757
828
  if (!text || !/[A-Za-z]/.test(text)) return;
758
829
  if ((text.match(/…/g)?.length ?? 0) > 1) return;
759
- into.push(trim(text, LIMITS.label));
830
+ into.push(trim(text, limit));
831
+ }
832
+ function pushWords(into, value) {
833
+ if (value && value.trim().split(/\s+/).length >= 2) push(into, value, LIMITS.sentence);
834
+ }
835
+ var EMPTY_PROPERTY = /(?:^|[\s,{(])(?:emptyState|emptyText|emptyMessage|emptyLabel|emptyTitle|emptyDescription)\s*:\s*(["'`])((?:(?!\1)[\s\S]){4,}?)\1/g;
836
+ function readEmptyMessages(source, into) {
837
+ for (const match of source.matchAll(EMPTY_PROPERTY)) pushWords(into, match[2].replace(/\s+/g, " ").trim());
838
+ }
839
+ var FLAG_NAME = /\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*_(?:ENABLED|ENABLE|FLAG|FEATURE)|(?:ENABLE|FEATURE|FLAG)_[A-Z0-9]+(?:_[A-Z0-9]+)*)\b/g;
840
+ var FLAG_NAME_ONLY = /^[A-Za-z0-9_.:-]{1,80}$/;
841
+ var DECLARATION = /^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=>/gm;
842
+ var NOT_THERE = /\b(?:notFound|redirect)\s*\(/g;
843
+ function flagsIn(text) {
844
+ return [...new Set([...text.matchAll(FLAG_NAME)].map((match) => match[1]))];
845
+ }
846
+ function flagsGating(files, messageDepth) {
847
+ const named = /* @__PURE__ */ new Map();
848
+ for (const file of files) {
849
+ if ((file.depth ?? 0) > messageDepth) continue;
850
+ const source = withoutComments(file.contents);
851
+ const declarations = [...source.matchAll(DECLARATION)];
852
+ declarations.forEach((declaration, index) => {
853
+ const name = declaration[1] ?? declaration[2];
854
+ const body = source.slice(declaration.index, declarations[index + 1]?.index ?? source.length);
855
+ const flags = flagsIn(body);
856
+ if (flags.length) named.set(name, /* @__PURE__ */ new Set([...named.get(name) ?? [], ...flags]));
857
+ });
858
+ }
859
+ const gating = /* @__PURE__ */ new Set();
860
+ for (const file of files) {
861
+ if ((file.depth ?? 0) > 0) continue;
862
+ const source = withoutComments(file.contents);
863
+ for (const call of source.matchAll(NOT_THERE)) {
864
+ const condition = guardOf(source, call.index);
865
+ if (condition === void 0) continue;
866
+ for (const flag of flagsIn(condition)) gating.add(flag);
867
+ for (const [, dotted, bracketed] of condition.matchAll(
868
+ /process\.env(?:\.([A-Z][A-Z0-9_]*)|\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\])/g
869
+ )) {
870
+ gating.add(dotted ?? bracketed);
871
+ }
872
+ for (const [, args] of condition.matchAll(/\b\w*(?:[Ff]lag|[Ff]eature|[Ee]nabled)\w*\s*\(([^()]*)\)/g)) {
873
+ for (const [, literal] of args.matchAll(/["'`]([^"'`]+)["'`]/g)) {
874
+ if (FLAG_NAME_ONLY.test(literal)) gating.add(literal);
875
+ }
876
+ }
877
+ for (const [, callee] of condition.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)) {
878
+ for (const flag of named.get(callee) ?? []) gating.add(flag);
879
+ }
880
+ }
881
+ }
882
+ return [...gating];
883
+ }
884
+ function guardOf(source, callAt) {
885
+ const before = source.slice(Math.max(0, callAt - 400), callAt);
886
+ const opened = [...before.matchAll(/\bif\s*\(/g)].at(-1);
887
+ if (!opened) return void 0;
888
+ const start = opened.index + opened[0].length;
889
+ let depth2 = 1;
890
+ let cursor = start;
891
+ while (cursor < before.length && depth2 > 0) {
892
+ const character = before[cursor];
893
+ if (character === "(") depth2 += 1;
894
+ else if (character === ")") depth2 -= 1;
895
+ cursor += 1;
896
+ }
897
+ if (depth2 !== 0) return void 0;
898
+ if (!/^\s*(?:\{\s*)?(?:return\s+)?$/.test(before.slice(cursor))) return void 0;
899
+ return before.slice(start, cursor - 1);
760
900
  }
761
901
  function trim(value, limit) {
762
902
  return value.length <= limit ? value : `${value.slice(0, limit - 1).trimEnd()}\u2026`;
@@ -818,7 +958,9 @@ function readProduct(routes, options) {
818
958
  }
819
959
  function hasAnything(facts) {
820
960
  return Boolean(
821
- facts.headings.length || facts.actions.length || facts.links.length || facts.fields.length || facts.notices.length
961
+ facts.headings.length || facts.actions.length || facts.links.length || facts.fields.length || facts.notices.length || // A flagged page whose screen is all in a component the reader cannot
962
+ // follow still says it is behind a flag, and an empty state is still words.
963
+ facts.gatedBy?.length || facts.empty?.length
822
964
  );
823
965
  }
824
966
  function describeReading(routes) {
@@ -827,6 +969,34 @@ function describeReading(routes) {
827
969
  return { screens: documentable.length, read, pathsOnly: read === 0 };
828
970
  }
829
971
 
972
+ // core/github/remote.ts
973
+ function repositoryFromRemote(remote) {
974
+ const raw = remote?.trim();
975
+ if (!raw) return void 0;
976
+ if (OWNER_NAME.test(raw)) return withoutGit(raw);
977
+ const scp = SCP.exec(raw);
978
+ const at = scp ? { host: scp[1], hostname: scp[1], path: scp[2] } : located(raw);
979
+ if (!at) return void 0;
980
+ const parts = withoutGit(at.path.replace(/^\/+|\/+$/g, "")).split("/").filter(Boolean);
981
+ if (!parts.length) return void 0;
982
+ if (GITHUB.test(at.hostname)) return parts.length === 2 ? `${parts[0]}/${parts[1]}` : void 0;
983
+ return `${at.host}/${parts.join("/")}`;
984
+ }
985
+ var OWNER_NAME = /^[A-Za-z0-9][\w-]*\/(?!\.\.?$)[\w.-]+$/;
986
+ var SCP = /^(?:[^@\s/]+@)?([^:\s/]{2,}):(?!\/)(\S+)$/;
987
+ var GITHUB = /^(www\.)?github\.com$/i;
988
+ function withoutGit(path) {
989
+ return path.replace(/\.git$/, "");
990
+ }
991
+ function located(raw) {
992
+ try {
993
+ const url = new URL(raw);
994
+ return url.hostname && /^(https?|ssh|git):$/.test(url.protocol) ? { host: url.host, hostname: url.hostname, path: url.pathname } : void 0;
995
+ } catch {
996
+ return void 0;
997
+ }
998
+ }
999
+
830
1000
  // core/markers/propose.ts
831
1001
  function toLines(contents) {
832
1002
  const lines = contents.split("\n");
@@ -950,7 +1120,7 @@ function markersPullRequestBody(result) {
950
1120
  }
951
1121
  lines.push(
952
1122
  "",
953
- "**A wrong one costs nothing.** Delete the line and the article stops being linked to that file. If we missed one, the screen is still matched by its route \u2014 this only makes the match more precise."
1123
+ "**A wrong one costs nothing.** Delete the line and the article stops being linked to that file. If we missed one, the screen is still matched by its route. This only makes the match more precise."
954
1124
  );
955
1125
  if (result.unmatchedSlugs.length > 0) {
956
1126
  lines.push(
@@ -1031,7 +1201,7 @@ async function runMap(options) {
1031
1201
  lines.push(
1032
1202
  "",
1033
1203
  proposed.proposals.length ? `Wrote ${proposed.proposals.length} marker(s) to ${options.markersOut}. Apply with:
1034
- git apply ${options.markersOut}` : "Every file already carries its marker \u2014 nothing to propose."
1204
+ git apply ${options.markersOut}` : "Every file already carries its marker: nothing to propose."
1035
1205
  );
1036
1206
  }
1037
1207
  let sent = false;
@@ -1054,7 +1224,7 @@ async function runMap(options) {
1054
1224
  throw new Error(`The help desk refused the map (${response.status}). ${body}`);
1055
1225
  }
1056
1226
  sent = true;
1057
- lines.push("", "Sent. Whoever asked you to run this can see it now \u2014 you are done.");
1227
+ lines.push("", "Sent. Whoever asked you to run this can see it now. You are done.");
1058
1228
  } else if (options.token) {
1059
1229
  lines.push("", "No destination given. Pass --to https://your-helpdesk.example to send this.");
1060
1230
  }
@@ -1077,7 +1247,7 @@ function readTsconfig(cwd) {
1077
1247
  }
1078
1248
  function originOf(cwd) {
1079
1249
  try {
1080
- return execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" }).trim();
1250
+ return repositoryFromRemote(execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" }));
1081
1251
  } catch {
1082
1252
  return void 0;
1083
1253
  }
@@ -1089,6 +1259,16 @@ function headCommit(cwd) {
1089
1259
  return null;
1090
1260
  }
1091
1261
  }
1262
+ var WORKFLOW_ACTIONS = {
1263
+ checkout: "actions/checkout@v7",
1264
+ setupNode: "actions/setup-node@v7"
1265
+ };
1266
+ var SETUP_STEPS = ` - uses: ${WORKFLOW_ACTIONS.checkout}
1267
+ - uses: ${WORKFLOW_ACTIONS.setupNode}
1268
+ with:
1269
+ node-version: "22"
1270
+ package-manager-cache: false
1271
+ `;
1092
1272
 
1093
1273
  // adapters/invoked-as-command.ts
1094
1274
  import { realpathSync } from "node:fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olomonhelpdesk2/helpdesk",
3
- "version": "0.1.5",
3
+ "version": "0.1.9",
4
4
  "description": "Reads a product's screens, plans its help articles, and photographs them.",
5
5
  "type": "module",
6
6
  "engines": {