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.
- package/CHANGELOG.md +25 -0
- package/README.md +61 -3
- package/dist/browsers.js +32 -0
- package/dist/check-run.js +150 -0
- package/dist/cli.js +97 -6
- package/dist/engine/bench.js +154 -17
- package/dist/engine/browser.js +526 -38
- package/dist/engine/check.js +705 -0
- package/dist/engine/collector.js +94 -18
- package/dist/engine/design.js +24 -8
- package/dist/engine/flow.js +398 -0
- package/dist/engine/forms.js +290 -0
- package/dist/engine/lane.js +179 -2
- package/dist/engine/memory.js +64 -3
- package/dist/engine/oracles.js +79 -2
- package/dist/engine/pace.js +14 -1
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +40 -9
- package/package.json +8 -4
- package/skills/scenescout/SKILL.md +3 -3
|
@@ -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
|
+
}
|
package/dist/engine/lane.js
CHANGED
|
@@ -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
|
-
|
|
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 `&` 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 (` `, `�`, 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
|
+
* `&lt;` becomes `<`, 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
|
+
* `->` 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 `"`, 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
|
+
}
|
package/dist/engine/memory.js
CHANGED
|
@@ -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
|
-
|
|
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)) {
|