Skip to content

Commit 6e7f47f

Browse files
dmealingclaude
andauthored
fix(docs-site): load files before subdirs so cross-file overlays resolve (#176)
The site loader (loadModel) fed metadata to the parser via fromDirectory, whose DirectorySource sorts by basename (a cross-port ordering contract). When a base object lives in a top-level file and an `overlay: true` extension lives in a subdir whose basename sorts earlier, the overlay parsed before its base and the load failed with ERR_OVERLAY_NO_TARGET — even though the sdk's loadMemory (and so meta gen/migrate) loads the same model fine, because loadMemory collects files-before-subdirs. loadModel now collects files in that same files-before-subdirs, per-level-sorted order and feeds them to MetaDataLoader.load directly, leaving the cross-port DirectorySource basename order untouched. No treeOf change (source ids are basenames, so it already falls back to sourceDirs[0]). Surfaced by dogfooding a real model whose admin-UI presentation overlays live in a nested dir: it now renders end-to-end (0 dangling). The acme golden is byte-identical; a new overlay-order test reproduces the base-in-file / overlay-in-subdir case (ERR_OVERLAY_NO_TARGET before the fix). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d3175d3 commit 6e7f47f

2 files changed

Lines changed: 100 additions & 2 deletions

File tree

server/typescript/packages/docs-site/src/load.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
1+
import { mkdtempSync, readdirSync, rmSync, statSync, symlinkSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { basename, join, resolve } from "node:path";
44
import { MetaDataLoader, composeRegistry, coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider } from "@metaobjectsdev/metadata";
55
import type { MetaData, MetaRoot, MetaDataTypeProvider } from "@metaobjectsdev/metadata";
6+
import { FileSource } from "@metaobjectsdev/metadata/core";
67

78
export interface LoadedModel {
89
root: MetaRoot;
@@ -35,7 +36,17 @@ export async function loadModel(
3536
symlinkSync(resolve(dir), join(staging, baseName));
3637
}
3738
const registry = composeRegistry([coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider, ...extraProviders]);
38-
const result = await MetaDataLoader.fromDirectory(staging, { registry, strict: false });
39+
// Feed files in files-before-subdirs order (the same order the sdk's loadMemory
40+
// uses), NOT fromDirectory's flat basename sort. Cross-file overlays require the
41+
// base (typically a top-level file) to load before an overlay that lives in a
42+
// nested dir; the basename sort can otherwise process e.g.
43+
// `admin-ui/x.admin.yaml` before its base `x.yaml` and fail with
44+
// ERR_OVERLAY_NO_TARGET. (fromDirectory's basename order is a cross-port
45+
// DirectorySource contract, so we order at this boundary rather than change it.)
46+
const files = collectOrderedMetadataFiles(staging);
47+
const result = await new MetaDataLoader({ registry, strict: false }).load(
48+
files.map((f) => new FileSource(f)),
49+
);
3950
if (result.errors.length > 0) {
4051
throw new Error(`metadata load failed:\n${result.errors.map((e) => String(e)).join("\n")}`);
4152
}
@@ -49,6 +60,24 @@ export async function loadModel(
4960
}
5061
}
5162

63+
/** Metadata files under `dir`, files-before-subdirs with each level sorted — the
64+
* overlay-safe order the sdk's loadMemory uses, so a base loads before an overlay
65+
* nested under it. Symlinks (the staging dir uses them) are followed. */
66+
function collectOrderedMetadataFiles(dir: string): string[] {
67+
const files: string[] = [];
68+
const subdirs: string[] = [];
69+
for (const entry of readdirSync(dir)) {
70+
const full = join(dir, entry);
71+
const s = statSync(full); // follows symlinks — staging entries are symlinked source dirs
72+
if (s.isDirectory()) subdirs.push(full);
73+
else if (s.isFile() && /\.(json|ya?ml)$/i.test(entry)) files.push(full);
74+
}
75+
files.sort();
76+
const out = [...files];
77+
for (const sub of subdirs.sort()) out.push(...collectOrderedMetadataFiles(sub));
78+
return out;
79+
}
80+
5281
/** Which top-level source dir a node came from (first file path segment of its source envelope). */
5382
export function treeOf(node: MetaData, model: LoadedModel): string {
5483
const src = node.source as { files?: string[] };
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { expect, test } from "bun:test";
2+
import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { generateSite } from "../src/site";
6+
import { loadModel } from "../src/load";
7+
8+
// A base object in a TOP-LEVEL file + an `overlay: true` extension in a SUBDIR
9+
// whose basename ("aaa-overlay.yaml") sorts BEFORE the base ("base.yaml"). Under
10+
// fromDirectory's flat basename sort the overlay loads first → ERR_OVERLAY_NO_TARGET;
11+
// the files-before-subdirs order loads the base first so the overlay merges.
12+
function overlayFixture(): string {
13+
const root = mkdtempSync(join(tmpdir(), "docs-overlay-"));
14+
const src = join(root, "acme");
15+
mkdirSync(join(src, "z-sub"), { recursive: true });
16+
writeFileSync(
17+
join(src, "base.yaml"),
18+
[
19+
"metadata:",
20+
" package: acme",
21+
" children:",
22+
" - object.value:",
23+
" name: Widget",
24+
" children:",
25+
" - field.string: { name: id }",
26+
"",
27+
].join("\n"),
28+
);
29+
writeFileSync(
30+
join(src, "z-sub", "aaa-overlay.yaml"),
31+
[
32+
"metadata:",
33+
" package: acme",
34+
" children:",
35+
" - object.value:",
36+
" name: Widget",
37+
" overlay: true",
38+
" children:",
39+
" - field.string: { name: extra }",
40+
"",
41+
].join("\n"),
42+
);
43+
return src;
44+
}
45+
46+
test("cross-file overlay: base (top-level) loads before an overlay nested in a subdir", async () => {
47+
const dir = overlayFixture();
48+
const model = await loadModel([dir]);
49+
const widget = model.root.objects().find((o) => o.name === "Widget");
50+
expect(widget).toBeDefined();
51+
const fieldNames = widget!.childrenOfType("field").map((f) => f.name);
52+
// base field preserved AND the subdir overlay merged its field in
53+
expect(fieldNames).toContain("id");
54+
expect(fieldNames).toContain("extra");
55+
});
56+
57+
test("generateSite succeeds on a model with a subdir overlay", async () => {
58+
const dir = overlayFixture();
59+
const out = mkdtempSync(join(tmpdir(), "docs-overlay-out-"));
60+
const r = await generateSite({
61+
sourceDirs: [dir],
62+
outDir: out,
63+
title: "Fixture",
64+
stamp: "2026-01-01",
65+
commit: "abc1234",
66+
});
67+
expect(existsSync(join(out, "index.html"))).toBe(true);
68+
expect(r.dangling).toEqual([]);
69+
});

0 commit comments

Comments
 (0)