@gotgenes/pi-permission-system 16.2.0 → 16.2.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 +15 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/access-intent/bash/command-enumeration.ts +79 -2
- package/src/access-intent/bash/parser.ts +2 -0
- package/src/access-intent/bash/program.ts +3 -0
- package/src/builtin-tool-input-formatters.ts +1 -1
- package/src/config-loader.ts +7 -7
- package/src/forwarded-permissions/permission-forwarder.ts +1 -1
- package/src/handlers/gates/bash-command.ts +15 -1
- package/src/handlers/gates/bash-external-directory.ts +1 -1
- package/src/handlers/gates/bash-path.ts +1 -1
- package/src/handlers/gates/skill-read.ts +1 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -1
- package/src/handlers/permission-gate-handler.ts +1 -2
- package/src/handlers/tool-call-boundary.ts +1 -2
- package/src/input-normalizer.ts +1 -1
- package/src/mcp-targets.ts +1 -1
- package/src/normalize.ts +1 -1
- package/src/path-utils.ts +1 -1
- package/src/permission-manager.ts +1 -1
- package/src/permission-prompts.ts +1 -1
- package/src/policy-loader.ts +2 -2
- package/src/tool-input-prompt-formatters.ts +1 -1
- package/src/tool-preview-formatter.ts +1 -1
- package/src/tool-registry.ts +1 -1
- package/src/{common.ts → value-guards.ts} +0 -66
- package/src/yaml-frontmatter.ts +65 -0
- package/test/access-intent/bash/node-text.test.ts +1 -0
- package/test/access-intent/bash/program.test.ts +67 -0
- package/test/handlers/external-directory-integration.test.ts +40 -153
- package/test/handlers/external-directory-session-dedup.test.ts +38 -262
- package/test/handlers/gates/bash-command.test.ts +63 -0
- package/test/handlers/gates/bash-external-directory.test.ts +1 -1
- package/test/handlers/gates/bash-path.test.ts +1 -1
- package/test/helpers/external-directory-fixtures.ts +269 -0
- package/test/{common.test.ts → value-guards.test.ts} +2 -96
- package/test/yaml-frontmatter.test.ts +91 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures for the external-directory handler-pipeline tests.
|
|
3
|
+
*
|
|
4
|
+
* Targets the collapsed external-directory gate (Phase 6 Step 5, #477).
|
|
5
|
+
* Consumed by external-directory-integration.test.ts and
|
|
6
|
+
* external-directory-session-dedup.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
import { GateDecisionReporter } from "#src/decision-reporter";
|
|
11
|
+
import type { GatePrompter } from "#src/gate-prompter";
|
|
12
|
+
import { GateRunner } from "#src/handlers/gates/runner";
|
|
13
|
+
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
14
|
+
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
15
|
+
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
16
|
+
import type { ScopedPermissionManager } from "#src/permission-manager";
|
|
17
|
+
import type { SessionLogger } from "#src/session-logger";
|
|
18
|
+
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
19
|
+
import { wildcardMatch } from "#src/wildcard-matcher";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
getDecisionEvents,
|
|
23
|
+
makeEvents,
|
|
24
|
+
makeSurfaceCheck,
|
|
25
|
+
makeToolRegistry,
|
|
26
|
+
} from "#test/helpers/handler-fixtures";
|
|
27
|
+
import {
|
|
28
|
+
makeRealResolver,
|
|
29
|
+
makeRealSession,
|
|
30
|
+
} from "#test/helpers/session-fixtures";
|
|
31
|
+
|
|
32
|
+
// ── Shared constants ───────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** Working-directory used by the external-directory handler-pipeline tests. */
|
|
35
|
+
export const EXT_DIR_CWD = "/test/project";
|
|
36
|
+
|
|
37
|
+
/** An external path (outside {@link EXT_DIR_CWD}) used across the test suite. */
|
|
38
|
+
export const EXTERNAL_PATH = "/outside/project/file.ts";
|
|
39
|
+
|
|
40
|
+
/** All path-bearing tools subject to the external-directory gate. */
|
|
41
|
+
export const ALL_PATH_BEARING_TOOLS = [
|
|
42
|
+
"read",
|
|
43
|
+
"write",
|
|
44
|
+
"edit",
|
|
45
|
+
"find",
|
|
46
|
+
"grep",
|
|
47
|
+
"ls",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** Path-bearing tools where the path is optional (no input → gate is skipped). */
|
|
51
|
+
export const OPTIONAL_PATH_TOOLS = ["find", "grep", "ls"];
|
|
52
|
+
|
|
53
|
+
/** Full tool set used as the default registry in external-directory tests. */
|
|
54
|
+
export const ALL_TOOLS = [...ALL_PATH_BEARING_TOOLS, "bash"];
|
|
55
|
+
|
|
56
|
+
// ── Setup builders ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Builds a `checkPermission` mock for external-directory tests.
|
|
60
|
+
*
|
|
61
|
+
* Routes `external_directory` to `externalDirectoryState`, `path` to allow
|
|
62
|
+
* with `source: "special"` (so the cross-cutting path gate is transparent),
|
|
63
|
+
* and every other surface to `toolState` (default: allow).
|
|
64
|
+
*/
|
|
65
|
+
export function makeExtDirCheck(
|
|
66
|
+
externalDirectoryState: PermissionState,
|
|
67
|
+
toolState: PermissionState = "allow",
|
|
68
|
+
) {
|
|
69
|
+
return makeSurfaceCheck(
|
|
70
|
+
{
|
|
71
|
+
external_directory: { state: externalDirectoryState },
|
|
72
|
+
path: { state: "allow", source: "special" },
|
|
73
|
+
},
|
|
74
|
+
{ state: toolState },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** GatePrompter stub that approves with `state: "approved"`. */
|
|
79
|
+
export function makeApprovingPrompter(): GatePrompter {
|
|
80
|
+
return {
|
|
81
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
82
|
+
prompt: vi
|
|
83
|
+
.fn<GatePrompter["prompt"]>()
|
|
84
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* GatePrompter stub that denies.
|
|
90
|
+
*
|
|
91
|
+
* Pass `denialReason` to simulate a user who explains the refusal.
|
|
92
|
+
*/
|
|
93
|
+
export function makeDenyingPrompter(denialReason?: string): GatePrompter {
|
|
94
|
+
return {
|
|
95
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
96
|
+
prompt: vi
|
|
97
|
+
.fn<GatePrompter["prompt"]>()
|
|
98
|
+
.mockResolvedValue(
|
|
99
|
+
denialReason !== undefined
|
|
100
|
+
? { approved: false, state: "denied", denialReason }
|
|
101
|
+
: { approved: false, state: "denied" },
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** GatePrompter stub that reports no UI is available (`canConfirm: false`). */
|
|
107
|
+
export function makeUnavailablePrompter(): GatePrompter {
|
|
108
|
+
return {
|
|
109
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
110
|
+
prompt: vi.fn<GatePrompter["prompt"]>(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Query helpers ──────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** Find the `external_directory` decision event from the events mock. */
|
|
117
|
+
export function findExtDirDecision(events: ReturnType<typeof makeEvents>) {
|
|
118
|
+
return getDecisionEvents(events).find(
|
|
119
|
+
(d) => d.surface === "external_directory",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Return the `permission_request.blocked` review-log entries from the logger mock. */
|
|
124
|
+
export function blockReviewEntries(logger: SessionLogger) {
|
|
125
|
+
return (logger.review as ReturnType<typeof vi.fn>).mock.calls.filter(
|
|
126
|
+
([eventName]: string[]) => eventName === "permission_request.blocked",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Session-dedup wiring ──────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Installs the session-aware `check(intent)` mock on the permission manager.
|
|
134
|
+
*
|
|
135
|
+
* Returns `ask` for `external_directory` on first access; re-checks recorded
|
|
136
|
+
* session rules on subsequent calls and returns `allow` (source: "session")
|
|
137
|
+
* when a `wildcardMatch` covers the path.
|
|
138
|
+
*/
|
|
139
|
+
export function makeExtDirDedupCheck(
|
|
140
|
+
permissionManager: ScopedPermissionManager,
|
|
141
|
+
): void {
|
|
142
|
+
vi.mocked(permissionManager.check).mockImplementation(
|
|
143
|
+
(intent, rules): PermissionCheckResult => {
|
|
144
|
+
const { surface } = intent;
|
|
145
|
+
const pathValue =
|
|
146
|
+
intent.kind === "path-values" ? (intent.values[0] ?? null) : null;
|
|
147
|
+
|
|
148
|
+
if (surface === "external_directory") {
|
|
149
|
+
if (pathValue && rules && rules.length > 0) {
|
|
150
|
+
const match = rules.findLast(
|
|
151
|
+
(r) =>
|
|
152
|
+
r.surface === "external_directory" &&
|
|
153
|
+
wildcardMatch(r.pattern, pathValue),
|
|
154
|
+
);
|
|
155
|
+
if (match) {
|
|
156
|
+
return {
|
|
157
|
+
state: "allow",
|
|
158
|
+
toolName: surface,
|
|
159
|
+
source: "session",
|
|
160
|
+
origin: "session",
|
|
161
|
+
matchedPattern: match.pattern,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
state: "ask",
|
|
167
|
+
toolName: surface,
|
|
168
|
+
source: "special",
|
|
169
|
+
origin: "global",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
state: "allow",
|
|
175
|
+
toolName: surface,
|
|
176
|
+
source: "tool",
|
|
177
|
+
origin: "builtin",
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** GatePrompter stub that approves for the session (`state: "approved_for_session"`). */
|
|
184
|
+
function makeSessionApprovingPrompter(): GatePrompter {
|
|
185
|
+
return {
|
|
186
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
187
|
+
prompt: vi
|
|
188
|
+
.fn<GatePrompter["prompt"]>()
|
|
189
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Builds the fully-wired session-dedup handler with real collaborators.
|
|
195
|
+
*
|
|
196
|
+
* Unlike `makeHandler`, this wires `makeRealSession` + `makeRealResolver`
|
|
197
|
+
* manually so the caller can access the raw `session` for shutdown tests.
|
|
198
|
+
*
|
|
199
|
+
* Returns `{ handler, prompter, session }`.
|
|
200
|
+
*/
|
|
201
|
+
export function makeDedupWiring(prompter?: GatePrompter) {
|
|
202
|
+
const { session, permissionManager, sessionRules, logger } =
|
|
203
|
+
makeRealSession();
|
|
204
|
+
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
205
|
+
makeExtDirDedupCheck(permissionManager);
|
|
206
|
+
const events = makeEvents();
|
|
207
|
+
const reporter = new GateDecisionReporter(logger, events);
|
|
208
|
+
const resolvedPrompter: GatePrompter =
|
|
209
|
+
prompter ?? makeSessionApprovingPrompter();
|
|
210
|
+
const runner = new GateRunner(
|
|
211
|
+
resolver,
|
|
212
|
+
sessionRules,
|
|
213
|
+
resolvedPrompter,
|
|
214
|
+
reporter,
|
|
215
|
+
);
|
|
216
|
+
const handler = new PermissionGateHandler(
|
|
217
|
+
session,
|
|
218
|
+
makeToolRegistry({
|
|
219
|
+
getAll: vi
|
|
220
|
+
.fn()
|
|
221
|
+
.mockReturnValue([
|
|
222
|
+
{ name: "read" },
|
|
223
|
+
{ name: "write" },
|
|
224
|
+
{ name: "edit" },
|
|
225
|
+
{ name: "bash" },
|
|
226
|
+
]),
|
|
227
|
+
}),
|
|
228
|
+
new ToolCallGatePipeline(resolver, session),
|
|
229
|
+
new SkillInputGatePipeline(resolver),
|
|
230
|
+
runner,
|
|
231
|
+
);
|
|
232
|
+
return { handler, prompter: resolvedPrompter, session };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Builds the session-dedup handler without exposing the raw session.
|
|
237
|
+
*
|
|
238
|
+
* Wraps `makeDedupWiring`; returns `{ handler, prompter }`.
|
|
239
|
+
* Use `makeDedupWiring` when the test also needs `session.shutdown()`.
|
|
240
|
+
*/
|
|
241
|
+
export function makeDeduplicatingHandler(prompter?: GatePrompter) {
|
|
242
|
+
const { handler, prompter: resolvedPrompter } = makeDedupWiring(prompter);
|
|
243
|
+
return { handler, prompter: resolvedPrompter };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Event builders ─────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Builds a tool-call event in the shape that external-directory-session-dedup
|
|
250
|
+
* tests use — `toolName` field (not `name`); both are accepted by
|
|
251
|
+
* `getToolNameFromValue`.
|
|
252
|
+
*/
|
|
253
|
+
export function makeExtDirToolEvent(
|
|
254
|
+
toolName: string,
|
|
255
|
+
path: string,
|
|
256
|
+
toolCallId = "tc-1",
|
|
257
|
+
) {
|
|
258
|
+
return { type: "tool_call" as const, toolCallId, toolName, input: { path } };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Builds a bash tool-call event for external-directory session-dedup tests. */
|
|
262
|
+
export function makeExtDirBashEvent(command: string, toolCallId = "tc-1") {
|
|
263
|
+
return {
|
|
264
|
+
type: "tool_call" as const,
|
|
265
|
+
toolCallId,
|
|
266
|
+
toolName: "bash",
|
|
267
|
+
input: { command },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, it, test } from "vitest";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
extractFrontmatter,
|
|
5
4
|
getNonEmptyString,
|
|
6
5
|
isDenyWithReason,
|
|
7
6
|
isPermissionState,
|
|
8
7
|
normalizeOptionalPositiveInt,
|
|
9
8
|
normalizeOptionalStringArray,
|
|
10
|
-
parseSimpleYamlMap,
|
|
11
9
|
toRecord,
|
|
12
|
-
} from "#src/
|
|
13
|
-
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
vi.restoreAllMocks();
|
|
16
|
-
});
|
|
10
|
+
} from "#src/value-guards";
|
|
17
11
|
|
|
18
12
|
describe("toRecord", () => {
|
|
19
13
|
test("returns empty object for null", () => {
|
|
@@ -130,94 +124,6 @@ describe("isDenyWithReason", () => {
|
|
|
130
124
|
});
|
|
131
125
|
});
|
|
132
126
|
|
|
133
|
-
describe("extractFrontmatter", () => {
|
|
134
|
-
test("returns empty string when no frontmatter delimiter", () => {
|
|
135
|
-
expect(extractFrontmatter("# Hello\nSome content")).toBe("");
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test("returns empty string when only opening delimiter with no closing", () => {
|
|
139
|
-
expect(extractFrontmatter("---\nkey: value")).toBe("");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("returns frontmatter body between delimiters", () => {
|
|
143
|
-
const markdown = "---\nissue: 1\ntitle: Test\n---\n# Content";
|
|
144
|
-
expect(extractFrontmatter(markdown)).toBe("issue: 1\ntitle: Test");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("returns empty string when file does not start with ---", () => {
|
|
148
|
-
expect(extractFrontmatter("content\n---\nkey: val\n---")).toBe("");
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("handles CRLF line endings", () => {
|
|
152
|
-
const markdown = "---\r\nissue: 5\r\n---\r\n# Content";
|
|
153
|
-
expect(extractFrontmatter(markdown)).toBe("issue: 5");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test("returns empty string for empty string input", () => {
|
|
157
|
-
expect(extractFrontmatter("")).toBe("");
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("returns empty frontmatter for --- \\n--- with nothing between", () => {
|
|
161
|
-
const markdown = "---\n---\n# Content";
|
|
162
|
-
expect(extractFrontmatter(markdown)).toBe("");
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
describe("parseSimpleYamlMap", () => {
|
|
167
|
-
test("returns empty object for empty string", () => {
|
|
168
|
-
expect(parseSimpleYamlMap("")).toEqual({});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("parses simple key-value pairs", () => {
|
|
172
|
-
const yaml = "issue: 21\ntitle: Test";
|
|
173
|
-
expect(parseSimpleYamlMap(yaml)).toEqual({ issue: "21", title: "Test" });
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("strips surrounding quotes from values", () => {
|
|
177
|
-
const yaml = 'title: "My Title"';
|
|
178
|
-
expect(parseSimpleYamlMap(yaml)).toEqual({ title: "My Title" });
|
|
179
|
-
|
|
180
|
-
const yaml2 = "title: 'My Title'";
|
|
181
|
-
expect(parseSimpleYamlMap(yaml2)).toEqual({ title: "My Title" });
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test("skips lines without colon or with colon at position 0", () => {
|
|
185
|
-
const yaml = "no separator here\n:starts-with-colon: val\nkey: val";
|
|
186
|
-
const result = parseSimpleYamlMap(yaml);
|
|
187
|
-
expect(result.key).toBe("val");
|
|
188
|
-
expect(result["no separator here"]).toBeUndefined();
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test("skips comment lines", () => {
|
|
192
|
-
const yaml = "# This is a comment\nkey: value";
|
|
193
|
-
expect(parseSimpleYamlMap(yaml)).toEqual({ key: "value" });
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("skips blank lines", () => {
|
|
197
|
-
const yaml = "\n\nkey: value\n\n";
|
|
198
|
-
expect(parseSimpleYamlMap(yaml)).toEqual({ key: "value" });
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test("parses nested map (child indented under parent)", () => {
|
|
202
|
-
const yaml = "parent:\n child: nested_value";
|
|
203
|
-
const result = parseSimpleYamlMap(yaml);
|
|
204
|
-
expect(result.parent).toEqual({ child: "nested_value" });
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("handles multi-line values correctly (second line is new key)", () => {
|
|
208
|
-
const yaml = "key1: val1\nkey2: val2";
|
|
209
|
-
const result = parseSimpleYamlMap(yaml);
|
|
210
|
-
expect(result.key1).toBe("val1");
|
|
211
|
-
expect(result.key2).toBe("val2");
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test("strips quotes from keys", () => {
|
|
215
|
-
const yaml = '"quoted-key": value';
|
|
216
|
-
const result = parseSimpleYamlMap(yaml);
|
|
217
|
-
expect(result["quoted-key"]).toBe("value");
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
127
|
describe("normalizeOptionalStringArray", () => {
|
|
222
128
|
it("returns the array for a valid string array", () => {
|
|
223
129
|
expect(normalizeOptionalStringArray(["a", "b", "c"])).toEqual([
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { extractFrontmatter, parseSimpleYamlMap } from "#src/yaml-frontmatter";
|
|
4
|
+
|
|
5
|
+
describe("extractFrontmatter", () => {
|
|
6
|
+
test("returns empty string when no frontmatter delimiter", () => {
|
|
7
|
+
expect(extractFrontmatter("# Hello\nSome content")).toBe("");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("returns empty string when only opening delimiter with no closing", () => {
|
|
11
|
+
expect(extractFrontmatter("---\nkey: value")).toBe("");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("returns frontmatter body between delimiters", () => {
|
|
15
|
+
const markdown = "---\nissue: 1\ntitle: Test\n---\n# Content";
|
|
16
|
+
expect(extractFrontmatter(markdown)).toBe("issue: 1\ntitle: Test");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns empty string when file does not start with ---", () => {
|
|
20
|
+
expect(extractFrontmatter("content\n---\nkey: val\n---")).toBe("");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("handles CRLF line endings", () => {
|
|
24
|
+
const markdown = "---\r\nissue: 5\r\n---\r\n# Content";
|
|
25
|
+
expect(extractFrontmatter(markdown)).toBe("issue: 5");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns empty string for empty string input", () => {
|
|
29
|
+
expect(extractFrontmatter("")).toBe("");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns empty frontmatter for --- \\n--- with nothing between", () => {
|
|
33
|
+
const markdown = "---\n---\n# Content";
|
|
34
|
+
expect(extractFrontmatter(markdown)).toBe("");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("parseSimpleYamlMap", () => {
|
|
39
|
+
test("returns empty object for empty string", () => {
|
|
40
|
+
expect(parseSimpleYamlMap("")).toEqual({});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("parses simple key-value pairs", () => {
|
|
44
|
+
const yaml = "issue: 21\ntitle: Test";
|
|
45
|
+
expect(parseSimpleYamlMap(yaml)).toEqual({ issue: "21", title: "Test" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("strips surrounding quotes from values", () => {
|
|
49
|
+
const yaml = 'title: "My Title"';
|
|
50
|
+
expect(parseSimpleYamlMap(yaml)).toEqual({ title: "My Title" });
|
|
51
|
+
|
|
52
|
+
const yaml2 = "title: 'My Title'";
|
|
53
|
+
expect(parseSimpleYamlMap(yaml2)).toEqual({ title: "My Title" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("skips lines without colon or with colon at position 0", () => {
|
|
57
|
+
const yaml = "no separator here\n:starts-with-colon: val\nkey: val";
|
|
58
|
+
const result = parseSimpleYamlMap(yaml);
|
|
59
|
+
expect(result.key).toBe("val");
|
|
60
|
+
expect(result["no separator here"]).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("skips comment lines", () => {
|
|
64
|
+
const yaml = "# This is a comment\nkey: value";
|
|
65
|
+
expect(parseSimpleYamlMap(yaml)).toEqual({ key: "value" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("skips blank lines", () => {
|
|
69
|
+
const yaml = "\n\nkey: value\n\n";
|
|
70
|
+
expect(parseSimpleYamlMap(yaml)).toEqual({ key: "value" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("parses nested map (child indented under parent)", () => {
|
|
74
|
+
const yaml = "parent:\n child: nested_value";
|
|
75
|
+
const result = parseSimpleYamlMap(yaml);
|
|
76
|
+
expect(result.parent).toEqual({ child: "nested_value" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("handles multi-line values correctly (second line is new key)", () => {
|
|
80
|
+
const yaml = "key1: val1\nkey2: val2";
|
|
81
|
+
const result = parseSimpleYamlMap(yaml);
|
|
82
|
+
expect(result.key1).toBe("val1");
|
|
83
|
+
expect(result.key2).toBe("val2");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("strips quotes from keys", () => {
|
|
87
|
+
const yaml = '"quoted-key": value';
|
|
88
|
+
const result = parseSimpleYamlMap(yaml);
|
|
89
|
+
expect(result["quoted-key"]).toBe("value");
|
|
90
|
+
});
|
|
91
|
+
});
|