Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ here.**

### Added

- **Java, Kotlin and C# can compile and match `scope` patterns.** `com.metaobjects.config.Scope`
(Java; Kotlin inherits it) and `MetaObjects.Config.Scope` (C#) port the scope-pattern grammar from
TypeScript (`compilePattern` / `compileScope` / `matchesScope`), and both run the shared
`fixtures/scope-conformance/` corpus. This is the grammar the cross-repo dependency work needs in
these ports (FR-023 Phase 2, #482); nothing reads `scope` from `.metaobjects/config.json` in either
port yet, so no command's behavior changes. A pattern with an empty segment, a trailing `::` or an
odd run of `:` is refused with `ERR_SCOPE_PATTERN_INVALID`.

- **C# reads its generated-code directory from `.metaobjects/config.json`, so a plain
`dotnet meta verify` runs the codegen gate.** Set `"csharp": { "outDir": "Generated" }`
(relative to the directory holding `.metaobjects/`). `dotnet meta gen` writes there and
Expand Down
15 changes: 10 additions & 5 deletions docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
| [`fixtures/generator-registry-conformance/`](../fixtures/generator-registry-conformance/) | 1 canonical manifest | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/provider-composition-conformance/`](../fixtures/provider-composition-conformance/) | 9 (5 error-shape + 4 compose-load) | ✓ | ✓ | — (JVM registry via Java) | ✓ | ✓ |
| [`fixtures/source-resolution-conformance/`](../fixtures/source-resolution-conformance/) | 25 cases | ✓ (reference implementation) | ✓ | inherits via Java | ✓ | ✓ |
| [`fixtures/scope-conformance/`](../fixtures/scope-conformance/) | 10 cases | ✓ (reference implementation) | — | — | — | ✓ |
| [`fixtures/scope-conformance/`](../fixtures/scope-conformance/) | 10 cases | ✓ (reference implementation) | ✓ | inherits via Java | ✓ | ✓ |
| [`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) |
| [`fixtures/agent-context-conformance/`](../fixtures/agent-context-conformance/) | 4 | ✓ (the emitter is TS-owned) | — | — | — | — |
| [`fixtures/metamodel-docs/`](../fixtures/metamodel-docs/) | 1 | ✓ (docs emit is TS-owned) | — | — | — | — |
Expand Down Expand Up @@ -423,15 +423,20 @@ matching is case-sensitive.** These are exactly the rules four independent
implementations would otherwise each get slightly wrong — the failure mode that
produced the cross-port `LIKE`/`ILIKE` divergence fixed in 0.21.6.

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

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

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

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

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

**TypeScript gates**
Expand Down
13 changes: 11 additions & 2 deletions fixtures/scope-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,14 @@ segment matcher) as long as every case in this file passes.
`re.fullmatch` — every compiled pattern is already anchored, but a `.match`
would still let a trailing-newline-in-the-name case through, which
`fullmatch` correctly refuses).
- Java, Kotlin and C# have no runner yet — `scope` stays Node-CLI-only (see
[`docs/features/metadata-sources.md`](../../docs/features/metadata-sources.md)).
- Java: `server/java/metadata/src/test/java/com/metaobjects/config/ScopeConformanceTest.java`
(against `com.metaobjects.config.Scope`, which matches with `Matcher.matches()` and
splits with a `-1` limit so a trailing `::` is refused, not dropped).
- C#: `server/csharp/MetaObjects.Conformance.Tests/ScopeConformanceTests.cs` (against
`MetaObjects.Config.Scope`, anchored with `\A…\z` because .NET's `$` also matches
before a trailing newline).
- Kotlin inherits the Java loader and has no runner of its own.

