@gotgenes/pi-permission-system 16.2.0 → 17.0.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/src/access-intent/access-intent.ts +8 -10
  5. package/src/access-intent/access-path.ts +39 -16
  6. package/src/access-intent/bash/command-enumeration.ts +79 -2
  7. package/src/access-intent/bash/cwd-projection.ts +16 -14
  8. package/src/access-intent/bash/parser.ts +2 -0
  9. package/src/access-intent/bash/program.ts +3 -0
  10. package/src/builtin-tool-input-formatters.ts +1 -1
  11. package/src/config-loader.ts +7 -7
  12. package/src/forwarded-permissions/permission-forwarder.ts +1 -1
  13. package/src/handlers/gates/bash-command.ts +15 -1
  14. package/src/handlers/gates/bash-external-directory.ts +1 -1
  15. package/src/handlers/gates/bash-path.ts +14 -14
  16. package/src/handlers/gates/external-directory.ts +3 -4
  17. package/src/handlers/gates/path.ts +11 -7
  18. package/src/handlers/gates/skill-read.ts +1 -1
  19. package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -1
  20. package/src/handlers/permission-gate-handler.ts +1 -2
  21. package/src/handlers/tool-call-boundary.ts +1 -2
  22. package/src/input-normalizer.ts +1 -1
  23. package/src/mcp-targets.ts +1 -1
  24. package/src/normalize.ts +1 -1
  25. package/src/path-utils.ts +1 -1
  26. package/src/permission-manager.ts +1 -1
  27. package/src/permission-prompts.ts +1 -1
  28. package/src/policy-loader.ts +2 -2
  29. package/src/tool-input-prompt-formatters.ts +1 -1
  30. package/src/tool-preview-formatter.ts +1 -1
  31. package/src/tool-registry.ts +1 -1
  32. package/src/{common.ts → value-guards.ts} +0 -66
  33. package/src/yaml-frontmatter.ts +65 -0
  34. package/test/access-intent/access-path.test.ts +83 -23
  35. package/test/access-intent/bash/node-text.test.ts +1 -0
  36. package/test/access-intent/bash/program.test.ts +116 -17
  37. package/test/handlers/external-directory-integration.test.ts +40 -153
  38. package/test/handlers/external-directory-session-dedup.test.ts +38 -262
  39. package/test/handlers/gates/bash-command.test.ts +63 -0
  40. package/test/handlers/gates/bash-external-directory.test.ts +1 -2
  41. package/test/handlers/gates/bash-path.test.ts +9 -9
  42. package/test/handlers/gates/external-directory-policy.test.ts +10 -18
  43. package/test/handlers/gates/path.test.ts +43 -12
  44. package/test/helpers/external-directory-fixtures.ts +269 -0
  45. package/test/helpers/gate-fixtures.ts +1 -2
  46. package/test/permission-resolver.test.ts +5 -32
  47. package/test/{common.test.ts → value-guards.test.ts} +2 -96
  48. package/test/yaml-frontmatter.test.ts +91 -0
@@ -1,5 +1,16 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
+ // Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
4
+ // Default implementation is identity — lexical tests are unaffected.
5
+ const realpathSync = vi.hoisted(() =>
6
+ vi.fn<(path: string) => string>((p) => p),
7
+ );
8
+ vi.mock("node:fs", () => ({
9
+ realpathSync,
10
+ default: { realpathSync },
11
+ }));
12
+
13
+ import { AccessPath } from "#src/access-intent/access-path";
3
14
  import type { GateDescriptor } from "#src/handlers/gates/descriptor";
4
15
  import { isGateDescriptor } from "#src/handlers/gates/descriptor";
5
16
  import { describePathGate } from "#src/handlers/gates/path";
@@ -27,6 +38,11 @@ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
27
38
  // ── tests ──────────────────────────────────────────────────────────────────
28
39
 
29
40
  describe("describePathGate", () => {
41
+ beforeEach(() => {
42
+ realpathSync.mockReset();
43
+ realpathSync.mockImplementation((p: string) => p);
44
+ });
45
+
30
46
  it("returns null for non-path-bearing tools", () => {
31
47
  const resolver = makeResolver();
32
48
  const result = describePathGate(
@@ -152,16 +168,31 @@ describe("describePathGate", () => {
152
168
  expect(result.decision.value).toBe(".env");
153
169
  });
154
170
 
155
- it("resolves the path surface with the file path and agent name", () => {
171
+ it("resolves the path surface with an access-path intent and agent name", () => {
156
172
  const resolver = makeResolver(makeCheckResult({ state: "allow" }));
157
173
  describePathGate(makeTcc({ agentName: "my-agent" }), resolver);
158
174
  expect(resolver.resolve).toHaveBeenCalledWith({
159
- kind: "tool",
175
+ kind: "access-path",
160
176
  surface: "path",
161
- input: { path: ".env" },
177
+ path: AccessPath.forPath(".env", { cwd: "/test/project" }),
162
178
  agentName: "my-agent",
163
179
  });
164
180
  });
181
+
182
+ it("emits an access-path whose matchValues include the symlink-resolved form (#486)", () => {
183
+ // /test/project/.env is a symlink to /vault/secret.env.
184
+ realpathSync.mockImplementation((p: string) =>
185
+ p === "/test/project/.env" ? "/vault/secret.env" : p,
186
+ );
187
+ const resolver = makeResolver(makeCheckResult({ state: "allow" }));
188
+ describePathGate(makeTcc(), resolver);
189
+
190
+ const intent = resolver.resolve.mock.lastCall?.[0];
191
+ expect(intent?.kind).toBe("access-path");
192
+ expect(intent?.kind === "access-path" && intent.path.matchValues()).toEqual(
193
+ ["/test/project/.env", ".env", "/vault/secret.env"],
194
+ );
195
+ });
165
196
  });
166
197
 
167
198
  // Home-relative path characterization (#350) ──────────────────────────────
@@ -189,9 +220,9 @@ describe("describePathGate — home-relative paths", () => {
189
220
  pathValue: "~/.ssh/config",
190
221
  });
191
222
  expect(resolver.resolve).toHaveBeenCalledWith({
192
- kind: "tool",
223
+ kind: "access-path",
193
224
  surface: "path",
194
- input: { path: "~/.ssh/config" },
225
+ path: AccessPath.forPath("~/.ssh/config", { cwd: "/test/project" }),
195
226
  agentName: undefined,
196
227
  });
197
228
  });
@@ -246,9 +277,9 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
246
277
  );
247
278
  expect(isGateDescriptor(result)).toBe(true);
248
279
  expect(resolver.resolve).toHaveBeenCalledWith({
249
- kind: "tool",
280
+ kind: "access-path",
250
281
  surface: "path",
251
- input: { path: ".env" },
282
+ path: AccessPath.forPath(".env", { cwd: "/test/project" }),
252
283
  agentName: undefined,
253
284
  });
254
285
  });
