Skip to content

Commit d54c9fd

Browse files
authored
feat: port the scope-pattern grammar to Java and C# (#496)
* feat(java,csharp): port the scope-pattern grammar and run the scope corpus (FR-023 Phase 2 S0a/S0b) Java (Kotlin inherits) and C# gain compilePattern/compileScope/matchesScope matching the TypeScript reference, gated by fixtures/scope-conformance/. * no-mistakes(review): apply reviewer inline fixes * no-mistakes(document): Fix stale scope-conformance port-support claims
1 parent 71e9cd7 commit d54c9fd

9 files changed

Lines changed: 509 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ here.**
1616

1717
### Added
1818

19+
- **Java, Kotlin and C# can compile and match `scope` patterns.** `com.metaobjects.config.Scope`
20+
(Java; Kotlin inherits it) and `MetaObjects.Config.Scope` (C#) port the scope-pattern grammar from
21+
TypeScript (`compilePattern` / `compileScope` / `matchesScope`), and both run the shared
22+
`fixtures/scope-conformance/` corpus. This is the grammar the cross-repo dependency work needs in
23+
these ports (FR-023 Phase 2, #482); nothing reads `scope` from `.metaobjects/config.json` in either
24+
port yet, so no command's behavior changes. A pattern with an empty segment, a trailing `::` or an
25+
odd run of `:` is refused with `ERR_SCOPE_PATTERN_INVALID`.
26+
1927
- **C# reads its generated-code directory from `.metaobjects/config.json`, so a plain
2028
`dotnet meta verify` runs the codegen gate.** Set `"csharp": { "outDir": "Generated" }`
2129
(relative to the directory holding `.metaobjects/`). `dotnet meta gen` writes there and

‎docs/CONFORMANCE.md‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
4545
| [`fixtures/generator-registry-conformance/`](../fixtures/generator-registry-conformance/) | 1 canonical manifest | ✓ | ✓ | ✓ | ✓ | ✓ |
4646
| [`fixtures/provider-composition-conformance/`](../fixtures/provider-composition-conformance/) | 9 (5 error-shape + 4 compose-load) | ✓ | ✓ | — (JVM registry via Java) | ✓ | ✓ |
4747
| [`fixtures/source-resolution-conformance/`](../fixtures/source-resolution-conformance/) | 25 cases | ✓ (reference implementation) | ✓ | inherits via Java | ✓ | ✓ |
48-
| [`fixtures/scope-conformance/`](../fixtures/scope-conformance/) | 10 cases | ✓ (reference implementation) | — | — | — | ✓ |
48+
| [`fixtures/scope-conformance/`](../fixtures/scope-conformance/) | 10 cases | ✓ (reference implementation) | ✓ | inherits via Java | ✓ | ✓ |
4949
| [`fixtures/dependency-conformance/`](../fixtures/dependency-conformance/) | 23 cases | ✓ (reference implementation) | — (Phase 2) | — (Phase 2) | — (Phase 2) | ✓ (2 of 23 assert a documented Python-only vocabulary gap instead of the corpus's full contract — see below) |
5050
| [`fixtures/agent-context-conformance/`](../fixtures/agent-context-conformance/) | 4 | ✓ (the emitter is TS-owned) | — | — | — | — |
5151
| [`fixtures/metamodel-docs/`](../fixtures/metamodel-docs/) | 1 | ✓ (docs emit is TS-owned) | — | — | — | — |
@@ -423,15 +423,20 @@ matching is case-sensitive.** These are exactly the rules four independent
423423
implementations would otherwise each get slightly wrong — the failure mode that
424424
produced the cross-port `LIKE`/`ILIKE` divergence fixed in 0.21.6.
425425

426-
**TypeScript and Python run it today.** The reference implementation is
426+
**TypeScript, Java, Kotlin, C# and Python run it.** The reference implementation is
427427
[`server/typescript/packages/metadata/src/scope.ts`](../server/typescript/packages/metadata/src/scope.ts)
428428
(`compilePattern` / `compileScope` / `matchesScope`; `@metaobjectsdev/sdk` re-exports
429429
it unchanged, since it moved there from `sdk` when FR-023 needed it from
430430
`codegen-ts` without a `sdk` dependency), and the corpus was authored against it.
431431
Python's port (`server/python/src/metaobjects/scope.py`, `matches_scope` using
432-
`re.fullmatch`) runs the same corpus. Java, Kotlin and C# have no runner yet; when
433-
each gains one, this corpus is what it implements against — it exists now precisely
434-
so those three land on one grammar rather than three.
432+
`re.fullmatch`) runs the same corpus. Java's (`com.metaobjects.config.Scope`, run by
433+
`ScopeConformanceTest`) matches with `Matcher.matches()` and splits patterns with a
434+
`-1` limit, because `String.split` would otherwise drop a trailing empty segment and
435+
let `acme::` compile instead of failing. C#'s (`MetaObjects.Config.Scope`, run by
436+
`ScopeConformanceTests`) anchors with `\A…\z` rather than `^…$`, since .NET's `$`
437+
also matches before a trailing newline. Kotlin has no runner of its own: it inherits
438+
the Java loader. Each of those two guards is pinned by a test beside the corpus, since
439+
the corpus asserts matches only and carries no invalid-pattern case.
435440

