From d6dc45ffb5628cb65e79d9320821aaa0f46029dd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 05:47:45 -0400 Subject: [PATCH 1/3] 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/. --- CHANGELOG.md | 8 + docs/CONFORMANCE.md | 15 +- docs/features/metadata-sources.md | 4 +- fixtures/scope-conformance/README.md | 13 +- .../ScopeConformanceTests.cs | 91 +++++++++++ server/csharp/MetaObjects/Config/Scope.cs | 90 +++++++++++ .../java/com/metaobjects/config/Scope.java | 152 ++++++++++++++++++ .../config/ScopeConformanceTest.java | 138 ++++++++++++++++ 8 files changed, 503 insertions(+), 8 deletions(-) create mode 100644 server/csharp/MetaObjects.Conformance.Tests/ScopeConformanceTests.cs create mode 100644 server/csharp/MetaObjects/Config/Scope.cs create mode 100644 server/java/metadata/src/main/java/com/metaobjects/config/Scope.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/config/ScopeConformanceTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c74c54af0d..874f7d0283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 9c7bef9982..000ea2e354 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -45,7 +45,7 @@ regenerate with `ls -d fixtures//*/ | 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) | — | — | — | — | @@ -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) diff --git a/docs/features/metadata-sources.md b/docs/features/metadata-sources.md index 185b94b93b..56145b2d1f 100644 --- a/docs/features/metadata-sources.md +++ b/docs/features/metadata-sources.md @@ -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: diff --git a/fixtures/scope-conformance/README.md b/fixtures/scope-conformance/README.md index f15457949f..a1b8c19fe1 100644 --- a/fixtures/scope-conformance/README.md +++ b/fixtures/scope-conformance/README.md @@ -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. diff --git a/server/csharp/MetaObjects.Conformance.Tests/ScopeConformanceTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ScopeConformanceTests.cs new file mode 100644 index 0000000000..9a23601abb --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/ScopeConformanceTests.cs @@ -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 CaseNames() + { + var data = new TheoryData(); + foreach (var c in Cases().EnumerateArray()) data.Add(c.GetProperty("name").GetString()!); + return data; + } + + private static List 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(() => 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)); + } +} diff --git a/server/csharp/MetaObjects/Config/Scope.cs b/server/csharp/MetaObjects/Config/Scope.cs new file mode 100644 index 0000000000..2b41aa96bd --- /dev/null +++ b/server/csharp/MetaObjects/Config/Scope.cs @@ -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; + +/// A compiled scope. Every pattern is already anchored to the whole name. +public sealed record CompiledScope(IReadOnlyList Include, IReadOnlyList Exclude); + +public static class Scope +{ + /// One package segment: any run of characters containing no separator char. + private const string Segment = "[^:]+"; + + /// One or more segments, separator-joined — the ** expansion. + private const string Segments = Segment + "(?:" + PACKAGE_SEPARATOR + Segment + ")*"; + + /// Regex metacharacters escaped for literal matching; mirrors the reference's class. + 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); + + /// Compile one segment. ** spans segments; * never crosses a separator. + 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); + } + + /// Absent (null) or empty include means "everything". + public static CompiledScope CompileScope( + IEnumerable? include = null, IEnumerable? exclude = null) => + new( + (include ?? []).Select(CompilePattern).ToList(), + (exclude ?? []).Select(CompilePattern).ToList()); + + /// True when is inside the scope. An empty include means everything. + 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)); + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/Scope.java b/server/java/metadata/src/main/java/com/metaobjects/config/Scope.java new file mode 100644 index 0000000000..04b55cd2f5 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/config/Scope.java @@ -0,0 +1,152 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.config; + +import com.metaobjects.ErrorCode; +import com.metaobjects.MetaData; +import com.metaobjects.MetaDataException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; + +/** + * FR-023 §4.3 — the scope-pattern grammar: whether a fully-qualified node name falls + * inside a consumer's declared {@code include}/{@code exclude} scope. + * + *

A pure, no-I/O port of {@code server/typescript/packages/metadata/src/scope.ts} + * ({@code compilePattern} / {@code compileScope} / {@code matchesScope}). The shared + * corpus {@code fixtures/scope-conformance/cases.json} pins the semantics, so the + * behavior must match the reference exactly: + * + *

    + *
  • {@code *} inside a segment matches any run of characters but never crosses a + * {@code ::}.
  • + *
  • A segment that is exactly {@code **} matches one or more whole segments.
  • + *
  • Every other character is literal, regex metacharacters included.
  • + *
  • An absent or empty {@code include} means everything; {@code exclude} is applied + * after it. Matching is case-sensitive.
  • + *
+ * + *

This is deliberately NOT the {@code } glob of the Maven plugin + * ({@code GeneratorUtil.createRegexFromGlob}), whose {@code *} and {@code @} are the + * inverse of {@code **} and {@code *} here. The two are separate surfaces. + */ +public final class Scope { + + private Scope() {} + + /** One package segment: any run of characters containing no separator char. */ + private static final String SEGMENT = "[^:]+"; + + /** One or more segments, separator-joined — the {@code **} expansion. */ + private static final String SEGMENTS = + SEGMENT + "(?:" + Pattern.quote(MetaData.PKG_SEPARATOR) + SEGMENT + ")*"; + + /** Regex metacharacters escaped for literal matching; mirrors the reference's class. */ + private static final String METACHARACTERS = ".*+?^${}()|[]\\"; + + /** A compiled scope. Both arrays are already anchored to the whole name. */ + public static final class Compiled { + private final List include; + private final List exclude; + + Compiled(List include, List exclude) { + this.include = Collections.unmodifiableList(include); + this.exclude = Collections.unmodifiableList(exclude); + } + + public List include() { return include; } + + public List exclude() { return exclude; } + } + + private static String escapeLiteral(String text) { + StringBuilder out = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (METACHARACTERS.indexOf(c) >= 0) out.append('\\'); + out.append(c); + } + return out.toString(); + } + + /** Compile one segment. {@code **} spans segments; {@code *} never crosses a separator. */ + private static String compileSegment(String segment, String pattern) { + if (segment.isEmpty()) { + throw new MetaDataException( + "empty segment in scope pattern \"" + pattern + "\"", + ErrorCode.ERR_SCOPE_PATTERN_INVALID); + } + // 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.indexOf(':') >= 0) { + throw new MetaDataException( + "scope pattern \"" + pattern + "\" has a malformed separator (an odd run of \":\")" + + " — segments are joined by \"::\", never a single \":\"", + ErrorCode.ERR_SCOPE_PATTERN_INVALID); + } + if (segment.equals("**")) return "(?:" + SEGMENTS + ")"; + // `*` inside a segment matches any characters except the separator char. The -1 + // limit keeps trailing empties, as the reference's split does ("a*" -> "a", ""). + String[] parts = segment.split("\\*", -1); + List escaped = new ArrayList<>(parts.length); + for (String part : parts) escaped.add(escapeLiteral(part)); + return String.join("[^:]*", escaped); + } + + public static Pattern compilePattern(String pattern) { + if (pattern.isEmpty()) { + throw new MetaDataException( + "scope pattern must not be empty", ErrorCode.ERR_SCOPE_PATTERN_INVALID); + } + // Limit -1: a trailing separator yields a trailing empty segment, which must be + // refused rather than dropped (Java's default split discards trailing empties). + String[] segments = pattern.split(Pattern.quote(MetaData.PKG_SEPARATOR), -1); + List body = new ArrayList<>(segments.length); + for (String segment : segments) body.add(compileSegment(segment, pattern)); + return Pattern.compile(String.join(MetaData.PKG_SEPARATOR, body)); + } + + /** Absent ({@code null}) or empty {@code include} means "everything". */ + public static Compiled compileScope(List include, List exclude) { + return new Compiled(compileAll(include), compileAll(exclude)); + } + + private static List compileAll(List patterns) { + List out = new ArrayList<>(); + if (patterns != null) for (String p : patterns) out.add(compilePattern(p)); + return out; + } + + /** True when {@code fqn} is inside the scope. An empty {@code include} means everything. */ + public static boolean matchesScope(String fqn, Compiled compiled) { + // matches() requires the WHOLE name to match; `$` would also accept a trailing + // newline, which the reference's anchored regex never does. + boolean included = compiled.include().isEmpty(); + for (Pattern p : compiled.include()) { + if (p.matcher(fqn).matches()) { included = true; break; } + } + if (!included) return false; + for (Pattern p : compiled.exclude()) { + if (p.matcher(fqn).matches()) return false; + } + return true; + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/config/ScopeConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/config/ScopeConformanceTest.java new file mode 100644 index 0000000000..14ea7e4448 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/config/ScopeConformanceTest.java @@ -0,0 +1,138 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.config; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.metaobjects.ErrorCode; +import com.metaobjects.MetaDataException; +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Runs the shared scope corpus against this port. Reads the single committed + * {@code fixtures/scope-conformance/cases.json} — no per-port fixture. The corpus is + * the contract; {@code server/typescript/packages/metadata/src/scope.ts} is the + * reference it was authored against. + */ +public class ScopeConformanceTest { + + private static JsonArray cases() throws IOException { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.getParent(); + assertNotNull("could not locate the repository fixtures/ directory", dir); + Path corpus = dir.resolve("fixtures/scope-conformance/cases.json"); + String content = new String(Files.readAllBytes(corpus), StandardCharsets.UTF_8); + return JsonParser.parseString(content).getAsJsonObject().getAsJsonArray("cases"); + } + + private static List strings(JsonObject scope, String key) { + List out = new ArrayList<>(); + if (!scope.has(key)) return out; + for (JsonElement e : scope.getAsJsonArray(key)) out.add(e.getAsString()); + return out; + } + + @Test + public void everyCorpusCaseMatchesTheReference() throws IOException { + JsonArray cases = cases(); + // Guard against a runner that silently reads nothing; the corpus grows, so no + // hard-coded count. + assertTrue("scope corpus is empty", cases.size() > 0); + for (JsonElement caseEl : cases) { + JsonObject c = caseEl.getAsJsonObject(); + String name = c.get("name").getAsString(); + JsonObject scope = c.getAsJsonObject("scope"); + Scope.Compiled compiled = + Scope.compileScope(strings(scope, "include"), strings(scope, "exclude")); + for (JsonElement expEl : c.getAsJsonArray("expect")) { + JsonObject exp = expEl.getAsJsonObject(); + String fqn = exp.get("fqn").getAsString(); + assertEquals(name + ": " + fqn, exp.get("matches").getAsBoolean(), + Scope.matchesScope(fqn, compiled)); + } + } + } + + // --- Pattern errors: not in the corpus (which is matches-only), so pinned here --- + + private static void assertInvalid(String pattern) { + try { + Scope.compilePattern(pattern); + fail("expected ERR_SCOPE_PATTERN_INVALID for \"" + pattern + "\""); + } catch (MetaDataException e) { + assertEquals(ErrorCode.ERR_SCOPE_PATTERN_INVALID, e.getCode().orElse(null)); + } + } + + @Test + public void anEmptyPatternIsRefused() { + assertInvalid(""); + } + + @Test + public void anEmptySegmentIsRefused() { + assertInvalid("acme::::Order"); + assertInvalid("::Order"); + } + + @Test + public void aTrailingSeparatorIsRefusedNotDropped() { + // Java's default String.split discards trailing empties; the reference keeps them. + assertInvalid("acme::"); + } + + @Test + public void anOddColonRunIsRefusedNotSilentlyUnmatchable() { + assertInvalid("acme:::Order"); + assertInvalid("acme:Order"); + } + + @Test + public void aTrailingNewlineInTheNameNeverMatches() { + Scope.Compiled compiled = + Scope.compileScope(Collections.singletonList("acme::Order"), null); + assertTrue(Scope.matchesScope("acme::Order", compiled)); + assertFalse(Scope.matchesScope("acme::Order\n", compiled)); + } + + @Test + public void aStarAdjacentToAMetacharacterKeepsTheLiteral() { + Scope.Compiled compiled = + Scope.compileScope(Arrays.asList("acme::a*.b", "acme::(x)*"), null); + assertTrue(Scope.matchesScope("acme::aXY.b", compiled)); + assertFalse(Scope.matchesScope("acme::aXYxb", compiled)); + assertTrue(Scope.matchesScope("acme::(x)tail", compiled)); + assertTrue(Scope.matchesScope("acme::(x)", compiled)); + } +} From f9b3f00c8ffbe0877618619ef4f83446ada730cd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 05:50:27 -0400 Subject: [PATCH 2/3] no-mistakes(review): apply reviewer inline fixes --- .../metadata/src/main/java/com/metaobjects/config/Scope.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/Scope.java b/server/java/metadata/src/main/java/com/metaobjects/config/Scope.java index 04b55cd2f5..1673fe3dec 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/config/Scope.java +++ b/server/java/metadata/src/main/java/com/metaobjects/config/Scope.java @@ -60,7 +60,10 @@ private Scope() {} /** Regex metacharacters escaped for literal matching; mirrors the reference's class. */ private static final String METACHARACTERS = ".*+?^${}()|[]\\"; - /** A compiled scope. Both arrays are already anchored to the whole name. */ + /** + * A compiled scope. The patterns carry no anchors: match them with + * {@code Matcher.matches()} (as {@link #matchesScope} does), never {@code find()}. + */ public static final class Compiled { private final List include; private final List exclude; From 02e108757bc5e933553eed62a659a1c650ca0513 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 07:36:08 -0400 Subject: [PATCH 3/3] no-mistakes(document): Fix stale scope-conformance port-support claims --- docs/features/metadata-sources.md | 4 ++-- fixtures/source-resolution-conformance/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/features/metadata-sources.md b/docs/features/metadata-sources.md index 56145b2d1f..c4754e9839 100644 --- a/docs/features/metadata-sources.md +++ b/docs/features/metadata-sources.md @@ -713,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** diff --git a/fixtures/source-resolution-conformance/README.md b/fixtures/source-resolution-conformance/README.md index 40168fbd1b..11792ba9b0 100644 --- a/fixtures/source-resolution-conformance/README.md +++ b/fixtures/source-resolution-conformance/README.md @@ -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.