@davisliu2006/pi-permission-system 0.1.0 → 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/src/shell.ts CHANGED
@@ -1,15 +1,45 @@
1
1
  /**
2
- * Conservative shell-chaining detector.
3
- * This is not a parser and is biased toward returning true.
4
- *
5
- * Guards against:
2
+ * Result of a conservative shell scan.
3
+ *
4
+ * When `isComplex` is false, `segments` are commands separated only by `;`,
5
+ * `&&`, `||`, or newlines (or a single command).
6
+ *
7
+ * When `isComplex` is true because of `|` / `|&`, `segments` are still filled
8
+ * so deny rules can be checked per stage. Other complex syntax returns empty
9
+ * `segments`.
10
+ */
11
+ export type ShellCommandAnalysis = {
12
+ isComplex: boolean;
13
+ segments: string[];
14
+ };
15
+
16
+ const COMPLEX: ShellCommandAnalysis = { isComplex: true, segments: [] };
17
+
18
+ /**
19
+ * Conservative shell command scanner.
20
+ * This is not a full parser and is biased toward `isComplex: true`.
21
+ *
22
+ * Splits on:
23
+ * - `;`, `&&`, `||`, and newlines outside quotes
24
+ * - '|' and '|&' outside quotes (also treated as complex)
25
+ *
26
+ * Treats as complex:
6
27
  * - Substitutions like `command` and $(command)
7
- * - Chaining like ;, &&, ||, \n, and \r
28
+ * - Pipes `|` and background `&`
8
29
  * - Parentheses
9
30
  */
