Skip to content

Commit 337b0bd

Browse files
committed
Merge FR-004 Java Layer 2: metaobjects-render module (full prompt-construction pillar complete)
New module under server/java/render/ — Mustache-driven render engine + Verify: - Provider interface + 3 impls (InMemoryProvider, FilesystemProvider with path-traversal guard, ClasspathResourceProvider) - Format-keyed Escapers: text/html/xml/csv/json/markdown/spreadsheet, with OWASP CSV cell-injection guard (=,+,-,@,tab,CR prefix → '\'') - Renderer pipeline: partial pre-expansion via regex (cycle-guarded, MAX_DEPTH=32) → Mustache.java compile → per-variable escape via DefaultMustacheFactory.encode() hook (matches C#/TS — final-output escape would corrupt CSV literals) - Verify: 1:1 port of C# Verify.cs — token-walking drift detection over template body + payload field tree, recurses through partials, checks required slots + required output tags Within-Java snapshot test gates stability (RenderSnapshotTest, 4 fixtures). Cross-port comparison runs report-only (RenderCrossPortReportTest) and prints ZERO drift — Java output is byte-identical to TS baseline for all 4 fixtures in fixtures/render-conformance/ (render-csv-injection, render-example-email, render-example-prompt, render-example-spreadsheet). 48 new tests in metaobjects-render module. Full Java reactor BUILD SUCCESS. Mustache library reuses Spullara mustache.java:0.9.14 (already in reactor via codegen-mustache — no second lib). **FR-004 Java port is complete** — both Layer 1 (template.* metatype) and Layer 2 (render engine + verify) shipped. Java now has the prompt-construction pillar parity with TS + C#. Unblocks the planned metadata-ktx Kotlin facade.
2 parents 2743207 + 20afb74 commit 337b0bd

29 files changed

Lines changed: 1528 additions & 0 deletions

server/java/pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
<module>metadata</module>
3737
<module>codegen-base</module>
3838
<module>codegen-mustache</module>
39+
<module>render</module>
3940
<module>codegen-plantuml</module>
4041
<module>maven-plugin</module>
4142
<module>core-spring</module>

server/java/render/KNOWN_DRIFT.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Known cross-port render drift (Java vs TS/C#)
2+
3+
This file documents intentional or known whitespace/escaping drift between
4+
Java's render output and the TS-baseline `fixtures/render-conformance/` corpus.
5+
6+
**Within-Java stability** is the build gate (see `RenderSnapshotTest`); this
7+
file tracks where Java *intentionally* diverges from TS so we don't get
8+
surprised when reading `RenderCrossPortReportTest` output.
9+
10+
| Fixture | Drift type | Notes |
11+
|---|---|---|
12+
| (none) || Java's render output is byte-identical to the TS baseline for all 4 fixtures currently in the corpus (render-csv-injection, render-example-email, render-example-prompt, render-example-spreadsheet). |
13+
14+
When you find a drift in `RenderCrossPortReportTest` output:
15+
1. Decide if it's worth fixing (most aren't — see FR-004 Java spec §6.4).
16+
2. If documenting: add a row above with fixture name + diff summary.
17+
3. If fixing: adjust `Renderer` / `Escapers` and verify both snapshot test AND
18+
cross-port report come into agreement.

server/java/render/pom.xml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
6+
<parent>
7+
<groupId>com.metaobjects</groupId>
8+
<artifactId>metaobjects</artifactId>
9+
<version>7.0.0-SNAPSHOT</version>
10+
</parent>
11+
12+
<artifactId>metaobjects-render</artifactId>
13+
<packaging>bundle</packaging>
14+
15+
<name>MetaObjects :: Render</name>
16+
<description>Mustache-driven render engine + verify for the template.* metatype (FR-004).</description>
17+
18+
<dependencies>
19+
<!-- Mustache Template Engine -->
20+
<dependency>
21+
<groupId>com.github.spullara.mustache.java</groupId>
22+
<artifactId>compiler</artifactId>
23+
<version>0.9.14</version>
24+
</dependency>
25+
26+
<!-- Test Dependencies -->
27+
<dependency>
28+
<groupId>com.fasterxml.jackson.core</groupId>
29+
<artifactId>jackson-databind</artifactId>
30+
<scope>test</scope>
31+
</dependency>
32+
33+
<!-- Inherited from parent POM: SLF4J, JUnit, Logback.
34+
No metaobjects-metadata dependency — Verify is zero-core-dependency by design
35+
(callers pass a plain PayloadField tree). -->
36+
</dependencies>
37+
38+
<build>
39+
<plugins>
40+
<!-- Maven Bundle Plugin for OSGi -->
41+
<plugin>
42+
<groupId>org.apache.felix</groupId>
43+
<artifactId>maven-bundle-plugin</artifactId>
44+
<extensions>true</extensions>
45+
<configuration>
46+
<instructions>
47+
<Bundle-SymbolicName>${project.artifactId}</Bundle-SymbolicName>
48+
<Bundle-Version>${project.version}</Bundle-Version>
49+
<Export-Package>
50+
com.metaobjects.render.*
51+
</Export-Package>
52+
<Import-Package>
53+
com.github.mustachejava.*,
54+
*
55+
</Import-Package>
56+
</instructions>
57+
</configuration>
58+
</plugin>
59+
</plugins>
60+
</build>
61+
</project>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.metaobjects.render;
2+
3+
import java.io.IOException;
4+
import java.io.InputStream;
5+
import java.nio.charset.StandardCharsets;
6+
import java.util.Objects;
7+
8+
/**
9+
* Resolves template references from classpath resources, e.g.
10+
* {@code new ClasspathResourceProvider(cl, "prompts/").resolve("lobby/welcome")}
11+
* reads {@code classpath:prompts/lobby/welcome.mustache}.
12+
*
13+
* <p>Path traversal ({@code ..}) is rejected by returning {@code null}.
14+
*/
15+
public final class ClasspathResourceProvider implements Provider {
16+
17+
private final ClassLoader classLoader;
18+
private final String basePrefix;
19+
private final String extension;
20+
21+
public ClasspathResourceProvider(ClassLoader classLoader, String basePrefix) {
22+
this(classLoader, basePrefix, ".mustache");
23+
}
24+
25+
public ClasspathResourceProvider(ClassLoader classLoader, String basePrefix, String extension) {
26+
this.classLoader = Objects.requireNonNull(classLoader, "classLoader");
27+
this.basePrefix = basePrefix == null ? "" : basePrefix;
28+
this.extension = Objects.requireNonNull(extension, "extension");
29+
}
30+
31+
@Override
32+
public String resolve(String reference) {
33+
if (reference == null || reference.isEmpty()) return null;
34+
if (reference.contains("..")) return null;
35+
36+
String resourcePath = basePrefix + reference + extension;
37+
try (InputStream in = classLoader.getResourceAsStream(resourcePath)) {
38+
if (in == null) return null;
39+
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
40+
} catch (IOException e) {
41+
return null;
42+
}
43+
}
44+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.metaobjects.render;
2+
3+
import java.util.Map;
4+
import java.util.function.UnaryOperator;
5+
6+
/**
7+
* Format-keyed escaper registry for the render engine (FR-004).
8+
*
9+
* <p>Tier-1 invariant: escaping behavior per format is character-by-character
10+
* identical to TS ({@code server/typescript/packages/render/src/escapers.ts})
11+
* and C# ({@code server/csharp/MetaObjects.Render/Escapers.cs}).
12+
*
13+
* <p>{@code {{var}}} substitutions in a Mustache template are escaped per
14+
* format; {@code {{{var}}}} bypasses escaping (Mustache's "triple-stache"
15+
* raw form is preserved by the {@link Renderer}).
16+
*/
17+
public final class Escapers {
18+
19+
private Escapers() {}
20+
21+
public static final String FORMAT_TEXT = "text";
22+
public static final String FORMAT_HTML = "html";
23+
public static final String FORMAT_XML = "xml";
24+
public static final String FORMAT_CSV = "csv";
25+
public static final String FORMAT_JSON = "json";
26+
public static final String FORMAT_MARKDOWN = "markdown";
27+
public static final String FORMAT_SPREADSHEET = "spreadsheet";
28+
29+
private static final Map<String, UnaryOperator<String>> REGISTRY = Map.of(
30+
FORMAT_TEXT, UnaryOperator.identity(),
31+
FORMAT_MARKDOWN, UnaryOperator.identity(),
32+
FORMAT_HTML, Escapers::escapeXml, // HTML uses XML entity set per FR-004 spec
33+
FORMAT_XML, Escapers::escapeXml,
34+
FORMAT_CSV, Escapers::escapeCsv,
35+
// XML-escape the content first, then guard — the guard's leading quote stays
36+
// a literal apostrophe (which tells Excel "treat as text"), not &#39;.
37+
FORMAT_SPREADSHEET, s -> injectionGuard(escapeXml(s)),
38+
FORMAT_JSON, Escapers::escapeJson
39+
);
40+
41+
/**
42+
* Escape {@code input} according to the given format. Unknown formats throw
43+
* — the Renderer defaults to {@code "text"} when no format is supplied.
44+
*/
45+
public static String escape(String format, String input) {
46+
UnaryOperator<String> fn = REGISTRY.get(format);
47+
if (fn == null) {
48+
throw new IllegalArgumentException("unknown render format \"" + format + "\"");
49+
}
50+
return fn.apply(input);
51+
}
52+
53+
static String escapeXml(String s) {
54+
StringBuilder sb = new StringBuilder(s.length() + 8);
55+
for (int i = 0; i < s.length(); i++) {
56+
char c = s.charAt(i);
57+
switch (c) {
58+
case '&' -> sb.append("&amp;");
59+
case '<' -> sb.append("&lt;");
60+
case '>' -> sb.append("&gt;");
61+
case '"' -> sb.append("&quot;");
62+
case '\'' -> sb.append("&#39;");
63+
default -> sb.append(c);
64+
}
65+
}
66+
return sb.toString();
67+
}
68+
69+
// OWASP CSV/Excel formula-injection guard: neutralize a leading active char.
70+
static String injectionGuard(String s) {
71+
if (s.isEmpty()) return s;
72+
char first = s.charAt(0);
73+
if (first == '=' || first == '+' || first == '-' || first == '@'
74+
|| first == '\t' || first == '\r') {
75+
return "'" + s;
76+
}
77+
return s;
78+
}
79+
80+
static String escapeCsv(String s) {
81+
String guarded = injectionGuard(s);
82+
boolean needsQuote = guarded.indexOf(',') >= 0
83+
|| guarded.indexOf('"') >= 0
84+
|| guarded.indexOf('\n') >= 0
85+
|| guarded.indexOf('\r') >= 0;
86+
if (!needsQuote) return guarded;
87+
return "\"" + guarded.replace("\"", "\"\"") + "\"";
88+
}
89+
90+
// Mirrors JS JSON.stringify(s).slice(1, -1): escape ", \, and control chars;
91+
// nothing else (no HTML-safety escaping of < > &).
92+
static String escapeJson(String s) {
93+
StringBuilder sb = new StringBuilder(s.length() + 4);
94+
for (int i = 0; i < s.length(); i++) {
95+
char c = s.charAt(i);
96+
switch (c) {
97+
case '"' -> sb.append("\\\"");
98+
case '\\' -> sb.append("\\\\");
99+
case '\b' -> sb.append("\\b");
100+
case '\f' -> sb.append("\\f");
101+
case '\n' -> sb.append("\\n");
102+
case '\r' -> sb.append("\\r");
103+
case '\t' -> sb.append("\\t");
104+
default -> {
105+
if (c < 0x20) {
106+
sb.append(String.format("\\u%04x", (int) c));
107+
} else {
108+
sb.append(c);
109+
}
110+
}
111+
}
112+
}
113+
return sb.toString();
114+
}
115+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.metaobjects.render;
2+
3+
import java.io.IOException;
4+
import java.nio.charset.StandardCharsets;
5+
import java.nio.file.Files;
6+
import java.nio.file.Path;
7+
import java.util.Objects;
8+
9+
/**
10+
* Resolves template references from a filesystem root, e.g.
11+
* {@code resolve("lobby/welcome")} reads {@code <root>/lobby/welcome.mustache}.
12+
*
13+
* <p>Path traversal (segments containing {@code ..}) is rejected at resolve
14+
* time: returns {@code null} rather than throwing, so callers see a clean
15+
* "unresolved" outcome. A second belt-and-suspenders check confirms the
16+
* normalized candidate path remains under {@code root}.
17+
*
18+
* <p>Mirrors {@code server/csharp/MetaObjects.Render/FilesystemProvider.cs}.
19+
*/
20+
public final class FilesystemProvider implements Provider {
21+
22+
private final Path root;
23+
private final String extension;
24+
25+
public FilesystemProvider(Path root) {
26+
this(root, ".mustache");
27+
}
28+
29+
public FilesystemProvider(Path root, String extension) {
30+
this.root = Objects.requireNonNull(root, "root").toAbsolutePath().normalize();
31+
this.extension = Objects.requireNonNull(extension, "extension");
32+
}
33+
34+
@Override
35+
public String resolve(String reference) {
36+
if (reference == null || reference.isEmpty()) return null;
37+
if (reference.contains("..")) return null; // path-traversal guard
38+
39+
Path candidate = root.resolve(reference + extension).normalize();
40+
if (!candidate.startsWith(root)) return null; // belt-and-suspenders
41+
if (!Files.isRegularFile(candidate)) return null;
42+
43+
try {
44+
return Files.readString(candidate, StandardCharsets.UTF_8);
45+
} catch (IOException e) {
46+
return null;
47+
}
48+
}
49+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.metaobjects.render;
2+
3+
import java.util.Map;
4+
import java.util.Objects;
5+
6+
/**
7+
* Deterministic, in-memory {@link Provider} — the conformance + unit-test
8+
* provider. Returns the mapped text for a known reference, or {@code null}
9+
* when the reference is absent.
10+
*/
11+
public final class InMemoryProvider implements Provider {
12+
13+
private final Map<String, String> map;
14+
15+
public InMemoryProvider(Map<String, String> map) {
16+
this.map = Objects.requireNonNull(map, "map");
17+
}
18+
19+
@Override
20+
public String resolve(String reference) {
21+
return map.get(reference);
22+
}
23+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.metaobjects.render;
2+
3+
import java.util.List;
4+
import java.util.Objects;
5+
6+
/**
7+
* A field node in a template-verification payload tree — a plain mirror of
8+
* an {@code object.value} view-object's field walk, decoupled from the
9+
* metadata package so the render engine remains a zero-core-dependency module.
10+
*
11+
* <p>{@code fields} present (non-null) → a context-pushing field (object or
12+
* array-of-object). {@code fields} null → a scalar field (string / number /
13+
* boolean / scalar array).
14+
*
15+
* <p>Mirrors {@code server/csharp/MetaObjects.Render/Verify.cs}
16+
* {@code PayloadField} record.
17+
*/
18+
public record PayloadField(String name, List<PayloadField> fields) {
19+
20+
public PayloadField {
21+
Objects.requireNonNull(name, "name");
22+
}
23+
24+
/** Convenience factory: a scalar field with no nested children. */
25+
public static PayloadField scalar(String name) {
26+
return new PayloadField(name, null);
27+
}
28+
29+
/** Convenience factory: a context-pushing object/array-of-object field. */
30+
public static PayloadField object(String name, List<PayloadField> children) {
31+
return new PayloadField(name, List.copyOf(children));
32+
}
33+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.metaobjects.render;
2+
3+
/**
4+
* Resolves a logical template reference (typically {@code group/source}) to
5+
* template text. The render engine never does I/O directly — it delegates
6+
* resolution to the injected {@code Provider}, so a fixed provider makes
7+
* {@link Renderer#render(RenderRequest)} deterministic.
8+
*
9+
* <p>Returns {@code null} when the reference can't be resolved. Render-time
10+
* code distinguishes "no provider supplied" from "provider returned null" —
11+
* the latter triggers {@code ERR_PARTIAL_UNRESOLVED} for partials.
12+
*
13+
* <p>Ported from {@code server/typescript/packages/render/src/provider.ts}
14+
* and {@code server/csharp/MetaObjects.Render/Provider.cs}.
15+
*/
16+
public interface Provider {
17+
18+
/**
19+
* Resolve a logical reference to its template text, or {@code null} when
20+
* the reference doesn't exist.
21+
*/
22+
String resolve(String reference);
23+
}

0 commit comments

Comments
 (0)