Skip to content

Commit f330eb3

Browse files
dmealingclaude
andcommitted
test(codegen-ts): RED — package-layout template-source field links point at wrong dir
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7aa6a6c commit f330eb3

6 files changed

Lines changed: 244 additions & 5 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{
2+
"metadata.root": {
3+
"children": [
4+
{
5+
"object.value": {
6+
"name": "Customer",
7+
"package": "acme::shop",
8+
"children": [
9+
{ "field.string": { "name": "name" } },
10+
{ "field.string": { "name": "email" } }
11+
]
12+
}
13+
},
14+
{
15+
"object.value": {
16+
"name": "Order",
17+
"package": "acme::shop",
18+
"children": [
19+
{ "field.string": { "name": "ref" } },
20+
{ "field.string": { "name": "total" } },
21+
{
22+
"field.object": {
23+
"name": "customer",
24+
"@objectRef": "Customer"
25+
}
26+
}
27+
]
28+
}
29+
},
30+
{
31+
"template.output": {
32+
"name": "OrderPage",
33+
"package": "acme::comms",
34+
"@kind": "document",
35+
"@payloadRef": "Order",
36+
"@textRef": "site/order",
37+
"@format": "html"
38+
}
39+
},
40+
{
41+
"template.output": {
42+
"name": "OrderEmail",
43+
"package": "acme::comms",
44+
"@kind": "email",
45+
"@payloadRef": "Order",
46+
"@subjectRef": "email/order.subject",
47+
"@htmlBodyRef": "email/order.html",
48+
"@textBodyRef": "email/order.text"
49+
}
50+
}
51+
]
52+
}
53+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<h1>Order {{ref}}</h1>
2+
<p>Total: {{total}}</p>
3+
{{#customer}}<p>Thanks, {{name}}!</p>{{/customer}}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Your order {{ref}}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Order {{ref}}
2+
Total: {{total}}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<h1>Order {{ref}}</h1>
2+
<p>Total: {{total}}</p>
3+
{{#customer}}<p>Placed by {{name}} ({{email}}).</p>{{/customer}}

server/typescript/packages/codegen-ts/test/golden/template-source-conformance.test.ts

Lines changed: 182 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,21 @@
2828
import { describe, expect, it } from "bun:test";
2929
import { readdirSync, readFileSync } from "node:fs";
3030
import { join, resolve } from "node:path";
31+
import { dirname, posix as posixPath } from "node:path";
3132
import { InMemoryStringSource, MetaDataLoader } from "@metaobjectsdev/metadata";
3233
import {
3334
ERR_VAR_NOT_ON_PAYLOAD,
3435
type PayloadField,
3536
verify,
3637
} from "@metaobjectsdev/render";
3738
import type { GenContext } from "../../src/generator.js";
39+
import {
40+
docPageHref,
41+
docPageNode,
42+
docPageOutputPath,
43+
} from "../../src/docs-paths.js";
3844
import { docsFile } from "../../src/generators/docs-file.js";
45+
import type { OutputLayout } from "../../src/import-path.js";
3946
import { buildEnrichedPayloadTree } from "../../src/generators/template-payload-tree.js";
4047
import {
4148
type AnnotatePayloadField,
@@ -52,15 +59,25 @@ const CORPUS = resolve(
5259
"../../../../../../fixtures/conformance",
5360
);
5461
const FIXTURE = "template-source-conformance";
62+
// Multi-package mirror of FIXTURE: the payload VOs live in `acme::shop`, the
63+
// two template.output nodes in `acme::comms`. Under package layout the doc
64+
// pages are written under their package dirs, so a template page links a field
65+
// on a VO in a DIFFERENT directory — the cross-package href case.
66+
const FIXTURE_PACKAGE = "template-source-conformance-package";
5567

5668
interface Emitted {
5769
path: string;
5870
content: string;
5971
}
6072

73+
// `outputLayout` defaults to "flat" so the existing flat cases are unchanged.
74+
// We thread it the SAME way the real `meta docs` command does (cli/docs.ts):
75+
// onto `config.outputLayout` (where the generator reads placement from) AND
76+
// into `makeRenderContext` — so this test exercises the exact production path.
6177
function makeCtx(
6278
root: Awaited<ReturnType<MetaDataLoader["load"]>>["root"],
6379
projectRoot: string,
80+
outputLayout: OutputLayout = "flat",
6481
): GenContext {
6582
const renderContext = makeRenderContext({
6683
dialect: "sqlite",
@@ -69,6 +86,7 @@ function makeCtx(
6986
dbImport: "~/db",
7087
pkMap: buildPkMap(root),
7188
relationMap: buildRelationMap(root),
89+
outputLayout,
7290
});
7391
return {
7492
entities: root.objects(),
@@ -79,15 +97,16 @@ function makeCtx(
7997
extStyle: "none",
8098
dbImport: "~/db",
8199
dialect: "sqlite",
100+
outputLayout,
82101
} as never,
83102
renderContext,
84103
warn: () => {},
85104
projectRoot,
86105
};
87106
}
88107

89-
async function loadFixture() {
90-
const inputDir = join(CORPUS, FIXTURE, "input");
108+
async function loadFixture(fixture: string = FIXTURE) {
109+
const inputDir = join(CORPUS, fixture, "input");
91110
const inputFiles = readdirSync(inputDir).filter((f) => f.endsWith(".json"));
92111
const sources = inputFiles.map(
93112
(f) =>
@@ -101,9 +120,14 @@ async function loadFixture() {
101120
return { root: res.root, inputDir };
102121
}
103122

104-
async function emit(): Promise<Emitted[]> {
105-
const { root, inputDir } = await loadFixture();
106-
return (await docsFile().generate(makeCtx(root, inputDir))) as Emitted[];
123+
async function emit(
124+
fixture: string = FIXTURE,
125+
outputLayout: OutputLayout = "flat",
126+
): Promise<Emitted[]> {
127+
const { root, inputDir } = await loadFixture(fixture);
128+
return (await docsFile().generate(
129+
makeCtx(root, inputDir, outputLayout),
130+
)) as Emitted[];
107131
}
108132

109133
// ── Link parsing (precise — no false-accept) ─────────────────────────────────
@@ -404,3 +428,156 @@ describe("teeth — the integrity check fails on real drift", () => {
404428
expect([...unlinked].sort()).toEqual([...flagged].sort());
405429
});
406430
});
431+
432+
// ── 4. Package layout — field links resolve cross-package ─────────────────────
433+
//
434+
// Under `outputLayout: "package"` the doc pages are written under their package
435+
// dirs (`acme/shop/Order.md`, `acme/comms/OrderPage.md`). A template page in
436+
// `acme/comms/` linking a field on a VO in `acme/shop/` MUST emit a relative
437+
// href that, resolved against the template page's OWN output path, lands on the
438+
// VO's REAL emitted page (`../shop/Order.md#field-ref`) — NOT a bare
439+
// `./Order.md` (which from `acme/comms/` points at the wrong directory). The
440+
// sibling "Payload" link already does this via docPageHref; the per-`{{var}}`
441+
// field links must match. `docPageOutputPath`/`docPageHref` are the source of
442+
// truth for expected placement.
443+
444+
// Parse field-doc links allowing BOTH `./` and `../` relative forms (package
445+
// layout yields `../shop/Order.md#field-…`). Captures the FULL relative href
446+
// (path + fragment) plus the page filename + anchor, on both surfaces.
447+
const PKG_MD_LINK =
448+
/\[[^\]]*\]\((\.\.?\/[^)#]*?([^/)#]+\.md)#(field-[A-Za-z0-9_]+))\)/g;
449+
const PKG_HREF_LINK =
450+
/href="(\.\.?\/[^"#]*?([^/"#]+\.md)#(field-[A-Za-z0-9_]+))"/g;
451+
452+
interface PkgFieldLink {
453+
href: string; // full relative href incl. fragment, e.g. ../shop/Order.md#field-ref
454+
page: string; // target page filename, e.g. Order.md
455+
anchor: string; // e.g. field-ref
456+
surface: "md" | "href";
457+
}
458+
459+
function parsePkgFieldLinks(content: string): PkgFieldLink[] {
460+
const out: PkgFieldLink[] = [];
461+
for (const m of content.matchAll(PKG_MD_LINK)) {
462+
out.push({ href: m[1]!, page: m[2]!, anchor: m[3]!, surface: "md" });
463+
}
464+
for (const m of content.matchAll(PKG_HREF_LINK)) {
465+
out.push({ href: m[1]!, page: m[2]!, anchor: m[3]!, surface: "href" });
466+
}
467+
return out;
468+
}
469+
470+
describe("package layout — field links resolve cross-package", () => {
471+
it("every {{var}} field link on a template page resolves to the field-owner's REAL package-layout page", async () => {
472+
const layout: OutputLayout = "package";
473+
const { root, inputDir } = await loadFixture(FIXTURE_PACKAGE);
474+
const files = (await docsFile().generate(
475+
makeCtx(root, inputDir, layout),
476+
)) as Emitted[];
477+
const emittedPaths = new Set(files.map((f) => f.path));
478+
479+
// Sanity: pages ARE folded under their package dirs.
480+
for (const p of [
481+
"acme/shop/Order.md",
482+
"acme/shop/Customer.md",
483+
"acme/comms/OrderPage.md",
484+
"acme/comms/OrderEmail.md",
485+
]) {
486+
expect(emittedPaths.has(p), `missing package-layout page ${p}`).toBe(
487+
true,
488+
);
489+
}
490+
491+
// Index: page output path → set of field anchors it emits.
492+
const anchorByPath = new Map<string, Set<string>>();
493+
const ID = /id="(field-[A-Za-z0-9_]+)"/g;
494+
for (const f of files) {
495+
const set = new Set<string>();
496+
for (const m of f.content.matchAll(ID)) set.add(m[1]!);
497+
anchorByPath.set(f.path, set);
498+
}
499+
500+
const templatePages = files.filter((f) =>
501+
f.path.startsWith("acme/comms/"),
502+
);
503+
expect(templatePages.length, "no template pages emitted").toBe(2);
504+
505+
let checked = 0;
506+
for (const page of templatePages) {
507+
// The template node that produced this page (carries its package, so its
508+
// placement folds under `acme/comms/`). Looked up off the root's own
509+
// children (templates aren't `findObject`-able).
510+
const templateName = page.path.split("/").pop()!.replace(/\.md$/, "");
511+
const templateNode = root
512+
.ownChildren()
513+
.find((c) => c.name === templateName);
514+
expect(
515+
templateNode,
516+
`could not find template node for ${page.path}`,
517+
).toBeDefined();
518+
const fromNode = docPageNode(templateNode!);
519+
// The from-node MUST place under the template's own dir.
520+
expect(docPageOutputPath(layout, fromNode)).toBe(page.path);
521+
522+
const links = parsePkgFieldLinks(page.content);
523+
// Each template page must carry field links on BOTH surfaces.
524+
expect(
525+
links.length,
526+
`${page.path}: no field links parsed (page content may use a bare ./<Owner>.md href the package-aware regex rejects — that IS the bug)`,
527+
).toBeGreaterThan(0);
528+
529+
for (const link of links) {
530+
// The owner VO is the target page's short name (Order.md → Order).
531+
const ownerName = link.page.replace(/\.md$/, "");
532+
const ownerObj = root.findObject(ownerName);
533+
expect(
534+
ownerObj,
535+
`${page.path}: link target ${link.page} is not a known VO`,
536+
).toBeDefined();
537+
538+
// Source of truth for WHERE the owner page is + WHAT href should point
539+
// at it from this template page (`fromNode` computed above).
540+
const toNode = docPageNode(ownerObj!);
541+
const expectedOwnerPath = docPageOutputPath(layout, toNode);
542+
const expectedHref = `${docPageHref(layout, fromNode, toNode)}#${link.anchor}`;
543+
544+
// (a) The href must NOT be a bare `./<Owner>.md…` — under package layout
545+
// it must carry the owner's package dir relative to the template page.
546+
expect(
547+
link.href.startsWith(`./${link.page}#`),
548+
`${page.path}: field link ${link.href} is a bare ./${link.page} — ignores package layout (owner page is ${expectedOwnerPath})`,
549+
).toBe(false);
550+
551+
// (b) The href must equal what docPageHref derives (the same helper the
552+
// Payload link already uses) for this from→to pair.
553+
expect(
554+
link.href,
555+
`${page.path}: field link ${link.href} != expected ${expectedHref}`,
556+
).toBe(expectedHref);
557+
558+
// (c) Resolving the href against the template page's own dir must land
559+
// on the owner's ACTUAL emitted page path.
560+
const resolved = posixPath.normalize(
561+
posixPath.join(dirname(page.path), link.href.split("#")[0]!),
562+
);
563+
expect(
564+
resolved,
565+
`${page.path}: field link ${link.href} resolves to ${resolved}, not the emitted owner page ${expectedOwnerPath}`,
566+
).toBe(expectedOwnerPath);
567+
568+
// (d) That resolved page is in the emitted set AND carries the anchor.
569+
expect(
570+
emittedPaths.has(resolved),
571+
`${page.path}: resolved target ${resolved} is not an emitted page`,
572+
).toBe(true);
573+
expect(
574+
anchorByPath.get(resolved)?.has(link.anchor),
575+
`${page.path}: ${resolved} is missing anchor ${link.anchor}`,
576+
).toBe(true);
577+
578+
checked++;
579+
}
580+
}
581+
expect(checked, "no cross-package field links checked").toBeGreaterThan(0);
582+
});
583+
});

0 commit comments

Comments
 (0)