The corpus asserts matches only. Each runner pins the invalid-pattern cases
(`ERR_SCOPE_PATTERN_INVALID`: empty pattern, empty segment, trailing separator, odd
colon run) in its own tests beside it.
2 changes: 1 addition & 1 deletion fixtures/source-resolution-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Pins how a consumer's `sources` set in `.metaobjects/config.json` resolves to a
set of metadata files. Every port's CLI must resolve the SAME FILES from the
same declaration — that is the cross-port promise this corpus exists to keep.

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Runs the shared scope corpus against this port. Reads the single committed
// fixtures/scope-conformance/cases.json — no per-port fixture. The corpus is the
// contract; server/typescript/packages/metadata/src/scope.ts is the reference it was
// authored against.
using System.Text.Json;
using MetaObjects;
using MetaObjects.Config;
using Xunit;

namespace MetaObjects.Conformance.Tests;

public class ScopeConformanceTests
{
private static JsonElement Cases()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "fixtures")))
dir = dir.Parent;
Assert.NotNull(dir);
var path = Path.Combine(dir!.FullName, "fixtures", "scope-conformance", "cases.json");
using var doc = JsonDocument.Parse(File.ReadAllText(path));
return doc.RootElement.GetProperty("cases").Clone();
}

public static TheoryData<string> CaseNames()
{
var data = new TheoryData<string>();
foreach (var c in Cases().EnumerateArray()) data.Add(c.GetProperty("name").GetString()!);
return data;
}

private static List<string> Strings(JsonElement scope, string key) =>
scope.TryGetProperty(key, out var arr)
? arr.EnumerateArray().Select(e => e.GetString()!).ToList()
: [];

[Fact]
public void CorpusIsNotEmpty() =>
// Guards against a runner that silently reads nothing; the corpus grows, so no
// hard-coded count.
Assert.NotEmpty(CaseNames());

[Theory]
[MemberData(nameof(CaseNames))]
public void CorpusCaseMatchesTheReference(string name)
{
var c = Cases().EnumerateArray().Single(x => x.GetProperty("name").GetString() == name);
var scope = c.GetProperty("scope");
var compiled = Scope.CompileScope(Strings(scope, "include"), Strings(scope, "exclude"));
foreach (var exp in c.GetProperty("expect").EnumerateArray())
{
var fqn = exp.GetProperty("fqn").GetString()!;
Assert.True(
exp.GetProperty("matches").GetBoolean() == Scope.MatchesScope(fqn, compiled),
$"{name}: {fqn}");
}
}

// --- Pattern errors: not in the corpus (which is matches-only), so pinned here ---

[Theory]
[InlineData("")]
[InlineData("acme::::Order")]
[InlineData("::Order")]
[InlineData("acme::")]
[InlineData("acme:::Order")]
[InlineData("acme:Order")]
public void AnInvalidPatternIsRefusedWithItsCode(string pattern)
{
var ex = Assert.Throws<MetaModelException>(() => Scope.CompilePattern(pattern));
Assert.Equal(ErrorCode.ERR_SCOPE_PATTERN_INVALID, ex.Code);
}

[Fact]
public void ATrailingNewlineInTheNameNeverMatches()
{
var compiled = Scope.CompileScope(["acme::Order"]);
Assert.True(Scope.MatchesScope("acme::Order", compiled));
Assert.False(Scope.MatchesScope("acme::Order\n", compiled));
}

[Fact]
public void AStarAdjacentToAMetacharacterKeepsTheLiteral()
{
var compiled = Scope.CompileScope(["acme::a*.b", "acme::(x)*"]);
Assert.True(Scope.MatchesScope("acme::aXY.b", compiled));
Assert.False(Scope.MatchesScope("acme::aXYxb", compiled));
Assert.True(Scope.MatchesScope("acme::(x)tail", compiled));
Assert.True(Scope.MatchesScope("acme::(x)", compiled));
}
}
90 changes: 90 additions & 0 deletions server/csharp/MetaObjects/Config/Scope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// FR-023 §4.3 — the scope-pattern grammar: whether a fully-qualified node name falls
// inside a consumer's declared `include`/`exclude` scope.
//
// A pure, no-I/O port of server/typescript/packages/metadata/src/scope.ts
// (compilePattern / compileScope / matchesScope). The shared corpus
// fixtures/scope-conformance/cases.json pins the semantics, so behavior must match the
// reference exactly:
//
// `*` inside a segment matches any run of characters but never crosses a `::`.
// A segment that is exactly `**` matches one or more whole segments.
// Every other character is literal, regex metacharacters included.
// An absent or empty `include` means everything; `exclude` is applied after it.
// Matching is case-sensitive.
using System.Text;
using System.Text.RegularExpressions;