@@ -263,9 +294,9 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
263
294
  );
264
295
  expect(isGateDescriptor(result)).toBe(true);
265
296
  expect(resolver.resolve).toHaveBeenCalledWith({
266
- kind: "tool",
297
+ kind: "access-path",
267
298
  surface: "path",
268
- input: { path: ".env" },
299
+ path: AccessPath.forPath(".env", { cwd: "/test/project" }),
269
300
  agentName: undefined,
270
301
  });
271
302
  });
@@ -280,9 +311,9 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
280
311
  extractorLookup("ffgrep", "target"),
281
312
  );
282
313
  expect(resolver.resolve).toHaveBeenCalledWith({
283
- kind: "tool",
314
+ kind: "access-path",
284
315
  surface: "path",
285
- input: { path: "/etc/passwd" },
316
+ path: AccessPath.forPath("/etc/passwd", { cwd: "/test/project" }),
286
317
  agentName: undefined,
287
318
  });
288
319
  });
@@ -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
+ }
@@ -213,8 +213,7 @@ export function makePathDispatchResolver(
213
213
  }
214
214
  return defaultResult;
215
215
  }
216
- const values =
217
- intent.kind === "access-path" ? intent.path.matchValues() : intent.values;
216
+ const values = intent.path.matchValues();
218
217
  for (const value of values) {
219
218
  if (value in byPath) return byPath[value];
220
219
  }
@@ -120,28 +120,7 @@ describe("PermissionResolver", () => {
120
120
  });
121
121
  });
122
122
 
123
- describe("resolve — path-values intent", () => {
124
- it("forwards a path-values intent with the current session ruleset", () => {
125
- const { resolver, permissionManager } = makeResolver();
126
-
127
- resolver.resolve({
128
- kind: "path-values",
129
- surface: "path",
130
- values: ["/proj/src/a.ts", "src/a.ts"],
131
- agentName: "agent-x",
132
- });
133
-
134
- expect(permissionManager.check).toHaveBeenCalledWith(
135
- {
136
- kind: "path-values",
137
- surface: "path",
138
- values: ["/proj/src/a.ts", "src/a.ts"],
139
- agentName: "agent-x",
140
- },
141
- [],
142
- );
143
- });
144
-
123
+ describe("resolve — session ruleset threading", () => {
145
124
  it("applies a recorded session approval on the next call", () => {
146
125
  const pm = makePermissionManager();
147
126
  const sessionRules = new SessionRules();
@@ -151,9 +130,9 @@ describe("PermissionResolver", () => {
151
130
  SessionApproval.single("path", "src/*"),
152
131
  );
153
132
  resolver.resolve({
154
- kind: "path-values",
133
+ kind: "access-path",
155
134
  surface: "path",
156
- values: ["src/a.ts"],
135
+ path: AccessPath.forPath("src/a.ts", { cwd: "/proj" }),
157
136
  });
158
137
 
159
138
  const passedRules = vi.mocked(pm.check).mock.calls[0][1];
@@ -169,10 +148,7 @@ describe("PermissionResolver", () => {
169
148
  describe("resolve — access-path intent", () => {
170
149
  it("unwraps the AccessPath via matchValues() into a path-values intent", () => {
171
150
  const { resolver, permissionManager } = makeResolver();
172
- const accessPath = AccessPath.forExternalDirectory(
173
- "/tmp/x",
174
- "/workspace",
175
- );
151
+ const accessPath = AccessPath.forPath("/tmp/x", { cwd: "/workspace" });
176
152
 
177
153
  resolver.resolve({
178
154
  kind: "access-path",
@@ -202,10 +178,7 @@ describe("PermissionResolver", () => {
202
178
  matchedPattern: "/tmp/*",
203
179
  });
204
180
  const { resolver } = makeResolver(pm);
205
- const accessPath = AccessPath.forExternalDirectory(
206
- "/tmp/x",
207
- "/workspace",
208
- );
181
+ const accessPath = AccessPath.forPath("/tmp/x", { cwd: "/workspace" });
209
182
 
210
183
  const result = resolver.resolve({
211
184
  kind: "access-path",
@@ -1,19 +1,13 @@
1
- import { afterEach, describe, expect, it, test, vi } from "vitest";
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/common";
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
+ });