scenescout 3.9.0 → 3.11.0

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.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Forms never submitted empty.
3
+ *
4
+ * The commonest defect behind a form is a submit that silently does nothing
5
+ * when its required fields are blank: no request, no message. The engine
6
+ * already notes a submit-style click that fires no request, but only when
7
+ * somebody tries the empty submit, and lanes that fill a form in and submit
8
+ * it rarely do. So scout_coverage lists every form a session saw that no
9
+ * session has yet submitted empty.
10
+ *
11
+ * THE DEFINITION. A form is a `<form>` element. Its controls are the ones the
12
+ * browser associates with it (`form.elements`), so a submit button outside
13
+ * the element linked back by `form="id"` is one of them. It is tracked when it
14
+ * has a native submit control (a `<button>` with no type or type="submit", an
15
+ * `<input type="submit|image">`) and at least one TEXT-ENTRY field.
16
+ *
17
+ * IDENTITY (formIdentity), per route, first match wins:
18
+ * 1. the form's own `id` attribute → `form:#signup`
19
+ * 2. its `name` attribute → `form:name=pay`
20
+ * 3. its `action` attribute with its method → `form:POST /things`
21
+ * 4. only when it has none of those, the coverage key the snapshot gives its
22
+ * first submit control (`tid:thing-save`, `button:save`).
23
+ * The form's own attributes come first because a submit control's key is the
24
+ * weakest name it has: an untagged "Save" in two tab panels is the same key
25
+ * for two forms, and a label that changes ("Pay $12", "Saving…") is a new key
26
+ * for the same one. Attributes are read with getAttribute, never `form.id` or
27
+ * `form.name`, which a field named "id" or "name" shadows.
28
+ *
29
+ * Only the inventory (a snapshot, a crawl, a plan's capture) puts a form on
30
+ * the list. A submit only marks a listed form; one that cannot be matched to
31
+ * a listed form is logged (FORMS_SUBMIT_UNMATCHED), never guessed.
32
+ *
33
+ * A text-entry field is a `<textarea>`, or an `<input>` whose type takes typed
34
+ * text (TEXT_ENTRY_TYPES), that is visible, not disabled (itself or by a
35
+ * disabled fieldset) and not read-only. Checkboxes, radios, selects, file,
36
+ * hidden, search, range and colour inputs are not: none of them can be "left
37
+ * blank" by typing, and a search box's empty submit is a filter, not the
38
+ * defect this looks for.
39
+ *
40
+ * A form counts as SUBMITTED EMPTY when a submit happened while every one of
41
+ * its text-entry fields held the empty string (whitespace is content: that is
42
+ * a separate boundary probe). A submit is a click on one of its native submit
43
+ * controls, or Enter where the browser's implicit submission applies: in one
44
+ * of its text-like inputs (IMPLICIT_SUBMIT_TYPES) while its default button is
45
+ * enabled, or on a focused submit control. It counts whether the click or key
46
+ * came from scout_click, scout_type's pressEnter, scout_press or a plan step.
47
+ * A form with no text-entry field is never listed, and neither is one whose
48
+ * every submit control is disabled while its fields are blank: the page
49
+ * already refuses the empty submit, and nothing could clear the entry.
50
+ *
51
+ * WHY ONLY `<form>`. A "form-like group" of loose fields and buttons had to be
52
+ * guessed from layout and button labels. The guess listed page chrome (a
53
+ * header search box beside "Sign in", under an app root that held every
54
+ * row's "Add" button) and walked the whole document once per button. A real
55
+ * form is the one grouping the browser itself submits, so it is exact and
56
+ * cheap (`document.forms`).
57
+ *
58
+ * NOT CAUGHT, by design of the above:
59
+ * - fields and a save button with no `<form>` around them (a JS-driven panel);
60
+ * - forms whose only submit control is a `role="button"` element, or a
61
+ * `<button type="button">` wired up in script;
62
+ * - forms that submit from script on change (`form.submit()` in a handler);
63
+ * - Ctrl+Enter or Cmd+Enter in a textarea, which some apps treat as send.
64
+ * And kept apart imperfectly, by design of IDENTITY:
65
+ * - forms with no id or name that share an action and method are one entry,
66
+ * so an empty submit of one clears them all;
67
+ * - forms with no id, name or action whose first submit controls are the
68
+ * same untagged button ("Save") are one entry too;
69
+ * - on a page with more controls than a snapshot lists (150), a form with
70
+ * none of those attributes whose submit control is past the cap is not
71
+ * tracked at all.
72
+ *
73
+ * The page reports facts; the rule deciding what they mean lives here as pure
74
+ * functions, so it is table-tested (memory-test) without a browser.
75
+ */
76
+ import { ACCESSIBLE_NAME_SRC, VISIBLE_SRC, XPATH_OF_SRC } from "./collector.js";
77
+ import { normalizePath } from "./fingerprint.js";
78
+ /**
79
+ * Action-log entries this bookkeeping writes. They record what the engine
80
+ * could not do, not a step anyone took on the page: pace.ts does not count
81
+ * them as actions and a finding's repro trace leaves them out.
82
+ */
83
+ export const FORMS_READ_FAILED = "forms:read-failed";
84
+ export const FORMS_SUBMIT_UNMATCHED = "forms:submit-unmatched";
85
+ /** Whether an action-log entry is this bookkeeping rather than a step. */
86
+ export function isFormBookkeeping(action) {
87
+ return action === FORMS_READ_FAILED || action === FORMS_SUBMIT_UNMATCHED;
88
+ }
89
+ /** The form's identity from its own attributes, or null when it has none (see IDENTITY above). */
90
+ export function formIdentity(attrs) {
91
+ const id = attrs.id.trim();
92
+ if (id)
93
+ return `form:#${id.slice(0, 80)}`;
94
+ const name = attrs.name.trim();
95
+ if (name)
96
+ return `form:name=${name.slice(0, 80)}`;
97
+ // Normalized as a route is: a record id or a per-load token in the action
98
+ // would otherwise name a new form on every record or every load.
99
+ const action = attrs.action.trim();
100
+ if (action)
101
+ return `form:${(attrs.method.trim() || "get").toUpperCase()} ${normalizePath(action).slice(0, 120)}`;
102
+ return null;
103
+ }
104
+ /**
105
+ * Whether a snapshot element is the submit control the probe found. An xpath
106
+ * alone is not enough: a sibling added or removed since the snapshot makes the
107
+ * same path name another button (a "Cancel" where "Send" was). The testid and
108
+ * the accessible name, computed by the collector's own rule, must agree too.
109
+ */
110
+ export function sameControl(el, probe) {
111
+ return (el.testid ?? null) === (probe.submitTestid ?? null) && el.name === probe.submitName;
112
+ }
113
+ /** Input types that take typed text. `search` is left out on purpose (see above). */
114
+ export const TEXT_ENTRY_TYPES = new Set([
115
+ "text",
116
+ "email",
117
+ "number",
118
+ "tel",
119
+ "url",
120
+ "password",
121
+ "date",
122
+ "datetime-local",
123
+ "month",
124
+ "week",
125
+ "time",
126
+ ]);
127
+ /**
128
+ * Input types in which Enter submits the form (the HTML standard's implicit
129
+ * submission). Not checkboxes, radios or buttons: Enter does nothing there.
130
+ */
131
+ export const IMPLICIT_SUBMIT_TYPES = new Set([...TEXT_ENTRY_TYPES, "search"]);
132
+ /** Whether a field takes typed text and a user could type into it right now. */
133
+ export function isTextEntry(f) {
134
+ if (!f.visible || f.disabled || f.readOnly)
135
+ return false;
136
+ if (f.tag === "textarea")
137
+ return true;
138
+ return f.tag === "input" && TEXT_ENTRY_TYPES.has(f.type || "text");
139
+ }
140
+ /** Whether a form is tracked at all: it must hold something to leave blank. */
141
+ export function tracksForm(fields) {
142
+ return fields.some(isTextEntry);
143
+ }
144
+ /** Whether every text-entry field is empty — and there is at least one. */
145
+ export function allTextEmpty(fields) {
146
+ const text = fields.filter(isTextEntry);
147
+ return text.length > 0 && text.every((f) => !f.filled);
148
+ }
149
+ /**
150
+ * What the inventory makes of a form: not tracked, tracked and owed an empty
151
+ * submit, or GUARDED — every submit control disabled while the fields are
152
+ * blank, so the page itself refuses the empty submit and nobody could clear
153
+ * the entry by trying it.
154
+ */
155
+ export function formStatus(form) {
156
+ if (!tracksForm(form.fields))
157
+ return "untracked";
158
+ return form.submitDisabled && allTextEmpty(form.fields) ? "guarded" : "open";
159
+ }
160
+ /**
161
+ * Whether the action submitted the form. A click submits when it lands on a
162
+ * native submit control. Enter submits from a focused submit control, or from
163
+ * a text-like input while the form's default button is enabled.
164
+ */
165
+ export function submits(kind, probe) {
166
+ if (probe.selfIsSubmit)
167
+ return true;
168
+ if (kind !== "enter" || probe.defaultDisabled)
169
+ return false;
170
+ return probe.self.tag === "input" && IMPLICIT_SUBMIT_TYPES.has(probe.self.type || "text");
171
+ }
172
+ /** Whether this action was an empty submit of a tracked form. */
173
+ export function isEmptySubmit(kind, probe) {
174
+ return probe !== null && submits(kind, probe) && allTextEmpty(probe.fields);
175
+ }
176
+ /**
177
+ * Whether a failed page read is the page going away under it (a navigation
178
+ * or a closed tab), which is expected after a submit and says nothing. Any
179
+ * other failure is worth a line in the action log.
180
+ */
181
+ export function isNavigationTeardown(message) {
182
+ return /execution context was destroyed|target (page, context or browser )?(has been )?closed|frame (was|has been) detached|navigat/i.test(message);
183
+ }
184
+ /** Page-side helpers shared by the inventory and the probe. Statements, not an expression. */
185
+ const FORM_HELPERS_SRC = `
186
+ const visible = ${VISIBLE_SRC};
187
+ const xpathOf = ${XPATH_OF_SRC};
188
+ const facts = (f) => ({
189
+ tag: f.tagName.toLowerCase(),
190
+ type: f.tagName.toLowerCase() === "input" ? String(f.type || "text").toLowerCase() : "",
191
+ disabled: f.matches(":disabled"),
192
+ readOnly: f.readOnly === true,
193
+ visible: visible(f),
194
+ filled: typeof f.value === "string" && f.value !== "",
195
+ });
196
+ // The type PROPERTY: a button with no type, or an unknown one, reports "submit".
197
+ const isSubmit = (el) => {
198
+ const tag = el.tagName.toLowerCase();
199
+ return (tag === "button" && el.type === "submit") || (tag === "input" && (el.type === "submit" || el.type === "image"));
200
+ };
201
+ const submitsOf = (form) => Array.from(form.elements).filter((c) => isSubmit(c) && visible(c));
202
+ // Each DISTINCT set of field facts once. The rules ask whether any field is
203
+ // text entry and whether every one is blank, which repeats do not change,
204
+ // and a form of 2000 identical rows would otherwise ship 2000 objects back.
205
+ const fieldFactsOf = (form) => {
206
+ const seen = new Map();
207
+ for (const c of Array.from(form.elements)) {
208
+ if (!/^(input|textarea|select)$/i.test(c.tagName) || c.type === "hidden") continue;
209
+ const f = facts(c);
210
+ const id = f.tag + "|" + f.type + "|" + f.disabled + "|" + f.readOnly + "|" + f.visible + "|" + f.filled;
211
+ if (!seen.has(id)) seen.set(id, f);
212
+ }
213
+ return Array.from(seen.values());
214
+ };
215
+ // Resolved against the base URL, so a relative and an absolute action agree;
216
+ // one that is not a URL at all is kept as written.
217
+ const resolvedAction = (form) => {
218
+ const raw = (form.getAttribute("action") || "").trim();
219
+ if (!raw) return "";
220
+ try { return new URL(raw, document.baseURI).href; } catch (e) { return raw; }
221
+ };
222
+ // getAttribute, not form.id / form.name: a field named "id" or "name" shadows those.
223
+ const attrsOf = (form) => ({
224
+ id: form.getAttribute("id") || "",
225
+ name: form.getAttribute("name") || "",
226
+ action: resolvedAction(form),
227
+ method: form.getAttribute("method") || "",
228
+ });
229
+ `;
230
+ /**
231
+ * Page-side: every form on the page with a visible native submit control, as
232
+ * the xpath of its first one and the facts of its fields. One pass over
233
+ * `document.forms`; which of them are tracked is decided in Node (formStatus).
234
+ */
235
+ export const FORMS_INVENTORY_SCRIPT = `(() => {
236
+ ${FORM_HELPERS_SRC}
237
+ const out = [];
238
+ for (const form of Array.from(document.forms)) {
239
+ const submits = submitsOf(form);
240
+ if (submits.length === 0) continue;
241
+ out.push({
242
+ attrs: attrsOf(form),
243
+ submit: xpathOf(submits[0]),
244
+ fields: fieldFactsOf(form),
245
+ submitDisabled: submits.every((s) => s.matches(":disabled")),
246
+ });
247
+ }
248
+ return out;
249
+ })()`;
250
+ /**
251
+ * Page-side body of the probe run on the element an action is about to
252
+ * submit through: `(node) => FormProbe | null`, null when it belongs to no
253
+ * form. Shipped as source rather than a transpiled function, for the reason
254
+ * the collector is a string: a loader's helpers do not exist in the page.
255
+ */
256
+ export const FORM_PROBE_BODY = `
257
+ if (!node || node.nodeType !== 1 || !node.form) return null;
258
+ ${FORM_HELPERS_SRC}
259
+ const form = node.form;
260
+ const submits = submitsOf(form);
261
+ if (submits.length === 0) return null;
262
+ const accessibleName = ${ACCESSIBLE_NAME_SRC};
263
+ return {
264
+ attrs: attrsOf(form),
265
+ submit: xpathOf(submits[0]),
266
+ submitTestid: submits[0].getAttribute("data-testid"),
267
+ submitName: accessibleName(submits[0]),
268
+ fields: fieldFactsOf(form),
269
+ self: facts(node),
270
+ selfIsSubmit: isSubmit(node),
271
+ defaultDisabled: submits[0].matches(":disabled"),
272
+ };
273
+ `;
274
+ /** The probe as an expression over a node expression, for a page-level evaluate. */
275
+ export function formProbeExpression(nodeExpression) {
276
+ return `((node) => {${FORM_PROBE_BODY}})(${nodeExpression})`;
277
+ }
278
+ /**
279
+ * The scout_coverage lines for forms no session has submitted empty this
280
+ * run. Empty when there is nothing to say.
281
+ */
282
+ export function formatNeverSubmittedEmpty(forms) {
283
+ if (forms.length === 0)
284
+ return [];
285
+ return [
286
+ "Forms never submitted empty this run (submit each once with every text field blank: a submit that silently does nothing — no request, no message — hides there):",
287
+ ...forms.slice(0, 15).map((f) => ` ${f.route} ${f.key}`),
288
+ ...(forms.length > 15 ? [` … +${forms.length - 15} more`] : []),
289
+ ];
290
+ }
@@ -135,7 +135,12 @@ export function parseLaneReport(text, expectedLane) {
135
135
  const trailing = /after JSON|Unexpected non-whitespace/i.test(message);
136
136
  return { ok: false, reason: trailing ? "the reply must be one JSON object, with no text after it" : `not valid JSON: ${message}` };
137
137
  }
138
- const result = LaneReport.safeParse(raw);
138
+ // Decoded before the schema, so every cap measures the text the lane wrote.
139
+ // Only a reply that looks relay-escaped is decoded: one holding a literal
140
+ // `<` or `>` was not escaped on the way, and its `&amp;` is what the lane
141
+ // meant (a page that double-escapes its text is a defect a lane can report).
142
+ const decoded = /[<>]/.test(text) ? { value: raw, count: 0 } : decodeStringValues(raw);
143
+ const result = LaneReport.safeParse(decoded.value);
139
144
  if (!result.success) {
140
145
  const issue = result.error.issues[0];
141
146
  const at = issue.path.length ? ` at ${issue.path.join(".")}` : "";
@@ -144,7 +149,77 @@ export function parseLaneReport(text, expectedLane) {
144
149
  if (expectedLane !== undefined && result.data.lane !== expectedLane) {
145
150
  return { ok: false, reason: `the report names lane "${result.data.lane}", but this reply was asked of lane "${expectedLane}"` };
146
151
  }
147
- return { ok: true, report: result.data, aroundIgnored };
152
+ return { ok: true, report: result.data, aroundIgnored, entitiesDecoded: decoded.count };
153
+ }
154
+ /**
155
+ * The HTML character references a relay adds when it escapes text: the five
156
+ * named ones an escaper emits, and decimal or hex numeric ones. Anything else
157
+ * that looks like a reference (`&nbsp;`, `&#0;`, a bare `&`) is left as it is.
158
+ */
159
+ const CHAR_REFERENCE = /&(?:(lt|gt|amp|quot|apos)|#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6}));/g;
160
+ const NAMED_REFERENCES = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
161
+ /**
162
+ * One pass over the text, so each reference is decoded exactly once:
163
+ * `&amp;lt;` becomes `&lt;`, not `<`, because the `&` a match produces is
164
+ * never scanned again.
165
+ */
166
+ function decodeReferences(text) {
167
+ let count = 0;
168
+ const out = text.replace(CHAR_REFERENCE, (whole, name, dec, hex) => {
169
+ if (name) {
170
+ count += 1;
171
+ return NAMED_REFERENCES[name];
172
+ }
173
+ const code = hex !== undefined ? parseInt(hex, 16) : Number(dec);
174
+ // NUL, surrogates and anything past the last code point are not characters a relay escaped.
175
+ if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff))
176
+ return whole;
177
+ count += 1;
178
+ return String.fromCodePoint(code);
179
+ });
180
+ return { text: out, count };
181
+ }
182
+ /**
183
+ * A planner relaying a lane's reply often HTML-escapes it, so `->` arrives as
184
+ * `-&gt;` and no longer matches the finding filed with the same evidence.
185
+ * Decoded in string VALUES only, after JSON.parse: JSON's own syntax contains
186
+ * no `<`, `>` or `&`, so an escaper can only have touched it by turning its
187
+ * quotes into `&quot;`, and that reply does not parse at all; it is refused
188
+ * as invalid JSON rather than repaired. Keys are a fixed set of plain words
189
+ * and are left alone.
190
+ */
191
+ function decodeStringValues(value) {
192
+ if (typeof value === "string") {
193
+ const d = decodeReferences(value);
194
+ return { value: d.text, count: d.count };
195
+ }
196
+ if (Array.isArray(value)) {
197
+ let count = 0;
198
+ const out = value.map((v) => {
199
+ const d = decodeStringValues(v);
200
+ count += d.count;
201
+ return d.value;
202
+ });
203
+ return { value: out, count };
204
+ }
205
+ if (value !== null && typeof value === "object") {
206
+ let count = 0;
207
+ const out = {};
208
+ for (const [k, v] of Object.entries(value)) {
209
+ const d = decodeStringValues(v);
210
+ count += d.count;
211
+ out[k] = d.value;
212
+ }
213
+ return { value: out, count };
214
+ }
215
+ return { value, count: 0 };
216
+ }
217
+ /** The line the fold adds when the parse decoded references, so a reader knows the tool changed the text; empty when it did not. */
218
+ export function decodedEntitiesNote(count) {
219
+ if (count === 0)
220
+ return "";
221
+ const what = count === 1 ? "1 HTML character reference" : `${count} HTML character references`;
222
+ return `\n(${what} in the report ${count === 1 ? "was" : "were"} decoded once, as a relay that escapes text would have added ${count === 1 ? "it" : "them"}.)`;
148
223
  }
149
224
  /**
150
225
  * The paragraph the planner puts in every lane's prompt. It is generated from
@@ -196,3 +271,105 @@ export function summarizeLaneReport(r) {
196
271
  parts.push(`blocked by ${r.blocked_by}`);
197
272
  return `${r.lane}: ${parts.join(", ")}`;
198
273
  }
274
+ /**
275
+ * Which sessions are lanes of a parallel run, and whether each one's report
276
+ * has been folded, so a lane's session is not closed before its decisions
277
+ * have somewhere to go.
278
+ *
279
+ * Folding a report writes its decisions to the lane's own session's project
280
+ * memory. Closing that session first leaves nothing to write to: a planner
281
+ * that closes a lane whose fold was just refused (a cap exceeded, say) and
282
+ * then re-sends the corrected object loses every decision in it.
283
+ *
284
+ * A session counts as a lane only when something in this run named it as
285
+ * one: scout_lane_brief listed it, scout_lane_report issued its instruction,
286
+ * or a reply was folded (accepted or refused) under its name while that
287
+ * session was attached. Nothing is inferred from other lanes' activity: the
288
+ * planner's own session is not a lane, and a single-session run never names
289
+ * one, so neither is ever refused a close by this. The ledger belongs to one
290
+ * run: the server clears it when the last session closes, because lane names
291
+ * are module names and a later run may well attach a role with one of them.
292
+ */
293
+ export class LaneLedger {
294
+ named = new Set();
295
+ refused = new Set();
296
+ folded = new Set();
297
+ /** An instruction named this session as a lane. */
298
+ name(lane) {
299
+ this.named.add(lane);
300
+ }
301
+ /**
302
+ * A brief's lanes. One whose name is a session already live is skipped:
303
+ * that session was attached before the split existed, so it is the
304
+ * planner's own (or another role's), not a lane waiting to report.
305
+ */
306
+ nameBriefed(lanes, isLive) {
307
+ for (const lane of lanes)
308
+ if (!isLive(lane))
309
+ this.named.add(lane);
310
+ }
311
+ /**
312
+ * A reply for this lane was refused: it is a lane, and nothing it decided is
313
+ * kept yet. Only while its session is attached: a reply for a session that
314
+ * is gone says nothing about a later session given the same name.
315
+ */
316
+ refuse(lane, attached) {
317
+ if (!attached)
318
+ return;
319
+ this.named.add(lane);
320
+ if (!this.folded.has(lane))
321
+ this.refused.add(lane);
322
+ }
323
+ /** A reply for this lane was accepted. Only while its session is attached, for the same reason. */
324
+ fold(lane, attached) {
325
+ if (!attached)
326
+ return;
327
+ this.named.add(lane);
328
+ this.folded.add(lane);
329
+ this.refused.delete(lane);
330
+ }
331
+ /** The session closed: a later session with the same name is a fresh lane. */
332
+ forget(lane) {
333
+ this.named.delete(lane);
334
+ this.refused.delete(lane);
335
+ this.folded.delete(lane);
336
+ }
337
+ clear() {
338
+ this.named.clear();
339
+ this.refused.clear();
340
+ this.folded.clear();
341
+ }
342
+ state(lane) {
343
+ if (!this.named.has(lane))
344
+ return "not-a-lane";
345
+ if (this.folded.has(lane))
346
+ return "folded";
347
+ return this.refused.has(lane) ? "refused" : "unfolded";
348
+ }
349
+ }
350
+ /**
351
+ * Whether scout_close may close these sessions. Refused only when one of them
352
+ * is a lane whose report has not been accepted; `force` closes anyway. The
353
+ * message names every such lane and the two ways on.
354
+ */
355
+ export function laneCloseGuard(sessions, ledger, force = false) {
356
+ if (force)
357
+ return { ok: true };
358
+ const pending = sessions.filter((s) => ledger.state(s) === "refused" || ledger.state(s) === "unfolded");
359
+ if (pending.length === 0)
360
+ return { ok: true };
361
+ const one = pending.length === 1;
362
+ const lines = pending.map((s) => ledger.state(s) === "refused"
363
+ ? ` · ${JSON.stringify(s)}: its last report was REFUSED and no corrected one has been accepted`
364
+ : ` · ${JSON.stringify(s)}: no report from it has been accepted yet`);
365
+ const others = sessions.filter((s) => !pending.includes(s));
366
+ const message = `Not closed: ${one ? "this session is a lane" : `${pending.length} sessions are lanes`} of a parallel run whose report has not been folded.\n` +
367
+ lines.join("\n") +
368
+ `\nFolding keeps a lane's decisions in its own session's project memory and checks its defects were filed; closing the session first loses them. ` +
369
+ `Fold with scout_lane_report { lane, reply } (for a refused report, the lane's corrected object), then close. ` +
370
+ `To close anyway and lose ${one ? "that lane's" : "those lanes'"} decisions, pass force: true.` +
371
+ (others.length > 0
372
+ ? `\nThe other session(s) can be closed one by one by name meanwhile: ${others.map((s) => `scout_close { session: ${JSON.stringify(s)} }`).join(", ")}.`
373
+ : "");
374
+ return { ok: false, unfolded: pending, message };
375
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { normalizePath, shortHash } from "./fingerprint.js";
4
+ import { isFormBookkeeping } from "./forms.js";
4
5
  /**
5
6
  * The kinds a finding can be. One list: scout_finding's input schema, the lane
6
7
  * report a parallel agent hands back, and the skill text all read it from
@@ -540,6 +541,8 @@ function sameFinding(a, b) {
540
541
  }
541
542
  /** Per-project directory for memory, session logs, and reports. */
542
543
  export const MEMORY_DIRNAME = ".scenescout";
544
+ /** The one directory under it that is committed rather than ignored: saved flows (flow.ts). */
545
+ export const SELF_IGNORE_KEEP = "flows";
543
546
  /** What the directory was called before the tool was renamed. */
544
547
  export const LEGACY_MEMORY_DIRNAME = ".scenecraft";
545
548
  /**
@@ -610,17 +613,24 @@ function liveOwnerPid(dir, isAlive) {
610
613
  * `git status`. That gets the same result without editing a file we do not
611
614
  * own, and it disappears cleanly when someone deletes the directory.
612
615
  *
616
+ * The one exception is `flows/`: saved flows are inputs a team commits so
617
+ * CI replays them, not output. A file written before that exception existed
618
+ * is still left alone; docs/ci.md says which two lines to add to it.
619
+ *
613
620
  * Best-effort and idempotent — a read-only checkout must never break attach,
614
621
  * and an existing file is left exactly as the user left it.
615
622
  */
616
- function writeSelfIgnore(dir) {
623
+ export function writeSelfIgnore(dir) {
617
624
  try {
618
625
  const ignorePath = path.join(dir, ".gitignore");
619
626
  if (fs.existsSync(ignorePath))
620
627
  return null;
621
628
  fs.writeFileSync(ignorePath, "# SceneScout exploratory-test artifacts: memory, session logs, status, report.\n" +
622
629
  "# Self-ignoring so a `git add -A` in the tested project can never commit them.\n" +
623
- "*\n");
630
+ "*\n" +
631
+ "# Except the flows `scenescout check` replays: they are written to be committed.\n" +
632
+ `!${SELF_IGNORE_KEEP}/\n` +
633
+ `!${SELF_IGNORE_KEEP}/*.json\n`);
624
634
  return `Wrote ${MEMORY_DIRNAME}/.gitignore so these test artifacts stay out of your commits.`;
625
635
  }
626
636
  catch {
@@ -690,6 +700,7 @@ export class MemoryStore {
690
700
  this.injectionsReported.clear();
691
701
  this.auditsThisRun = 0;
692
702
  this.selectChoices.clear();
703
+ this.emptySubmits.clear();
693
704
  }
694
705
  /**
695
706
  * Each dropdown's options and the ones chosen in THIS run, by any session,
@@ -728,6 +739,55 @@ export class MemoryStore {
728
739
  }
729
740
  return out;
730
741
  }
742
+ /**
743
+ * Each form a session saw this run, by route and identity (forms.ts
744
+ * formIdentity: its own attributes, else its first submit control's
745
+ * coverage key), and whether any session has submitted it with every
746
+ * text field blank (forms.ts has the definition). Per run and shared across
747
+ * sessions, like the dropdown choices: an earlier run's empty submit says
748
+ * nothing about this build, and in a parallel run any lane may try it.
749
+ */
750
+ emptySubmits = new Map();
751
+ /**
752
+ * A form seen on a visited state. Recording it again changes nothing, except
753
+ * that `guarded` (every submit control disabled while its fields were blank)
754
+ * takes it off the list for good: the page refuses the empty submit itself,
755
+ * and no one could clear an entry whose submit cannot be pressed.
756
+ */
757
+ recordForm(fingerprint, key, guarded = false) {
758
+ const entry = this.formEntry(fingerprint, key);
759
+ if (entry && guarded)
760
+ entry.guarded = true;
761
+ }
762
+ /**
763
+ * A submit of a form already seen; `empty` when every text field was blank
764
+ * at the moment it went. It only marks: a submit never puts a form on the
765
+ * list, since a key read at submit time is the one most likely to be stale.
766
+ * Returns whether the form was on the list, so a miss can be logged.
767
+ */
768
+ recordFormSubmit(fingerprint, key, empty) {
769
+ const entry = this.formEntry(fingerprint, key, false);
770
+ if (entry && empty)
771
+ entry.triedEmpty = true;
772
+ return entry !== null;
773
+ }
774
+ formEntry(fingerprint, key, create = true) {
775
+ // Another site's frame is not the app's form to probe.
776
+ if (isEmbedKey(key))
777
+ return null;
778
+ const route = fingerprint.split("#")[0];
779
+ const id = `${route}\u0000${key}`;
780
+ let entry = this.emptySubmits.get(id) ?? null;
781
+ if (!entry && create) {
782
+ entry = { route, key, triedEmpty: false };
783
+ this.emptySubmits.set(id, entry);
784
+ }
785
+ return entry;
786
+ }
787
+ /** Forms seen this run that no session has submitted empty, in the order they were first seen. */
788
+ formsNeverSubmittedEmpty() {
789
+ return [...this.emptySubmits.values()].filter((f) => !f.triedEmpty && !f.guarded).map(({ route, key }) => ({ route, key }));
790
+ }
731
791
  constructor(projectDir) {
732
792
  this.dir = path.join(projectDir, MEMORY_DIRNAME);
733
793
  this.legacyDirNote = adoptLegacyMemoryDir(projectDir);
@@ -1347,7 +1407,8 @@ export class MemoryStore {
1347
1407
  // The planner's fold marker is not a step anyone took on a page. Left in,
1348
1408
  // its empty URL read as a route change and cut the trace of a finding filed
1349
1409
  // right after a fold — which is when the fold asks lanes to file.
1350
- const log = this.actionLog.filter((a) => a.action !== "lane-report");
1410
+ // Nor is the forms bookkeeping (a read that failed, a submit it could not match).
1411
+ const log = this.actionLog.filter((a) => a.action !== "lane-report" && !isFormBookkeeping(a.action));
1351
1412
  let start = Math.max(0, log.length - 12);
1352
1413
  for (let i = log.length - 1; i >= 0 && i >= log.length - 12; i--) {
1353
1414
  if (routeOf(log[i].url) !== routeOf(f.url)) {