@olomonhelpdesk2/helpdesk 0.1.4 → 0.1.5

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,102 +1,4 @@
1
1
  #!/usr/bin/env node
2
- // core/capture/contract.ts
3
- var CAPTURE_CONTRACT = {
4
- /** Same for every shot, so type is the same size across the whole set. */
5
- viewport: { width: 1600, height: 1e3 },
6
- /** Stills render at 2x → 3200x2000. */
7
- stillScale: 2,
8
- /** Clips at 1x; video is resampled downstream anyway. */
9
- clipScale: 1,
10
- /** Below this a frame is a failed shot rather than a small one. */
11
- minStillWidth: 2300,
12
- /**
13
- * Clip length.
14
- *
15
- * A CAP rather than a target, and inherited from a marketing site's autoplay
16
- * loop — which is not this product's constraint. A help-center clip showing a
17
- * multi-step task may well want twenty seconds, so the window is the contract
18
- * and the number is configurable.
19
- */
20
- clipSeconds: { min: 6, target: 9, max: 10 },
21
- maxClipBytes: 2 * 1024 * 1024,
22
- /** Everything renders as though it were this instant. */
23
- pinnedClock: "2026-01-15T09:00:00.000Z",
24
- /**
25
- * And in this locale and this zone, set on the browser rather than inherited
26
- * from whoever runs it, so a date on a screen reads the same from any machine.
27
- *
28
- * American English, because the products this documents are read in it: a
29
- * frame showing "15/01/2026" beside an article written for an American
30
- * reader is a screenshot of a product nobody uses.
31
- */
32
- locale: "en-US",
33
- timezone: "UTC",
34
- /**
35
- * Injected before every shot. Necessary, not sufficient — see above.
36
- */
37
- motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}"
38
- };
39
- var VISUAL_STABILITY = {
40
- /** Gap between comparison frames. Short enough to be cheap, long enough to differ. */
41
- intervalMs: 120,
42
- /** Consecutive identical frames required. */
43
- identicalFrames: 2,
44
- /** Give up rather than hang; a page that never settles is a finding. */
45
- timeoutMs: 1e4
46
- };
47
- function pngSize(bytes) {
48
- const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
49
- if (bytes.length < 24) return void 0;
50
- if (SIGNATURE.some((byte, index) => bytes[index] !== byte)) return void 0;
51
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
52
- return { width: view.getUint32(16), height: view.getUint32(20) };
53
- }
54
- var RECORDING_MISS_STATUS = 599;
55
- function isPublishable(result) {
56
- return result === "ok" || result === "empty-state";
57
- }
58
- function actionFor(outcome) {
59
- switch (outcome.result) {
60
- case "ok":
61
- case "empty-state":
62
- case "canonical-redirect":
63
- return void 0;
64
- case "not-found": {
65
- const noun = subjectOf(outcome.missingEndpoint ?? outcome.route);
66
- return `Create one ${noun} in your test environment, then run this again.`;
67
- }
68
- case "thin":
69
- return `Nothing rendered on ${outcome.route}. If it needs data, add some; if it is not a real screen, we will leave it out.`;
70
- case "bounced":
71
- return `${outcome.route} redirected${outcome.landed ? ` to ${outcome.landed}` : " somewhere else"}. If it needs a permission your test account lacks, use one that has it.`;
72
- case "crashed":
73
- return `${outcome.route} hit an error${outcome.error ? `: ${outcome.error}` : ""}. This is a bug in the app rather than in the setup.`;
74
- case "unrecorded":
75
- return `${outcome.route} asked for ${outcome.missingEndpoint ?? "data"}, which is not in the recording. Visit this screen while recording, then run this again.`;
76
- }
77
- }
78
- function subjectOf(path3) {
79
- const segments = path3.split("/").filter(Boolean).filter((segment) => !/^(api|v\d+)$/.test(segment));
80
- while (segments.length > 1 && looksLikeAnId(segments[segments.length - 1])) segments.pop();
81
- const last = segments[segments.length - 1] ?? "record";
82
- return last.replace(/[-_]/g, " ").replace(/s$/, "");
83
- }
84
- function looksLikeAnId(segment) {
85
- return /^\d+$/.test(segment) || /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(segment) || // `inv_123`, `cus_9f2a`: a prefix, a separator, and something with a digit
86
- // in it. Deliberately narrow — `sign-up` must survive this.
87
- /^[a-z]{2,}[_-][a-z0-9]*\d[a-z0-9]*$/i.test(segment) || /^[0-9a-z]{16,}$/i.test(segment);
88
- }
89
- function captureReport(outcomes) {
90
- const ready = outcomes.filter((outcome) => isPublishable(outcome.result));
91
- const actionable = outcomes.map((outcome) => ({ outcome, action: actionFor(outcome) })).filter((entry) => Boolean(entry.action));
92
- const lines = [
93
- `${ready.length} screens ready \xB7 ${actionable.length} need attention`,
94
- "",
95
- ...actionable.map(({ outcome, action }) => ` ${outcome.route.padEnd(30)} ${action}`)
96
- ];
97
- return { ready: ready.length, needData: actionable.length, lines };
98
- }
99
-
100
2
  // core/capture/examples.ts