436441
### `fixtures/dependency-conformance/` (23 cases)
437442

‎docs/features/metadata-sources.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ exclusion and a `.[attr]` predicate that `scope` cannot express at all
9191
separator handling is wrong). Both are output filters over the same resolved
9292
file set, so they compete rather than layer, and reconciling them changes
9393
behavior for existing Java consumers — a separate, adopter-affecting decision
94-
rather than a mechanical port. No cross-port behavior depends on `scope`.
94+
rather than a mechanical port. No cross-port behavior depends on `scope`. (The pattern
95+
grammar itself is ported and corpus-gated in Java, Kotlin and C# as a library, ahead of the
96+
cross-repo dependency work; no command in those ports reads `scope` from the config yet.)
9597

9698
**Two things are deliberately not cross-port contracts**, because the corpus
9799
that gates this feature says so explicitly:
@@ -711,8 +713,8 @@ Design rationale and the full phase plan:
711713

712714
- [`fixtures/scope-conformance/`](../../fixtures/scope-conformance/) — 10 cases
713715
pinning `*` / `**`, include-union, exclude-after-include, literal metacharacters,
714-
and case sensitivity. TypeScript runs it today; the other four ports have no runner
715-
yet, because `scope` itself stays Node-CLI-only (see "Port support" above). See
716+
and case sensitivity. All five ports run it (Kotlin inherits Java's); only the Node CLI
717+
reads `scope` from the config (see "Port support" above). See
716718
[`CONFORMANCE.md`](../CONFORMANCE.md).
717719

718720
**TypeScript gates**

‎fixtures/scope-conformance/README.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,14 @@ segment matcher) as long as every case in this file passes.
6464
`re.fullmatch` — every compiled pattern is already anchored, but a `.match`
6565
would still let a trailing-newline-in-the-name case through, which
6666
`fullmatch` correctly refuses).
67-
- Java, Kotlin and C# have no runner yet — `scope` stays Node-CLI-only (see
68-
[`docs/features/metadata-sources.md`](../../docs/features/metadata-sources.md)).
67+
- Java: `server/java/metadata/src/test/java/com/metaobjects/config/ScopeConformanceTest.java`
68+
(against `com.metaobjects.config.Scope`, which matches with `Matcher.matches()` and
69+
splits with a `-1` limit so a trailing `::` is refused, not dropped).
70+
- C#: `server/csharp/MetaObjects.Conformance.Tests/ScopeConformanceTests.cs` (against
71+
`MetaObjects.Config.Scope`, anchored with `\A…\z` because .NET's `$` also matches
72+
before a trailing newline).
73+
- Kotlin inherits the Java loader and has no runner of its own.
74+
75+
The corpus asserts matches only. Each runner pins the invalid-pattern cases
76+
(`ERR_SCOPE_PATTERN_INVALID`: empty pattern, empty segment, trailing separator, odd
77+
colon run) in its own tests beside it.

‎fixtures/source-resolution-conformance/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Pins how a consumer's `sources` set in `.metaobjects/config.json` resolves to a
44
set of metadata files. Every port's CLI must resolve the SAME FILES from the
55
same declaration — that is the cross-port promise this corpus exists to keep.
66

