Skip to content

Commit a060520

Browse files
committed
fix(skills): serialize discovery scans, scope the quote hint, and parse frontmatter deterministically
Addresses the CodeRabbit review findings and completes the patch coverage: - Serialize discoverSkills() runs through a promise chain so overlapping watcher-triggered scans never interleave; an older scan can no longer append a stale diagnostic after a newer scan has observed the repaired file. - Only emit the unescaped-double-quotes hint when the parser error is located on the description line itself, so a valid quoted description plus an unrelated YAML error elsewhere no longer produces a misleading hint. - Parse SKILL.md frontmatter with explicit empty options so gray-matter's global content-keyed cache is bypassed. The cache is populated before parsing, so a frontmatter that throws on first parse is cached with an empty data object and every later parse of the same content silently returns that object instead of re-throwing - which resurfaces the misleading "missing required 'name' field" symptom from issue #859. Tests: - SkillsManager.spec: regression test that a delayed older scan cannot append stale diagnostics (serialization), a regression test that a re-scan of unchanged malformed content keeps reporting the parse failure (gray-matter cache poisoning), a no-false-hint case with a valid quoted description and an error on another line, and a non-Error parse failure exercising recordDiagnostic's defensive fallbacks (gray-matter is now vi.mocked with the real parser as the default implementation). - ExtensionStateContext.spec: the skills message test now asserts the transition that clears stored skills/diagnostics, including a message that omits skills entirely. - skills-diagnostics e2e: the malformed fixture is now a double-quoted description with unescaped inner quotes (the exact #859 failure mode), skill files are written atomically (sidecar + rename) so the watcher only observes complete files, and teardown removes only the skill directories the suite created. - api-get-skills-state.spec: document why the partial test doubles need as-unknown-as casts. - skillsMessageHandler.spec: cover the omitted newSkillModeSlugs case (passes undefined, still refreshes the posted state).
1 parent ae9b80a commit a060520

6 files changed

Lines changed: 306 additions & 22 deletions

File tree

apps/vscode-e2e/src/suite/skills-diagnostics.test.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ import { waitFor } from "./utils"
1010
const GOOD_SKILL = "e2e-skill-good"
1111
const BAD_SKILL = "e2e-skill-bad"
1212

13-
// Issue #859 reproduction content: unescaped double quotes in the description
14-
// make the YAML frontmatter unparseable.
13+
// Issue #859 reproduction content: the description is a double-quoted YAML
14+
// scalar whose inner double quotes are left unescaped, which makes the
15+
// frontmatter unparseable.
1516
const MALFORMED_SKILL_MD = `---
1617
name: ${BAD_SKILL}
17-
description: Use when implementing features. Triggers on: "TDD", "test-driven development"
18+
description: "Use when implementing features. Triggers on: "TDD", "test-driven development"
1819
---
1920
2021
# E2E Skill Bad
@@ -42,6 +43,17 @@ description: A healthy skill used by the skill diagnostics e2e smoke test.
4243
Instructions here.
4344
`
4445

46+
// Write a skill file atomically (write to a sidecar, then rename over the
47+
// target) so the extension host's file watcher only ever observes complete
48+
// content. An in-place fs.writeFile is visible mid-write, the watcher can
49+
// fire for that moment, and - because discovery scans are serialized - a
50+
// mid-write event could be the last one, leaving a stale scan result.
51+
const writeSkillFileAtomic = async (finalPath: string, content: string): Promise<void> => {
52+
const tmpPath = `${finalPath}.tmp`
53+
await fs.writeFile(tmpPath, content, "utf8")
54+
await fs.rename(tmpPath, finalPath)
55+
}
56+
4557
suite("Roo Code Skill Diagnostics", function () {
4658
setDefaultSuiteTimeout(this)
4759

@@ -54,18 +66,24 @@ suite("Roo Code Skill Diagnostics", function () {
5466
})
5567

5668
teardown(async function () {
57-
await fs.rm(skillsRoot, { recursive: true, force: true })
69+
// Remove only the skill directories this suite created so pre-existing
70+
// or other suites' fixtures under .roo/skills are left intact.
71+
await Promise.all(
72+
[GOOD_SKILL, BAD_SKILL].map((name) => fs.rm(path.join(skillsRoot, name), { recursive: true, force: true })),
73+
)
5874
})
5975

6076
test("should surface a malformed SKILL.md as a diagnostic without hiding healthy skills", async function () {
6177
this.timeout(180_000)
6278

6379
// Arrange: one healthy skill and one malformed skill on real disk in the
64-
// workspace's .roo/skills directory.
80+
// workspace's .roo/skills directory, written atomically so the watcher
81+
// only observes complete files.
6582
await fs.mkdir(path.join(skillsRoot, GOOD_SKILL), { recursive: true })
66-
await fs.writeFile(path.join(skillsRoot, GOOD_SKILL, "SKILL.md"), GOOD_SKILL_MD, "utf8")
83+
await writeSkillFileAtomic(path.join(skillsRoot, GOOD_SKILL, "SKILL.md"), GOOD_SKILL_MD)
6784
await fs.mkdir(path.join(skillsRoot, BAD_SKILL), { recursive: true })
68-
await fs.writeFile(path.join(skillsRoot, BAD_SKILL, "SKILL.md"), MALFORMED_SKILL_MD, "utf8")
85+
const badSkillMd = path.join(skillsRoot, BAD_SKILL, "SKILL.md")
86+
await writeSkillFileAtomic(badSkillMd, MALFORMED_SKILL_MD)
6987

7088
// Act: the extension host's file watcher re-discovers skills; wait until
7189
// the real SkillsManager reports the healthy skill and a diagnostic for
@@ -96,9 +114,9 @@ suite("Roo Code Skill Diagnostics", function () {
96114
"malformed skill should be omitted from skills",
97115
)
98116

99-
// Act: repair the frontmatter in place; the watcher re-discovers and the
100-
// diagnostic clears.
101-
await fs.writeFile(path.join(skillsRoot, BAD_SKILL, "SKILL.md"), FIXED_SKILL_MD, "utf8")
117+
// Act: repair the frontmatter in place (atomically); the watcher
118+
// re-discovers and the diagnostic clears.
119+
await writeSkillFileAtomic(badSkillMd, FIXED_SKILL_MD)
102120

103121
await waitFor(
104122
async () => {

src/core/webview/__tests__/skillsMessageHandler.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,28 @@ describe("skillsMessageHandler", () => {
426426
expect(mockUpdateSkillModes).toHaveBeenCalledWith("project-skill", "project", [])
427427
})
428428

429+
it("passes undefined mode slugs and refreshes state when newSkillModeSlugs is omitted", async () => {
430+
const provider = createMockProvider(true)
431+
mockUpdateSkillModes.mockResolvedValue(undefined)
432+
mockGetSkillsMetadata.mockReturnValue([mockSkills[0]])
433+
434+
const message: WebviewMessage = {
435+
type: "updateSkillModes",
436+
skillName: "test-skill",
437+
source: "global",
438+
// newSkillModeSlugs omitted
439+
}
440+
const result = await handleUpdateSkillModes(provider, message)
441+
442+
expect(result).toEqual([mockSkills[0]])
443+
expect(mockUpdateSkillModes).toHaveBeenCalledWith("test-skill", "global", undefined)
444+
expect(mockPostMessageToWebview).toHaveBeenCalledWith({
445+
type: "skills",
446+
skills: [mockSkills[0]],
447+
skillDiagnostics: [],
448+
})
449+
})
450+
429451
it("returns undefined when required fields are missing", async () => {
430452
const provider = createMockProvider(true)
431453

src/extension/__tests__/api-get-skills-state.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ describe("API#getSkillsState", () => {
1414
let mockGetSkillsManager: ReturnType<typeof vi.fn>
1515

1616
beforeEach(() => {
17+
// mockOutputChannel and mockProvider are intentionally partial
18+
// doubles: they implement only the members API touches (appendLine;
19+
// context, getSkillsManager, on). The as-unknown-as casts are the
20+
// last resort because the partial shapes are not subtypes of the full
21+
// vscode.OutputChannel / ClineProvider types.
1722
mockOutputChannel = {
1823
appendLine: vi.fn(),
1924
} as unknown as vscode.OutputChannel

src/services/skills/SkillsManager.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,24 @@ export type { SkillMetadata, SkillContent, SkillDiagnostic }
2020

2121
/**
2222
* Extract the raw top-level `key: ...` line from the frontmatter block of a
23-
* SKILL.md file. Used to point users at the exact line when YAML parsing
24-
* fails (see issue #859). Returns undefined when the file has no
25-
* frontmatter block or no matching top-level line.
23+
* SKILL.md file, along with its 1-based line number in the file. Used to
24+
* point users at the exact line when YAML parsing fails (see issue #859).
25+
* Returns undefined when the file has no frontmatter block or no matching
26+
* top-level line.
2627
*/
27-
function getRawFrontmatterLine(fileContent: string, key: string): string | undefined {
28+
function getFrontmatterLine(fileContent: string, key: string): { line: string; lineNumber: number } | undefined {
2829
const match = fileContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
2930
if (!match) {
3031
return undefined
3132
}
3233
const linePattern = new RegExp(`^${key}\\s*:`)
33-
return match[1].split(/\r?\n/).find((line) => linePattern.test(line))
34+
const frontmatterLines = match[1].split(/\r?\n/)
35+
const index = frontmatterLines.findIndex((line) => linePattern.test(line))
36+
if (index === -1) {
37+
return undefined
38+
}
39+
// The opening `---` occupies file line 1, so frontmatter index 0 is file line 2.
40+
return { line: frontmatterLines[index], lineNumber: index + 2 }
3441
}
3542

3643
// gray-matter's bundled typings predate its options passthrough: `stringify`
@@ -51,6 +58,7 @@ export class SkillsManager {
5158
private providerRef: WeakRef<ClineProvider>
5259
private disposables: vscode.Disposable[] = []
5360
private isDisposed = false
61+
private discoveryChain: Promise<void> = Promise.resolve()
5462

5563
constructor(provider: ClineProvider) {
5664
this.providerRef = new WeakRef(provider)
@@ -67,8 +75,19 @@ export class SkillsManager {
6775
* Also supports symlinks:
6876
* - .roo/skills can be a symlink to a directory containing skill subdirectories
6977
* - .roo/skills/[dirname] can be a symlink to a skill directory
78+
*
79+
* Scans are serialized so overlapping watcher-triggered runs never
80+
* interleave: an older scan must not commit state after a newer scan has
81+
* already observed the (possibly repaired) files.
7082
*/
71-
async discoverSkills(): Promise<void> {
83+
discoverSkills(): Promise<void> {
84+
const run = this.discoveryChain.then(() => this.performDiscovery())
85+
// Keep the chain alive even if a scan rejects so later scans still run.
86+
this.discoveryChain = run.catch(() => undefined)
87+
return run
88+
}
89+
90+
private async performDiscovery(): Promise<void> {
7291
this.skills.clear()
7392
this.diagnostics = []
7493
const skillsDirs = await this.getSkillsDirectories()
@@ -135,15 +154,30 @@ export class SkillsManager {
135154
// YAML syntax problems (e.g. unescaped double quotes in the
136155
// description) report the actual cause instead of a misleading
137156
// "missing required field" message (see issue #859).
157+
//
158+
// The `{}` options argument is deliberate: gray-matter keeps a global
159+
// content-keyed cache and populates it *before* parsing, so a
160+
// frontmatter that throws on first parse is cached with an empty data
161+
// object and every later parse of the same content silently returns
162+
// that empty object instead of re-throwing. Passing options disables
163+
// the cache for this call, keeping the parse deterministic.
138164
try {
139-
parsed = matter(fileContent)
165+
parsed = matter(fileContent, {})
140166
} catch (error) {
141167
this.recordDiagnostic(skillMdPath, source, error)
142168
console.error(`Failed to parse skill at ${skillDir}:`, error)
143169
// The most common cause is unescaped double quotes in the
144-
// description value - point the user at the exact line.
145-
const descriptionLine = getRawFrontmatterLine(fileContent, "description")
146-
if (descriptionLine?.includes('"')) {
170+
// description value. Only hint at that when the parser error is
171+
// located on the description line itself, so a valid quoted
172+
// description plus an unrelated YAML error elsewhere does not
173+
// produce a misleading hint (see issue #859).
174+
const description = getFrontmatterLine(fileContent, "description")
175+
const errorMark = (error as { mark?: { line?: unknown } }).mark
176+
if (
177+
description?.line.includes('"') &&
178+
typeof errorMark?.line === "number" &&
179+
errorMark.line + 1 === description.lineNumber
180+
) {
147181
console.error(
148182
`Hint: the "description" value in ${skillMdPath} contains unescaped double quotes. ` +
149183
"Wrap the value in single quotes (or escape the double quotes) and save.",

src/services/skills/__tests__/SkillsManager.spec.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,20 @@ vi.mock("os", () => ({
6868
homedir: mockHomedir,
6969
}))
7070

71+
// Keep the real gray-matter parser by default, but let individual tests script
72+
// the failure shape (e.g. a non-Error throw) to exercise recordDiagnostic's
73+
// defensive fallbacks (see issue #859). vi.importActual resolves the CJS module
74+
// to a namespace that exposes the parser as `default`, which the module's
75+
// declared `export =` type does not carry, so the namespace is bridged through
76+
// `unknown` once; the double assertion then bridges the mock back to the
77+
// module's declared type.
78+
vi.mock("gray-matter", async () => {
79+
const actual = await vi.importActual("gray-matter")
80+
const realParse = (actual as { default: typeof matter }).default
81+
const parse = Object.assign(vi.fn(realParse), actual) as unknown as typeof matter
82+
return { default: parse }
83+
})
84+
7185
// Mock vscode
7286
vi.mock("vscode", () => ({
7387
workspace: {
@@ -1648,6 +1662,158 @@ Body here.`
16481662
}),
16491663
])
16501664
})
1665+
1666+
it("serializes overlapping scans so an older scan cannot append stale diagnostics", async () => {
1667+
const skillDir = p(globalSkillsDir, "flaky-skill")
1668+
const skillPath = p(skillDir, "SKILL.md")
1669+
const staleContent = `---
1670+
name: flaky-skill
1671+
description: "Broken "quote" frontmatter
1672+
---
1673+
1674+
Body.`
1675+
const goodContent = `---
1676+
name: flaky-skill
1677+
description: Healthy after repair
1678+
---
1679+
1680+
Body.`
1681+
1682+
mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir)
1683+
mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
1684+
mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["flaky-skill"] : []))
1685+
mockStat.mockResolvedValue({ isDirectory: () => true })
1686+
mockFileExists.mockImplementation(async (file: string) => file === skillPath)
1687+
1688+
// The first scan's read is delayed (as when a watcher fires while the
1689+
// file is still being written) and observes malformed content. A second
1690+
// scan started before that read resolves must run afterwards, and its
1691+
// result must win.
1692+
let resolveFirstRead!: (content: string) => void
1693+
const firstRead = new Promise<string>((resolve) => {
1694+
resolveFirstRead = resolve
1695+
})
1696+
let reads = 0
1697+
mockReadFile.mockImplementation(async () => {
1698+
reads += 1
1699+
return reads === 1 ? firstRead : goodContent
1700+
})
1701+
1702+
const first = skillsManager.discoverSkills()
1703+
const second = skillsManager.discoverSkills()
1704+
resolveFirstRead(staleContent)
1705+
await Promise.all([first, second])
1706+
1707+
expect(skillsManager.getSkillsMetadata()).toEqual([
1708+
expect.objectContaining({ name: "flaky-skill", description: "Healthy after repair" }),
1709+
])
1710+
expect(skillsManager.getSkillDiagnostics()).toEqual([])
1711+
})
1712+
1713+
it("does not hint at unescaped quotes when the parse error is on another line", async () => {
1714+
const skillDir = p(globalSkillsDir, "hint-skill")
1715+
const skillPath = p(skillDir, "SKILL.md")
1716+
1717+
mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir)
1718+
mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
1719+
mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["hint-skill"] : []))
1720+
mockStat.mockResolvedValue({ isDirectory: () => true })
1721+
mockFileExists.mockImplementation(async (file: string) => file === skillPath)
1722+
// The description is a valid single-quoted value that itself contains
1723+
// double quotes; the YAML error is on the unclosed name line instead,
1724+
// so the unescaped-quotes hint must not fire.
1725+
mockReadFile.mockResolvedValue(`---
1726+
name: "unclosed
1727+
description: 'Triggers on "TDD" - valid quotes'
1728+
---
1729+
1730+
Body.`)
1731+
1732+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
1733+
1734+
try {
1735+
await skillsManager.discoverSkills()
1736+
1737+
const logged = consoleErrorSpy.mock.calls.map((call) => call.join(" "))
1738+
expect(logged.some((line) => line.includes("unescaped double quotes"))).toBe(false)
1739+
expect(skillsManager.getSkillDiagnostics()).toEqual([
1740+
expect.objectContaining({ path: skillPath, source: "global", line: 4 }),
1741+
])
1742+
} finally {
1743+
consoleErrorSpy.mockRestore()
1744+
}
1745+
})
1746+
1747+
it("keeps reporting the same malformed skill on a re-scan (gray-matter cache must not swallow the error)", async () => {
1748+
const skillDir = p(globalSkillsDir, "cached-bad-skill")
1749+
const skillPath = p(skillDir, "SKILL.md")
1750+
1751+
mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir)
1752+
mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
1753+
mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["cached-bad-skill"] : []))
1754+
mockStat.mockResolvedValue({ isDirectory: () => true })
1755+
mockFileExists.mockImplementation(async (file: string) => file === skillPath)
1756+
mockReadFile.mockResolvedValue(`---
1757+
name: cached-bad-skill
1758+
description: "Broken "quote" description
1759+
---
1760+
1761+
Body.`)
1762+
1763+
await skillsManager.discoverSkills()
1764+
expect(skillsManager.getSkillsMetadata()).toEqual([])
1765+
expect(skillsManager.getSkillDiagnostics()).toHaveLength(1)
1766+
1767+
// A re-scan of the identical (unchanged) content must keep reporting the
1768+
// parse failure. gray-matter keeps a global content-keyed cache that it
1769+
// populates before parsing, so without bypassing it the first throw is
1770+
// cached with an empty data object and every later parse of the same
1771+
// content silently returns that object instead of re-throwing - which
1772+
// would resurface the misleading "missing required 'name' field" symptom
1773+
// from issue #859.
1774+
await skillsManager.discoverSkills()
1775+
expect(skillsManager.getSkillsMetadata()).toEqual([])
1776+
expect(skillsManager.getSkillDiagnostics()).toHaveLength(1)
1777+
expect(skillsManager.getSkillDiagnostics()[0]).toEqual(
1778+
expect.objectContaining({ path: skillPath, source: "global" }),
1779+
)
1780+
})
1781+
1782+
it("records a diagnostic from a non-Error parse failure without location details", async () => {
1783+
const skillDir = p(globalSkillsDir, "raw-error-skill")
1784+
const skillPath = p(skillDir, "SKILL.md")
1785+
1786+
mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir)
1787+
mockRealpath.mockImplementation(async (pathArg: string) => pathArg)
1788+
mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["raw-error-skill"] : []))
1789+
mockStat.mockResolvedValue({ isDirectory: () => true })
1790+
mockFileExists.mockImplementation(async (file: string) => file === skillPath)
1791+
mockReadFile.mockResolvedValue(`---
1792+
name: raw-error-skill
1793+
description: Healthy
1794+
---
1795+
1796+
Body.`)
1797+
// Script a parse failure that is a plain string (no YAML reason, no
1798+
// mark, not an Error) so every defensive fallback in recordDiagnostic
1799+
// is exercised.
1800+
vi.mocked(matter).mockImplementationOnce(() => {
1801+
throw "gray-matter exploded"
1802+
})
1803+
1804+
await skillsManager.discoverSkills()
1805+
1806+
expect(skillsManager.getSkillsMetadata()).toEqual([])
1807+
expect(skillsManager.getSkillDiagnostics()).toEqual([
1808+
{
1809+
path: skillPath,
1810+
source: "global",
1811+
message: "gray-matter exploded",
1812+
line: undefined,
1813+
column: undefined,
1814+
},
1815+
])
1816+
})
16511817
})
16521818

16531819
describe("deleteSkill", () => {

0 commit comments

Comments
 (0)