namespace MetaObjects.Config;

/// <summary>A compiled scope. Every pattern is already anchored to the whole name.</summary>
public sealed record CompiledScope(IReadOnlyList<Regex> Include, IReadOnlyList<Regex> Exclude);

public static class Scope
{
/// <summary>One package segment: any run of characters containing no separator char.</summary>
private const string Segment = "[^:]+";

/// <summary>One or more segments, separator-joined — the <c>**</c> expansion.</summary>
private const string Segments = Segment + "(?:" + PACKAGE_SEPARATOR + Segment + ")*";

/// <summary>Regex metacharacters escaped for literal matching; mirrors the reference's class.</summary>
private const string Metacharacters = ".*+?^${}()|[]\\";

private static string EscapeLiteral(string text)
{
var sb = new StringBuilder(text.Length);
foreach (var c in text)
{
if (Metacharacters.Contains(c)) sb.Append('\\');
sb.Append(c);
}
return sb.ToString();
}

private static MetaModelException Invalid(string message) =>
new(message, ErrorCode.ERR_SCOPE_PATTERN_INVALID);

/// <summary>Compile one segment. <c>**</c> spans segments; <c>*</c> never crosses a separator.</summary>
private static string CompileSegment(string segment, string pattern)
{
if (segment.Length == 0)
throw Invalid($"empty segment in scope pattern \"{pattern}\"");
// A segment surviving the split on "::" can still hold a lone ":" when the pattern
// has an odd colon run ("acme:::Order" splits to "acme", ":Order"). Treating it as
// literal would compile a pattern no legal name can match, so it is refused loudly
// instead of silently matching nothing.
if (segment.Contains(':'))
throw Invalid(
$"scope pattern \"{pattern}\" has a malformed separator (an odd run of \":\")" +
" — segments are joined by \"::\", never a single \":\"");
if (segment == "**") return $"(?:{Segments})";
// `*` inside a segment matches any characters except the separator char.
return string.Join("[^:]*", segment.Split('*').Select(EscapeLiteral));
}

public static Regex CompilePattern(string pattern)
{
if (pattern.Length == 0) throw Invalid("scope pattern must not be empty");
var body = string.Join(
PACKAGE_SEPARATOR,
pattern.Split(PACKAGE_SEPARATOR).Select(s => CompileSegment(s, pattern)));
// \A ... \z, not ^ ... $: .NET's `$` also matches before a trailing newline, which
// the reference's anchored regex never does.
return new Regex($@"\A{body}\z", RegexOptions.CultureInvariant);
}

/// <summary>Absent (<c>null</c>) or empty <c>include</c> means "everything".</summary>
public static CompiledScope CompileScope(
IEnumerable<string>? include = null, IEnumerable<string>? exclude = null) =>
new(
(include ?? []).Select(CompilePattern).ToList(),
(exclude ?? []).Select(CompilePattern).ToList());

/// <summary>True when <paramref name="fqn"/> is inside the scope. An empty <c>include</c> means everything.</summary>
public static bool MatchesScope(string fqn, CompiledScope compiled)
{
var included = compiled.Include.Count == 0 || compiled.Include.Any(re => re.IsMatch(fqn));
if (!included) return false;
return !compiled.Exclude.Any(re => re.IsMatch(fqn));
}
}
Loading
Loading