Skip to content

Commit 1289abd

Browse files
SunnyShu0925claudePeter Steinberger
authored
fix(memory-wiki): gracefully handle unparsable YAML frontmatter in vault scans (#96125) (#97177)
* [AI] fix(memory-wiki): gracefully handle unparsable YAML frontmatter in vault scans (#96125) toWikiPageSummary now catches YAML parse errors during vault-wide scans (compile/lint/query/status) and returns a degraded WikiPageSummary with frontmatterError set instead of letting the error propagate through readPageSummaries -> compileMemoryWikiVault, which previously caused one bad page to crash the entire vault. Edit paths (apply, chatgpt-import) still throw on bad frontmatter to prevent silent metadata loss on write. - markdown.ts: add frontmatterError field to WikiPageSummary, extract splitWikiFrontmatterBlock, catch YAML.parse in toWikiPageSummary - lint.ts: add invalid-frontmatter lint code, skip frontmatter-derived checks when frontmatterError is present - markdown.test.ts: test degraded parse preserves body, empty frontmatter - lint.test.ts: test invalid-frontmatter lint on broken page, healthy page unaffected Related to #96125 Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(memory-wiki): isolate invalid frontmatter scans * fix(memory-wiki): keep malformed lint reports fail-closed * fix(memory-wiki): reject non-mapping frontmatter * fix(memory-wiki): validate generated index targets --------- Co-authored-by: Claude Opus 4.8 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 82871fe commit 1289abd

10 files changed

Lines changed: 588 additions & 88 deletions

File tree

extensions/memory-wiki/src/apply.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,93 @@ describe("applyMemoryWikiMutation", () => {
143143
);
144144
});
145145

146+
it("applies a write when an unrelated vault page has malformed frontmatter (#96125)", async () => {
147+
const { rootDir, config } = await createVault({
148+
prefix: "memory-wiki-apply-unrelated-invalid-",
149+
});
150+
await fs.mkdir(path.join(rootDir, "sources"), { recursive: true });
151+
const brokenPath = path.join(rootDir, "sources", "broken.md");
152+
const brokenPage = [
153+
"---",
154+
"pageType: source",
155+
"id: source.broken",
156+
"sourceIds:",
157+
' - **MEMORY.md line 235**:"some quoted, value"',
158+
"---",
159+
"",
160+
"# Broken",
161+
].join("\n");
162+
await fs.writeFile(brokenPath, brokenPage, "utf8");
163+
164+
const result = await applyMemoryWikiMutation({
165+
config,
166+
mutation: {
167+
op: "create_synthesis",
168+
title: "Healthy Synthesis",
169+
body: "Healthy summary body.",
170+
sourceIds: ["source.healthy"],
171+
},
172+
});
173+
174+
expect(result.changed).toBe(true);
175+
expect(result.compile.pageCounts.source).toBe(0);
176+
expect(result.compile.pageCounts.synthesis).toBe(1);
177+
expect(result.compile.frontmatterErrors).toEqual([
178+
expect.objectContaining({ relativePath: "sources/broken.md" }),
179+
]);
180+
await expect(fs.readFile(brokenPath, "utf8")).resolves.toBe(brokenPage);
181+
});
182+
183+
it.each([
184+
{
185+
name: "syntax-error",
186+
frontmatterLines: [
187+
"pageType: synthesis",
188+
"id: synthesis.conflicted",
189+
"sourceIds:",
190+
' - **MEMORY.md line 235**:"some quoted, value"',
191+
],
192+
error: "Unexpected scalar",
193+
},
194+
{
195+
name: "sequence-root",
196+
frontmatterLines: ["- pageType: synthesis", " id: synthesis.conflicted"],
197+
error: "Wiki frontmatter must be a YAML mapping",
198+
},
199+
])(
200+
"rejects a malformed write target without replacing its metadata ($name) (#96125)",
201+
async ({ frontmatterLines, error }) => {
202+
const { rootDir, config } = await createVault({
203+
prefix: "memory-wiki-apply-invalid-target-",
204+
});
205+
await fs.mkdir(path.join(rootDir, "syntheses"), { recursive: true });
206+
const targetPath = path.join(rootDir, "syntheses", "conflicted.md");
207+
const original = [
208+
"---",
209+
...frontmatterLines,
210+
"---",
211+
"",
212+
"# Conflicted",
213+
"",
214+
"Keep this body.",
215+
].join("\n");
216+
await fs.writeFile(targetPath, original, "utf8");
217+
218+
await expect(
219+
applyMemoryWikiMutation({
220+
config,
221+
mutation: {
222+
op: "create_synthesis",
223+
title: "Conflicted",
224+
body: "Replacement body.",
225+
sourceIds: ["source.new"],
226+
},
227+
}),
228+
).rejects.toThrow(error);
229+
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe(original);
230+
},
231+
);
232+
146233
it("updates page metadata without overwriting existing human notes", async () => {
147234
const { rootDir, config } = await createVault({
148235
prefix: "memory-wiki-apply-",

extensions/memory-wiki/src/compile.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,115 @@ describe("compileMemoryWikiVault", () => {
121121
).resolves.toContain('"text":"Alpha is the canonical source page."');
122122
});
123123

124+
it("excludes malformed pages from indexes, digests, counts, and page writes (#96125)", async () => {
125+
const { rootDir, config } = await createVault({
126+
rootDir: nextCaseRoot(),
127+
initialize: true,
128+
config: { render: { createDashboards: false } },
129+
});
130+
const brokenPath = path.join(rootDir, "syntheses", "broken.md");
131+
const brokenPage = [
132+
"---",
133+
"pageType: synthesis",
134+
"id: synthesis.broken",
135+
"sourceIds:",
136+
' - **MEMORY.md line 235**:"some quoted, value"',
137+
"---",
138+
"",
139+
"# Broken",
140+
"",
141+
"Body that compile must not rewrite.",
142+
].join("\n");
143+
await fs.writeFile(brokenPath, brokenPage, "utf8");
144+
await fs.writeFile(
145+
path.join(rootDir, "syntheses", "healthy.md"),
146+
renderWikiMarkdown({
147+
frontmatter: {
148+
pageType: "synthesis",
149+
id: "synthesis.healthy",
150+
title: "Healthy",
151+
sourceIds: ["source.alpha"],
152+
},
153+
body: "# Healthy\n",
154+
}),
155+
"utf8",
156+
);
157+
158+
const result = await compileMemoryWikiVault(config);
159+
160+
expect(result.frontmatterErrors).toHaveLength(1);
161+
expect(result.frontmatterErrors[0]).toMatchObject({
162+
relativePath: "syntheses/broken.md",
163+
});
164+
expect(result.pageCounts.synthesis).toBe(1);
165+
expect(result.pages.map((page) => page.relativePath)).not.toContain("syntheses/broken.md");
166+
await expect(fs.readFile(brokenPath, "utf8")).resolves.toBe(brokenPage);
167+
await expect(fs.readFile(path.join(rootDir, "index.md"), "utf8")).resolves.not.toContain(
168+
"Broken",
169+
);
170+
await expect(
171+
fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), "utf8"),
172+
).resolves.not.toContain("syntheses/broken.md");
173+
});
174+
175+
it.each([
176+
{
177+
name: "root index with syntax-error frontmatter",
178+
relativePath: "index.md",
179+
frontmatterLines: [
180+
"pageType: report",
181+
"sourceIds:",
182+
' - **MEMORY.md line 235**:"some quoted, value"',
183+
],
184+
error: "Unexpected scalar",
185+
},
186+
{
187+
name: "root index with sequence-root frontmatter",
188+
relativePath: "index.md",
189+
frontmatterLines: ["- pageType: report"],
190+
error: "Wiki frontmatter must be a YAML mapping",
191+
},
192+
{
193+
name: "directory index with syntax-error frontmatter",
194+
relativePath: "sources/index.md",
195+
frontmatterLines: [
196+
"pageType: report",
197+
"sourceIds:",
198+
' - **MEMORY.md line 235**:"some quoted, value"',
199+
],
200+
error: "Unexpected scalar",
201+
},
202+
{
203+
name: "directory index with scalar-root frontmatter",
204+
relativePath: "sources/index.md",
205+
frontmatterLines: ["report"],
206+
error: "Wiki frontmatter must be a YAML mapping",
207+
},
208+
])(
209+
"rejects $name without changing its bytes",
210+
async ({ relativePath, frontmatterLines, error }) => {
211+
const { rootDir, config } = await createVault({
212+
rootDir: nextCaseRoot(),
213+
initialize: true,
214+
config: { render: { createDashboards: false } },
215+
});
216+
const targetPath = path.join(rootDir, relativePath);
217+
const original = [
218+
"---",
219+
...frontmatterLines,
220+
"---",
221+
"",
222+
"# Existing Index",
223+
"",
224+
"Keep this body.",
225+
].join("\n");
226+
await fs.writeFile(targetPath, original, "utf8");
227+
228+
await expect(compileMemoryWikiVault(config)).rejects.toThrow(error);
229+
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe(original);
230+
},
231+
);
232+
124233
it("discovers pages in nested subdirectories during compile", async () => {
125234
const { rootDir, config } = await createVault({
126235
rootDir: nextCaseRoot(),

extensions/memory-wiki/src/compile.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@ import {
3434
isUnmanagedRawSourceSummary,
3535
parseWikiMarkdown,
3636
renderWikiMarkdown,
37-
toWikiPageSummary,
37+
scanWikiPageSummary,
3838
type WikiClaim,
3939
type WikiClaimEvidence,
40+
type WikiPageFrontmatterError,
4041
type WikiPageKind,
4142
type WikiPageSummary,
4243
type WikiRelationship,
@@ -352,6 +353,7 @@ export type CompileMemoryWikiResult = {
352353
vaultRoot: string;
353354
pageCounts: Record<WikiPageKind, number>;
354355
pages: WikiPageSummary[];
356+
frontmatterErrors: WikiPageFrontmatterError[];
355357
claimCount: number;
356358
updatedFiles: string[];
357359
};
@@ -377,7 +379,10 @@ async function collectMarkdownFiles(rootDir: string, relativeDir: string): Promi
377379
.toSorted((left, right) => left.localeCompare(right));
378380
}
379381

380-
async function readPageSummaries(rootDir: string): Promise<WikiPageSummary[]> {
382+
async function readPageSummaries(rootDir: string): Promise<{
383+
pages: WikiPageSummary[];
384+
frontmatterErrors: WikiPageFrontmatterError[];
385+
}> {
381386
const filePaths = (
382387
await Promise.all(COMPILE_PAGE_GROUPS.map((group) => collectMarkdownFiles(rootDir, group.dir)))
383388
).flat();
@@ -389,7 +394,7 @@ async function readPageSummaries(rootDir: string): Promise<WikiPageSummary[]> {
389394
() => fs.readFile(absolutePath, "utf8"),
390395
`read wiki page ${absolutePath}`,
391396
);
392-
return toWikiPageSummary({ absolutePath, relativePath, raw });
397+
return scanWikiPageSummary({ absolutePath, relativePath, raw });
393398
}),
394399
limit: READ_PAGE_SUMMARIES_CONCURRENCY,
395400
errorMode: "stop",
@@ -398,9 +403,14 @@ async function readPageSummaries(rootDir: string): Promise<WikiPageSummary[]> {
398403
throw readResult.firstError;
399404
}
400405

401-
return readResult.results
402-
.flatMap((page) => (page ? [page] : []))
403-
.toSorted((left, right) => left.title.localeCompare(right.title));
406+
return {
407+
pages: readResult.results
408+
.flatMap((result) => (result.status === "valid" ? [result.page] : []))
409+
.toSorted((left, right) => left.title.localeCompare(right.title)),
410+
frontmatterErrors: readResult.results.flatMap((result) =>
411+
result.status === "invalid-frontmatter" ? [result.error] : [],
412+
),
413+
};
404414
}
405415

406416
function buildPageCounts(pages: WikiPageSummary[]): Record<WikiPageKind, number> {
@@ -910,6 +920,9 @@ async function writeManagedMarkdownFile(params: {
910920
}): Promise<boolean> {
911921
const root = await fsRoot(params.rootDir);
912922
const original = await root.readText(params.relativePath).catch(() => `# ${params.title}\n`);
923+
// Generated indexes bypass page discovery. Parse existing content here so
924+
// managed-block updates cannot rewrite malformed frontmatter.
925+
parseWikiMarkdown(original);
913926
const updated = replaceManagedMarkdownBlock({
914927
original,
915928
heading: "## Generated",
@@ -1380,10 +1393,12 @@ export async function compileMemoryWikiVault(
13801393
const managedImportedSourcePagePaths = new Set(
13811394
Object.values(sourceSyncState.entries).map((entry) => entry.pagePath.split(path.sep).join("/")),
13821395
);
1383-
let pages = await readPageSummaries(rootDir);
1396+
let scan = await readPageSummaries(rootDir);
1397+
let pages = scan.pages;
13841398
const updatedFiles = await refreshPageRelatedBlocks({ config, pages });
13851399
if (updatedFiles.length > 0) {
1386-
pages = await readPageSummaries(rootDir);
1400+
scan = await readPageSummaries(rootDir);
1401+
pages = scan.pages;
13871402
}
13881403
const dashboardUpdatedFiles = await refreshDashboardPages({
13891404
config,
@@ -1393,7 +1408,8 @@ export async function compileMemoryWikiVault(
13931408
});
13941409
updatedFiles.push(...dashboardUpdatedFiles);
13951410
if (dashboardUpdatedFiles.length > 0) {
1396-
pages = await readPageSummaries(rootDir);
1411+
scan = await readPageSummaries(rootDir);
1412+
pages = scan.pages;
13971413
}
13981414
const counts = buildPageCounts(pages);
13991415
const digestUpdatedFiles = await writeAgentDigestArtifacts({
@@ -1449,6 +1465,7 @@ export async function compileMemoryWikiVault(
14491465
vaultRoot: rootDir,
14501466
pageCounts: counts,
14511467
pages,
1468+
frontmatterErrors: scan.frontmatterErrors,
14521469
claimCount: pages.reduce((total, page) => total + page.claims.length, 0),
14531470
updatedFiles,
14541471
};

0 commit comments

Comments
 (0)