@aliou/pi-guardrails 0.12.0 → 0.13.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.
@@ -1,293 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { describe, expect, it } from "vitest";
3
- import {
4
- expandHomePath,
5
- isWithinBoundary,
6
- maybePathLike,
7
- normalizeForDisplay,
8
- resolveFromCwd,
9
- toStorageForm,
10
- } from "./path";
11
-
12
- const HOME = homedir();
13
-
14
- describe("expandHomePath", () => {
15
- it.each([
16
- { desc: "bare ~", input: "~", expected: HOME },
17
- { desc: "~/foo", input: "~/foo", expected: `${HOME}/foo` },
18
- {
19
- desc: "~\\foo (Windows tilde)",
20
- input: "~\\foo",
21
- expected: `${HOME}/foo`,
22
- },
23
- {
24
- desc: "an absolute path",
25
- input: "/absolute/path",
26
- expected: "/absolute/path",
27
- },
28
- {
29
- desc: "a relative path",
30
- input: "relative/path",
31
- expected: "relative/path",
32
- },
33
- { desc: "an empty string", input: "", expected: "" },
34
- ])("given $desc, returns the expanded path", ({ input, expected }) => {
35
- expect(expandHomePath(input)).toBe(expected);
36
- });
37
- });
38
-
39
- describe("resolveFromCwd", () => {
40
- const cwd = "/some/cwd";
41
-
42
- it.each([
43
- {
44
- desc: "a relative path",
45
- input: "sub/file",
46
- expected: "/some/cwd/sub/file",
47
- },
48
- { desc: "an absolute path", input: "/etc/hosts", expected: "/etc/hosts" },
49
- { desc: "a ~ path", input: "~/foo", expected: `${HOME}/foo` },
50
- { desc: "'.'", input: ".", expected: "/some/cwd" },
51
- ])("given $desc, resolves against cwd", ({ input, expected }) => {
52
- expect(resolveFromCwd(input, cwd)).toBe(expected);
53
- });
54
- });
55
-
56
- describe("isWithinBoundary", () => {
57
- it.each([
58
- {
59
- desc: "paths are identical",
60
- target: "/foo/bar",
61
- root: "/foo/bar",
62
- expected: true,
63
- },
64
- {
65
- desc: "target is a direct child",
66
- target: "/foo/bar/baz",
67
- root: "/foo/bar",
68
- expected: true,
69
- },
70
- {
71
- desc: "target is a grandchild",
72
- target: "/foo/bar/baz/qux",
73
- root: "/foo/bar",
74
- expected: true,
75
- },
76
- {
77
- desc: "target is a parent",
78
- target: "/foo",
79
- root: "/foo/bar",
80
- expected: false,
81
- },
82
- {
83
- desc: "target is a sibling",
84
- target: "/foo/other",
85
- root: "/foo/bar",
86
- expected: false,
87
- },
88
- {
89
- desc: "target shares a string prefix but is not a child (critical case)",
90
- target: "/foo/barbaz",
91
- root: "/foo/bar",
92
- expected: false,
93
- },
94
- {
95
- desc: "paths are completely unrelated",
96
- target: "/tmp",
97
- root: "/home/user",
98
- expected: false,
99
- },
100
- ])("when $desc, returns $expected", ({ target, root, expected }) => {
101
- expect(isWithinBoundary(target, root)).toBe(expected);
102
- });
103
- });
104
-
105
- describe("normalizeForDisplay", () => {
106
- const cwd = "/work/project";
107
-
108
- it.each([
109
- { desc: "path equals cwd", input: cwd, expected: "." },
110
- {
111
- desc: "path is a child of cwd",
112
- input: "/work/project/src/file.ts",
113
- expected: "src/file.ts",
114
- },
115
- {
116
- desc: "path is under home but not cwd",
117
- input: `${HOME}/config/file`,
118
- expected: "~/config/file",
119
- },
120
- {
121
- desc: "path is outside both cwd and home",
122
- input: "/etc/hosts",
123
- expected: "/etc/hosts",
124
- },
125
- { desc: "path is home itself", input: HOME, expected: "~" },
126
- ])("when $desc, returns $expected", ({ input, expected }) => {
127
- expect(normalizeForDisplay(input, cwd)).toBe(expected);
128
- });
129
- });
130
-
131
- describe("toStorageForm", () => {
132
- it.each([
133
- {
134
- desc: "file under home",
135
- absPath: `${HOME}/code/file.ts`,
136
- isDirectory: false,
137
- expected: "~/code/file.ts",
138
- },
139
- {
140
- desc: "directory under home",
141
- absPath: `${HOME}/code`,
142
- isDirectory: true,
143
- expected: "~/code/",
144
- },
145
- {
146
- desc: "absolute file outside home",
147
- absPath: "/etc/hosts",
148
- isDirectory: false,
149
- expected: "/etc/hosts",
150
- },
151
- {
152
- desc: "absolute directory outside home",
153
- absPath: "/etc",
154
- isDirectory: true,
155
- expected: "/etc/",
156
- },
157
- {
158
- desc: "input has trailing slash but isDirectory=false",
159
- absPath: "/etc/hosts/",
160
- isDirectory: false,
161
- expected: "/etc/hosts",
162
- },
163
- {
164
- desc: "input uses Windows backslashes",
165
- absPath: "C:\\Users\\foo",
166
- isDirectory: false,
167
- expected: "C:/Users/foo",
168
- },
169
- {
170
- desc: "input is home itself with isDirectory=true",
171
- absPath: HOME,
172
- isDirectory: true,
173
- expected: "~/",
174
- },
175
- ])("when $desc, returns $expected", ({ absPath, isDirectory, expected }) => {
176
- expect(toStorageForm(absPath, isDirectory)).toBe(expected);
177
- });
178
- });
179
-
180
- describe("maybePathLike", () => {
181
- it.each([
182
- // --- True cases: structural path signals ---
183
- {
184
- desc: "absolute Unix path",
185
- input: "/etc/hosts",
186
- expected: true,
187
- },
188
- {
189
- desc: "relative path with /",
190
- input: "src/index.ts",
191
- expected: true,
192
- },
193
- {
194
- desc: "./ prefix",
195
- input: "./foo",
196
- expected: true,
197
- },
198
- {
199
- desc: "../ prefix",
200
- input: "../bar",
201
- expected: true,
202
- },
203
- {
204
- desc: "backslash path (Windows)",
205
- input: "foo\\bar",
206
- expected: true,
207
- },
208
- {
209
- desc: "Windows drive letter",
210
- input: "C:\\tmp",
211
- expected: true,
212
- },
213
- {
214
- desc: "Windows drive with forward slash",
215
- input: "C:/tmp",
216
- expected: true,
217
- },
218
- {
219
- desc: "tilde home path",
220
- input: "~/code",
221
- expected: true,
222
- },
223
- {
224
- desc: "MIME type (has / — safe false positive)",
225
- input: "application/json",
226
- expected: true,
227
- },
228
- {
229
- desc: "regular expression with braces (has / — safe false positive)",
230
- input: "/abc/{2,3}",
231
- expected: true,
232
- },
233
- // --- False cases: non-path tokens ---
234
- {
235
- desc: "empty string",
236
- input: "",
237
- expected: false,
238
- },
239
- {
240
- desc: "simple command name",
241
- input: "rm",
242
- expected: false,
243
- },
244
- {
245
- desc: "flag",
246
- input: "--force",
247
- expected: false,
248
- },
249
- {
250
- desc: "short flag",
251
- input: "-rf",
252
- expected: false,
253
- },
254
- {
255
- desc: "bare word",
256
- input: "build",
257
- expected: false,
258
- },
259
- {
260
- desc: "bare tilde (no slash)",
261
- input: "~",
262
- expected: false,
263
- },
264
- {
265
- desc: "version number",
266
- input: "3.14",
267
- expected: false,
268
- },
269
- {
270
- desc: "domain name",
271
- input: "example.com",
272
- expected: false,
273
- },
274
- {
275
- desc: "bare filename with extension",
276
- input: "README.md",
277
- expected: false,
278
- },
279
- {
280
- desc: "dotfile without slash",
281
- input: ".env",
282
- expected: false,
283
- },
284
- ])("when $desc, returns $expected", ({ input, expected }) => {
285
- expect(maybePathLike(input)).toBe(expected);
286
- });
287
-
288
- // maybePathLike is command-agnostic. Command-specific regex/code args are
289
- // filtered by extractBashPathCandidates before this fallback heuristic runs.
290
- it("treats awk-looking regex text as path-like without command context", () => {
291
- expect(maybePathLike("/aaa/{flag=1} flag{print}")).toBe(true);
292
- });
293
- });
@@ -1,94 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { classifyCommandArgs } from "./command-args";
3
-
4
- const tokens = (command: string, args: string[]) =>
5
- classifyCommandArgs(command, args).map((arg) => arg.token);
6
-
7
- describe("classifyCommandArgs", () => {
8
- it("keeps unknown command arguments unchanged", () => {
9
- expect(tokens("cat", ["/etc/hosts", "./file"])).toEqual([
10
- "/etc/hosts",
11
- "./file",
12
- ]);
13
- });
14
-
15
- it("normalizes command basenames", () => {
16
- expect(tokens("/usr/bin/awk", ["/aaa/{print}", "./input"])).toEqual([
17
- "./input",
18
- ]);
19
- });
20
-
21
- it("ignores awk inline program and keeps file operands", () => {
22
- expect(tokens("awk", ["/aaa/{print}", "./input"])).toEqual(["./input"]);
23
- });
24
-
25
- it.each([
26
- ["-f as separate option", ["-f", "./prog.awk", "./input"]],
27
- ["-f as joined option", ["-f./prog.awk", "./input"]],
28
- ])("keeps awk program files with %s", (_label, args) => {
29
- expect(tokens("awk", args)).toEqual(["./prog.awk", "./input"]);
30
- });
31
-
32
- it("ignores sed inline scripts and keeps file operands", () => {
33
- expect(tokens("sed", ["s#/old#/new#g", "./file"])).toEqual(["./file"]);
34
- });
35
-
36
- it.each([
37
- ["-f as separate option", ["-f", "./script.sed", "./file"]],
38
- ["--file as long option", ["--file", "./script.sed", "./file"]],
39
- ["-f as joined option", ["-f./script.sed", "./file"]],
40
- ])("keeps sed script files with %s", (_label, args) => {
41
- expect(tokens("sed", args)).toEqual(["./script.sed", "./file"]);
42
- });
43
-
44
- it("ignores grep patterns and keeps file operands", () => {
45
- expect(tokens("grep", ["/api/v1", "./src"])).toEqual(["./src"]);
46
- });
47
-
48
- it.each([
49
- ["-f as separate option", ["-f", "./patterns", "./src"]],
50
- ["--file as long option", ["--file", "./patterns", "./src"]],
51
- ["-f as joined option", ["-f./patterns", "./src"]],
52
- ])("keeps grep pattern files with %s", (_label, args) => {
53
- expect(tokens("grep", args)).toEqual(["./patterns", "./src"]);
54
- });
55
-
56
- it("keeps find roots and ignores expression patterns", () => {
57
- expect(tokens("find", ["./src", "-regex", ".*/test/.*"])).toEqual([
58
- "./src",
59
- ]);
60
- });
61
-
62
- it("ignores jq filters and keeps file operands", () => {
63
- expect(tokens("jq", ['.path | test("^/tmp/")', "./data.json"])).toEqual([
64
- "./data.json",
65
- ]);
66
- });
67
-
68
- it.each([
69
- ["-f as separate option", ["-f", "./filter.jq", "./data.json"]],
70
- [
71
- "--from-file as long option",
72
- ["--from-file", "./filter.jq", "./data.json"],
73
- ],
74
- ])("keeps jq filter files with %s", (_label, args) => {
75
- expect(tokens("jq", args)).toEqual(["./filter.jq", "./data.json"]);
76
- });
77
-
78
- it("ignores interpreter inline code", () => {
79
- expect(tokens("python3", ["-c", 'open("/etc/passwd")'])).toEqual([]);
80
- });
81
-
82
- it("keeps interpreter script operands", () => {
83
- expect(tokens("python3", ["./script.py", "./data.json"])).toEqual([
84
- "./script.py",
85
- "./data.json",
86
- ]);
87
- });
88
-
89
- it("ignores delimiter args", () => {
90
- expect(tokens("cut", ["-d", "/", "./file"])).toEqual(["./file"]);
91
- expect(tokens("sort", ["-t", "/", "./file"])).toEqual(["./file"]);
92
- expect(tokens("tr", ["/", ":"])).toEqual([]);
93
- });
94
- });
@@ -1,86 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- compileCommandPattern,
4
- compileFilePattern,
5
- normalizeFilePath,
6
- } from "./matching";
7
- import { drainPendingWarnings } from "./warnings";
8
-
9
- describe("normalizeFilePath", () => {
10
- it.each([
11
- ["./src//file.ts", "src/file.ts"],
12
- ["src\\file.ts", "src/file.ts"],
13
- ["./foo\\bar//baz", "foo/bar/baz"],
14
- ])("normalizes %s", (input, expected) => {
15
- expect(normalizeFilePath(input)).toBe(expected);
16
- });
17
- });
18
-
19
- describe("compileFilePattern", () => {
20
- it("matches basename when the pattern has no slash", () => {
21
- const pattern = compileFilePattern({ pattern: ".env" });
22
-
23
- expect(pattern.test(".env")).toBe(true);
24
- expect(pattern.test("config/.env")).toBe(true);
25
- expect(pattern.test("config/.env.local")).toBe(false);
26
- });
27
-
28
- it("matches full normalized paths when the pattern has a slash", () => {
29
- const pattern = compileFilePattern({ pattern: "config/*.env" });
30
-
31
- expect(pattern.test("config/app.env")).toBe(true);
32
- expect(pattern.test("./config//app.env")).toBe(true);
33
- expect(pattern.test("nested/config/app.env")).toBe(false);
34
- });
35
-
36
- it("uses case-insensitive regex matching for file patterns", () => {
37
- const pattern = compileFilePattern({
38
- pattern: "SECRET\\.TXT$",
39
- regex: true,
40
- });
41
-
42
- expect(pattern.test("docs/secret.txt")).toBe(true);
43
- expect(pattern.test("docs/public.txt")).toBe(false);
44
- });
45
-
46
- it("records a warning and returns a non-matching pattern for invalid regex", () => {
47
- drainPendingWarnings();
48
-
49
- const pattern = compileFilePattern({ pattern: "[", regex: true });
50
-
51
- expect(pattern.test("anything")).toBe(false);
52
- expect(drainPendingWarnings()).toEqual([
53
- "Invalid regex in guardrails config: [",
54
- ]);
55
- });
56
- });
57
-
58
- describe("compileCommandPattern", () => {
59
- it("uses substring matching by default", () => {
60
- const pattern = compileCommandPattern({ pattern: "deploy production" });
61
-
62
- expect(pattern.test("please deploy production now")).toBe(true);
63
- expect(pattern.test("deploy staging")).toBe(false);
64
- });
65
-
66
- it("uses regex matching when requested", () => {
67
- const pattern = compileCommandPattern({
68
- pattern: "terraform\\s+apply",
69
- regex: true,
70
- });
71
-
72
- expect(pattern.test("terraform apply -auto-approve")).toBe(true);
73
- expect(pattern.test("terraform plan")).toBe(false);
74
- });
75
-
76
- it("records a warning and returns a non-matching pattern for invalid regex", () => {
77
- drainPendingWarnings();
78
-
79
- const pattern = compileCommandPattern({ pattern: "[", regex: true });
80
-
81
- expect(pattern.test("anything")).toBe(false);
82
- expect(drainPendingWarnings()).toEqual([
83
- "Invalid regex in guardrails config: [",
84
- ]);
85
- });
86
- });
@@ -1,150 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { describe, expect, it } from "vitest";
3
- import { extractBashPathCandidates } from "./bash-paths";
4
-
5
- const CWD = "/work/project";
6
- const HOME = homedir();
7
-
8
- describe("extractBashPathCandidates", () => {
9
- describe("when a command has regular expression arguments", () => {
10
- it("ignores sed expressions and extracts file operands", async () => {
11
- const result = await extractBashPathCandidates(
12
- "sed 's/abc/{2,3}/g' ./file",
13
- CWD,
14
- );
15
- expect(result).toEqual(["/work/project/file"]);
16
- });
17
-
18
- it("ignores grep patterns and extracts file operands", async () => {
19
- const result = await extractBashPathCandidates(
20
- "grep '/api/v1' ./src",
21
- CWD,
22
- );
23
- expect(result).toEqual(["/work/project/src"]);
24
- });
25
-
26
- it("ignores ripgrep patterns and extracts search roots", async () => {
27
- const result = await extractBashPathCandidates("rg '/api/v1' ./src", CWD);
28
- expect(result).toEqual(["/work/project/src"]);
29
- });
30
-
31
- it("ignores jq filters and extracts file operands", async () => {
32
- const result = await extractBashPathCandidates(
33
- "jq '.path | test(\"^/tmp/\")' ./data.json",
34
- CWD,
35
- );
36
- expect(result).toEqual(["/work/project/data.json"]);
37
- });
38
-
39
- it("ignores interpreter inline code", async () => {
40
- const result = await extractBashPathCandidates(
41
- "python3 -c 'open(\"/etc/passwd\").read()'",
42
- CWD,
43
- );
44
- expect(result).toEqual([]);
45
- });
46
- });
47
-
48
- // Regression: github issue #32 — awk regex patterns should not be
49
- // treated as file paths.
50
- it("does not extract awk regex patterns as paths", async () => {
51
- const result = await extractBashPathCandidates(
52
- "awk '/aaa/{flag=1} flag{print}' test.txt",
53
- CWD,
54
- );
55
- // The awk program should NOT be treated as a path
56
- expect(result).toEqual([]);
57
- });
58
-
59
- describe("when command has path arguments", () => {
60
- it("extracts a single absolute path", async () => {
61
- expect(await extractBashPathCandidates("cat /etc/hosts", CWD)).toEqual([
62
- "/etc/hosts",
63
- ]);
64
- });
65
-
66
- it("extracts multiple absolute paths", async () => {
67
- expect(await extractBashPathCandidates("cp /a /b", CWD)).toEqual([
68
- "/a",
69
- "/b",
70
- ]);
71
- });
72
-
73
- it("resolves a relative path with ./ against cwd", async () => {
74
- expect(await extractBashPathCandidates("cat ./foo/bar", CWD)).toEqual([
75
- "/work/project/foo/bar",
76
- ]);
77
- });
78
-
79
- it("expands ~ to home", async () => {
80
- expect(await extractBashPathCandidates("cat ~/file", CWD)).toEqual([
81
- `${HOME}/file`,
82
- ]);
83
- });
84
-
85
- it("detects Windows-style paths", async () => {
86
- const result = await extractBashPathCandidates("type C:\\foo\\bar", CWD);
87
-
88
- expect(result).toHaveLength(1);
89
- expect(result[0]).toContain("C:\\foo\\bar");
90
- });
91
- });
92
-
93
- describe("when command has flags and redirects", () => {
94
- it("ignores flag arguments", async () => {
95
- expect(await extractBashPathCandidates("ls -la /tmp", CWD)).toEqual([
96
- "/tmp",
97
- ]);
98
- });
99
-
100
- it("extracts redirect targets", async () => {
101
- expect(
102
- await extractBashPathCandidates("echo foo > /tmp/out", CWD),
103
- ).toEqual(["/tmp/out"]);
104
- });
105
-
106
- it("extracts paths from multiple commands and redirects", async () => {
107
- expect(
108
- await extractBashPathCandidates(
109
- "cat ./input && grep needle /tmp/log > ./out",
110
- CWD,
111
- ),
112
- ).toEqual(["/work/project/input", "/tmp/log", "/work/project/out"]);
113
- });
114
- });
115
-
116
- describe("when command has no path-like tokens", () => {
117
- it("returns an empty array for bare filenames (no separators)", async () => {
118
- expect(await extractBashPathCandidates("cat README.md", CWD)).toEqual([]);
119
- });
120
-
121
- it("returns an empty array for commands with no file arguments", async () => {
122
- expect(await extractBashPathCandidates("echo hello", CWD)).toEqual([]);
123
- });
124
- });
125
-
126
- describe("when command uses quoting", () => {
127
- it("handles quoted paths with spaces", async () => {
128
- expect(
129
- await extractBashPathCandidates('cat "/tmp/hello world"', CWD),
130
- ).toEqual(["/tmp/hello world"]);
131
- });
132
- });
133
-
134
- describe("when command has duplicate paths", () => {
135
- it("deduplicates results", async () => {
136
- expect(await extractBashPathCandidates("cat /a /a", CWD)).toEqual(["/a"]);
137
- });
138
- });
139
-
140
- describe("when command is malformed", () => {
141
- it("falls back to regex tokenization on parse failure", async () => {
142
- // Unbalanced quote triggers parse error; regex fallback still finds paths
143
- const result = await extractBashPathCandidates(
144
- "cat /tmp/foo 'unterminated",
145
- CWD,
146
- );
147
- expect(result).toContain("/tmp/foo");
148
- });
149
- });
150
- });