@gotgenes/pi-permission-system 16.0.1 → 16.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +88 -0
- package/src/access-intent/bash/command-enumeration.ts +154 -0
- package/src/access-intent/bash/cwd-projection.ts +498 -0
- package/src/access-intent/bash/node-text.ts +75 -0
- package/src/access-intent/bash/parser.ts +42 -0
- package/src/access-intent/bash/program.ts +102 -0
- package/src/{handlers/gates/bash-token-classification.ts → access-intent/bash/token-classification.ts} +1 -1
- package/src/access-intent/bash/token-collection.ts +351 -0
- package/src/handlers/gates/bash-command.ts +1 -1
- package/src/handlers/gates/bash-external-directory.ts +19 -33
- package/src/handlers/gates/bash-path-extractor.ts +10 -4
- package/src/handlers/gates/bash-path.ts +2 -2
- package/src/handlers/gates/external-directory-policy.ts +65 -0
- package/src/handlers/gates/external-directory.ts +8 -11
- package/src/handlers/gates/path.ts +1 -3
- package/src/handlers/gates/skill-read.ts +0 -4
- package/src/handlers/gates/tool-call-gate-pipeline.ts +2 -2
- package/src/handlers/gates/tool.ts +1 -1
- package/src/handlers/gates/types.ts +1 -1
- package/src/path-utils.ts +0 -20
- package/test/access-intent/access-path.test.ts +107 -0
- package/test/access-intent/bash/node-text.test.ts +147 -0
- package/test/access-intent/bash/parser.test.ts +19 -0
- package/test/{handlers/gates/bash-program.test.ts → access-intent/bash/program.test.ts} +114 -73
- package/test/{handlers/gates/bash-token-classification.test.ts → access-intent/bash/token-classification.test.ts} +1 -1
- package/test/access-intent/bash/token-collection.test.ts +300 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +2 -3
- package/test/handlers/gates/bash-command-metamorphic.test.ts +2 -3
- package/test/handlers/gates/bash-external-directory.test.ts +2 -10
- package/test/handlers/gates/bash-path.test.ts +2 -2
- package/test/handlers/gates/external-directory-policy.test.ts +112 -0
- package/test/handlers/gates/external-directory.test.ts +0 -5
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +7 -3
- package/test/path-utils.test.ts +0 -34
- package/src/handlers/gates/bash-program.ts +0 -1143
|
@@ -1,6 +1,5 @@
|
|
|
1
|
+
import { AccessPath } from "#src/access-intent/access-path";
|
|
1
2
|
import {
|
|
2
|
-
canonicalNormalizePathForComparison,
|
|
3
|
-
getExternalDirectoryPolicyValues,
|
|
4
3
|
getToolInputPath,
|
|
5
4
|
isPathOutsideWorkingDirectory,
|
|
6
5
|
isPiInfrastructureRead,
|
|
@@ -12,6 +11,7 @@ import { deriveApprovalPattern } from "#src/session-rules";
|
|
|
12
11
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
13
12
|
import type { GateResult } from "./descriptor";
|
|
14
13
|
import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
14
|
+
import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
|
|
15
15
|
import type { ToolCallContext } from "./types";
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -28,8 +28,6 @@ export function describeExternalDirectoryGate(
|
|
|
28
28
|
resolver: ScopedPermissionResolver,
|
|
29
29
|
extractors?: ToolAccessExtractorLookup,
|
|
30
30
|
): GateResult {
|
|
31
|
-
if (!tcc.cwd) return null;
|
|
32
|
-
|
|
33
31
|
const externalDirectoryPath = getToolInputPath(
|
|
34
32
|
tcc.toolName,
|
|
35
33
|
tcc.input,
|
|
@@ -44,10 +42,11 @@ export function describeExternalDirectoryGate(
|
|
|
44
42
|
// The boundary decision (above) and the infrastructure-read containment
|
|
45
43
|
// check (below) use the canonical, symlink-resolved path; pattern matching
|
|
46
44
|
// uses the typed and resolved aliases (#418).
|
|
47
|
-
const
|
|
45
|
+
const accessPath = AccessPath.forExternalDirectory(
|
|
48
46
|
externalDirectoryPath,
|
|
49
47
|
tcc.cwd,
|
|
50
48
|
);
|
|
49
|
+
const canonicalExtPath = accessPath.boundaryValue();
|
|
51
50
|
|
|
52
51
|
// ── Pi infrastructure read bypass ──────────────────────────────────────
|
|
53
52
|
if (
|
|
@@ -85,13 +84,11 @@ export function describeExternalDirectoryGate(
|
|
|
85
84
|
tcc.agentName ?? undefined,
|
|
86
85
|
);
|
|
87
86
|
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
getExternalDirectoryPolicyValues(externalDirectoryPath, tcc.cwd),
|
|
87
|
+
// The runner consumes this preCheck and skips its own resolve.
|
|
88
|
+
const preCheck = resolveExternalDirectoryPolicy(
|
|
89
|
+
accessPath,
|
|
90
|
+
resolver,
|
|
93
91
|
tcc.agentName ?? undefined,
|
|
94
|
-
"external_directory",
|
|
95
92
|
);
|
|
96
93
|
const pattern = deriveApprovalPattern(
|
|
97
94
|
normalizePathForComparison(externalDirectoryPath, tcc.cwd),
|
|
@@ -37,9 +37,7 @@ export function describePathGate(
|
|
|
37
37
|
|
|
38
38
|
// Resolve to the canonical (cwd-anchored, absolute) path so the approval
|
|
39
39
|
// pattern matches the policy values a later call produces.
|
|
40
|
-
const approvalPath = tcc.cwd
|
|
41
|
-
? normalizePathForComparison(filePath, tcc.cwd)
|
|
42
|
-
: filePath;
|
|
40
|
+
const approvalPath = normalizePathForComparison(filePath, tcc.cwd);
|
|
43
41
|
const pattern = deriveApprovalPattern(approvalPath);
|
|
44
42
|
|
|
45
43
|
const descriptor: GateDescriptor = {
|
|
@@ -29,10 +29,6 @@ export function describeSkillReadGate(
|
|
|
29
29
|
return null;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
if (tcc.cwd === undefined) {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
32
|
const normalizedReadPath = normalizePathForComparison(path, tcc.cwd);
|
|
37
33
|
const matchedSkill = findSkillPathMatch(
|
|
38
34
|
normalizedReadPath,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BashProgram } from "#src/access-intent/bash/program";
|
|
1
2
|
import { getNonEmptyString, toRecord } from "#src/common";
|
|
2
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
4
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
@@ -10,7 +11,6 @@ import {
|
|
|
10
11
|
import { resolveBashCommandCheck } from "./bash-command";
|
|
11
12
|
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
|
|
12
13
|
import { describeBashPathGate } from "./bash-path";
|
|
13
|
-
import { BashProgram } from "./bash-program";
|
|
14
14
|
import type { GateResult } from "./descriptor";
|
|
15
15
|
import { describeExternalDirectoryGate } from "./external-directory";
|
|
16
16
|
import { describePathGate } from "./path";
|
|
@@ -66,7 +66,7 @@ export class ToolCallGatePipeline {
|
|
|
66
66
|
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
67
67
|
const bashProgram =
|
|
68
68
|
tcc.toolName === "bash" && command
|
|
69
|
-
? await BashProgram.parse(command)
|
|
69
|
+
? await BashProgram.parse(command, tcc.cwd)
|
|
70
70
|
: null;
|
|
71
71
|
|
|
72
72
|
const formatter = new ToolPreviewFormatter(
|
|
@@ -28,7 +28,7 @@ function deriveSuggestionValue(
|
|
|
28
28
|
if (tcc.toolName === "mcp") return check.target ?? "mcp";
|
|
29
29
|
const path = getPathBearingToolPath(tcc.toolName, tcc.input);
|
|
30
30
|
if (path === null) return "*";
|
|
31
|
-
return
|
|
31
|
+
return normalizePathForComparison(path, tcc.cwd);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
package/src/path-utils.ts
CHANGED
|
@@ -112,26 +112,6 @@ export function getPathPolicyValues(
|
|
|
112
112
|
];
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
/**
|
|
116
|
-
* Equivalent `external_directory` policy-match values for a path: the lexical
|
|
117
|
-
* (as-typed) alias list plus the canonical (symlink-resolved) absolute path.
|
|
118
|
-
*
|
|
119
|
-
* The outside-CWD boundary decision uses the canonical form separately; this
|
|
120
|
-
* helper exists only for pattern matching, so a user's pattern on the typed
|
|
121
|
-
* path (`/tmp/*`) and on the resolved path (`/private/tmp/*`) both match under
|
|
122
|
-
* the last-match-wins alias evaluation. On systems where the path is not a
|
|
123
|
-
* symlink the canonical form equals the lexical absolute alias and the `Set`
|
|
124
|
-
* collapses it, leaving today's behavior unchanged.
|
|
125
|
-
*/
|
|
126
|
-
export function getExternalDirectoryPolicyValues(
|
|
127
|
-
pathValue: string,
|
|
128
|
-
cwd: string,
|
|
129
|
-
): string[] {
|
|
130
|
-
const lexical = getPathPolicyValues(pathValue, { cwd });
|
|
131
|
-
const canonical = canonicalNormalizePathForComparison(pathValue, cwd);
|
|
132
|
-
return canonical ? [...new Set([...lexical, canonical])] : lexical;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
115
|
function getAbsolutePathPolicyValues(
|
|
136
116
|
pathValue: string,
|
|
137
117
|
options: PathPolicyValueOptions,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
4
|
+
vi.mock("node:os", () => {
|
|
5
|
+
const homedir = vi.fn(() => "/mock/home");
|
|
6
|
+
return {
|
|
7
|
+
homedir,
|
|
8
|
+
default: { homedir },
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
|
|
13
|
+
// Default implementation is identity — lexical tests are unaffected.
|
|
14
|
+
const realpathSync = vi.hoisted(() =>
|
|
15
|
+
vi.fn<(path: string) => string>((p) => p),
|
|
16
|
+
);
|
|
17
|
+
vi.mock("node:fs", () => ({
|
|
18
|
+
realpathSync,
|
|
19
|
+
default: { realpathSync },
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { AccessPath } from "#src/access-intent/access-path";
|
|
23
|
+
|
|
24
|
+
describe("AccessPath.forExternalDirectory", () => {
|
|
25
|
+
const cwd = "/projects/my-app";
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
realpathSync.mockReset();
|
|
29
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("matchValues()", () => {
|
|
33
|
+
test("adds the symlink-resolved alias alongside the typed path", () => {
|
|
34
|
+
// /tmp -> /private/tmp (the macOS symlink from the bug report, #418).
|
|
35
|
+
realpathSync.mockImplementation((p: string) =>
|
|
36
|
+
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
37
|
+
);
|
|
38
|
+
expect(
|
|
39
|
+
AccessPath.forExternalDirectory("/tmp/x", cwd).matchValues(),
|
|
40
|
+
).toEqual(["/tmp/x", "/private/tmp/x"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("deduplicates when the canonical form equals the lexical form", () => {
|
|
44
|
+
expect(
|
|
45
|
+
AccessPath.forExternalDirectory("/etc/hosts", cwd).matchValues(),
|
|
46
|
+
).toEqual(["/etc/hosts"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("keeps the relative aliases for an in-cwd token without duplicating", () => {
|
|
50
|
+
expect(
|
|
51
|
+
AccessPath.forExternalDirectory("src/foo.ts", cwd).matchValues(),
|
|
52
|
+
).toEqual(["/projects/my-app/src/foo.ts", "src/foo.ts"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("includes only the lexical aliases when canonical is empty", () => {
|
|
56
|
+
// Force canonicalizePath to return the original (no-op symlink resolution
|
|
57
|
+
// effectively means canonical === lexical, handled by dedup).
|
|
58
|
+
expect(
|
|
59
|
+
AccessPath.forExternalDirectory("/etc/hosts", cwd).matchValues(),
|
|
60
|
+
).not.toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("boundaryValue()", () => {
|
|
65
|
+
test("returns the canonical (symlink-resolved) form", () => {
|
|
66
|
+
realpathSync.mockImplementation((p: string) =>
|
|
67
|
+
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
68
|
+
);
|
|
69
|
+
expect(
|
|
70
|
+
AccessPath.forExternalDirectory("/tmp/x", cwd).boundaryValue(),
|
|
71
|
+
).toBe("/private/tmp/x");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns the lexical form when path has no symlinks", () => {
|
|
75
|
+
expect(
|
|
76
|
+
AccessPath.forExternalDirectory("/etc/hosts", cwd).boundaryValue(),
|
|
77
|
+
).toBe("/etc/hosts");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("returns empty string for empty input", () => {
|
|
81
|
+
expect(AccessPath.forExternalDirectory("", cwd).boundaryValue()).toBe("");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("value()", () => {
|
|
86
|
+
test("returns the lexical (as-typed, normalized) form", () => {
|
|
87
|
+
realpathSync.mockImplementation((p: string) =>
|
|
88
|
+
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
89
|
+
);
|
|
90
|
+
// Even when the path resolves to a different canonical, value() stays lexical.
|
|
91
|
+
expect(AccessPath.forExternalDirectory("/tmp/x", cwd).value()).toBe(
|
|
92
|
+
"/tmp/x",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("normalizes the path against cwd", () => {
|
|
97
|
+
// A relative path becomes an absolute lexical value.
|
|
98
|
+
expect(AccessPath.forExternalDirectory("src/foo.ts", cwd).value()).toBe(
|
|
99
|
+
"/projects/my-app/src/foo.ts",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("returns empty string for empty input", () => {
|
|
104
|
+
expect(AccessPath.forExternalDirectory("", cwd).value()).toBe("");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
resolveNodeText,
|
|
4
|
+
SKIP_SUBTREE_TYPES,
|
|
5
|
+
} from "#src/access-intent/bash/node-text";
|
|
6
|
+
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
7
|
+
|
|
8
|
+
// Minimal fake TSNode builder — only fills the fields resolveNodeText reads.
|
|
9
|
+
function makeNode(type: string, text: string, children: TSNode[] = []): TSNode {
|
|
10
|
+
return {
|
|
11
|
+
type,
|
|
12
|
+
text,
|
|
13
|
+
childCount: children.length,
|
|
14
|
+
isNamed: true,
|
|
15
|
+
child: (i) => children[i] ?? null,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("SKIP_SUBTREE_TYPES", () => {
|
|
20
|
+
it("contains the three node types that must not be descended", () => {
|
|
21
|
+
expect(SKIP_SUBTREE_TYPES.has("heredoc_body")).toBe(true);
|
|
22
|
+
expect(SKIP_SUBTREE_TYPES.has("heredoc_end")).toBe(true);
|
|
23
|
+
expect(SKIP_SUBTREE_TYPES.has("comment")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("does not contain common argument node types", () => {
|
|
27
|
+
expect(SKIP_SUBTREE_TYPES.has("word")).toBe(false);
|
|
28
|
+
expect(SKIP_SUBTREE_TYPES.has("string")).toBe(false);
|
|
29
|
+
expect(SKIP_SUBTREE_TYPES.has("raw_string")).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("resolveNodeText", () => {
|
|
34
|
+
describe("word nodes", () => {
|
|
35
|
+
it("returns the node text unchanged", () => {
|
|
36
|
+
expect(resolveNodeText(makeNode("word", "hello"))).toBe("hello");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("raw_string nodes (single-quoted)", () => {
|
|
41
|
+
it("strips surrounding single quotes", () => {
|
|
42
|
+
expect(resolveNodeText(makeNode("raw_string", "'content'"))).toBe(
|
|
43
|
+
"content",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("strips single quotes around a path", () => {
|
|
48
|
+
expect(resolveNodeText(makeNode("raw_string", "'/etc/hosts'"))).toBe(
|
|
49
|
+
"/etc/hosts",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns text as-is when not fully single-quoted", () => {
|
|
54
|
+
// A raw_string node without enclosing quotes (defensive fallback)
|
|
55
|
+
expect(resolveNodeText(makeNode("raw_string", "noquotes"))).toBe(
|
|
56
|
+
"noquotes",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("string nodes (double-quoted)", () => {
|
|
62
|
+
it("concatenates inner word children, skipping quote delimiters", () => {
|
|
63
|
+
const quoteOpen = makeNode('"', '"');
|
|
64
|
+
const content = makeNode("string_content", "hello world");
|
|
65
|
+
const quoteClose = makeNode('"', '"');
|
|
66
|
+
const node = makeNode("string", '"hello world"', [
|
|
67
|
+
quoteOpen,
|
|
68
|
+
content,
|
|
69
|
+
quoteClose,
|
|
70
|
+
]);
|
|
71
|
+
expect(resolveNodeText(node)).toBe("hello world");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("concatenates multiple inner children", () => {
|
|
75
|
+
const quoteOpen = makeNode('"', '"');
|
|
76
|
+
const part1 = makeNode("string_content", "foo");
|
|
77
|
+
const part2 = makeNode("simple_expansion", "$BAR");
|
|
78
|
+
const quoteClose = makeNode('"', '"');
|
|
79
|
+
const node = makeNode("string", '"foo$BAR"', [
|
|
80
|
+
quoteOpen,
|
|
81
|
+
part1,
|
|
82
|
+
part2,
|
|
83
|
+
quoteClose,
|
|
84
|
+
]);
|
|
85
|
+
expect(resolveNodeText(node)).toBe("foo$BAR");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns empty string for an empty double-quoted string", () => {
|
|
89
|
+
const quoteOpen = makeNode('"', '"');
|
|
90
|
+
const quoteClose = makeNode('"', '"');
|
|
91
|
+
const node = makeNode("string", '""', [quoteOpen, quoteClose]);
|
|
92
|
+
expect(resolveNodeText(node)).toBe("");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("string_content, simple_expansion, and expansion nodes", () => {
|
|
97
|
+
it("returns text as-is for string_content", () => {
|
|
98
|
+
expect(resolveNodeText(makeNode("string_content", "plain text"))).toBe(
|
|
99
|
+
"plain text",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns text as-is for simple_expansion (e.g. $HOME)", () => {
|
|
104
|
+
// retro 0350: $HOME returns the literal text of a simple_expansion node
|
|
105
|
+
expect(resolveNodeText(makeNode("simple_expansion", "$HOME"))).toBe(
|
|
106
|
+
"$HOME",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns text as-is for expansion", () => {
|
|
111
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — testing that expansion node text is returned verbatim
|
|
112
|
+
expect(resolveNodeText(makeNode("expansion", "${VAR}"))).toBe("${VAR}");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("concatenation nodes", () => {
|
|
117
|
+
it("concatenates resolved children", () => {
|
|
118
|
+
const word = makeNode("word", "/etc/");
|
|
119
|
+
const expansion = makeNode("simple_expansion", "$FILE");
|
|
120
|
+
const node = makeNode("concatenation", "/etc/$FILE", [word, expansion]);
|
|
121
|
+
expect(resolveNodeText(node)).toBe("/etc/$FILE");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("handles nested concatenation-of-string", () => {
|
|
125
|
+
// A concatenation whose child is a double-quoted string
|
|
126
|
+
const quoteOpen = makeNode('"', '"');
|
|
127
|
+
const content = makeNode("string_content", "bar");
|
|
128
|
+
const quoteClose = makeNode('"', '"');
|
|
129
|
+
const inner = makeNode("string", '"bar"', [
|
|
130
|
+
quoteOpen,
|
|
131
|
+
content,
|
|
132
|
+
quoteClose,
|
|
133
|
+
]);
|
|
134
|
+
const prefix = makeNode("word", "foo");
|
|
135
|
+
const node = makeNode("concatenation", 'foo"bar"', [prefix, inner]);
|
|
136
|
+
expect(resolveNodeText(node)).toBe("foobar");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("default fallback", () => {
|
|
141
|
+
it("returns the raw text for unknown node types", () => {
|
|
142
|
+
expect(resolveNodeText(makeNode("unknown_type", "rawtext"))).toBe(
|
|
143
|
+
"rawtext",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getParser } from "#src/access-intent/bash/parser";
|
|
3
|
+
|
|
4
|
+
describe("getParser", () => {
|
|
5
|
+
it("parses a simple bash command and returns a non-null root node", async () => {
|
|
6
|
+
const parser = await getParser();
|
|
7
|
+
const tree = parser.parse("echo hi");
|
|
8
|
+
expect(tree).not.toBeNull();
|
|
9
|
+
expect(tree?.rootNode).toBeDefined();
|
|
10
|
+
expect(tree?.rootNode.type).toBe("program");
|
|
11
|
+
tree?.delete();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns the same memoized parser instance on repeated calls", async () => {
|
|
15
|
+
const first = await getParser();
|
|
16
|
+
const second = await getParser();
|
|
17
|
+
expect(first).toBe(second);
|
|
18
|
+
});
|
|
19
|
+
});
|