@basein/runner 0.2.11 → 0.3.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/README.md +540 -350
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -10
- package/dist/bin/bir-scenario.js +704 -135
- package/dist/bin/bir.d.ts +14 -8
- package/dist/bin/bir.js +2937 -488
- package/dist/bin/investigate.js +41 -16
- package/dist/bin/scenario-edit.d.ts +43 -10
- package/dist/bin/scenario-edit.js +281 -80
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2803 -306
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/housekeeping.d.ts +71 -0
- package/dist/record/housekeeping.js +417 -0
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +371 -20
- package/dist/replay/controller.js +1185 -111
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1244
- package/docs/calculatedReplayGuide.md +1212 -879
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -271
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safety — values a plan must not template, unattended, into a command.
|
|
3
|
+
*
|
|
4
|
+
* A plan's parameters are read from the turn, and the turn includes the last
|
|
5
|
+
* few tool results (segmented.md R-PARAM-2): a web page the agent fetched, an
|
|
6
|
+
* email, a file it read. Those values then flow through LLM-written
|
|
7
|
+
* `toolInputLogic` — typically plain concatenation, `"… WHERE region = '" +
|
|
8
|
+
* parameters.region + "'"` — into calls that nobody approves one by one: a
|
|
9
|
+
* steered step is answered `allow`, and a direct step runs through the proxy
|
|
10
|
+
* with no prompt at all. A page that says `EMEA'; DROP TABLE orders; --` or
|
|
11
|
+
* `x; curl … | sh` in the right place would be executed on the user's database
|
|
12
|
+
* or shell by the replay, not by anything the user asked for.
|
|
13
|
+
*
|
|
14
|
+
* The runner cannot know how a body quotes. It can refuse to guess: when a plan
|
|
15
|
+
* has a step whose input is a command — a shell, an editor that writes files,
|
|
16
|
+
* an MCP tool named for SQL or execution — every value derived for *this* turn
|
|
17
|
+
* must be plain text, free of the characters that end a quoted string or start
|
|
18
|
+
* a new command. A value equal to its recorded sample is the user's own and
|
|
19
|
+
* passes. Anything else stops the plan, and the agent does the task itself, in
|
|
20
|
+
* the open, where the user's own permission rules apply. A false positive
|
|
21
|
+
* costs one replay; a false negative runs someone else's command.
|
|
22
|
+
*
|
|
23
|
+
* Quoting is not the only way in. A body that templates a value *unquoted* —
|
|
24
|
+
* `"… WHERE user_id = " + parameters.userId`, `"find . -name " +
|
|
25
|
+
* parameters.pattern`, `"ssh " + parameters.host` — is broken open by plain
|
|
26
|
+
* text: `1 OR 1=1`, `x -exec rm -rf {} +`, `-oProxyCommand=…`. So a value for a
|
|
27
|
+
* command also keeps the *shape* of its recorded sample: a number stays a
|
|
28
|
+
* number and a boolean a boolean; nothing starts with `-` (an option, not a
|
|
29
|
+
* value); and whitespace, a glob, a brace or a parenthesis the sample did not
|
|
30
|
+
* have is not let in. Where the sample itself had spaces — a name like `EMEA
|
|
31
|
+
* West`, which the recorded body must have quoted to work at all — the value
|
|
32
|
+
* may too, but not together with boolean SQL (`OR`/`AND`/`UNION`), a `+`, or a
|
|
33
|
+
* word that starts with `-`.
|
|
34
|
+
*
|
|
35
|
+
* A plan whose only sink is a file edit is held to less: no line break, no
|
|
36
|
+
* NUL. A file is not a shell, and a quote or a backslash — every Windows path —
|
|
37
|
+
* is text there.
|
|
38
|
+
*/
|
|
39
|
+
import type { FlatEntry } from "./flatten.js";
|
|
40
|
+
import type { ParamsSchema } from "./types.js";
|
|
41
|
+
/**
|
|
42
|
+
* How a step consumes its input, strictest first: `command` is run by a shell
|
|
43
|
+
* or a database, `write` is only written into a file.
|
|
44
|
+
*/
|
|
45
|
+
export type SinkKind = "command" | "write";
|
|
46
|
+
/** Whether this tool's input is run by a shell. */
|
|
47
|
+
export declare function isShellTool(toolName: string | null | undefined): boolean;
|
|
48
|
+
/** How this tool consumes its input, or undefined when it is neither run nor written. */
|
|
49
|
+
export declare function sinkOf(toolName: string | null | undefined): SinkKind | undefined;
|
|
50
|
+
/** Whether this tool's input is run or written: a shell, a file edit, a query. */
|
|
51
|
+
export declare function isCommandSink(toolName: string | null | undefined): boolean;
|
|
52
|
+
/** The strictest sink among the planned steps, or undefined when there is none. */
|
|
53
|
+
export declare function sinkOfPlan(entries: readonly FlatEntry[]): SinkKind | undefined;
|
|
54
|
+
/** Whether any planned step runs its input through a shell. */
|
|
55
|
+
export declare function hasShellStep(entries: readonly FlatEntry[]): boolean;
|
|
56
|
+
/** Whether any planned step's input is run or written. */
|
|
57
|
+
export declare function hasCommandSink(entries: readonly FlatEntry[]): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* The first value derived for this turn that is not safe to template into a
|
|
60
|
+
* command, or undefined when every one is.
|
|
61
|
+
*
|
|
62
|
+
* `derived` is the derivation's own answer, *before* `paramsLogic`: the
|
|
63
|
+
* scenario's logic may well build a quoted SQL string on purpose, and that is
|
|
64
|
+
* the recorded chain's doing, not the turn's.
|
|
65
|
+
*/
|
|
66
|
+
export declare function unsafeParameter(derived: Readonly<Record<string, unknown>>, schema: ParamsSchema | null | undefined,
|
|
67
|
+
/** The plan's strictest sink ({@link sinkOfPlan}); `command` when omitted. */
|
|
68
|
+
sink?: SinkKind): {
|
|
69
|
+
key: string;
|
|
70
|
+
value: string;
|
|
71
|
+
} | undefined;
|
|
72
|
+
//# sourceMappingURL=safety.d.ts.map
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safety — values a plan must not template, unattended, into a command.
|
|
3
|
+
*
|
|
4
|
+
* A plan's parameters are read from the turn, and the turn includes the last
|
|
5
|
+
* few tool results (segmented.md R-PARAM-2): a web page the agent fetched, an
|
|
6
|
+
* email, a file it read. Those values then flow through LLM-written
|
|
7
|
+
* `toolInputLogic` — typically plain concatenation, `"… WHERE region = '" +
|
|
8
|
+
* parameters.region + "'"` — into calls that nobody approves one by one: a
|
|
9
|
+
* steered step is answered `allow`, and a direct step runs through the proxy
|
|
10
|
+
* with no prompt at all. A page that says `EMEA'; DROP TABLE orders; --` or
|
|
11
|
+
* `x; curl … | sh` in the right place would be executed on the user's database
|
|
12
|
+
* or shell by the replay, not by anything the user asked for.
|
|
13
|
+
*
|
|
14
|
+
* The runner cannot know how a body quotes. It can refuse to guess: when a plan
|
|
15
|
+
* has a step whose input is a command — a shell, an editor that writes files,
|
|
16
|
+
* an MCP tool named for SQL or execution — every value derived for *this* turn
|
|
17
|
+
* must be plain text, free of the characters that end a quoted string or start
|
|
18
|
+
* a new command. A value equal to its recorded sample is the user's own and
|
|
19
|
+
* passes. Anything else stops the plan, and the agent does the task itself, in
|
|
20
|
+
* the open, where the user's own permission rules apply. A false positive
|
|
21
|
+
* costs one replay; a false negative runs someone else's command.
|
|
22
|
+
*
|
|
23
|
+
* Quoting is not the only way in. A body that templates a value *unquoted* —
|
|
24
|
+
* `"… WHERE user_id = " + parameters.userId`, `"find . -name " +
|
|
25
|
+
* parameters.pattern`, `"ssh " + parameters.host` — is broken open by plain
|
|
26
|
+
* text: `1 OR 1=1`, `x -exec rm -rf {} +`, `-oProxyCommand=…`. So a value for a
|
|
27
|
+
* command also keeps the *shape* of its recorded sample: a number stays a
|
|
28
|
+
* number and a boolean a boolean; nothing starts with `-` (an option, not a
|
|
29
|
+
* value); and whitespace, a glob, a brace or a parenthesis the sample did not
|
|
30
|
+
* have is not let in. Where the sample itself had spaces — a name like `EMEA
|
|
31
|
+
* West`, which the recorded body must have quoted to work at all — the value
|
|
32
|
+
* may too, but not together with boolean SQL (`OR`/`AND`/`UNION`), a `+`, or a
|
|
33
|
+
* word that starts with `-`.
|
|
34
|
+
*
|
|
35
|
+
* A plan whose only sink is a file edit is held to less: no line break, no
|
|
36
|
+
* NUL. A file is not a shell, and a quote or a backslash — every Windows path —
|
|
37
|
+
* is text there.
|
|
38
|
+
*/
|
|
39
|
+
import { parseQualifiedName } from "../control/correlation.js";
|
|
40
|
+
/** Built-ins whose input is run by a shell. */
|
|
41
|
+
const SHELL_BUILTINS = new Set(["Bash", "PowerShell"]);
|
|
42
|
+
/** Built-ins whose input is written into a file. */
|
|
43
|
+
const WRITE_BUILTINS = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
|
|
44
|
+
/**
|
|
45
|
+
* An MCP tool whose name says its input is run: a shell, a query, a script.
|
|
46
|
+
* Matched against the name split into words ({@link nameWords}), so
|
|
47
|
+
* `run_command`, `run-command` and `runCommand` are the same tool — and against
|
|
48
|
+
* the server's name as well as the tool's, because `mcp__shell__run` says it
|
|
49
|
+
* with the server.
|
|
50
|
+
*/
|
|
51
|
+
const MCP_SHELL_WORD = /(?:^|[_.-])(?:shell|bash|sh|zsh|ssh|exec|execute|command|cmd|terminal|powershell|pwsh)(?:$|[_.-])/i;
|
|
52
|
+
const MCP_COMMAND_WORD = /(?:^|[_.-])(?:sql|tsql|query|exec|execute|shell|bash|sh|zsh|ssh|command|cmd|script|eval|terminal|psql|mysql|mssql|sqlite|duckdb|postgres|postgresql|powershell|pwsh)(?:$|[_.-])/i;
|
|
53
|
+
/**
|
|
54
|
+
* A tool or server name as delimited words: camelCase, PascalCase and acronym
|
|
55
|
+
* runs are split with `_`, so `executeSql` reads as `execute_Sql`, `runSQL` as
|
|
56
|
+
* `run_SQL` and `SQLQuery` as `SQL_Query`. Without this every camelCase server —
|
|
57
|
+
* a common naming convention — slipped past the guard.
|
|
58
|
+
*/
|
|
59
|
+
function nameWords(name) {
|
|
60
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2");
|
|
61
|
+
}
|
|
62
|
+
/** Whether an MCP tool's own name, or its server's, matches `words`. */
|
|
63
|
+
function mcpNameSays(toolName, words) {
|
|
64
|
+
const mcp = parseQualifiedName(toolName);
|
|
65
|
+
if (!mcp)
|
|
66
|
+
return false;
|
|
67
|
+
return words.test(nameWords(mcp.toolName)) || words.test(nameWords(mcp.serverName));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Characters and sequences that end a quoted string or begin another command
|
|
71
|
+
* in a POSIX shell, PowerShell or SQL. Deliberately wide: an apostrophe in a
|
|
72
|
+
* name is legitimate text and still stops the plan, because the runner cannot
|
|
73
|
+
* see whether the body quotes it.
|
|
74
|
+
*/
|
|
75
|
+
const UNSAFE = /[`$;&|<>\\"'\r\n\u0000]|--|\/\*/;
|
|
76
|
+
/**
|
|
77
|
+
* What a value written into a file must not carry: a line break or a NUL,
|
|
78
|
+
* which is how a value meant for one line or one path becomes another.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately narrower than {@link UNSAFE}. A file is not parsed by a shell,
|
|
81
|
+
* so a quote or a backslash is text there — every Windows path has one — and
|
|
82
|
+
* checking for them caught none of the actual risk (hostile file *content*)
|
|
83
|
+
* while handing over every replay that edits `C:\Users\…` or a name with an
|
|
84
|
+
* apostrophe, at the cost of a denied call and a model round trip each time.
|
|
85
|
+
*/
|
|
86
|
+
const WRITE_UNSAFE = /[\r\n\u0000]/;
|
|
87
|
+
/** Whether this tool's input is run by a shell. */
|
|
88
|
+
export function isShellTool(toolName) {
|
|
89
|
+
const name = toolName ?? "";
|
|
90
|
+
if (SHELL_BUILTINS.has(name))
|
|
91
|
+
return true;
|
|
92
|
+
return mcpNameSays(name, MCP_SHELL_WORD);
|
|
93
|
+
}
|
|
94
|
+
/** How this tool consumes its input, or undefined when it is neither run nor written. */
|
|
95
|
+
export function sinkOf(toolName) {
|
|
96
|
+
const name = toolName ?? "";
|
|
97
|
+
if (SHELL_BUILTINS.has(name))
|
|
98
|
+
return "command";
|
|
99
|
+
if (WRITE_BUILTINS.has(name))
|
|
100
|
+
return "write";
|
|
101
|
+
return mcpNameSays(name, MCP_COMMAND_WORD) ? "command" : undefined;
|
|
102
|
+
}
|
|
103
|
+
/** Whether this tool's input is run or written: a shell, a file edit, a query. */
|
|
104
|
+
export function isCommandSink(toolName) {
|
|
105
|
+
return sinkOf(toolName) !== undefined;
|
|
106
|
+
}
|
|
107
|
+
/** The strictest sink among the planned steps, or undefined when there is none. */
|
|
108
|
+
export function sinkOfPlan(entries) {
|
|
109
|
+
let found;
|
|
110
|
+
for (const e of entries) {
|
|
111
|
+
const sink = sinkOf(e.step.toolName);
|
|
112
|
+
if (sink === "command")
|
|
113
|
+
return sink;
|
|
114
|
+
found ??= sink;
|
|
115
|
+
}
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
/** Whether any planned step runs its input through a shell. */
|
|
119
|
+
export function hasShellStep(entries) {
|
|
120
|
+
return entries.some((e) => isShellTool(e.step.toolName));
|
|
121
|
+
}
|
|
122
|
+
/** Whether any planned step's input is run or written. */
|
|
123
|
+
export function hasCommandSink(entries) {
|
|
124
|
+
return entries.some((e) => isCommandSink(e.step.toolName));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The first value derived for this turn that is not safe to template into a
|
|
128
|
+
* command, or undefined when every one is.
|
|
129
|
+
*
|
|
130
|
+
* `derived` is the derivation's own answer, *before* `paramsLogic`: the
|
|
131
|
+
* scenario's logic may well build a quoted SQL string on purpose, and that is
|
|
132
|
+
* the recorded chain's doing, not the turn's.
|
|
133
|
+
*/
|
|
134
|
+
export function unsafeParameter(derived, schema,
|
|
135
|
+
/** The plan's strictest sink ({@link sinkOfPlan}); `command` when omitted. */
|
|
136
|
+
sink = "command") {
|
|
137
|
+
const unsafe = sink === "write" ? WRITE_UNSAFE : UNSAFE;
|
|
138
|
+
for (const [key, value] of Object.entries(derived)) {
|
|
139
|
+
if (value === null || value === undefined)
|
|
140
|
+
continue;
|
|
141
|
+
const sample = schema?.[key]?.sampleValue;
|
|
142
|
+
// The recorded sample is the user's own value from their own run.
|
|
143
|
+
if (sameValue(value, sample))
|
|
144
|
+
continue;
|
|
145
|
+
const bad = firstUnsafeString(value, 0, unsafe);
|
|
146
|
+
if (bad !== undefined)
|
|
147
|
+
return { key, value: bad };
|
|
148
|
+
// A file is not parsed, so only a command is held to its sample's shape.
|
|
149
|
+
if (sink === "command") {
|
|
150
|
+
const off = offShape(value, sample);
|
|
151
|
+
if (off !== undefined)
|
|
152
|
+
return { key, value: off };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
/** A number as text: what a numeric sample's value may be when it arrives as a string. */
|
|
158
|
+
const NUMERIC = /^-?\d+(?:\.\d+)?$/;
|
|
159
|
+
/** Boolean text, for a boolean sample's value that arrives as a string. */
|
|
160
|
+
const BOOLEAN_TEXT = /^(?:true|false)$/i;
|
|
161
|
+
/**
|
|
162
|
+
* Characters that change what an *unquoted* value is to a shell or a
|
|
163
|
+
* database — a word break, a glob, a brace expansion (`{-rf,/}` is two
|
|
164
|
+
* arguments), a sub-expression. Let in only when the sample had them.
|
|
165
|
+
*/
|
|
166
|
+
const SHAPE_CHARS = ["\\s", "*", "?", "[", "]", "{", "}", "(", ")"];
|
|
167
|
+
const SHAPE_TESTS = SHAPE_CHARS.map((char) => ({
|
|
168
|
+
char,
|
|
169
|
+
re: char === "\\s" ? /\s/ : new RegExp(`\\${char}`),
|
|
170
|
+
}));
|
|
171
|
+
/** Boolean SQL: what turns `WHERE id = <value>` into every row. */
|
|
172
|
+
const SQL_BOOLEAN_WORD = /\b(?:or|and|union)\b/gi;
|
|
173
|
+
/**
|
|
174
|
+
* Where a value derived for a `command` sink leaves its recorded sample's
|
|
175
|
+
* shape (see the file comment), as the text to report; undefined when it
|
|
176
|
+
* keeps it.
|
|
177
|
+
*/
|
|
178
|
+
function offShape(value, sample) {
|
|
179
|
+
// A number stays a number: `1 OR 1=1` is not a user id.
|
|
180
|
+
if (typeof sample === "number") {
|
|
181
|
+
if (typeof value === "number")
|
|
182
|
+
return Number.isFinite(value) ? undefined : String(value);
|
|
183
|
+
if (typeof value !== "string" || !NUMERIC.test(value))
|
|
184
|
+
return display(value);
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
if (typeof sample === "boolean") {
|
|
188
|
+
if (typeof value === "boolean")
|
|
189
|
+
return undefined;
|
|
190
|
+
if (typeof value === "string" && BOOLEAN_TEXT.test(value))
|
|
191
|
+
return undefined;
|
|
192
|
+
return display(value);
|
|
193
|
+
}
|
|
194
|
+
const shape = sampleText(sample);
|
|
195
|
+
return firstOffShapeString(value, 0, shape);
|
|
196
|
+
}
|
|
197
|
+
/** Every string a sample holds — values and keys — for its shape. */
|
|
198
|
+
function sampleText(sample) {
|
|
199
|
+
const out = [];
|
|
200
|
+
const walk = (v, depth) => {
|
|
201
|
+
if (typeof v === "string")
|
|
202
|
+
out.push(v);
|
|
203
|
+
else if (typeof v === "number" || typeof v === "boolean")
|
|
204
|
+
out.push(String(v));
|
|
205
|
+
else if (v && typeof v === "object" && depth <= 8) {
|
|
206
|
+
if (!Array.isArray(v))
|
|
207
|
+
out.push(...Object.keys(v));
|
|
208
|
+
for (const item of Array.isArray(v) ? v : Object.values(v)) {
|
|
209
|
+
walk(item, depth + 1);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
walk(sample, 0);
|
|
214
|
+
// NUL never matches `\s`, and is a word boundary: joined, the parts neither
|
|
215
|
+
// gain whitespace nor merge into a word.
|
|
216
|
+
return out.join("\u0000");
|
|
217
|
+
}
|
|
218
|
+
function firstOffShapeString(value, depth, shape) {
|
|
219
|
+
if (typeof value === "string")
|
|
220
|
+
return offShapeText(value, shape) ? value : undefined;
|
|
221
|
+
if (depth > 8 || value === null || typeof value !== "object")
|
|
222
|
+
return undefined;
|
|
223
|
+
const items = Array.isArray(value) ? value : Object.values(value);
|
|
224
|
+
for (const item of items) {
|
|
225
|
+
const bad = firstOffShapeString(item, depth + 1, shape);
|
|
226
|
+
if (bad !== undefined)
|
|
227
|
+
return bad;
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(value)) {
|
|
230
|
+
for (const k of Object.keys(value)) {
|
|
231
|
+
if (offShapeText(k, shape))
|
|
232
|
+
return k;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
/** Whether one string breaks the sample's shape (see the file comment). */
|
|
238
|
+
function offShapeText(text, shape) {
|
|
239
|
+
// An option, not a value: `-oProxyCommand=…`, `--upload-pack=…`, `-exec`.
|
|
240
|
+
if (text.trimStart().startsWith("-"))
|
|
241
|
+
return true;
|
|
242
|
+
for (const { re } of SHAPE_TESTS) {
|
|
243
|
+
if (re.test(text) && !re.test(shape))
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
if (/\s/.test(text)) {
|
|
247
|
+
// Words the sample had spaces for — but not a new option among them,
|
|
248
|
+
if (/(?:^|\s)-/.test(text))
|
|
249
|
+
return true;
|
|
250
|
+
// a `+` (`-exec … {} +`),
|
|
251
|
+
if (text.includes("+") && !shape.includes("+"))
|
|
252
|
+
return true;
|
|
253
|
+
// or boolean SQL the sample did not say itself.
|
|
254
|
+
const had = new Set((shape.match(SQL_BOOLEAN_WORD) ?? []).map((w) => w.toLowerCase()));
|
|
255
|
+
for (const word of text.match(SQL_BOOLEAN_WORD) ?? []) {
|
|
256
|
+
if (!had.has(word.toLowerCase()))
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
/** A value as text for the audit line. */
|
|
263
|
+
function display(value) {
|
|
264
|
+
if (typeof value === "string")
|
|
265
|
+
return value;
|
|
266
|
+
try {
|
|
267
|
+
return JSON.stringify(value) ?? String(value);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return String(value);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function firstUnsafeString(value, depth, unsafe) {
|
|
274
|
+
if (typeof value === "string")
|
|
275
|
+
return unsafe.test(value) ? value : undefined;
|
|
276
|
+
if (depth > 8 || value === null || typeof value !== "object")
|
|
277
|
+
return undefined;
|
|
278
|
+
const items = Array.isArray(value) ? value : Object.values(value);
|
|
279
|
+
for (const item of items) {
|
|
280
|
+
const bad = firstUnsafeString(item, depth + 1, unsafe);
|
|
281
|
+
if (bad !== undefined)
|
|
282
|
+
return bad;
|
|
283
|
+
}
|
|
284
|
+
// A key is text too, and an object's keys reach a body just as its values do.
|
|
285
|
+
if (!Array.isArray(value)) {
|
|
286
|
+
for (const k of Object.keys(value)) {
|
|
287
|
+
if (unsafe.test(k))
|
|
288
|
+
return k;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
function sameValue(a, b) {
|
|
294
|
+
if (a === b)
|
|
295
|
+
return true;
|
|
296
|
+
try {
|
|
297
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=safety.js.map
|
|
@@ -27,6 +27,16 @@
|
|
|
27
27
|
* (segmented.md R-CALL-31). So the rows are cached per run id, and the cursors
|
|
28
28
|
* are kept per frame — a segment called twice in one chain replays from the
|
|
29
29
|
* start of its range both times, as two separate replays of it would.
|
|
30
|
+
*
|
|
31
|
+
* OFF BY DEFAULT NOW. A recorded output shown to the model as a step's result
|
|
32
|
+
* is last week's answer standing in for a call that did not happen here — and
|
|
33
|
+
* for a write ("INSERT 0 1", "file updated") that is a lie the model repeats to
|
|
34
|
+
* the user. A plan therefore stops at a step that cannot run and hands the rest
|
|
35
|
+
* over (plan.ts). `BIR_REPLAY_RECORDED_OUTPUTS=1` brings the stand-in back for
|
|
36
|
+
* steps that could not be *sent* at all — a built-in, a server with no proxy
|
|
37
|
+
* here — never for one that was sent and failed or did not answer; `bir replay`
|
|
38
|
+
* keeps it on, because its operator reads a per-step trace. Nothing else asks
|
|
39
|
+
* for these rows.
|
|
30
40
|
*/
|
|
31
41
|
import type { FlatEntry } from "./flatten.js";
|
|
32
42
|
export interface SourceRunOptions {
|
|
@@ -27,8 +27,19 @@
|
|
|
27
27
|
* (segmented.md R-CALL-31). So the rows are cached per run id, and the cursors
|
|
28
28
|
* are kept per frame — a segment called twice in one chain replays from the
|
|
29
29
|
* start of its range both times, as two separate replays of it would.
|
|
30
|
+
*
|
|
31
|
+
* OFF BY DEFAULT NOW. A recorded output shown to the model as a step's result
|
|
32
|
+
* is last week's answer standing in for a call that did not happen here — and
|
|
33
|
+
* for a write ("INSERT 0 1", "file updated") that is a lie the model repeats to
|
|
34
|
+
* the user. A plan therefore stops at a step that cannot run and hands the rest
|
|
35
|
+
* over (plan.ts). `BIR_REPLAY_RECORDED_OUTPUTS=1` brings the stand-in back for
|
|
36
|
+
* steps that could not be *sent* at all — a built-in, a server with no proxy
|
|
37
|
+
* here — never for one that was sent and failed or did not answer; `bir replay`
|
|
38
|
+
* keeps it on, because its operator reads a per-step trace. Nothing else asks
|
|
39
|
+
* for these rows.
|
|
30
40
|
*/
|
|
31
41
|
import { logDetail, logLine, errText } from "../util/log.js";
|
|
42
|
+
import { isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal, } from "../util/service-health.js";
|
|
32
43
|
export class SourceRunCache {
|
|
33
44
|
opts;
|
|
34
45
|
/** run id → every recorded output of that run, in order. */
|
|
@@ -82,47 +93,71 @@ export class SourceRunCache {
|
|
|
82
93
|
return pending;
|
|
83
94
|
}
|
|
84
95
|
async fetchRun(runId) {
|
|
96
|
+
// Waited on inside a step of a replay the user is watching, so it obeys the
|
|
97
|
+
// same breaker as every other service wait (util/service-health.ts): while
|
|
98
|
+
// the service is cooling down this is not attempted at all, and the step
|
|
99
|
+
// simply has no recorded output — for this plan, like any failed fetch.
|
|
100
|
+
if (!serviceHealth.canWait())
|
|
101
|
+
throw new Error("the service is not answering right now");
|
|
102
|
+
// The token is read here, at use, never kept: the recorder refreshes it as
|
|
103
|
+
// a session outlives it, and a copy taken at arming goes stale.
|
|
104
|
+
const token = this.opts.token();
|
|
105
|
+
if (!token)
|
|
106
|
+
throw new Error("not signed in");
|
|
85
107
|
const doFetch = this.opts.fetchImpl ?? fetch;
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
timer.unref?.();
|
|
108
|
+
const startedAt = Date.now();
|
|
109
|
+
let res;
|
|
89
110
|
try {
|
|
90
|
-
|
|
91
|
-
headers: { authorization: `Bearer ${
|
|
92
|
-
signal:
|
|
111
|
+
res = await doFetch(`${this.opts.baseUrl.replace(/\/+$/, "")}/recordings/runs/${runId}`, {
|
|
112
|
+
headers: { authorization: `Bearer ${token}` },
|
|
113
|
+
signal: timeoutSignal(this.opts.timeoutMs ?? serviceTimeoutMs()),
|
|
93
114
|
});
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
serviceHealth.fail(`source run: ${errText(err)}`);
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
if (isUnhealthyStatus(res.status))
|
|
122
|
+
serviceHealth.fail(`source run: HTTP ${res.status}`);
|
|
123
|
+
else
|
|
124
|
+
serviceOk(startedAt);
|
|
125
|
+
throw new Error(`HTTP ${res.status}`);
|
|
126
|
+
}
|
|
127
|
+
let body;
|
|
128
|
+
try {
|
|
129
|
+
body = (await res.json());
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
serviceHealth.fail("source run: the answer was not JSON");
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
serviceOk(startedAt);
|
|
136
|
+
const outputs = [];
|
|
137
|
+
// A `tool_response` belongs to the `tool_selected` before it, and it is
|
|
138
|
+
// *that* row's position the range is cut on: a response row may sit just
|
|
139
|
+
// outside a range whose call sits inside it.
|
|
140
|
+
const lastCall = new Map();
|
|
141
|
+
for (const row of body.steps ?? []) {
|
|
142
|
+
if (!row.tool_name)
|
|
143
|
+
continue;
|
|
144
|
+
if (row.type === "tool_selected") {
|
|
145
|
+
lastCall.set(row.tool_name, row.step_index ?? -1);
|
|
146
|
+
continue;
|
|
116
147
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
148
|
+
if (row.type !== "tool_response")
|
|
149
|
+
continue;
|
|
150
|
+
outputs.push({
|
|
151
|
+
toolName: row.tool_name,
|
|
152
|
+
output: row.tool_output ?? "{}",
|
|
153
|
+
position: lastCall.get(row.tool_name) ?? row.step_index ?? -1,
|
|
121
154
|
});
|
|
122
155
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
156
|
+
this.runs.set(runId, outputs);
|
|
157
|
+
logDetail("replay.source_run_loaded", {
|
|
158
|
+
run: runId,
|
|
159
|
+
outputs: outputs.length,
|
|
160
|
+
});
|
|
126
161
|
}
|
|
127
162
|
}
|
|
128
163
|
/**
|
|
@@ -130,4 +165,14 @@ export class SourceRunCache {
|
|
|
130
165
|
* recording. Kept so nothing outside has to change its import.
|
|
131
166
|
*/
|
|
132
167
|
export { SourceRunCache as SourceRunOutputs };
|
|
168
|
+
/**
|
|
169
|
+
* Report a service answer to the breaker with the time its request started:
|
|
170
|
+
* an answer to a request sent before a hot path gave up on the service does
|
|
171
|
+
* not end the cool-down that give-up began (util/service-health.ts). A
|
|
172
|
+
* breaker that takes no start time reads it as a plain `ok()`.
|
|
173
|
+
*/
|
|
174
|
+
function serviceOk(startedAt) {
|
|
175
|
+
const ok = serviceHealth.ok.bind(serviceHealth);
|
|
176
|
+
ok(startedAt);
|
|
177
|
+
}
|
|
133
178
|
//# sourceMappingURL=source-run.js.map
|
package/dist/replay/targets.d.ts
CHANGED
|
@@ -38,6 +38,17 @@
|
|
|
38
38
|
* Only frame 0 is judged here. A called segment runs on the parameters its
|
|
39
39
|
* call's `paramMapLogic` builds, not on these, so that body is scanned as a
|
|
40
40
|
* consumer and the segment's own steps are not (R-CALL-29).
|
|
41
|
+
*
|
|
42
|
+
* WHEN IN DOUBT, IT BLOCKS. This reads source text, not a parse tree, so it can
|
|
43
|
+
* only prove "safe" for the shapes it recognises: `parameters.key`,
|
|
44
|
+
* `parameters?.key` and `parameters["key"]`. Any other use of `parameters` —
|
|
45
|
+
* destructured (`const { region } = parameters`), passed whole
|
|
46
|
+
* (`Object.entries(parameters)`), aliased (`const p = parameters`), read with a
|
|
47
|
+
* computed key — could reach any target, so every missing target blocks. A
|
|
48
|
+
* false "blocks" costs one replay; a false "safe" runs a query on `null` and
|
|
49
|
+
* tells the user there are no orders. `paramsLogic` is judged the same way and
|
|
50
|
+
* more strictly still, because it runs before any step and can rename a target
|
|
51
|
+
* the steps then read under another name.
|
|
41
52
|
*/
|
|
42
53
|
import type { FlatEntry } from "./flatten.js";
|
|
43
54
|
import type { ParamsSchema } from "./types.js";
|
|
@@ -47,6 +58,9 @@ import type { ParamsSchema } from "./types.js";
|
|
|
47
58
|
* Empty means every target the turn left unnamed is one the chain finds for
|
|
48
59
|
* itself. `missing` is returned unchanged whenever the schema or the plan is
|
|
49
60
|
* missing, so a caller that knows less than this one is never made to guess.
|
|
61
|
+
*
|
|
62
|
+
* `paramsLogic` is the scenario's own, when it has one: it runs on these
|
|
63
|
+
* parameters before step 0, and what it returns is what every step reads.
|
|
50
64
|
*/
|
|
51
|
-
export declare function blockingTargets(missing: readonly string[], schema: ParamsSchema | null | undefined, planned: readonly FlatEntry[]): string[];
|
|
65
|
+
export declare function blockingTargets(missing: readonly string[], schema: ParamsSchema | null | undefined, planned: readonly FlatEntry[], paramsLogic?: string | null): string[];
|
|
52
66
|
//# sourceMappingURL=targets.d.ts.map
|
package/dist/replay/targets.js
CHANGED
|
@@ -38,6 +38,17 @@
|
|
|
38
38
|
* Only frame 0 is judged here. A called segment runs on the parameters its
|
|
39
39
|
* call's `paramMapLogic` builds, not on these, so that body is scanned as a
|
|
40
40
|
* consumer and the segment's own steps are not (R-CALL-29).
|
|
41
|
+
*
|
|
42
|
+
* WHEN IN DOUBT, IT BLOCKS. This reads source text, not a parse tree, so it can
|
|
43
|
+
* only prove "safe" for the shapes it recognises: `parameters.key`,
|
|
44
|
+
* `parameters?.key` and `parameters["key"]`. Any other use of `parameters` —
|
|
45
|
+
* destructured (`const { region } = parameters`), passed whole
|
|
46
|
+
* (`Object.entries(parameters)`), aliased (`const p = parameters`), read with a
|
|
47
|
+
* computed key — could reach any target, so every missing target blocks. A
|
|
48
|
+
* false "blocks" costs one replay; a false "safe" runs a query on `null` and
|
|
49
|
+
* tells the user there are no orders. `paramsLogic` is judged the same way and
|
|
50
|
+
* more strictly still, because it runs before any step and can rename a target
|
|
51
|
+
* the steps then read under another name.
|
|
41
52
|
*/
|
|
42
53
|
/**
|
|
43
54
|
* The subset of `missing` this plan must decline on.
|
|
@@ -45,8 +56,11 @@
|
|
|
45
56
|
* Empty means every target the turn left unnamed is one the chain finds for
|
|
46
57
|
* itself. `missing` is returned unchanged whenever the schema or the plan is
|
|
47
58
|
* missing, so a caller that knows less than this one is never made to guess.
|
|
59
|
+
*
|
|
60
|
+
* `paramsLogic` is the scenario's own, when it has one: it runs on these
|
|
61
|
+
* parameters before step 0, and what it returns is what every step reads.
|
|
48
62
|
*/
|
|
49
|
-
export function blockingTargets(missing, schema, planned) {
|
|
63
|
+
export function blockingTargets(missing, schema, planned, paramsLogic) {
|
|
50
64
|
if (missing.length === 0)
|
|
51
65
|
return [];
|
|
52
66
|
if (!schema || planned.length === 0)
|
|
@@ -56,7 +70,48 @@ export function blockingTargets(missing, schema, planned) {
|
|
|
56
70
|
// anything, but it is also not a shape worth reasoning about — keep it strict.
|
|
57
71
|
if (bodies.length === 0)
|
|
58
72
|
return [...missing];
|
|
59
|
-
|
|
73
|
+
// A body that uses `parameters` in a shape this file cannot follow may read
|
|
74
|
+
// any of them.
|
|
75
|
+
if (bodies.some(readsWhole))
|
|
76
|
+
return [...missing];
|
|
77
|
+
// `paramsLogic` passes a missing target on under whatever name it likes
|
|
78
|
+
// (`return { r: parameters.region }`), and the steps then read `parameters.r`
|
|
79
|
+
// — which no scan of the steps connects to `region`. So there, any mention
|
|
80
|
+
// of a missing target is a read, `??` or not: a fallback in paramsLogic is a
|
|
81
|
+
// default someone typed, which is exactly a guessed target.
|
|
82
|
+
const logic = paramsLogic?.trim() ? paramsLogic : undefined;
|
|
83
|
+
if (logic && readsWhole(withoutSpread(logic)))
|
|
84
|
+
return [...missing];
|
|
85
|
+
return missing.filter((key) => (logic !== undefined &&
|
|
86
|
+
(mentions(logic, key) || carriesLiteral(logic, schema[key]?.sampleValue))) ||
|
|
87
|
+
bodies.some((body) => readsOutright(body, key) || carriesLiteral(body, schema[key]?.sampleValue)));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `parameters` used in any shape other than `parameters.key`,
|
|
91
|
+
* `parameters?.key`, `parameters["key"]` or `parameters?.["key"]` — spread,
|
|
92
|
+
* destructured, passed or aliased.
|
|
93
|
+
*
|
|
94
|
+
* A member access *of something else* (`respParams.parameters`,
|
|
95
|
+
* `x?.parameters`) is not the argument and does not count; a spread
|
|
96
|
+
* (`...parameters`) is and does. A mention inside a string or a comment counts
|
|
97
|
+
* too — this reads text — and that errs in the safe direction.
|
|
98
|
+
*/
|
|
99
|
+
function readsWhole(body) {
|
|
100
|
+
return BARE_PARAMETERS.test(body);
|
|
101
|
+
}
|
|
102
|
+
const BARE_PARAMETERS = /(?<![\w$])(?<!(?<!\.)\.)parameters\b(?!\s*(?:\?\.\s*[A-Za-z_$]|\?\.\s*\[\s*["'`]|\.\s*[A-Za-z_$]|\[\s*["'`]))/;
|
|
103
|
+
/**
|
|
104
|
+
* `paramsLogic` with its spreads of `parameters` removed. `{ ...parameters,
|
|
105
|
+
* extra: … }` hands every key on unchanged, which the step scan then judges;
|
|
106
|
+
* what the rest of the body does with `parameters` is still read.
|
|
107
|
+
*/
|
|
108
|
+
function withoutSpread(body) {
|
|
109
|
+
return body.replace(/\.\.\.\s*parameters\b(?!\s*(?:\?\.|\.|\[))/g, "{}");
|
|
110
|
+
}
|
|
111
|
+
/** Whether `body` refers to `parameters.<key>` in any recognised form, fallback or not. */
|
|
112
|
+
function mentions(body, key) {
|
|
113
|
+
const pattern = referencePattern(key);
|
|
114
|
+
return pattern.test(body);
|
|
60
115
|
}
|
|
61
116
|
/**
|
|
62
117
|
* Every logic body that runs with the caller's own `parameters`.
|
|
@@ -87,10 +142,13 @@ function callerBodies(planned) {
|
|
|
87
142
|
}
|
|
88
143
|
return bodies;
|
|
89
144
|
}
|
|
90
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* `parameters.key`, `parameters["key"]`, `parameters['key']`, and each of them
|
|
147
|
+
* through optional chaining (`parameters?.key`, `parameters?.["key"]`).
|
|
148
|
+
*/
|
|
91
149
|
function referencePattern(key) {
|
|
92
150
|
const k = escapeRegExp(key);
|
|
93
|
-
return new RegExp(`parameters\\s*(
|
|
151
|
+
return new RegExp(`parameters\\s*(?:\\?\\.\\s*${k}\\b|\\.\\s*${k}\\b|(?:\\?\\.)?\\s*\\[\\s*["'\`]${k}["'\`]\\s*\\])`, "g");
|
|
94
152
|
}
|
|
95
153
|
/**
|
|
96
154
|
* Whether `body` reads `key` other than as a fallback.
|