fix(utils): handled unquoted YAML plain scalars containing colon-space in frontmatter
- Added preprocessing to quote ambiguous plain scalars containing a colon-space sequence when standard YAML parsing fails. - Preserves the parsed types of unaffected fields and prevents fallback warnings for common unquoted description strings. - Added tests to verify successful recovery of unquoted values and continued fallback warning coverage for unrecoverable syntax.
This commit is contained in:
@@ -6,6 +6,10 @@
|
||||
|
||||
- Added a response-body retry gate to `fetchWithRetry()` for deterministic provider failures that return retryable HTTP statuses.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed YAML frontmatter parsing for skill descriptions that use common unquoted plain scalars containing `: `, preserving typed fields without logging a warning.
|
||||
|
||||
## [16.2.7] - 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -37,6 +37,31 @@ function normalizeKeys<T>(obj: T): T {
|
||||
return (changed ? result : obj) as T;
|
||||
}
|
||||
|
||||
const PLAIN_SCALAR_KEY_VALUE = /^(\s*[A-Za-z_][\w-]*:\s+)(\S.*?)(\s*)$/;
|
||||
const FLOW_OR_EXPLICIT_VALUE_START = new Set(['"', "'", "[", "{", "|", ">", "!", "&", "*", "#"]);
|
||||
|
||||
function quoteAmbiguousPlainScalars(metadata: string): string | undefined {
|
||||
let changed = false;
|
||||
const lines = metadata.split("\n").map(line => {
|
||||
const match = line.match(PLAIN_SCALAR_KEY_VALUE);
|
||||
if (!match) return line;
|
||||
const [, prefix, rawValue, suffix] = match;
|
||||
const value = rawValue.trimEnd();
|
||||
if (!value.includes(": ")) return line;
|
||||
if (FLOW_OR_EXPLICIT_VALUE_START.has(value[0])) return line;
|
||||
changed = true;
|
||||
return `${prefix}${JSON.stringify(value)}${suffix}`;
|
||||
});
|
||||
return changed ? lines.join("\n") : undefined;
|
||||
}
|
||||
|
||||
function parseYamlRecord(metadata: string): Record<string, unknown> | null {
|
||||
const loaded = YAML.parse(metadata.replaceAll("\t", " "));
|
||||
if (loaded === null || loaded === undefined) return null;
|
||||
if (typeof loaded !== "object" || Array.isArray(loaded)) return null;
|
||||
return loaded as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class FrontmatterError extends Error {
|
||||
constructor(
|
||||
error: Error,
|
||||
@@ -100,10 +125,19 @@ export function parseFrontmatter(
|
||||
const body = normalized.slice(endIndex + 4).trim();
|
||||
|
||||
try {
|
||||
// Replace tabs with spaces for YAML compatibility, use failsafe mode for robustness
|
||||
const loaded = YAML.parse(metadata.replaceAll("\t", " ")) as Record<string, unknown> | null;
|
||||
const loaded = parseYamlRecord(metadata);
|
||||
return { frontmatter: normalizeKeys({ ...frontmatter, ...loaded }), body };
|
||||
} catch (error) {
|
||||
const quotedMetadata = quoteAmbiguousPlainScalars(metadata);
|
||||
if (quotedMetadata) {
|
||||
try {
|
||||
const loaded = parseYamlRecord(quotedMetadata);
|
||||
return { frontmatter: normalizeKeys({ ...frontmatter, ...loaded }), body };
|
||||
} catch {
|
||||
// Fall through to the existing warning + simple key/value fallback.
|
||||
}
|
||||
}
|
||||
|
||||
const err = new FrontmatterError(
|
||||
error instanceof Error ? error : new Error(`YAML: ${error}`),
|
||||
loc ?? `Inline '${truncate(content, 64)}'`,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { parseFrontmatter } from "@oh-my-pi/pi-utils";
|
||||
import * as logger from "@oh-my-pi/pi-utils/logger";
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("accepts unquoted skill descriptions containing colon-space without warning", () => {
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
const content = `---
|
||||
name: tool-prompt-optimization
|
||||
description: Optimize tool prompts. Two halves: measure schema overlap; keep scar tissue.
|
||||
enabled: true
|
||||
---
|
||||
Skill body`;
|
||||
|
||||
const result = parseFrontmatter(content, { source: "bad-skill/SKILL.md" });
|
||||
|
||||
expect(result.frontmatter).toEqual({
|
||||
name: "tool-prompt-optimization",
|
||||
description: "Optimize tool prompts. Two halves: measure schema overlap; keep scar tissue.",
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.body).toBe("Skill body");
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still warns and falls back for unrecoverable malformed frontmatter", () => {
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
const content = `---
|
||||
invalid: [unclosed array
|
||||
---
|
||||
Body content`;
|
||||
|
||||
const result = parseFrontmatter(content, { source: "broken.md" });
|
||||
|
||||
expect(result.frontmatter).toEqual({ invalid: "[unclosed array" });
|
||||
expect(result.body).toBe("Body content");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"Failed to parse YAML frontmatter",
|
||||
expect.objectContaining({ err: expect.stringContaining("broken.md") }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user