7-
Companion to `scope-conformance/`, which pins the (currently TypeScript-only)
7+
Companion to `scope-conformance/`, which pins the
88
`scope` pattern grammar. The two are independent: `sources` decides which files
99
are read, `scope` filters what is emitted from them.
1010

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Runs the shared scope corpus against this port. Reads the single committed
2+
// fixtures/scope-conformance/cases.json — no per-port fixture. The corpus is the
3+
// contract; server/typescript/packages/metadata/src/scope.ts is the reference it was
4+
// authored against.
5+
using System.Text.Json;
6+
using MetaObjects;
7+
using MetaObjects.Config;
8+
using Xunit;
9+
10+
namespace MetaObjects.Conformance.Tests;
11+
12+
public class ScopeConformanceTests
13+
{
14+
private static JsonElement Cases()
15+
{
16+
var dir = new DirectoryInfo(AppContext.BaseDirectory);
17+
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "fixtures")))
18+
dir = dir.Parent;
19+
Assert.NotNull(dir);
20+
var path = Path.Combine(dir!.FullName, "fixtures", "scope-conformance", "cases.json");
21+
using var doc = JsonDocument.Parse(File.ReadAllText(path));
22+
return doc.RootElement.GetProperty("cases").Clone();
23+
}
24+
25+
public static TheoryData<string> CaseNames()
26+
{
27+
var data = new TheoryData<string>();
28+
foreach (var c in Cases().EnumerateArray()) data.Add(c.GetProperty("name").GetString()!);
29+
return data;
30+
}
31+
32+
private static List<string> Strings(JsonElement scope, string key) =>
33+
scope.TryGetProperty(key, out var arr)
34+
? arr.EnumerateArray().Select(e => e.GetString()!).ToList()
35+
: [];
36+
37+
[Fact]
38+
public void CorpusIsNotEmpty() =>
39+
// Guards against a runner that silently reads nothing; the corpus grows, so no
40+
// hard-coded count.
41+
Assert.NotEmpty(CaseNames());
42+
43+
[Theory]
44+
[MemberData(nameof(CaseNames))]
45+
public void CorpusCaseMatchesTheReference(string name)
46+
{
47+
var c = Cases().EnumerateArray().Single(x => x.GetProperty("name").GetString() == name);
48+
var scope = c.GetProperty("scope");
49+
var compiled = Scope.CompileScope(Strings(scope, "include"), Strings(scope, "exclude"));
50+
foreach (var exp in c.GetProperty("expect").EnumerateArray())
51+
{
52+
var fqn = exp.GetProperty("fqn").GetString()!;
53+
Assert.True(
54+
exp.GetProperty("matches").GetBoolean() == Scope.MatchesScope(fqn, compiled),
55+
$"{name}: {fqn}");
56+
}
57+
}
58+
59+
// --- Pattern errors: not in the corpus (which is matches-only), so pinned here ---
60+
61+
[Theory]
62+
[InlineData("")]
63+
[InlineData("acme::::Order")]
64+
[InlineData("::Order")]
65+
[InlineData("acme::")]
66+
[InlineData("acme:::Order")]
67+
[InlineData("acme:Order")]
68+
public void AnInvalidPatternIsRefusedWithItsCode(string pattern)
69+
{
70+
var ex = Assert.Throws<MetaModelException>(() => Scope.CompilePattern(pattern));
71+
Assert.Equal(ErrorCode.ERR_SCOPE_PATTERN_INVALID, ex.Code);
72+
}
73+
74+
[Fact]
75+
public void ATrailingNewlineInTheNameNeverMatches()
76+
{
77+
var compiled = Scope.CompileScope(["acme::Order"]);
78+
Assert.True(Scope.MatchesScope("acme::Order", compiled));
79+
Assert.False(Scope.MatchesScope("acme::Order\n", compiled));
80+
}
81+
82+
[Fact]
83+
public void AStarAdjacentToAMetacharacterKeepsTheLiteral()
84+
{
85+
var compiled = Scope.CompileScope(["acme::a*.b", "acme::(x)*"]);
86+
Assert.True(Scope.MatchesScope("acme::aXY.b", compiled));
87+
Assert.False(Scope.MatchesScope("acme::aXYxb", compiled));
88+
Assert.True(Scope.MatchesScope("acme::(x)tail", compiled));
89+
Assert.True(Scope.MatchesScope("acme::(x)", compiled));
90+
}
91+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// FR-023 §4.3 — the scope-pattern grammar: whether a fully-qualified node name falls
2+
// inside a consumer's declared `include`/`exclude` scope.
3+
//
4+
// A pure, no-I/O port of server/typescript/packages/metadata/src/scope.ts
5+
// (compilePattern / compileScope / matchesScope). The shared corpus
6+
// fixtures/scope-conformance/cases.json pins the semantics, so behavior must match the
7+
// reference exactly:
8+
//
9+
// `*` inside a segment matches any run of characters but never crosses a `::`.
10+
// A segment that is exactly `**` matches one or more whole segments.
11+
// Every other character is literal, regex metacharacters included.
12+
// An absent or empty `include` means everything; `exclude` is applied after it.
13+
// Matching is case-sensitive.
14+
using System.Text;
15+
using System.Text.RegularExpressions;
16+
17+
namespace MetaObjects.Config;
18+
19+
/// <summary>A compiled scope. Every pattern is already anchored to the whole name.</summary>
20+
public sealed record CompiledScope(IReadOnlyList<Regex> Include, IReadOnlyList<Regex> Exclude);
21+
22+
public static class Scope
23+
{
24+
/// <summary>One package segment: any run of characters containing no separator char.</summary>
25+
private const string Segment = "[^:]+";
26+
27+
/// <summary>One or more segments, separator-joined — the <c>**</c> expansion.</summary>
28+
private const string Segments = Segment + "(?:" + PACKAGE_SEPARATOR + Segment + ")*";
29+
30+
/// <summary>Regex metacharacters escaped for literal matching; mirrors the reference's class.</summary>
31+
private const string Metacharacters = ".*+?^${}()|[]\\";
32+
33+
private static string EscapeLiteral(string text)
34+
{
35+
var sb = new StringBuilder(text.Length);
36+
foreach (var c in text)
37+
{
38+
if (Metacharacters.Contains(c)) sb.Append('\\');
39+
sb.Append(c);
40+
}
41+
return sb.ToString();
42+
}
43+
44+
private static MetaModelException Invalid(string message) =>
45+
new(message, ErrorCode.ERR_SCOPE_PATTERN_INVALID);
46+
47+
/// <summary>Compile one segment. <c>**</c> spans segments; <c>*</c> never crosses a separator.</summary>
48+
private static string CompileSegment(string segment, string pattern)
49+
{
50+
if (segment.Length == 0)
51+
throw Invalid($"empty segment in scope pattern \"{pattern}\"");
52+
// A segment surviving the split on "::" can still hold a lone ":" when the pattern
53+
// has an odd colon run ("acme:::Order" splits to "acme", ":Order"). Treating it as
54+
// literal would compile a pattern no legal name can match, so it is refused loudly
55+
// instead of silently matching nothing.
56+
if (segment.Contains(':'))
57+
throw Invalid(
58+
$"scope pattern \"{pattern}\" has a malformed separator (an odd run of \":\")" +
59+
" — segments are joined by \"::\", never a single \":\"");
60+
if (segment == "**") return $"(?:{Segments})";
61+
// `*` inside a segment matches any characters except the separator char.
62+
return string.Join("[^:]*", segment.Split('*').Select(EscapeLiteral));
63+
}
64+
65+
public static Regex CompilePattern(string pattern)
66+
{
67+
if (pattern.Length == 0) throw Invalid("scope pattern must not be empty");
68+
var body = string.Join(
69+
PACKAGE_SEPARATOR,
70+
pattern.Split(PACKAGE_SEPARATOR).Select(s => CompileSegment(s, pattern)));
71+
// \A ... \z, not ^ ... $: .NET's `$` also matches before a trailing newline, which
72+
// the reference's anchored regex never does.
73+
return new Regex($@"\A{body}\z", RegexOptions.CultureInvariant);
74+
}
75+
76+
/// <summary>Absent (<c>null</c>) or empty <c>include</c> means "everything".</summary>
77+
public static CompiledScope CompileScope(
78+
IEnumerable<string>? include = null, IEnumerable<string>? exclude = null) =>
79+
new(
80+
(include ?? []).Select(CompilePattern).ToList(),
81+
(exclude ?? []).Select(CompilePattern).ToList());
82+
83+
/// <summary>True when <paramref name="fqn"/> is inside the scope. An empty <c>include</c> means everything.</summary>
84+
public static bool MatchesScope(string fqn, CompiledScope compiled)
85+
{
86+
var included = compiled.Include.Count == 0 || compiled.Include.Any(re => re.IsMatch(fqn));
87+
if (!included) return false;
88+
return !compiled.Exclude.Any(re => re.IsMatch(fqn));
89+
}
90+
}

0 commit comments

Comments
 (0)