@zeldrisho/pi-gate 0.4.0 → 0.4.1
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 +8 -1
- package/README.md +1 -1
- package/package.json +4 -6
- package/src/config.ts +51 -4
- package/src/display.ts +16 -0
- package/src/index.ts +36 -7
- package/src/rules.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.1] - 2026-09-17
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- prompt for all commands when an existing configuration cannot be read or parsed instead of allowing every command through unblocked
|
|
15
|
+
|
|
10
16
|
## [0.4.0] - 2026-09-12
|
|
11
17
|
|
|
12
18
|
### Changed
|
|
@@ -50,7 +56,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
50
56
|
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
|
|
51
57
|
- block rather than auto-approve in non-UI modes
|
|
52
58
|
|
|
53
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.
|
|
59
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.1...HEAD
|
|
60
|
+
[0.4.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.0...pi-gate-v0.4.1
|
|
54
61
|
[0.4.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.3.0...pi-gate-v0.4.0
|
|
55
62
|
[0.3.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...pi-gate-v0.3.0
|
|
56
63
|
[0.2.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.1...pi-gate-v0.2.0
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ pi install -l npm:@zeldrisho/pi-gate
|
|
|
12
12
|
|
|
13
13
|
## Configure
|
|
14
14
|
|
|
15
|
-
On first load, the extension creates `~/.pi/agent/pi-gate.json` with starter rules and a 30-second prompt timeout. Existing files are never overwritten. The published package includes [`config.schema.json`](config.schema.json); use this schema URL for editor completion:
|
|
15
|
+
On first load, the extension creates `~/.pi/agent/pi-gate.json` with starter rules and a 30-second prompt timeout. Existing files are never overwritten. If no configuration can be created yet, pi-gate warns at session start and allows commands until the file is created. If an existing configuration cannot be read or parsed, pi-gate warns and prompts for every command until it is fixed. The published package includes [`config.schema.json`](config.schema.json); use this schema URL for editor completion:
|
|
16
16
|
|
|
17
17
|
```json
|
|
18
18
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-gate",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Pi extension that blocks or confirms bash commands based on a user-provided JSON configuration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"command-blocking",
|
|
@@ -25,9 +25,7 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
"src",
|
|
27
27
|
"config.schema.json",
|
|
28
|
-
"
|
|
29
|
-
"CHANGELOG.md",
|
|
30
|
-
"LICENSE"
|
|
28
|
+
"CHANGELOG.md"
|
|
31
29
|
],
|
|
32
30
|
"type": "module",
|
|
33
31
|
"publishConfig": {
|
|
@@ -36,8 +34,8 @@
|
|
|
36
34
|
"devDependencies": {
|
|
37
35
|
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
38
36
|
"typescript": "^7.0.2",
|
|
39
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.
|
|
40
|
-
"vite-plus": "0.3.
|
|
37
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.2",
|
|
38
|
+
"vite-plus": "0.3.2"
|
|
41
39
|
},
|
|
42
40
|
"peerDependencies": {
|
|
43
41
|
"@earendil-works/pi-coding-agent": "*"
|
package/src/config.ts
CHANGED
|
@@ -9,13 +9,26 @@ export interface GateConfig {
|
|
|
9
9
|
promptTimeoutMs: number;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export type ConfigLoadStatus = "missing" | "loaded" | "failed";
|
|
13
|
+
|
|
14
|
+
export interface ConfigLoadResult {
|
|
15
|
+
config: GateConfig;
|
|
16
|
+
status: ConfigLoadStatus;
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
const CONFIG_FILE_NAME = "pi-gate.json";
|
|
20
|
+
|
|
13
21
|
export const CONFIG_SCHEMA_URL =
|
|
14
22
|
"https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json";
|
|
23
|
+
|
|
15
24
|
const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
|
|
25
|
+
|
|
16
26
|
export const DEFAULT_PROMPT_TIMEOUT_MS = 30_000;
|
|
27
|
+
|
|
17
28
|
export const MAX_RULE_COUNT = 1_000;
|
|
29
|
+
|
|
18
30
|
export const MAX_RULE_PATTERN_LENGTH = 1_024;
|
|
31
|
+
|
|
19
32
|
export const MAX_PROMPT_TIMEOUT_MS = 86_400_000;
|
|
20
33
|
|
|
21
34
|
const DEFAULT_CONFIG: GateConfig = {
|
|
@@ -87,15 +100,19 @@ export function parseConfig(content: string): GateConfig {
|
|
|
87
100
|
const rules: Record<string, Action> = {};
|
|
88
101
|
let acceptedRuleCount = 0;
|
|
89
102
|
let parsed: unknown;
|
|
103
|
+
|
|
90
104
|
try {
|
|
91
105
|
parsed = JSON.parse(content);
|
|
92
106
|
} catch {
|
|
93
107
|
return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
94
108
|
}
|
|
109
|
+
|
|
95
110
|
if (!isJsonObject(parsed)) {
|
|
96
111
|
return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
97
112
|
}
|
|
113
|
+
|
|
98
114
|
const configuredTimeout = parsed["promptTimeoutMs"];
|
|
115
|
+
|
|
99
116
|
const promptTimeoutMs =
|
|
100
117
|
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validate the optional numeric field at the untrusted JSON boundary before applying range checks
|
|
101
118
|
typeof configuredTimeout === "number" &&
|
|
@@ -104,18 +121,24 @@ export function parseConfig(content: string): GateConfig {
|
|
|
104
121
|
configuredTimeout <= MAX_PROMPT_TIMEOUT_MS
|
|
105
122
|
? configuredTimeout
|
|
106
123
|
: DEFAULT_PROMPT_TIMEOUT_MS;
|
|
124
|
+
|
|
107
125
|
const operations = parsed["operations"];
|
|
126
|
+
|
|
108
127
|
if (!isJsonObject(operations)) {
|
|
109
128
|
return { operations: rules, promptTimeoutMs };
|
|
110
129
|
}
|
|
130
|
+
|
|
111
131
|
for (const [pattern, action] of Object.entries(operations)) {
|
|
112
132
|
if (acceptedRuleCount >= MAX_RULE_COUNT) break;
|
|
133
|
+
|
|
113
134
|
if (pattern.length === 0 || pattern.length > MAX_RULE_PATTERN_LENGTH) continue;
|
|
135
|
+
|
|
114
136
|
if (isAction(action)) {
|
|
115
137
|
rules[pattern] = action;
|
|
116
138
|
acceptedRuleCount += 1;
|
|
117
139
|
}
|
|
118
140
|
}
|
|
141
|
+
|
|
119
142
|
return { operations: rules, promptTimeoutMs };
|
|
120
143
|
}
|
|
121
144
|
|
|
@@ -123,18 +146,38 @@ export function parseConfig(content: string): GateConfig {
|
|
|
123
146
|
* Loads the configuration from disk. Returns an empty configuration when the
|
|
124
147
|
* file is missing; returns the parsed configuration otherwise.
|
|
125
148
|
*/
|
|
126
|
-
export function
|
|
149
|
+
export function loadConfigResult(): ConfigLoadResult {
|
|
150
|
+
const emptyConfig = { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
127
151
|
const path = configPath();
|
|
152
|
+
|
|
128
153
|
if (!existsSync(path)) {
|
|
129
|
-
return {
|
|
154
|
+
return { config: emptyConfig, status: "missing" };
|
|
130
155
|
}
|
|
156
|
+
|
|
131
157
|
let content: string;
|
|
158
|
+
|
|
132
159
|
try {
|
|
133
160
|
content = readFileSync(path, "utf-8");
|
|
134
161
|
} catch {
|
|
135
|
-
return {
|
|
162
|
+
return { config: emptyConfig, status: "failed" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const parsed: unknown = JSON.parse(content);
|
|
167
|
+
|
|
168
|
+
if (!isJsonObject(parsed) || !isJsonObject(parsed["operations"])) {
|
|
169
|
+
return { config: parseConfig(content), status: "failed" };
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
return { config: emptyConfig, status: "failed" };
|
|
136
173
|
}
|
|
137
|
-
|
|
174
|
+
|
|
175
|
+
return { config: parseConfig(content), status: "loaded" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Loads the configuration, preserving the historical empty-config fallback. */
|
|
179
|
+
export function loadConfig(): GateConfig {
|
|
180
|
+
return loadConfigResult().config;
|
|
138
181
|
}
|
|
139
182
|
|
|
140
183
|
/**
|
|
@@ -143,13 +186,17 @@ export function loadConfig(): GateConfig {
|
|
|
143
186
|
*/
|
|
144
187
|
export function ensureConfig(): void {
|
|
145
188
|
const path = configPath();
|
|
189
|
+
|
|
146
190
|
if (existsSync(path)) return;
|
|
191
|
+
|
|
147
192
|
try {
|
|
148
193
|
mkdirSync(agentDir(), { recursive: true });
|
|
149
194
|
} catch {
|
|
150
195
|
return;
|
|
151
196
|
}
|
|
197
|
+
|
|
152
198
|
const content = JSON.stringify({ $schema: CONFIG_SCHEMA_URL, ...DEFAULT_CONFIG }, null, 2) + "\n";
|
|
199
|
+
|
|
153
200
|
try {
|
|
154
201
|
writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
|
|
155
202
|
} catch {
|
package/src/display.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { RuleMatch } from "./rules";
|
|
2
2
|
|
|
3
3
|
export const MAX_DISPLAY_COMMAND_CHARACTERS = 2_000;
|
|
4
|
+
|
|
4
5
|
export const MAX_DISPLAY_COMMAND_LINES = 20;
|
|
6
|
+
|
|
5
7
|
const DISPLAY_TRUNCATION_MARKER = "\n … [command display truncated]";
|
|
8
|
+
|
|
6
9
|
const BIDI_CONTROL_CODE_POINTS = new Set([
|
|
7
10
|
0x061c, 0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069,
|
|
8
11
|
]);
|
|
@@ -10,7 +13,9 @@ const BIDI_CONTROL_CODE_POINTS = new Set([
|
|
|
10
13
|
/** Converts terminal controls and bidirectional formatting characters to visible escapes. */
|
|
11
14
|
function escapeUnsafeDisplayCharacter(character: string): string {
|
|
12
15
|
const codePoint = character.codePointAt(0);
|
|
16
|
+
|
|
13
17
|
if (codePoint === undefined) return "";
|
|
18
|
+
|
|
14
19
|
if (
|
|
15
20
|
codePoint < 0x20 ||
|
|
16
21
|
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
|
@@ -18,6 +23,7 @@ function escapeUnsafeDisplayCharacter(character: string): string {
|
|
|
18
23
|
) {
|
|
19
24
|
return `\\u{${codePoint.toString(16).padStart(4, "0")}}`;
|
|
20
25
|
}
|
|
26
|
+
|
|
21
27
|
return character;
|
|
22
28
|
}
|
|
23
29
|
|
|
@@ -31,12 +37,15 @@ function findMatchRanges(command: string, pattern?: string): MatchRange[] {
|
|
|
31
37
|
if (!pattern) return [];
|
|
32
38
|
const ranges: MatchRange[] = [];
|
|
33
39
|
let offset = 0;
|
|
40
|
+
|
|
34
41
|
while (offset <= command.length - pattern.length) {
|
|
35
42
|
const start = command.indexOf(pattern, offset);
|
|
43
|
+
|
|
36
44
|
if (start === -1) break;
|
|
37
45
|
ranges.push({ start, end: start + pattern.length });
|
|
38
46
|
offset = start + pattern.length;
|
|
39
47
|
}
|
|
48
|
+
|
|
40
49
|
return ranges;
|
|
41
50
|
}
|
|
42
51
|
|
|
@@ -57,6 +66,7 @@ function renderCommandForDisplay(command: string, pattern?: string): string {
|
|
|
57
66
|
let rawEnd: number;
|
|
58
67
|
let rendered: string;
|
|
59
68
|
const codePoint = command.codePointAt(offset);
|
|
69
|
+
|
|
60
70
|
if (codePoint === 0x0d) {
|
|
61
71
|
rawEnd = offset + (command.codePointAt(offset + 1) === 0x0a ? 2 : 1);
|
|
62
72
|
rendered = "\n";
|
|
@@ -68,11 +78,13 @@ function renderCommandForDisplay(command: string, pattern?: string): string {
|
|
|
68
78
|
|
|
69
79
|
const nextRange = ranges[rangeIndex];
|
|
70
80
|
const wasInsideRange = activeRange !== undefined;
|
|
81
|
+
|
|
71
82
|
const opensRange =
|
|
72
83
|
activeRange === undefined &&
|
|
73
84
|
nextRange !== undefined &&
|
|
74
85
|
nextRange.start < rawEnd &&
|
|
75
86
|
nextRange.end > rawStart;
|
|
87
|
+
|
|
76
88
|
if (opensRange) activeRange = nextRange;
|
|
77
89
|
const closesRange = activeRange !== undefined && activeRange.end <= rawEnd;
|
|
78
90
|
const decorated = `${opensRange ? "»" : ""}${rendered}${closesRange ? "«" : ""}`;
|
|
@@ -81,17 +93,21 @@ function renderCommandForDisplay(command: string, pattern?: string): string {
|
|
|
81
93
|
if (lineCount >= MAX_DISPLAY_COMMAND_LINES) return truncate(wasInsideRange);
|
|
82
94
|
lineCount += 1;
|
|
83
95
|
}
|
|
96
|
+
|
|
84
97
|
if (output.length + decorated.length > MAX_DISPLAY_COMMAND_CHARACTERS) {
|
|
85
98
|
return truncate(wasInsideRange);
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
output += decorated;
|
|
102
|
+
|
|
89
103
|
if (closesRange) {
|
|
90
104
|
activeRange = undefined;
|
|
91
105
|
rangeIndex += 1;
|
|
92
106
|
}
|
|
107
|
+
|
|
93
108
|
offset = rawEnd;
|
|
94
109
|
}
|
|
110
|
+
|
|
95
111
|
return output;
|
|
96
112
|
}
|
|
97
113
|
|
package/src/index.ts
CHANGED
|
@@ -26,9 +26,8 @@
|
|
|
26
26
|
* indefinitely.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { existsSync } from "node:fs";
|
|
30
29
|
import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
31
|
-
import { configPath, ensureConfig,
|
|
30
|
+
import { configPath, ensureConfig, loadConfigResult } from "./config";
|
|
32
31
|
import { resolveRule } from "./rules";
|
|
33
32
|
import { formatPromptCommand, formatRule } from "./display";
|
|
34
33
|
|
|
@@ -58,6 +57,7 @@ function reportHerdrBlocked(pi: ExtensionAPI, ctx: { mode?: string }, active: bo
|
|
|
58
57
|
}
|
|
59
58
|
|
|
60
59
|
export type { Action, GateConfig } from "./config";
|
|
60
|
+
|
|
61
61
|
export {
|
|
62
62
|
CONFIG_SCHEMA_URL,
|
|
63
63
|
DEFAULT_PROMPT_TIMEOUT_MS,
|
|
@@ -66,10 +66,16 @@ export {
|
|
|
66
66
|
MAX_RULE_PATTERN_LENGTH,
|
|
67
67
|
ensureConfig,
|
|
68
68
|
loadConfig,
|
|
69
|
+
loadConfigResult,
|
|
69
70
|
parseConfig,
|
|
70
71
|
} from "./config";
|
|
72
|
+
|
|
73
|
+
export type { ConfigLoadResult, ConfigLoadStatus } from "./config";
|
|
74
|
+
|
|
71
75
|
export type { RuleMatch } from "./rules";
|
|
76
|
+
|
|
72
77
|
export { resolveAction, resolveRule } from "./rules";
|
|
78
|
+
|
|
73
79
|
export {
|
|
74
80
|
MAX_DISPLAY_COMMAND_CHARACTERS,
|
|
75
81
|
MAX_DISPLAY_COMMAND_LINES,
|
|
@@ -80,12 +86,14 @@ export {
|
|
|
80
86
|
/** Gate the built-in `bash` tool against the user-provided rules. */
|
|
81
87
|
export default function piGate(pi: ExtensionAPI): void {
|
|
82
88
|
ensureConfig();
|
|
83
|
-
|
|
89
|
+
let loadResult = loadConfigResult();
|
|
90
|
+
const { config } = loadResult;
|
|
84
91
|
|
|
85
92
|
pi.on("session_start", (_event, ctx) => {
|
|
86
93
|
const path = configPath();
|
|
87
94
|
const ruleCount = Object.keys(config.operations).length;
|
|
88
|
-
|
|
95
|
+
|
|
96
|
+
if (loadResult.status === "loaded") {
|
|
89
97
|
if (ruleCount === 0) {
|
|
90
98
|
ctx.ui.notify(
|
|
91
99
|
`pi-gate: ${path} is empty; no commands are gated. Add rules to the "operations" object to start gating.`,
|
|
@@ -97,9 +105,14 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
97
105
|
"info",
|
|
98
106
|
);
|
|
99
107
|
}
|
|
108
|
+
} else if (loadResult.status === "failed") {
|
|
109
|
+
ctx.ui.notify(
|
|
110
|
+
"pi-gate: configuration exists but could not be loaded; prompting for all commands until it is fixed.",
|
|
111
|
+
"warning",
|
|
112
|
+
);
|
|
100
113
|
} else {
|
|
101
114
|
ctx.ui.notify(
|
|
102
|
-
`pi-gate:
|
|
115
|
+
`pi-gate: no configuration found at ${configPath()}; all commands are allowed until it is created.`,
|
|
103
116
|
"warning",
|
|
104
117
|
);
|
|
105
118
|
}
|
|
@@ -107,17 +120,29 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
107
120
|
|
|
108
121
|
pi.on("tool_call", async (event, ctx) => {
|
|
109
122
|
if (event.toolName !== "bash") return undefined;
|
|
123
|
+
|
|
110
124
|
if (!isToolCallEventType("bash", event)) return undefined;
|
|
111
125
|
const command = event.input.command;
|
|
112
|
-
|
|
126
|
+
|
|
127
|
+
if (loadResult.status === "failed") {
|
|
128
|
+
loadResult = loadConfigResult();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const match =
|
|
132
|
+
loadResult.status === "failed"
|
|
133
|
+
? { pattern: "configuration unavailable", action: "prompt" as const }
|
|
134
|
+
: resolveRule(command, loadResult.config.operations);
|
|
135
|
+
|
|
113
136
|
if (match === null || match.action === "allow") return undefined;
|
|
114
137
|
const rule = formatRule(match);
|
|
115
138
|
|
|
116
139
|
if (match.action === "block") {
|
|
117
140
|
const reason = `pi-gate: command blocked by rule ${rule}`;
|
|
141
|
+
|
|
118
142
|
if (ctx.hasUI) {
|
|
119
143
|
ctx.ui.notify(reason, "warning");
|
|
120
144
|
}
|
|
145
|
+
|
|
121
146
|
return { block: true, reason, terminate: true };
|
|
122
147
|
}
|
|
123
148
|
|
|
@@ -129,17 +154,20 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
129
154
|
terminate: true,
|
|
130
155
|
};
|
|
131
156
|
}
|
|
157
|
+
|
|
132
158
|
reportHerdrBlocked(pi, ctx, true);
|
|
133
159
|
let choice: string | undefined;
|
|
160
|
+
|
|
134
161
|
try {
|
|
135
162
|
choice = await ctx.ui.select(
|
|
136
163
|
`pi-gate: allow this command?\n\n${formatPromptCommand(command, match.pattern)}\n\nMatched rule: ${rule}\nMatched command text is wrapped in »…«`,
|
|
137
164
|
["Allow", "Deny"],
|
|
138
|
-
{ timeout: config.promptTimeoutMs },
|
|
165
|
+
{ timeout: loadResult.config.promptTimeoutMs },
|
|
139
166
|
);
|
|
140
167
|
} finally {
|
|
141
168
|
reportHerdrBlocked(pi, ctx, false);
|
|
142
169
|
}
|
|
170
|
+
|
|
143
171
|
if (choice !== "Allow") {
|
|
144
172
|
return {
|
|
145
173
|
block: true,
|
|
@@ -147,6 +175,7 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
147
175
|
terminate: true,
|
|
148
176
|
};
|
|
149
177
|
}
|
|
178
|
+
|
|
150
179
|
return undefined;
|
|
151
180
|
});
|
|
152
181
|
}
|
package/src/rules.ts
CHANGED
|
@@ -18,13 +18,17 @@ export interface RuleMatch {
|
|
|
18
18
|
*/
|
|
19
19
|
export function resolveRule(command: string, rules: Record<string, Action>): RuleMatch | null {
|
|
20
20
|
let bestPattern: string | null = null;
|
|
21
|
+
|
|
21
22
|
for (const pattern of Object.keys(rules)) {
|
|
22
23
|
if (!command.includes(pattern)) continue;
|
|
24
|
+
|
|
23
25
|
if (bestPattern === null || pattern.length > bestPattern.length) {
|
|
24
26
|
bestPattern = pattern;
|
|
25
27
|
}
|
|
26
28
|
}
|
|
29
|
+
|
|
27
30
|
if (bestPattern === null) return null;
|
|
31
|
+
|
|
28
32
|
return { pattern: bestPattern, action: rules[bestPattern] };
|
|
29
33
|
}
|
|
30
34
|
|