101
3
  var CI_EXAMPLES_OUT_ENV = "HELPDESK_EXAMPLES_OUT";
102
4
  var EXAMPLES_SCHEMA = "helpdesk.examples/1";
@@ -199,6 +101,108 @@ function describeMissingExample(route, needs) {
199
101
  return `the seed command named no record for ${named} on ${route}, so there is nothing real to open. Add one to the file it writes at ${CI_EXAMPLES_OUT_ENV}`;
200
102
  }
201
103
 
104
+ // core/capture/contract.ts
105
+ var CAPTURE_CONTRACT = {
106
+ /** Same for every shot, so type is the same size across the whole set. */
107
+ viewport: { width: 1600, height: 1e3 },
108
+ /** Stills render at 2x → 3200x2000. */
109
+ stillScale: 2,
110
+ /** Clips at 1x; video is resampled downstream anyway. */
111
+ clipScale: 1,
112
+ /** Below this a frame is a failed shot rather than a small one. */
113
+ minStillWidth: 2300,
114
+ /**
115
+ * Clip length.
116
+ *
117
+ * A CAP rather than a target, and inherited from a marketing site's autoplay
118
+ * loop — which is not this product's constraint. A help-center clip showing a
119
+ * multi-step task may well want twenty seconds, so the window is the contract
120
+ * and the number is configurable.
121
+ */
122
+ clipSeconds: { min: 6, target: 9, max: 10 },
123
+ maxClipBytes: 2 * 1024 * 1024,
124
+ /** Everything renders as though it were this instant. */
125
+ pinnedClock: "2026-01-15T09:00:00.000Z",
126
+ /**
127
+ * And in this locale and this zone, set on the browser rather than inherited
128
+ * from whoever runs it, so a date on a screen reads the same from any machine.
129
+ *
130
+ * American English, because the products this documents are read in it: a
131
+ * frame showing "15/01/2026" beside an article written for an American
132
+ * reader is a screenshot of a product nobody uses.
133
+ */
134
+ locale: "en-US",
135
+ timezone: "UTC",
136
+ /**
137
+ * Injected before every shot. Necessary, not sufficient — see above.
138
+ */
139
+ motionOff: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}"
140
+ };
141
+ var VISUAL_STABILITY = {
142
+ /** Gap between comparison frames. Short enough to be cheap, long enough to differ. */
143
+ intervalMs: 120,
144
+ /** Consecutive identical frames required. */
145
+ identicalFrames: 2,
146
+ /** Give up rather than hang; a page that never settles is a finding. */
147
+ timeoutMs: 1e4
148
+ };
149
+ function pngSize(bytes) {
150
+ const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
151
+ if (bytes.length < 24) return void 0;
152
+ if (SIGNATURE.some((byte, index) => bytes[index] !== byte)) return void 0;
153
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
154
+ return { width: view.getUint32(16), height: view.getUint32(20) };
155
+ }
156
+ var RECORDING_MISS_STATUS = 599;
157
+ function isPublishable(result) {
158
+ return result === "ok" || result === "empty-state";
159
+ }
160
+ function actionFor(outcome) {
161
+ switch (outcome.result) {
162
+ case "ok":
163
+ case "empty-state":
164
+ case "canonical-redirect":
165
+ return void 0;
166
+ case "not-found": {
167
+ 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.`;
169
+ }
170
+ const asked = outcome.missingEndpoint ?? outcome.route;
171
+ const noun = subjectOf(fillsPattern(outcome.route, asked) ? outcome.route : asked);
172
+ return `Create one ${noun} in your test environment, then run this again.`;
173
+ }
174
+ case "thin":
175
+ return `Nothing rendered on ${outcome.route}. If it needs data, add some; if it is not a real screen, we will leave it out.`;
176
+ case "bounced":
177
+ return `${outcome.route} redirected${outcome.landed ? ` to ${outcome.landed}` : " somewhere else"}. If it needs a permission your test account lacks, use one that has it.`;
178
+ case "crashed":
179
+ return `${outcome.route} hit an error${outcome.error ? `: ${outcome.error}` : ""}. This is a bug in the app rather than in the setup.`;
180
+ case "unrecorded":
181
+ return `${outcome.route} asked for ${outcome.missingEndpoint ?? "data"}, which is not in the recording. Visit this screen while recording, then run this again.`;
182
+ }
183
+ }
184
+ function subjectOf(path3) {
185
+ const segments = path3.split("/").filter(Boolean).filter((segment) => !/^(api|v\d+)$/.test(segment));
186
+ while (segments.length > 1 && looksLikeAnId(segments[segments.length - 1])) segments.pop();
187
+ const last = segments[segments.length - 1] ?? "record";
188
+ return last.replace(/[-_]/g, " ").replace(/s$/, "");
189
+ }
190
+ function looksLikeAnId(segment) {
191
+ return /^\[{1,2}[^\]]+\]{1,2}$/.test(segment) || /^\d+$/.test(segment) || /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(segment) || // `inv_123`, `cus_9f2a`: a prefix, a separator, and something with a digit
192
+ // in it. Deliberately narrow — `sign-up` must survive this.
193
+ /^[a-z]{2,}[_-][a-z0-9]*\d[a-z0-9]*$/i.test(segment) || /^[0-9a-z]{16,}$/i.test(segment);
194
+ }
195
+ function captureReport(outcomes) {
196
+ const ready = outcomes.filter((outcome) => isPublishable(outcome.result));
197
+ const actionable = outcomes.map((outcome) => ({ outcome, action: actionFor(outcome) })).filter((entry) => Boolean(entry.action));
198
+ const lines = [
199
+ `${ready.length} screens ready \xB7 ${actionable.length} need attention`,
200
+ "",
201
+ ...actionable.map(({ outcome, action }) => ` ${outcome.route.padEnd(30)} ${action}`)
202
+ ];
203
+ return { ready: ready.length, needData: actionable.length, lines };
204
+ }
205
+
202
206
  // core/cli-package.ts
203
207
  var HELPDESK_CLI_PACKAGE = "@olomonhelpdesk2/helpdesk";
204
208
  function helpdeskCommand(command, args = []) {
@@ -234,7 +238,7 @@ var EMPTY_STATE_PHRASES = [
234
238
  /\bno results\b/i,
235
239
  /\bthis (list|table) is empty\b/i
236
240
  ];
237
- function classifyRoute(observation) {
241
+ function classifyRoute(observation, options = {}) {
238
242
  const text2 = observation.text.trim();
239
243
  const unrecorded = observation.failedRequests?.find((request) => request.status === RECORDING_MISS_STATUS);
240
244
  if (unrecorded) {
@@ -244,21 +248,26 @@ function classifyRoute(observation) {
244
248
  if (crash) return { route: observation.route, result: "crashed", error: crash };
245
249
  const missing = missingBecause(observation, text2);
246
250
  if (missing) return { route: observation.route, result: "not-found", missingEndpoint: missing };
251
+ let followed;
247
252
  if (observation.landedOn && !samePlace(observation.route, observation.landedOn)) {
248
253
  const canonical = canonicalisation(observation.route, observation.landedOn);
249
- return {
250
- route: observation.route,
251
- result: canonical ? "canonical-redirect" : "bounced",
252
- // Carried only when it is a finding. A parent handing off to its default
253
- // child needs no row, so it needs no detail either.
254
- ...canonical ? {} : { landed: observation.landedOn }
255
- };
254
+ if (!canonical && options.follow?.(observation.landedOn)) {
255
+ followed = { landed: observation.landedOn };
256
+ } else {
257
+ return {
258
+ route: observation.route,
259
+ result: canonical ? "canonical-redirect" : "bounced",
260
+ // Carried only when it is a finding. A parent handing off to its default
261
+ // child needs no row, so it needs no detail either.
262
+ ...canonical ? {} : { landed: observation.landedOn }
263
+ };
264
+ }
256
265
  }
257
266
  if (EMPTY_STATE_PHRASES.some((phrase) => phrase.test(text2))) {
258
- return { route: observation.route, result: "empty-state" };
267
+ return { route: observation.route, result: "empty-state", ...followed };
259
268
  }
260
- if (text2.length < THIN_TEXT_CHARS) return { route: observation.route, result: "thin" };
261
- return { route: observation.route, result: "ok" };
269
+ if (text2.length < THIN_TEXT_CHARS) return { route: observation.route, result: "thin", ...followed };
270
+ return { route: observation.route, result: "ok", ...followed };
262
271
  }
263
272
  function crashedBecause(observation, text2) {
264
273
  const uncaught = observation.errors?.[0];
@@ -416,6 +425,82 @@ function attestation(run) {
416
425
  ].filter(Boolean).join("\n");
417
426
  }
418
427
 
428
+ // core/capture/run-verdict.ts
429
+ function runVerdict(run) {
430
+ const delivered = run.captured + run.unchanged;
431
+ 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`;
433
+ if (run.screens > 0 && delivered === 0) {
434
+ return {
435
+ exit: 1,
436
+ annotation: `::error title=No screenshots were delivered::None of the ${run.screens} screens was photographed (${counts}). The step log says why for each one.`,
437
+ sentence: `Nothing was delivered, so this run fails rather than passing with no screenshots: ${counts}.`
438
+ };
439
+ }
440
+ if (missed > 0) {
441
+ return {
442
+ exit: run.failed > 0 ? 1 : 0,
443
+ annotation: `::warning title=${missed} of ${run.screens} screens were not photographed::${counts}. The step log says why for each one.`
444
+ };
445
+ }
446
+ return { exit: 0 };
447
+ }
448
+
449
+ // core/capture/unclassified.ts
450
+ var SEPARATOR = " > ";
451
+ function commonRegion(selectors) {
452
+ const parts = selectors.map((selector) => selector.split(SEPARATOR));
453
+ const first = parts[0] ?? [];
454
+ let shared = 0;
455
+ while (shared < first.length && parts.every((steps) => steps[shared] === first[shared])) shared += 1;
456
+ return shared >= 3 ? first.slice(0, shared).join(SEPARATOR) : "";
457
+ }
458
+ function groupUnclassified(refused) {
459
+ const enough = Math.max(2, Math.ceil(new Set(refused.map((screen) => screen.route)).size / 2));
460
+ const where = /* @__PURE__ */ new Map();
461
+ for (const screen of refused) {
462
+ for (const value of screen.values) {
463
+ const seen = where.get(value.at) ?? { screens: /* @__PURE__ */ new Set(), lengths: /* @__PURE__ */ new Set() };
464
+ seen.screens.add(screen.route);
465
+ seen.lengths.add(value.length);
466
+ where.set(value.at, seen);
467
+ }
468
+ }
469
+ const shared = [...where.entries()].filter(([, seen]) => seen.screens.size >= enough).map(([at, seen]) => ({ at, screens: [...seen.screens].sort(), lengths: [...seen.lengths].sort((a, b) => a - b) })).sort((a, b) => b.screens.length - a.screens.length || a.at.localeCompare(b.at));
470
+ const isShared = new Set(shared.map((element) => element.at));
471
+ const screens = [];
472
+ const onlyShared = [];
473
+ for (const screen of refused) {
474
+ const rest = screen.values.filter((value) => !isShared.has(value.at));
475
+ if (rest.length) {
476
+ screens.push({ route: screen.route, count: rest.length, within: commonRegion(rest.map((value) => value.at)) });
477
+ } else if (screen.values.length) {
478
+ onlyShared.push(screen.route);
479
+ }
480
+ }
481
+ return { shared, screens, onlyShared: [...new Set(onlyShared)].sort() };
482
+ }
483
+ function describeLengths(lengths) {
484
+ const low = lengths[0] ?? 0;
485
+ const high = lengths[lengths.length - 1] ?? low;
486
+ return low === high ? `${low} character${low === 1 ? "" : "s"}` : `${low} to ${high} characters`;
487
+ }
488
+ function describeShared(element, summary) {
489
+ const unblocks = summary.onlyShared.filter((route) => element.screens.includes(route)).length;
490
+ return `One element sits outside every part of the page the clearance names, in the same place on ${element.screens.length} screens: ${element.at} (${describeLengths(element.lengths)}). Something in the same place on every screen is part of the app's own layout, such as a feedback or help button, so it is classified once. Say what it is on Setup` + (unblocks ? `, and the ${unblocks} screen${unblocks === 1 ? "" : "s"} held back only by it can be photographed.` : ".");
491
+ }
492
+ function describeRemainder(screen) {
493
+ return `${screen.route}: ${screen.count} value${screen.count === 1 ? "" : "s"} outside every part of the page the clearance names` + (screen.within ? `, all inside ${screen.within}.` : ", spread across the page.");
494
+ }
495
+ function unclassifiedLines(summary) {
496
+ if (!summary.shared.length && !summary.screens.length) return [];
497
+ return [
498
+ "Not photographed until somebody says what is there:",
499
+ ...summary.shared.map((element) => ` ${describeShared(element, summary)}`),
500
+ ...summary.screens.map((screen) => ` ${describeRemainder(screen)}`)
501
+ ];
502
+ }
503
+
419
504
  // adapters/capture-cli.ts
420
505
  import { createRequire } from "node:module";
421
506
  import path from "node:path";
@@ -550,7 +635,7 @@ var PATTERNS = [
550
635
  {
551
636
  kind: "passport",
552
637
  regex: /\b[A-Z]{1,2}\d{6,9}\b/g,
553
- because: "the shape of a passport or licence number",
638
+ because: "the shape of a passport or license number",
554
639
  replace: (match) => `${match.slice(0, 1)}${"0".repeat(match.length - 1)}`
555
640
  },
556
641
  {
@@ -616,6 +701,20 @@ function gateFrame(pageText, options = {}) {
616
701
  };
617
702
  }
618
703
 
704
+ // core/capture/sign-in.ts
705
+ var IDENTIFIER_HINT = /\b(username|email)\b/;
706
+ var IDENTIFIER_NAME = /user|login|e-?mail/;
707
+ function identifies(field) {
708
+ if (field.type === "email") return true;
709
+ if (IDENTIFIER_HINT.test(field.autocomplete)) return true;
710
+ return (field.type === "text" || field.type === "") && IDENTIFIER_NAME.test(field.name);
711
+ }
712
+ function looksLikeSignIn(fields) {
713
+ const secrets = fields.filter((field) => field.type === "password");
714
+ if (secrets.some((field) => field.autocomplete.split(/\s+/).includes("current-password"))) return true;
715
+ return secrets.some((secret) => fields.some((field) => field.form === secret.form && identifies(field)));
716
+ }
717
+
619
718
  // core/capture/publication/pixels.ts
620
719
  import { deflateSync, inflateSync } from "node:zlib";
621
720
  var MediaRefused = class extends Error {
@@ -649,7 +748,7 @@ function decodePng(buffer) {
649
748
  offset += 12 + length;
650
749
  }
651
750
  if (bitDepth !== 8 || colourType !== 2 && colourType !== 6) {
652
- throw new MediaRefused(`only 8-bit RGB and RGBA PNGs are supported here, not colour type ${colourType} at ${bitDepth} bits`);
751
+ throw new MediaRefused(`only 8-bit RGB and RGBA PNGs are supported here, not color type ${colourType} at ${bitDepth} bits`);
653
752
  }
654
753
  const channels = colourType === 6 ? 4 : 3;
655
754
  const raw = inflateSync(Buffer.concat(data));
@@ -1594,7 +1693,9 @@ async function captureOne(options, shot, build) {
1594
1693
  };
1595
1694
  }
1596
1695
  const seen = await page.observe?.() ?? {};
1597
- const observed = { ...classifyRoute({ ...seen, route: visit, text: pageText }), route: shot.route };
1696
+ const origin = new URL(options.baseUrl).origin;
1697
+ const follow = (landed) => seen.landedOrigin === origin && options.shots.some((other) => fillsPattern(other.route, landed.split(/[?#]/)[0] ?? landed));
1698
+ const observed = { ...classifyRoute({ ...seen, route: visit, text: pageText }, { follow }), route: shot.route };
1598
1699
  if (!isPublishable(observed.result)) {
1599
1700
  return {
1600
1701
  outcome: {
@@ -1634,7 +1735,8 @@ async function captureOne(options, shot, build) {
1634
1735
  // Where and never what: a value nobody has classified is a value
1635
1736
  // nobody has established is safe to write down, and a reason that
1636
1737
  // quoted it would put it in this run's log.
1637
- reason: `${unaccounted.length} value(s) on this screen are outside every region the clearance names (${unaccounted.map((value) => `${value.at} (${value.length} character(s))`).join("; ")}), so it is restricted until somebody classifies them`
1738
+ reason: `${unaccounted.length} value(s) on this screen are outside every region the clearance names (${unaccounted.map((value) => `${value.at} (${value.length} character(s))`).join("; ")}), so it is restricted until somebody classifies them`,
1739
+ unaccounted: unaccounted.map((value) => ({ at: value.at, length: value.length }))
1638
1740
  },
1639
1741
  observed,
1640
1742
  ...evidence ? { seen: evidence } : {}
@@ -1750,7 +1852,7 @@ async function captureOne(options, shot, build) {
1750
1852
  result: observed.result,
1751
1853
  sha256,
1752
1854
  settled: frame.settled,
1753
- ...frame.settled ? {} : {
1855
+ ...frame.settled ? observed.landed ? { reason: `followed its redirect to ${observed.landed}` } : {} : {
1754
1856
  reason: "photographed, but this screen never stopped moving \u2014 its hash will differ every run, so drift on it means nothing"
1755
1857
  }
1756
1858
  } : {
@@ -1913,11 +2015,21 @@ async function playwrightBrowser(options = {}) {
1913
2015
  return page.locator("body").ariaSnapshot().catch(() => "");
1914
2016
  },
1915
2017
  async isSignIn() {
1916
- return await page.locator('input[type="password"]').count() > 0;
2018
+ const fields = await page.evaluate(() => {
2019
+ const forms = Array.from(document.forms);
2020
+ return Array.from(document.querySelectorAll("input")).map((input) => ({
2021
+ type: (input.getAttribute("type") ?? "text").toLowerCase(),
2022
+ autocomplete: (input.getAttribute("autocomplete") ?? "").toLowerCase(),
2023
+ name: `${input.name} ${input.id}`.toLowerCase(),
2024
+ form: input.form ? forms.indexOf(input.form) : -1
2025
+ }));
2026
+ }).catch(() => []);
2027
+ return looksLikeSignIn(fields);
1917
2028
  },
1918
2029
  async observe() {
1919
2030
  return {
1920
2031
  landedOn: pathOf(page.url()),
2032
+ landedOrigin: new URL(page.url()).origin,
1921
2033
  ...status === void 0 ? {} : { status },
1922
2034
  errors: [...errors],
1923
2035
  consoleErrors: [...consoleErrors],
@@ -3045,7 +3157,7 @@ function parseArgs(argv) {
3045
3157
  else if (flag === "--live-clock") args.liveClock = true;
3046
3158
  else if (flag === "--allow-production") args.allowProduction = true;
3047
3159
  else if (flag === "--rebaseline" || flag === "--force") {
3048
- return '--rebaseline needs a reason: `--rebaseline "new brand colours shipped"`. Replacing every stored frame moves every checksum at once, and a re-baseline nobody wrote down is indistinguishable from the product changing.';
3160
+ return '--rebaseline needs a reason: `--rebaseline "new brand colors shipped"`. Replacing every stored frame moves every checksum at once, and a re-baseline nobody wrote down is indistinguishable from the product changing.';
3049
3161
  }
3050
3162
  }
3051
3163
  if (args.out) {
@@ -3249,7 +3361,9 @@ Approve one on the plan page, then run this again.`
3249
3361
  jobId: runId,
3250
3362
  item: outcome.route,
3251
3363
  status: outcome.status === "captured" || outcome.status === "unchanged" ? "ok" : outcome.status === "failed" ? "failed" : "skipped",
3252
- ...outcome.reason ? { note: outcome.reason } : {}
3364
+ ...outcome.reason ? { note: outcome.reason } : {},
3365
+ // So Setup can ask about them, once each. See `core/capture/unclassified.ts`.
3366
+ ...outcome.unaccounted?.length ? { unaccounted: outcome.unaccounted } : {}
3253
3367
  });
3254
3368
  }
3255
3369
  },
@@ -3303,6 +3417,13 @@ Approve one on the plan page, then run this again.`
3303
3417
  `
3304
3418
  ${report.captured} photographed, ${report.unchanged} unchanged, ${report.skipped} skipped, ${report.refused} refused, ${report.failed} failed.`
3305
3419
  );
3420
+ const unclassified = unclassifiedLines(
3421
+ groupUnclassified(
3422
+ report.outcomes.flatMap((outcome) => outcome.unaccounted?.length ? [{ route: outcome.route, values: outcome.unaccounted }] : [])
3423
+ )
3424
+ );
3425
+ if (unclassified.length) console.log(`
3426
+ ${unclassified.join("\n")}`);
3306
3427
  if (local) {
3307
3428
  const kept = new Map(frames.map((frame) => [frame.id, frame]));
3308
3429
  for (const shot of shots) {
@@ -3394,7 +3515,18 @@ ${gapReport(gapsFrom([...stage.replay.misses])).join("\n")}`);
3394
3515
  ...report.survey.length ? { survey: report.survey } : {}
3395
3516
  });
3396
3517
  }
3397
- return report.failed ? 1 : 0;
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
+ });
3526
+ if (verdict.sentence) console.log(`
3527
+ ${verdict.sentence}`);
3528
+ if (verdict.annotation && process.env["GITHUB_ACTIONS"] === "true") console.log(verdict.annotation);
3529
+ return verdict.exit;
3398
3530
  } catch (error) {
3399
3531
  if (runId) {
3400
3532
  await tellHelpDesk(parsed.to, headers, {
package/helpdesk-seed.mjs CHANGED
@@ -788,7 +788,7 @@ var PATTERNS = [
788
788
  {
789
789
  kind: "passport",
790
790
  regex: /\b[A-Z]{1,2}\d{6,9}\b/g,
791
- because: "the shape of a passport or licence number",
791
+ because: "the shape of a passport or license number",
792
792
  replace: (match) => `${match.slice(0, 1)}${"0".repeat(match.length - 1)}`
793
793
  },
794
794
  {
@@ -1270,7 +1270,7 @@ ${carriedReport(sealed.carried, args.cassette).join("\n")}`);
1270
1270
  console.log(`
1271
1271
  ${describeScrub({ scrubbed: "", findings: sealed.findings, needsReview: true })}`);
1272
1272
  console.log(
1273
- "\nThese survived the field pass, which means they were in free text or under a key nothing\nrecognised. Read them before you commit this."
1273
+ "\nThese survived the field pass, which means they were in free text or under a key nothing\nrecognized. Read them before you commit this."
1274
1274
  );
1275
1275
  return 1;
1276
1276
  }
@@ -1278,7 +1278,7 @@ ${describeScrub({ scrubbed: "", findings: sealed.findings, needsReview: true })}
1278
1278
  return 0;
1279
1279
  }
1280
1280
  function carriedReport(carried, cassette) {
1281
- if (!carried.length) return ["Every key in the recording was recognised and rewritten."];
1281
+ if (!carried.length) return ["Every key in the recording was recognized and rewritten."];
1282
1282
  const ranked = [...carried].sort((a, b) => score(b) - score(a) || b.count - a.count);
1283
1283
  const width = Math.max(...ranked.map((field) => field.key.length));
1284
1284
  const worth = ranked.filter((field) => score(field) > 0);
@@ -1380,7 +1380,7 @@ async function routes(args) {
1380
1380
  const cassette = parseCassette(await readFile(args.cassette, "utf8"));
1381
1381
  const found = exampleRoutes(cassette);
1382
1382
  if (!found.length) {
1383
- console.log("The recording holds no parameterised routes, so there are no example ids to offer.");
1383
+ console.log("The recording holds no parameterized routes, so there are no example ids to offer.");
1384
1384
  return 0;
1385
1385
  }
1386
1386
  console.log("Routes the recording can answer, with an identifier it has already served:\n");
package/helpdesk.mjs CHANGED
@@ -793,6 +793,7 @@ function readProduct(routes, options) {
793
793
  };
794
794
  const documentable = routes.filter((route) => !route.skipped);
795
795
  const total = Math.min(documentable.length, maxScreens);
796
+ const reading = { how: "source", commit: options.commit ?? null };
796
797
  let done = 0;
797
798
  let withFacts = 0;
798
799
  const out = routes.map((route) => {
@@ -803,12 +804,15 @@ function readProduct(routes, options) {
803
804
  const source = read(reached.path);
804
805
  if (source !== void 0) files.push({ path: reached.path, contents: source, depth: reached.depth });
805
806
  }
806
- const facts = readScreenFacts(files);
807
807
  done += 1;
808
808
  options.onScreen?.(route, done, total);
809
- if (!hasAnything(facts)) return route;
809
+ if (files.length === 0) return route;
810
+ const facts = readScreenFacts(files);
811
+ if (!hasAnything(facts)) {
812
+ return { ...route, facts: noScreenFacts(reading) };
813
+ }
810
814
  withFacts += 1;
811
- return { ...route, facts };
815
+ return { ...route, facts: { ...facts, read: reading } };
812
816
  });
813
817
  return { routes: out, read: withFacts, filesOpened: opened.size };
814
818
  }
@@ -937,7 +941,7 @@ function markersPullRequestBody(result) {
937
941
  const lines = [
938
942
  "This adds one-line comments that link screens in this repository to the help articles that describe them.",
939
943
  "",
940
- "**Nothing here changes behaviour.** Each line is a comment. Once merged, a change to one of these files tells our help desk which article may now be out of date, so somebody can check it before a customer does.",
944
+ "**Nothing here changes behavior.** Each line is a comment. Once merged, a change to one of these files tells our help desk which article may now be out of date, so somebody can check it before a customer does.",
941
945
  "",
942
946
  `${result.proposals.length} marker${result.proposals.length === 1 ? "" : "s"} proposed.`
943
947
  ];
@@ -1000,7 +1004,10 @@ async function runMap(options) {
1000
1004
  }
1001
1005
  },
1002
1006
  aliases: aliasesFromTsconfig(tsconfig),
1003
- baseUrl: baseUrlFromTsconfig(tsconfig)
1007
+ baseUrl: baseUrlFromTsconfig(tsconfig),
1008
+ // Stamped onto every reading, so a map carries the commit its labels and
1009
+ // controls were read at and a later one can tell fresh from stale.
1010
+ commit: headCommit(options.cwd)
1004
1011
  });
1005
1012
  routes = read.routes;
1006
1013
  const reading = describeReading(routes);
@@ -1075,6 +1082,13 @@ function originOf(cwd) {
1075
1082
  return void 0;
1076
1083
  }
1077
1084
  }
1085
+ function headCommit(cwd) {
1086
+ try {
1087
+ return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim() || null;
1088
+ } catch {
1089
+ return null;
1090
+ }
1091
+ }
1078
1092
 
1079
1093
  // adapters/invoked-as-command.ts
1080
1094
  import { realpathSync } from "node:fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olomonhelpdesk2/helpdesk",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Reads a product's screens, plans its help articles, and photographs them.",
5
5
  "type": "module",
6
6
  "engines": {