@funkai/cli 0.3.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funkai/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "description": "CLI for the funkai AI SDK framework",
6
6
  "keywords": [
@@ -31,13 +31,13 @@
31
31
  "dependencies": {
32
32
  "@kidd-cli/core": "^0.10.0",
33
33
  "es-toolkit": "^1.45.1",
34
- "liquidjs": "^10.25.0",
34
+ "liquidjs": "^10.25.1",
35
35
  "picomatch": "^4.0.3",
36
36
  "ts-pattern": "^5.9.0",
37
- "yaml": "^2.8.2",
37
+ "yaml": "^2.8.3",
38
38
  "zod": "^4.3.6",
39
39
  "@funkai/config": "0.2.0",
40
- "@funkai/prompts": "0.4.0"
40
+ "@funkai/prompts": "0.4.1"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@kidd-cli/cli": "^0.4.9",
@@ -15,8 +15,23 @@ export default command({
15
15
  ctx.logger.info("Running prompts code generation...");
16
16
  }
17
17
 
18
+ const generateHandleArgs: {
19
+ silent: boolean;
20
+ out?: string;
21
+ includes?: readonly string[];
22
+ partials?: string;
23
+ } = { silent: ctx.args.silent };
24
+ if (ctx.args.out !== undefined) {
25
+ generateHandleArgs.out = ctx.args.out;
26
+ }
27
+ if (ctx.args.includes !== undefined) {
28
+ generateHandleArgs.includes = ctx.args.includes;
29
+ }
30
+ if (ctx.args.partials !== undefined) {
31
+ generateHandleArgs.partials = ctx.args.partials;
32
+ }
18
33
  handleGenerate({
19
- args: ctx.args,
34
+ args: generateHandleArgs,
20
35
  config: config.prompts,
21
36
  logger: ctx.logger,
22
37
  fail: ctx.fail,
@@ -33,6 +33,9 @@ export default command({
33
33
  if (includes.length > 0) {
34
34
  // Extract the static base directory from the first include pattern
35
35
  const [pattern] = includes;
36
+ if (pattern === undefined) {
37
+ return undefined;
38
+ }
36
39
  const parts = pattern.split("/");
37
40
  const staticParts = parts.filter((p) => !p.includes("*") && !p.includes("?"));
38
41
  if (staticParts.length > 0) {
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
 
4
- import type { FunkaiConfig } from "@funkai/config";
4
+ import type { FunkaiConfig, PromptGroup } from "@funkai/config";
5
5
  import { command } from "@kidd-cli/core";
6
6
  import { match } from "ts-pattern";
7
7
  import { z } from "zod";
@@ -71,7 +71,22 @@ function resolveGenerateArgs(
71
71
  fail("Missing --out flag. Provide it via CLI or set prompts.out in funkai.config.ts.");
72
72
  }
73
73
 
74
- return { out, includes, excludes, partials, silent: args.silent };
74
+ const resolved: {
75
+ out: string;
76
+ includes: readonly string[];
77
+ excludes: readonly string[];
78
+ silent: boolean;
79
+ partials?: string;
80
+ } = {
81
+ out,
82
+ includes,
83
+ excludes,
84
+ silent: args.silent,
85
+ };
86
+ if (partials !== undefined) {
87
+ resolved.partials = partials;
88
+ }
89
+ return resolved;
75
90
  }
76
91
 
77
92
  /**
@@ -82,13 +97,21 @@ function resolveGenerateArgs(
82
97
  export function handleGenerate({ args, config, logger, fail }: HandleGenerateParams): void {
83
98
  const { out, includes, excludes, partials, silent } = resolveGenerateArgs(args, config, fail);
84
99
 
85
- const { discovered, lintResults, prompts } = runGeneratePipeline({
86
- includes,
87
- excludes,
88
- out,
89
- partials,
90
- groups: config && config.groups,
91
- });
100
+ const configGroups = config && config.groups;
101
+ const pipelineOptions: {
102
+ includes: readonly string[];
103
+ excludes: readonly string[];
104
+ out: string;
105
+ partials?: string;
106
+ groups?: readonly PromptGroup[];
107
+ } = { includes, excludes, out };
108
+ if (partials !== undefined) {
109
+ pipelineOptions.partials = partials;
110
+ }
111
+ if (configGroups !== undefined) {
112
+ pipelineOptions.groups = configGroups;
113
+ }
114
+ const { discovered, lintResults, prompts } = runGeneratePipeline(pipelineOptions);
92
115
 
93
116
  if (!silent) {
94
117
  logger.info(`Found ${discovered} prompt(s)`);
@@ -149,8 +172,23 @@ export default command({
149
172
  options: generateArgs,
150
173
  handler(ctx) {
151
174
  const config = getConfig(ctx);
175
+ const generateArgs2: {
176
+ silent: boolean;
177
+ out?: string;
178
+ includes?: readonly string[];
179
+ partials?: string;
180
+ } = { silent: ctx.args.silent };
181
+ if (ctx.args.out !== undefined) {
182
+ generateArgs2.out = ctx.args.out;
183
+ }
184
+ if (ctx.args.includes !== undefined) {
185
+ generateArgs2.includes = ctx.args.includes;
186
+ }
187
+ if (ctx.args.partials !== undefined) {
188
+ generateArgs2.partials = ctx.args.partials;
189
+ }
152
190
  handleGenerate({
153
- args: ctx.args,
191
+ args: generateArgs2,
154
192
  config: config.prompts,
155
193
  logger: ctx.logger,
156
194
  fail: ctx.fail,
@@ -57,7 +57,16 @@ function resolveLintArgs(
57
57
  const excludes = (config && config.excludes) ?? [];
58
58
  const partials = args.partials ?? (config && config.partials);
59
59
 
60
- return { includes, excludes, partials, silent: args.silent };
60
+ const resolved: {
61
+ includes: readonly string[];
62
+ excludes: readonly string[];
63
+ silent: boolean;
64
+ partials?: string;
65
+ } = { includes, excludes, silent: args.silent };
66
+ if (partials !== undefined) {
67
+ resolved.partials = partials;
68
+ }
69
+ return resolved;
61
70
  }
62
71
 
63
72
  /**
@@ -68,7 +77,15 @@ function resolveLintArgs(
68
77
  export function handleLint({ args, config, logger, fail }: HandleLintParams): void {
69
78
  const { includes, excludes, partials, silent } = resolveLintArgs(args, config, fail);
70
79
 
71
- const { discovered, results } = runLintPipeline({ includes, excludes, partials });
80
+ const lintPipelineOptions: {
81
+ includes: readonly string[];
82
+ excludes: readonly string[];
83
+ partials?: string;
84
+ } = { includes, excludes };
85
+ if (partials !== undefined) {
86
+ lintPipelineOptions.partials = partials;
87
+ }
88
+ const { discovered, results } = runLintPipeline(lintPipelineOptions);
72
89
 
73
90
  if (!silent) {
74
91
  logger.info(`Linting ${discovered} prompt(s)...`);
@@ -109,8 +126,19 @@ export default command({
109
126
  options: lintArgs,
110
127
  handler(ctx) {
111
128
  const config = getConfig(ctx);
129
+ const lintHandleArgs: {
130
+ silent: boolean;
131
+ includes?: readonly string[];
132
+ partials?: string;
133
+ } = { silent: ctx.args.silent };
134
+ if (ctx.args.includes !== undefined) {
135
+ lintHandleArgs.includes = ctx.args.includes;
136
+ }
137
+ if (ctx.args.partials !== undefined) {
138
+ lintHandleArgs.partials = ctx.args.partials;
139
+ }
112
140
  handleLint({
113
- args: ctx.args,
141
+ args: lintHandleArgs,
114
142
  config: config.prompts,
115
143
  logger: ctx.logger,
116
144
  fail: ctx.fail,
@@ -56,7 +56,7 @@ export async function setupPrompts(ctx: Pick<Context, "prompts" | "logger">): Pr
56
56
  const extensionsPath = resolve(vscodeDir, EXTENSIONS_FILE);
57
57
  const extensions = readJsonFile(extensionsPath);
58
58
 
59
- const currentRecs = (extensions.recommendations ?? []) as string[];
59
+ const currentRecs = (extensions["recommendations"] ?? []) as string[];
60
60
  const extensionId = "sissel.shopify-liquid";
61
61
 
62
62
  const recommendations = ensureRecommendation(currentRecs, extensionId);
@@ -97,8 +97,8 @@ export async function setupPrompts(ctx: Pick<Context, "prompts" | "logger">): Pr
97
97
  const tsconfigPath = resolve(TSCONFIG_FILE);
98
98
  const tsconfig = readJsonFile(tsconfigPath);
99
99
 
100
- const compilerOptions = (tsconfig.compilerOptions ?? {}) as Record<string, unknown>;
101
- const existingPaths = (compilerOptions.paths ?? {}) as Record<string, string[]>;
100
+ const compilerOptions = (tsconfig["compilerOptions"] ?? {}) as Record<string, unknown>;
101
+ const existingPaths = (compilerOptions["paths"] ?? {}) as Record<string, string[]>;
102
102
 
103
103
  // oxlint-disable-next-line security/detect-object-injection -- safe: PROMPTS_ALIAS is a known constant string
104
104
  if (existingPaths[PROMPTS_ALIAS]) {
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
 
4
+ import type { Context } from "@kidd-cli/core";
4
5
  import { command } from "@kidd-cli/core";
5
6
  import { match } from "ts-pattern";
6
7
 
@@ -44,23 +45,7 @@ export default command({
44
45
  });
45
46
 
46
47
  if (shouldCreateConfig) {
47
- let includes = ["src/prompts/**"];
48
- let out = ".prompts/client";
49
-
50
- if (hasPrompts) {
51
- const includesInput = await ctx.prompts.text({
52
- message: "Prompt include patterns (comma-separated)",
53
- defaultValue: "src/prompts/**",
54
- placeholder: "src/prompts/**",
55
- });
56
- includes = includesInput.split(",").map((r) => r.trim());
57
-
58
- out = await ctx.prompts.text({
59
- message: "Output directory for generated prompt modules",
60
- defaultValue: ".prompts/client",
61
- placeholder: ".prompts/client",
62
- });
63
- }
48
+ const { includes, out } = await resolvePromptSettings(ctx, hasPrompts);
64
49
 
65
50
  const template = buildConfigTemplate({ hasPrompts, hasAgents, includes, out });
66
51
  const configPath = resolve("funkai.config.ts");
@@ -90,6 +75,43 @@ export default command({
90
75
  // Private
91
76
  // ---------------------------------------------------------------------------
92
77
 
78
+ /** @private */
79
+ interface PromptSettings {
80
+ readonly includes: readonly string[];
81
+ readonly out: string;
82
+ }
83
+
84
+ /**
85
+ * Gather prompt include patterns and output directory from the user.
86
+ *
87
+ * @private
88
+ * @param ctx - CLI context for prompts.
89
+ * @param hasPrompts - Whether the prompts domain is selected.
90
+ * @returns The resolved prompt settings.
91
+ */
92
+ async function resolvePromptSettings(ctx: Context, hasPrompts: boolean): Promise<PromptSettings> {
93
+ if (!hasPrompts) {
94
+ return { includes: ["src/prompts/**"], out: ".prompts/client" };
95
+ }
96
+
97
+ const includesInput = await ctx.prompts.text({
98
+ message: "Prompt include patterns (comma-separated)",
99
+ defaultValue: "src/prompts/**",
100
+ placeholder: "src/prompts/**",
101
+ });
102
+
103
+ const out = await ctx.prompts.text({
104
+ message: "Output directory for generated prompt modules",
105
+ defaultValue: ".prompts/client",
106
+ placeholder: ".prompts/client",
107
+ });
108
+
109
+ return {
110
+ includes: includesInput.split(",").map((r) => r.trim()),
111
+ out,
112
+ };
113
+ }
114
+
93
115
  /** @private */
94
116
  interface ConfigTemplateOptions {
95
117
  readonly hasPrompts: boolean;
@@ -15,8 +15,19 @@ export default command({
15
15
  ctx.logger.info("Running prompts validation...");
16
16
  }
17
17
 
18
+ const lintHandleArgs: {
19
+ silent: boolean;
20
+ includes?: readonly string[];
21
+ partials?: string;
22
+ } = { silent: ctx.args.silent };
23
+ if (ctx.args.includes !== undefined) {
24
+ lintHandleArgs.includes = ctx.args.includes;
25
+ }
26
+ if (ctx.args.partials !== undefined) {
27
+ lintHandleArgs.partials = ctx.args.partials;
28
+ }
18
29
  handleLint({
19
- args: ctx.args,
30
+ args: lintHandleArgs,
20
31
  config: config.prompts,
21
32
  logger: ctx.logger,
22
33
  fail: ctx.fail,
@@ -25,7 +36,7 @@ export default command({
25
36
  // --- Future: agents validation ---
26
37
 
27
38
  if (!silent) {
28
- ctx.logger.success("All validations passed.");
39
+ ctx.logger.success("No errors found.");
29
40
  }
30
41
  },
31
42
  });
@@ -4,48 +4,60 @@ import { hasLintErrors, lintPrompt } from "@/lib/prompts/lint.js";
4
4
 
5
5
  describe(lintPrompt, () => {
6
6
  it("returns no diagnostics when vars match schema", () => {
7
- const result = lintPrompt(
8
- "test",
9
- "test.prompt",
10
- [{ name: "scope", type: "string", required: true }],
11
- ["scope"],
12
- );
7
+ const result = lintPrompt({
8
+ name: "test",
9
+ filePath: "test.prompt",
10
+ schemaVars: [{ name: "scope", type: "string", required: true }],
11
+ templateVars: ["scope"],
12
+ });
13
13
  expect(result.diagnostics).toEqual([]);
14
14
  });
15
15
 
16
16
  it("errors on undefined template variable", () => {
17
- const result = lintPrompt("test", "test.prompt", [], ["scope"]);
17
+ const result = lintPrompt({
18
+ name: "test",
19
+ filePath: "test.prompt",
20
+ schemaVars: [],
21
+ templateVars: ["scope"],
22
+ });
18
23
  expect(result.diagnostics).toHaveLength(1);
19
- expect(result.diagnostics[0].level).toBe("error");
20
- expect(result.diagnostics[0].message).toContain('Undefined variable "scope"');
24
+ const [diag0] = result.diagnostics;
25
+ expect(diag0?.level).toBe("error");
26
+ expect(diag0?.message).toContain('Undefined variable "scope"');
21
27
  });
22
28
 
23
29
  it("warns on unused schema variable", () => {
24
- const result = lintPrompt(
25
- "test",
26
- "test.prompt",
27
- [{ name: "scope", type: "string", required: true }],
28
- [],
29
- );
30
+ const result = lintPrompt({
31
+ name: "test",
32
+ filePath: "test.prompt",
33
+ schemaVars: [{ name: "scope", type: "string", required: true }],
34
+ templateVars: [],
35
+ });
30
36
  expect(result.diagnostics).toHaveLength(1);
31
- expect(result.diagnostics[0].level).toBe("warn");
32
- expect(result.diagnostics[0].message).toContain('Unused variable "scope"');
37
+ const [diag0] = result.diagnostics;
38
+ expect(diag0?.level).toBe("warn");
39
+ expect(diag0?.message).toContain('Unused variable "scope"');
33
40
  });
34
41
 
35
42
  it("reports both errors and warnings", () => {
36
- const result = lintPrompt(
37
- "test",
38
- "test.prompt",
39
- [{ name: "declared", type: "string", required: true }],
40
- ["undeclared"],
41
- );
43
+ const result = lintPrompt({
44
+ name: "test",
45
+ filePath: "test.prompt",
46
+ schemaVars: [{ name: "declared", type: "string", required: true }],
47
+ templateVars: ["undeclared"],
48
+ });
42
49
  expect(result.diagnostics).toHaveLength(2);
43
50
  const levels = result.diagnostics.map((d) => d.level).toSorted();
44
51
  expect(levels).toEqual(["error", "warn"]);
45
52
  });
46
53
 
47
54
  it("returns no diagnostics for prompts with no schema and no vars", () => {
48
- const result = lintPrompt("test", "test.prompt", [], []);
55
+ const result = lintPrompt({
56
+ name: "test",
57
+ filePath: "test.prompt",
58
+ schemaVars: [],
59
+ templateVars: [],
60
+ });
49
61
  expect(result.diagnostics).toEqual([]);
50
62
  });
51
63
  });
@@ -108,10 +108,9 @@ function generateSchemaExpression(vars: readonly SchemaVariable[]): string {
108
108
 
109
109
  /** @private */
110
110
  function formatHeader(sourcePath?: string): string {
111
- let sourceLine = "";
112
- if (sourcePath) {
113
- sourceLine = `// Source: ${sourcePath}\n`;
114
- }
111
+ const sourceLine = match(sourcePath)
112
+ .with(undefined, () => "")
113
+ .otherwise((p) => `// Source: ${p}\n`);
115
114
  return [
116
115
  "// ─── AUTO-GENERATED ────────────────────────────────────────",
117
116
  `${sourceLine}// Regenerate: funkai prompts generate`,
@@ -32,18 +32,19 @@ function parseParamsOrEmpty(raw: string, partialName: string): Record<string, st
32
32
  */
33
33
  function parseParams(raw: string, partialName: string): Record<string, string> {
34
34
  const literalMatches = [...raw.matchAll(LITERAL_PARAM_RE)];
35
- const allParamNames = [...raw.matchAll(/(\w+)\s*:/g)].map((m) => m[1]);
35
+ const allParamNames = [...raw.matchAll(/(\w+)\s*:/g)].map(([, m1]) => m1);
36
36
 
37
37
  return Object.fromEntries(
38
38
  allParamNames.map((name) => {
39
- const literal = literalMatches.find((m) => m[1] === name);
39
+ const literal = literalMatches.find(([, m1]) => m1 === name);
40
40
  if (!literal) {
41
41
  throw new Error(
42
42
  `Cannot flatten {% render '${partialName}' %}: parameter "${name}" uses a variable reference. ` +
43
43
  "Only literal string values are supported at codegen time.",
44
44
  );
45
45
  }
46
- return [name, literal[2]];
46
+ const { 2: literalValue } = literal;
47
+ return [name, literalValue];
47
48
  }),
48
49
  );
49
50
  }
@@ -81,10 +82,14 @@ function renderPartial(engine: Liquid, tag: RenderTag): string {
81
82
  */
82
83
  function parseRenderTags(template: string): RenderTag[] {
83
84
  return [...template.matchAll(RENDER_TAG_RE)].map((m) => {
85
+ const [, partialName] = m;
86
+ if (partialName === undefined) {
87
+ throw new Error("Malformed render tag: missing partial name");
88
+ }
84
89
  const rawParams: string = (m[2] ?? "").trim();
85
- const params = parseParamsOrEmpty(rawParams, m[1]);
90
+ const params = parseParamsOrEmpty(rawParams, partialName);
86
91
 
87
- return { fullMatch: m[0], partialName: m[1], params };
92
+ return { fullMatch: m[0], partialName, params };
88
93
  });
89
94
  }
90
95
 
@@ -76,7 +76,11 @@ export function parseFrontmatter({ content, filePath }: ParseFrontmatterParams):
76
76
  throw new Error(`No frontmatter found in ${filePath}`);
77
77
  }
78
78
 
79
- const parsed = parseYamlContent(fmMatch[1], filePath);
79
+ const [, fmContent] = fmMatch;
80
+ if (fmContent === undefined) {
81
+ throw new Error(`No frontmatter content found in ${filePath}`);
82
+ }
83
+ const parsed = parseYamlContent(fmContent, filePath);
80
84
 
81
85
  if (!parsed || typeof parsed !== "object") {
82
86
  throw new Error(`Frontmatter is not a valid object in ${filePath}`);
@@ -94,12 +98,24 @@ export function parseFrontmatter({ content, filePath }: ParseFrontmatterParams):
94
98
  );
95
99
  }
96
100
 
97
- const group = parseGroup(parsed.group, filePath);
98
- const version = parseVersion(parsed.version);
101
+ const group = parseGroup(parsed["group"], filePath);
102
+ const version = parseVersion(parsed["version"]);
99
103
 
100
- const schema = parseSchemaBlock(parsed.schema, filePath);
104
+ const schema = parseSchemaBlock(parsed["schema"], filePath);
101
105
 
102
- return { name, group, version, schema };
106
+ const result: {
107
+ name: string;
108
+ schema: readonly SchemaVariable[];
109
+ group?: string;
110
+ version?: string;
111
+ } = { name, schema };
112
+ if (group !== undefined) {
113
+ result.group = group;
114
+ }
115
+ if (version !== undefined) {
116
+ result.version = version;
117
+ }
118
+ return result;
103
119
  }
104
120
 
105
121
  // ---------------------------------------------------------------------------
@@ -180,9 +196,9 @@ function parseSchemaBlock(raw: unknown, filePath: string): readonly SchemaVariab
180
196
  typeof v === "object" && v !== null && !Array.isArray(v),
181
197
  ),
182
198
  (def) => {
183
- const type = stringOrDefault(def.type, "string");
184
- const required = def.required !== false;
185
- const description = stringOrUndefined(def.description);
199
+ const type = stringOrDefault(def["type"], "string");
200
+ const required = def["required"] !== false;
201
+ const description = stringOrUndefined(def["description"]);
186
202
  return { name: varName, type, required, description };
187
203
  },
188
204
  )
@@ -17,6 +17,20 @@ export interface LintResult {
17
17
  readonly diagnostics: readonly LintDiagnostic[];
18
18
  }
19
19
 
20
+ /**
21
+ * Parameters for linting a single prompt.
22
+ */
23
+ export interface LintPromptParams {
24
+ /** Prompt name (for error messages). */
25
+ readonly name: string;
26
+ /** Source file path (for error messages). */
27
+ readonly filePath: string;
28
+ /** Variables declared in frontmatter schema. */
29
+ readonly schemaVars: readonly SchemaVariable[];
30
+ /** Variables extracted from the template body. */
31
+ readonly templateVars: readonly string[];
32
+ }
33
+
20
34
  /**
21
35
  * Lint a prompt by comparing declared schema variables against
22
36
  * variables actually used in the template body.
@@ -24,18 +38,25 @@ export interface LintResult {
24
38
  * - **Error**: template uses a variable NOT declared in the schema (undefined var).
25
39
  * - **Warn**: schema declares a variable NOT used in the template (unused var).
26
40
  *
27
- * @param name - Prompt name (for error messages).
28
- * @param filePath - Source file path (for error messages).
29
- * @param schemaVars - Variables declared in frontmatter schema.
30
- * @param templateVars - Variables extracted from the template body.
41
+ * @param params - Lint prompt parameters.
31
42
  * @returns Lint result with diagnostics.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * const result = lintPrompt({
47
+ * name: 'greeting',
48
+ * filePath: 'prompts/greeting.prompt',
49
+ * schemaVars: [{ name: 'name', type: 'string', required: true }],
50
+ * templateVars: ['name'],
51
+ * });
52
+ * ```
32
53
  */
33
- export function lintPrompt(
34
- name: string,
35
- filePath: string,
36
- schemaVars: readonly SchemaVariable[],
37
- templateVars: readonly string[],
38
- ): LintResult {
54
+ export function lintPrompt({
55
+ name,
56
+ filePath,
57
+ schemaVars,
58
+ templateVars,
59
+ }: LintPromptParams): LintResult {
39
60
  const declared = new Set(schemaVars.map((v) => v.name));
40
61
  const used = new Set(templateVars);
41
62
 
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { basename, extname, join, relative, resolve } from "node:path";
3
3
 
4
4
  import picomatch from "picomatch";
5
+ import { match } from "ts-pattern";
5
6
  import { parse as parseYaml } from "yaml";
6
7
 
7
8
  import { FRONTMATTER_RE, NAME_RE } from "./frontmatter.js";
@@ -43,9 +44,13 @@ function extractName(content: string): string | undefined {
43
44
  }
44
45
 
45
46
  try {
46
- const parsed = parseYaml(fmMatch[1]) as Record<string, unknown> | null;
47
- if (parsed !== null && parsed !== undefined && typeof parsed.name === "string") {
48
- return parsed.name;
47
+ const [, fmContent] = fmMatch;
48
+ if (fmContent === undefined) {
49
+ return undefined;
50
+ }
51
+ const parsed = parseYaml(fmContent) as Record<string, unknown> | null;
52
+ if (parsed !== null && parsed !== undefined && typeof parsed["name"] === "string") {
53
+ return parsed["name"];
49
54
  }
50
55
  return undefined;
51
56
  } catch {
@@ -81,14 +86,10 @@ function deriveNameFromPath(filePath: string): string {
81
86
  function extractBaseDir(pattern: string): string {
82
87
  const globChars = new Set(["*", "?", "{", "["]);
83
88
  const parts = pattern.split("/");
84
- const staticParts: string[] = [];
85
-
86
- for (const part of parts) {
87
- if ([...part].some((ch) => globChars.has(ch))) {
88
- break;
89
- }
90
- staticParts.push(part);
91
- }
89
+ const firstGlobIndex = parts.findIndex((part) => [...part].some((ch) => globChars.has(ch)));
90
+ const staticParts = match(firstGlobIndex)
91
+ .with(-1, () => parts)
92
+ .otherwise(() => parts.slice(0, firstGlobIndex));
92
93
 
93
94
  if (staticParts.length === 0) {
94
95
  return ".";