10
- export function hasShellChaining(command: string): boolean {
31
+ export function analyzeShellCommand(command: string): ShellCommandAnalysis {
11
32
  let quote: "'" | '"' | undefined;
12
33
  let index = 0;
34
+ let current = "";
35
+ let isComplex = false;
36
+ const segments: string[] = [];
37
+
38
+ const pushCurrent = (): void => {
39
+ const trimmed = current.trim();
40
+ if (trimmed) {segments.push(trimmed);}
41
+ current = "";
42
+ };
13
43
 
14
44
  while (index < command.length) {
15
45
  const character = command[index]!;
@@ -17,52 +47,93 @@ export function hasShellChaining(command: string): boolean {
17
47
  // inside quote
18
48
  if (quote === "'") {
19
49
  if (character === "'") {quote = undefined;}
50
+ current += character;
20
51
  index++;
21
52
  continue;
22
53
  }
23
54
  if (quote === '"') {
24
55
  if (character === "\\") {
56
+ current += character + (command[index+1] ?? "");
25
57
  index += 2;
26
58
  continue;
27
59
  }
28
60
  if (character === '"') {
29
61
  quote = undefined;
62
+ current += character;
30
63
  index++;
31
64
  continue;
32
65
  }
33
- if (character === "`") {return true;}
34
- if (character === "$" && command[index + 1] === "(") {return true;}
66
+ if (character === "`") {return COMPLEX;}
67
+ if (character === "$" && command[index+1] === "(") {return COMPLEX;}
68
+ current += character;
35
69
  index++;
36
70
  continue;
37
71
  }
38
72
 
39
73
  // escape character
40
74
  if (character === "\\") {
75
+ current += character + (command[index+1] ?? "");
41
76
  index += 2;
42
77
  continue;
43
78
  }
44
-
45
- // strat quote
79
+
80
+ // start quote
46
81
  if (character === "'") {
47
82
  quote = "'";
83
+ current += character;
48
84
  index++;
49
85
  continue;
50
86
  }
51
87
  if (character === '"') {
52
88
  quote = '"';
89
+ current += character;
53
90
  index++;
54
91
  continue;
55
92
  }
56
93
 
57
- if (character === "`") {return true;}
58
- if (character === "$" && command[index + 1] === "(") {return true;}
59
- if (character === ";" || character === "|" || character === "&") {return true;}
60
- if (character === "\n" || character === "\r") {return true;}
61
- if (character === "(" || character === ")") {return true;}
94
+ if (character === "`") {return COMPLEX;}
95
+ if (character === "$" && command[index+1] === "(") {return COMPLEX;}
96
+ if (character === "(" || character === ")") {return COMPLEX;}
62
97
 
98
+ // simple separators
99
+ if (character === ";") {
100
+ pushCurrent();
101
+ index++;
102
+ continue;
103
+ }
104
+ if (character === "&") {
105
+ if (command[index+1] !== "&") {return COMPLEX;}
106
+ pushCurrent();
107
+ index += 2;
108
+ continue;
109
+ }
110
+ if (character === "|") {
111
+ pushCurrent();
112
+ if (command[index+1] === "|") {
113
+ index += 2;
114
+ continue;
115
+ }
116
+ isComplex = true;
117
+ index += command[index+1] === "&" ? 2 : 1;
118
+ continue;
119
+ }
120
+ if (character === "\n" || character === "\r") {
121
+ pushCurrent();
122
+ if (character === "\r" && command[index+1] === "\n") {index += 2;}
123
+ else {index++;}
124
+ continue;
125
+ }
126
+
127
+ current += character;
63
128
  index++;
64
129
  }
65
130
 
66
131
  // Unterminated quotes are unsafe to reason about.
67
- return quote !== undefined;
132
+ if (quote !== undefined) {return COMPLEX;}
133
+
134
+ pushCurrent();
135
+ return {
136
+ isComplex,
137
+ segments: segments.length > 0 ? segments : [""],
138
+ };
68
139
  }
package/src/types-pi.ts CHANGED
@@ -23,12 +23,3 @@ export type PiToolInput =
23
23
  | PathToolInput
24
24
  | BashToolInput
25
25
  | Record<string, unknown>;
26
-
27
- export function validatePathToolInput(input: PiToolInput): PathToolInput | undefined {
28
- if (!("path" in input) || typeof(input.path) !== "string") {return undefined;}
29
- return input as PathToolInput;
30
- }
31
- export function validateBashToolInput(input: PiToolInput): BashToolInput | undefined {
32
- if (!("command" in input) || typeof(input.command) !== "string") {return undefined;}
33
- return input as BashToolInput;
34
- }
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ASK, PERMISSION_STATES } from "./constants.ts";
2
+ import type { ShellCommandAnalysis } from "./shell.ts";
2
3
  import type { PiToolInput } from "./types-pi.ts";
3
4
 
4
5
  // utility functions
@@ -20,22 +21,42 @@ export function toPermissionState(value: unknown): PermissionState | undefined {
20
21
 
21
22
  export type PermissionSection = Record<string, PermissionState>;
22
23
 
24
+ export interface PermissionSettings {
25
+ /** `provider/model-id` override for small helper completions. */
26
+ ai_model?: string;
27
+ }
28
+
23
29
  export interface PermissionConfig {
24
30
  default: PermissionState;
25
31
  tools: PermissionSection;
26
32
  bash: PermissionSection;
27
33
  external_directory: PermissionSection;
34
+ settings: PermissionSettings;
28
35
  }
29
36
 
30
37
  export function createDefaultPermissionConfig(): PermissionConfig {
31
- return { default: ASK, tools: {}, bash: {}, external_directory: {} };
38
+ return { default: ASK, tools: {}, bash: {}, external_directory: {}, settings: {} };
32
39
  }
33
40
 
34
- export const DEFAULT_PERMISSION_CONFIG = createDefaultPermissionConfig();
41
+ /**
42
+ * Parses `provider/model-id`.
43
+ * Splits using the first '/'.
44
+ */
45
+ export function parseAiModelRef(
46
+ value: string
47
+ ): { provider: string; modelId: string } | undefined {
48
+ const trimmed = value.trim();
49
+ const slash = trimmed.indexOf("/");
50
+ if (slash <= 0 || slash >= trimmed.length - 1) {return undefined;}
51
+ const provider = trimmed.slice(0, slash).trim();
52
+ const modelId = trimmed.slice(slash + 1).trim();
53
+ if (!provider || !modelId) {return undefined;}
54
+ return { provider, modelId };
55
+ }
35
56
 
36
57
  // permission request
37
58
 
38
- export type RequestBodyKind = "command" | "path" | "generic";
59
+ export type RequestBodyKind = "path" | "command" | "generic";
39
60
 
40
61
  /**
41
62
  * Request for a tool.
@@ -44,6 +65,8 @@ export type RequestBodyKind = "command" | "path" | "generic";
44
65
  * @property body - The body for the permission request.
45
66
  * @property bodyKind - The meaning of the body.
46
67
  * @property cwd - The current working directory.
68
+ * @property isExternalDirectory - Whether the request uses path to an external directory.
69
+ * @property bashAnalysis - Shell scan for bash requests.
47
70
  */
48
71
  export interface PermissionRequest {
49
72
  toolName: string;
@@ -52,12 +75,12 @@ export interface PermissionRequest {
52
75
  bodyKind: RequestBodyKind;
53
76
  cwd: string;
54
77
  isExternalDirectory?: boolean;
55
- isChainedBash?: boolean;
78
+ bashAnalysis?: ShellCommandAnalysis;
56
79
  }
57
80
 
58
81
  // resolved policy
59
82
 
60
- export type PolicySource = "bash" | "tool" | "default" | "external";
83
+ export type PolicySource = "default" | "tool" | "bash" | "external";
61
84
 
62
85
  /**
63
86
  * A resolved policy.
@@ -66,18 +89,17 @@ export interface PolicyResolution {
66
89
  state: PermissionState;
67
90
  source: PolicySource;
68
91
  matchedPattern?: string;
69
- isChainedBash: boolean;
70
92
  }
71
93
 
94
+ export type AllowAlwaysMode = "wildcard" | "wildcard-list" | "exact";
95
+
72
96
  // other types
73
97
 
74
98
  export type AllowAlwaysRule = {
75
- scope: "tool" | "external_directory";
76
99
  kind: "exact";
77
100
  tool: string;
78
101
  body: string;
79
102
  } | {
80
- scope: "tool";
81
103
  kind: "wildcard";
82
104
  tool: "bash";
83
105
  body: string;
package/src/ui-strings.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
1
2
  import {
2
3
  ALLOW_ALWAYS_EXACT,
3
4
  ALLOW_ALWAYS_WILDCARD,
4
5
  } from "./constants.ts";
5
- import { willGenerateBashWildcard } from "./policy.ts";
6
+ import { resolveAllowAlwaysMode } from "./policy-bash.ts";
6
7
  import type { PermissionRequest, PolicyResolution } from "./types.ts";
7
8
 
8
9
  export const STATUS_SUMMARIZING_REQUEST = "Summarizing permission request...";
@@ -10,57 +11,101 @@ export const STATUS_GENERATING_WILDCARD = "Generating bash wildcard...";
10
11
 
11
12
  export const PERMISSION_DIALOG_TITLE = "Permission Required";
12
13
  export const PERMISSION_DIALOG_QUESTION = "What should the agent be allowed to do?";
13
- export const PURPOSE_PREFIX = "Purpose: ";
14
14
 
15
15
  export const WILDCARD_EDITOR_TITLE = "Review bash wildcard";
16
+ export const WILDCARD_LIST_EDITOR_TITLE = "Review bash wildcards (semicolon-separated)";
16
17
  export const BROAD_WILDCARD_TITLE = "Very broad wildcard";
17
- export const INVALID_WILDCARD_PREFIX = "Invalid wildcard: ";
18
18
 
19
19
  const CHAINED_BASH_NOTE = "Shell chaining detected.";
20
20
  const EXTERNAL_DIRECTORY_NOTE = "Outside the current working directory.";
21
21
 
22
+ type SummaryLine = {
23
+ label?: string;
24
+ value: string;
25
+ };
26
+
22
27
  function preview(value: string, limit: number): string {
23
28
  const normalized = value.replace(/\r\n/g, "\n");
24
29
  return normalized.length > limit ? `${normalized.slice(0, limit)}...` : normalized;
25
30
  }
26
31
 
27
- /**
28
- * Summary shown in the permission select dialog.
29
- */
30
- export function buildSelectorSummary(request: PermissionRequest): string {
31
- const lines = [
32
- `Tool: ${request.toolName}`,
32
+ function buildSelectorSummaryLines(
33
+ request: PermissionRequest,
34
+ purpose?: string
35
+ ): SummaryLine[] {
36
+ const lines: SummaryLine[] = [
37
+ { label: "Tool", value: request.toolName },
33
38
  ];
34
-
35
39
  if (request.bodyKind === "command") {
36
- lines.push(`Command: \`${preview(request.body, 600)}\``);
37
- if (request.isChainedBash) {
38
- lines.push(CHAINED_BASH_NOTE);
40
+ lines.push({ label: "Command", value: preview(request.body, 600) });
41
+ if (request.bashAnalysis?.isComplex) {
42
+ lines.push({ value: CHAINED_BASH_NOTE });
39
43
  }
40
44
  } else if (request.bodyKind === "path") {
41
- lines.push(`Path: ${preview(request.body, 400)}`);
45
+ lines.push({ label: "Path", value: preview(request.body, 400) });
42
46
  if (request.isExternalDirectory) {
43
- lines.push(EXTERNAL_DIRECTORY_NOTE);
47
+ lines.push({ value: EXTERNAL_DIRECTORY_NOTE });
44
48
  }
45
49
  } else {
46
- lines.push(`Input: ${preview(request.body, 600)}`);
50
+ lines.push({ label: "Input", value: preview(request.body, 600) });
47
51
  }
52
+ if (purpose) {
53
+ lines.push({ label: "Purpose", value: purpose });
54
+ }
55
+ return lines;
56
+ }
57
+
58
+ /**
59
+ * Plain summary shown in the non-TUI permission dialog.
60
+ */
61
+ export function buildPlainSelectorSummary(
62
+ request: PermissionRequest,
63
+ purpose?: string
64
+ ): string {
65
+ return buildSelectorSummaryLines(request, purpose)
66
+ .map((line) => (
67
+ line.label ? `${line.label}: ${line.value}` : line.value)
68
+ )
69
+ .join("\n");
70
+ }
48
71
 
49
- return lines.join("\n");
72
+ /**
73
+ * Styled summary lines shown in the TUI permission dialog.
74
+ */
75
+ export function buildStyledSelectorSummary(
76
+ theme: Theme,
77
+ request: PermissionRequest,
78
+ purpose?: string
79
+ ): string[] {
80
+ return buildSelectorSummaryLines(request, purpose).map((line) => {
81
+ if (!line.label) {
82
+ return theme.fg("muted", line.value);
83
+ }
84
+ if (line.label === "Purpose") {
85
+ return theme.fg("accent", theme.bold(`${line.label}:`))
86
+ + theme.fg("muted", ` ${line.value}`);
87
+ }
88
+ return theme.fg("accent", theme.bold(`${line.label}:`))
89
+ + theme.fg("text", ` ${line.value}`);
90
+ });
50
91
  }
51
92
 
52
93
  /**
53
- * Label for the "Allow Always" select option.
94
+ * Label for the "allow always" select option.
54
95
  */
55
96
  export function formatAllowAlwaysOption(
56
97
  request: PermissionRequest,
57
98
  policy: PolicyResolution
58
99
  ): string {
59
- return willGenerateBashWildcard(request, policy)
60
- ? ALLOW_ALWAYS_WILDCARD
61
- : ALLOW_ALWAYS_EXACT;
100
+ return resolveAllowAlwaysMode(request, policy) === "exact"
101
+ ? ALLOW_ALWAYS_EXACT
102
+ : ALLOW_ALWAYS_WILDCARD;
62
103
  }
63
104
 
64
105
  export function formatBroadWildcardConfirm(pattern: string): string {
65
106
  return `This pattern can match many commands:\n\n${pattern}\n\nSave it for this session?`;
66
107
  }
108
+
109
+ export function formatBroadWildcardListConfirm(patterns: readonly string[]): string {
110
+ return `These patterns can match many commands:\n\n${patterns.join("\n")}\n\nSave them for this session?`;
111
+ }
@@ -0,0 +1,58 @@
1
+ import { EXTENSION_NAME, PERMISSION_STATES_DESCRIPTION } from "./constants.ts";
2
+
3
+ export const CONFIG_WARNING_PREFIX = `${EXTENSION_NAME}: `;
4
+ export const INVALID_WILDCARD_PREFIX = "Invalid wildcard: ";
5
+
6
+ export const CONFIG_MUST_BE_OBJECT =
7
+ "Permission config must be a JSON object; using defaults.";
8
+ export const IGNORED_DEFAULT_WARNING =
9
+ `Ignoring "default": expected ${PERMISSION_STATES_DESCRIPTION}.`;
10
+ export const IGNORED_SETTINGS_WARNING =
11
+ 'Ignoring "settings": expected an object.';
12
+ export const IGNORED_AI_MODEL_WARNING =
13
+ 'Ignoring "settings.ai_model": expected a string like "provider/model-id".';
14
+
15
+ export const WILDCARD_LENGTH_REASON =
16
+ "Pattern must be between 1 and 500 characters.";
17
+ export const WILDCARD_NO_MATCH_REASON =
18
+ "Pattern does not match the current command.";
19
+
20
+ export function formatIgnoredSectionWarning(sectionName: string): string {
21
+ return `Ignoring "${sectionName}": expected an object mapping names to ${PERMISSION_STATES_DESCRIPTION}.`;
22
+ }
23
+
24
+ export function formatIgnoredSectionEntryWarning(
25
+ sectionName: string,
26
+ key: string
27
+ ): string {
28
+ return `Ignoring "${sectionName}.${key}": expected ${PERMISSION_STATES_DESCRIPTION}.`;
29
+ }
30
+
31
+ export function formatConfigReadFailure(error: unknown): string {
32
+ const detail = error instanceof Error ? error.message : String(error);
33
+ return `Failed to read permission config: ${detail}`;
34
+ }
35
+
36
+ export function formatConfigParseFailure(error: unknown): string {
37
+ const detail = error instanceof Error ? error.message : String(error);
38
+ return `Failed to parse permission config: ${detail}`;
39
+ }
40
+
41
+ export function formatSuppressedConfigWarnings(suppressed: number): string {
42
+ return `${suppressed} additional config warning(s) suppressed.`;
43
+ }
44
+
45
+ export function formatWildcardListCountReason(
46
+ expected: number,
47
+ actual: number
48
+ ): string {
49
+ return `Expected ${expected} semicolon-separated pattern(s), got ${actual}.`;
50
+ }
51
+
52
+ export function formatWildcardListItemReason(
53
+ index: number,
54
+ pattern: string,
55
+ reason: string
56
+ ): string {
57
+ return `Pattern ${index} (${JSON.stringify(pattern)}): ${reason}`;
58
+ }
@@ -0,0 +1,139 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { generateBashWildcard, generateBashWildcards } from "./ai-wildcard.ts";
4
+ import { STATUS_KEY } from "./constants.ts";
5
+ import { resolveAllowAlwaysMode } from "./policy-bash.ts";
6
+ import { analyzeShellCommand } from "./shell.ts";
7
+ import {
8
+ BROAD_WILDCARD_TITLE,
9
+ formatBroadWildcardConfirm,
10
+ formatBroadWildcardListConfirm,
11
+ STATUS_GENERATING_WILDCARD,
12
+ WILDCARD_EDITOR_TITLE,
13
+ WILDCARD_LIST_EDITOR_TITLE,
14
+ } from "./ui-strings.ts";
15
+ import { INVALID_WILDCARD_PREFIX } from "./warning-strings.ts";
16
+ import {
17
+ validateWildcardSuggestion,
18
+ validateWildcardSuggestionList,
19
+ } from "./wildcard-validation.ts";
20
+ import type {
21
+ AllowAlwaysRule,
22
+ PermissionRequest,
23
+ PolicyResolution,
24
+ } from "./types.ts";
25
+
26
+ /**
27
+ * Single-command wildcard path.
28
+ */
29
+ async function chooseSingleWildcard(
30
+ ctx: ExtensionContext,
31
+ command: string,
32
+ aiModel?: string
33
+ ): Promise<AllowAlwaysRule | undefined> {
34
+ ctx.ui.setStatus(STATUS_KEY, STATUS_GENERATING_WILDCARD);
35
+ let suggestion: string | undefined;
36
+ try {
37
+ suggestion = await generateBashWildcard(ctx, command, aiModel);
38
+ } finally {
39
+ ctx.ui.setStatus(STATUS_KEY, undefined);
40
+ }
41
+
42
+ let current = suggestion ?? command;
43
+ while (true) {
44
+ const edited = await ctx.ui.editor(WILDCARD_EDITOR_TITLE, current);
45
+ if (edited === undefined) {return undefined;}
46
+
47
+ const pattern = edited.trim();
48
+ const validation = validateWildcardSuggestion(pattern, command);
49
+ if (!validation.valid) {
50
+ ctx.ui.notify(`${INVALID_WILDCARD_PREFIX}${validation.reason}`, "warning");
51
+ current = pattern || command;
52
+ continue;
53
+ }
54
+
55
+ if (validation.broad) {
56
+ const confirmed = await ctx.ui.confirm(
57
+ BROAD_WILDCARD_TITLE,
58
+ formatBroadWildcardConfirm(pattern)
59
+ );
60
+ if (!confirmed) {
61
+ current = pattern;
62
+ continue;
63
+ }
64
+ }
65
+
66
+ return { kind: "wildcard", tool: "bash", body: pattern };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Multi-command wildcard path.
72
+ */
73
+ async function chooseWildcardListRules(
74
+ ctx: ExtensionContext,
75
+ commands: readonly string[],
76
+ aiModel?: string
77
+ ): Promise<AllowAlwaysRule[] | undefined> {
78
+ ctx.ui.setStatus(STATUS_KEY, STATUS_GENERATING_WILDCARD);
79
+ let suggestion: string[] | undefined;
80
+ try {
81
+ suggestion = await generateBashWildcards(ctx, commands, aiModel);
82
+ } finally {
83
+ ctx.ui.setStatus(STATUS_KEY, undefined);
84
+ }
85
+
86
+ let current = (suggestion ?? commands).join("; ");
87
+ while (true) {
88
+ const edited = await ctx.ui.editor(WILDCARD_LIST_EDITOR_TITLE, current);
89
+ if (edited === undefined) {return undefined;}
90
+
91
+ const validation = validateWildcardSuggestionList(edited, commands);
92
+ if (!validation.valid) {
93
+ ctx.ui.notify(`${INVALID_WILDCARD_PREFIX}${validation.reason}`, "warning");
94
+ current = edited.trim() || commands.join("; ");
95
+ continue;
96
+ }
97
+
98
+ if (validation.broadPatterns.length > 0) {
99
+ const confirmed = await ctx.ui.confirm(
100
+ BROAD_WILDCARD_TITLE,
101
+ formatBroadWildcardListConfirm(validation.broadPatterns)
102
+ );
103
+ if (!confirmed) {
104
+ current = validation.patterns.join("; ");
105
+ continue;
106
+ }
107
+ }
108
+
109
+ return validation.patterns.map((pattern) => ({
110
+ kind: "wildcard" as const,
111
+ tool: "bash" as const,
112
+ body: pattern,
113
+ }));
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Selects the "allow always" matching rules for the request.
119
+ */
120
+ export async function chooseAlwaysRules(
121
+ ctx: ExtensionContext,
122
+ request: PermissionRequest,
123
+ policy: PolicyResolution,
124
+ aiModel?: string
125
+ ): Promise<AllowAlwaysRule[] | undefined> {
126
+ const mode = resolveAllowAlwaysMode(request, policy);
127
+
128
+ if (mode === "wildcard") {
129
+ const rule = await chooseSingleWildcard(ctx, request.body, aiModel);
130
+ return rule ? [rule] : undefined;
131
+ }
132
+
133
+ if (mode === "wildcard-list") {
134
+ const analysis = request.bashAnalysis ?? analyzeShellCommand(request.body);
135
+ return chooseWildcardListRules(ctx, analysis.segments, aiModel);
136
+ }
137
+
138
+ return [{ kind: "exact", tool: request.toolName, body: request.body }];
139
+ }
@@ -3,6 +3,29 @@ import {
3
3
  isValidWildcardPattern,
4
4
  matchesWildcard,
5
5
  } from "./matcher.ts";
6
+ import {
7
+ formatWildcardListCountReason,
8
+ formatWildcardListItemReason,
9
+ WILDCARD_LENGTH_REASON,
10
+ WILDCARD_NO_MATCH_REASON,
11
+ } from "./warning-strings.ts";
12
+
13
+ export type WildcardValidationResult = {
14
+ valid: true;
15
+ broad: boolean;
16
+ } | {
17
+ valid: false;
18
+ reason: string;
19
+ };
20
+
21
+ export type WildcardListValidationResult = {
22
+ valid: true;
23
+ patterns: string[];
24
+ broadPatterns: string[];
25
+ } | {
26
+ valid: false;
27
+ reason: string;
28
+ };
6
29
 
7
30
  /**
8
31
  * Validates a wildcard pattern against a command.
@@ -10,12 +33,54 @@ import {
10
33
  export function validateWildcardSuggestion(
11
34
  pattern: string,
12
35
  command: string
13
- ): { valid: true; broad: boolean } | { valid: false; reason: string } {
36
+ ): WildcardValidationResult {
14
37
  if (!isValidWildcardPattern(pattern)) {
15
- return { valid: false, reason: "Pattern must be between 1 and 500 characters." };
38
+ return { valid: false, reason: WILDCARD_LENGTH_REASON };
16
39
  }
17
40
  if (!matchesWildcard(pattern, command)) {
18
- return { valid: false, reason: "Pattern does not match the current command." };
41
+ return { valid: false, reason: WILDCARD_NO_MATCH_REASON };
19
42
  }
20
43
  return { valid: true, broad: isMatchEverythingPattern(pattern) };
21
44
  }
45
+
46
+ /**
47
+ * Splits a semicolon-separated wildcard list from the model or editor.
48
+ */
49
+ export function splitWildcardList(value: string): string[] {
50
+ return value
51
+ .split(";")
52
+ .map((part) => part.trim())
53
+ .filter((part) => part.length > 0);
54
+ }
55
+
56
+ /**
57
+ * Validates semicolon-separated patterns against a list of commands.
58
+ */
59
+ export function validateWildcardSuggestionList(
60
+ patternsText: string,
61
+ commands: readonly string[]
62
+ ): WildcardListValidationResult {
63
+ const patterns = splitWildcardList(patternsText);
64
+ if (patterns.length !== commands.length) {
65
+ return {
66
+ valid: false,
67
+ reason: formatWildcardListCountReason(commands.length, patterns.length),
68
+ };
69
+ }
70
+
71
+ const broadPatterns: string[] = [];
72
+ for (let index = 0; index < patterns.length; index++) {
73
+ const pattern = patterns[index]!;
74
+ const command = commands[index]!;
75
+ const result = validateWildcardSuggestion(pattern, command);
76
+ if (!result.valid) {
77
+ return {
78
+ valid: false,
79
+ reason: formatWildcardListItemReason(index+1, pattern, result.reason),
80
+ };
81
+ }
82
+ if (result.broad) {broadPatterns.push(pattern);}
83
+ }
84
+
85
+ return { valid: true, patterns, broadPatterns };
86
+ }