diff --git a/Scene.slnx b/Scene.slnx
index 4287bcc..3c5894c 100644
--- a/Scene.slnx
+++ b/Scene.slnx
@@ -3,5 +3,7 @@
+
+
diff --git a/Source/DotNET/Engine.Specs/Engine.Specs.csproj b/Source/DotNET/Engine.Specs/Engine.Specs.csproj
new file mode 100644
index 0000000..430b117
--- /dev/null
+++ b/Source/DotNET/Engine.Specs/Engine.Specs.csproj
@@ -0,0 +1,12 @@
+
+
+
+ Cratis.Scene.Engine.Specs
+ Cratis.Scene.Engine
+ false
+ true
+
+
+
+
+
diff --git a/Source/DotNET/Engine.Specs/for_PackageResolver/when_resolving_against_the_shared_fixture_corpus.cs b/Source/DotNET/Engine.Specs/for_PackageResolver/when_resolving_against_the_shared_fixture_corpus.cs
new file mode 100644
index 0000000..bbfe1b6
--- /dev/null
+++ b/Source/DotNET/Engine.Specs/for_PackageResolver/when_resolving_against_the_shared_fixture_corpus.cs
@@ -0,0 +1,75 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text.Json;
+using Cratis.Scene.Engine.Profiles;
+using Cratis.Scene.Model.Profiles;
+
+namespace Cratis.Scene.Engine.for_PackageResolver;
+
+public class when_resolving_against_the_shared_fixture_corpus : Specification
+{
+ record FixtureCase(string Name, UiProfile Profile, IReadOnlyDictionary> Catalog, string RequestedName, ComponentResolution? Expected);
+
+ List _cases = null!;
+ List<(FixtureCase Case, ComponentResolution? Actual)> _results = null!;
+
+ void Establish()
+ {
+ var manifestPath = Path.Combine(FindRepositoryRoot(), "package-resolution-fixtures.json");
+ using var document = JsonDocument.Parse(File.ReadAllText(manifestPath));
+
+ _cases = document.RootElement.GetProperty("cases").EnumerateArray().Select(ToFixtureCase).ToList();
+ }
+
+ void Because() => _results = [.. _cases.Select(fixtureCase => (fixtureCase, PackageResolver.Resolve(fixtureCase.RequestedName, fixtureCase.Profile, fixtureCase.Catalog)))];
+
+ [Fact]
+ void should_match_the_expected_resolution_for_every_case()
+ {
+ foreach (var (fixtureCase, actual) in _results)
+ {
+ (fixtureCase.Name, Flatten(actual)).ShouldEqual((fixtureCase.Name, Flatten(fixtureCase.Expected)));
+ }
+ }
+
+ static (string Name, string Package, string Shadows)? Flatten(ComponentResolution? resolution) =>
+ resolution is null ? null : (resolution.Name, resolution.Package, string.Join(',', resolution.Shadows));
+
+ static FixtureCase ToFixtureCase(JsonElement element)
+ {
+ var name = element.GetProperty("name").GetString()!;
+ var profile = new UiProfile(
+ "test",
+ "web",
+ element.GetProperty("profile").GetProperty("packages").EnumerateArray().Select(value => value.GetString()!).ToList());
+
+ var catalog = element.GetProperty("catalog").EnumerateObject()
+ .ToDictionary(
+ property => property.Name,
+ IReadOnlyList (property) => property.Value.EnumerateArray().Select(value => value.GetString()!).ToList());
+
+ var requestedName = element.GetProperty("requestedName").GetString()!;
+
+ var expectedElement = element.GetProperty("expected");
+ var expected = expectedElement.ValueKind == JsonValueKind.Null
+ ? null
+ : new ComponentResolution(
+ expectedElement.GetProperty("name").GetString()!,
+ expectedElement.GetProperty("package").GetString()!,
+ expectedElement.GetProperty("shadows").EnumerateArray().Select(value => value.GetString()!).ToList());
+
+ return new(name, profile, catalog, requestedName, expected);
+ }
+
+ static string FindRepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Scene.slnx")))
+ {
+ directory = directory.Parent;
+ }
+
+ return directory?.FullName ?? throw new DirectoryNotFoundException("Could not locate the repository root (Scene.slnx) above " + AppContext.BaseDirectory);
+ }
+}
diff --git a/Source/DotNET/Engine/Engine.csproj b/Source/DotNET/Engine/Engine.csproj
new file mode 100644
index 0000000..75f2006
--- /dev/null
+++ b/Source/DotNET/Engine/Engine.csproj
@@ -0,0 +1,11 @@
+
+
+ Cratis.Scene.Engine
+ Cratis.Scene.Engine
+ Platform-agnostic runtime and design-time resolution logic for Scene: package/component name resolution, layout arrangement evaluation, contribution aggregation.
+ cratis;scene;screenplay;ui
+
+
+
+
+
diff --git a/Source/DotNET/Engine/Profiles/ComponentResolution.cs b/Source/DotNET/Engine/Profiles/ComponentResolution.cs
new file mode 100644
index 0000000..ecd4685
--- /dev/null
+++ b/Source/DotNET/Engine/Profiles/ComponentResolution.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Scene.Engine.Profiles;
+
+///
+/// The outcome of resolving a bare or package-qualified component name against a 's package list.
+///
+/// The bare component name within .
+/// The package the name resolved to.
+///
+/// Other active packages, in descending priority order, that also declare this name but were shadowed by
+/// - this is what answers "why did this resolve to X and not Y". Always empty for a
+/// package-qualified reference, which resolves directly against its named package and never runs shadow
+/// tracking.
+///
+public record ComponentResolution(string Name, string Package, IReadOnlyList Shadows);
diff --git a/Source/DotNET/Engine/Profiles/PackageResolver.cs b/Source/DotNET/Engine/Profiles/PackageResolver.cs
new file mode 100644
index 0000000..e4e31e2
--- /dev/null
+++ b/Source/DotNET/Engine/Profiles/PackageResolver.cs
@@ -0,0 +1,79 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Scene.Model.Profiles;
+
+namespace Cratis.Scene.Engine.Profiles;
+
+///
+/// Resolves a bare or package-qualified component name against a 's package list -
+/// the design-time half of Cratis/Scene#3, run by Studio's tooling. Stage's build-time resolution and
+/// Scene.React's runtime resolution use the TypeScript twin of this algorithm in @cratis/scene.engine;
+/// both sides are asserted against the same shared fixture corpus so they cannot drift apart.
+///
+public static class PackageResolver
+{
+ ///
+ /// The name of the package that is always present as the final fallback, regardless of what a
+ /// lists in its own .
+ ///
+ public const string Core = "core";
+
+ ///
+ /// Computes a 's effective package priority order - its own declared packages,
+ /// with prepended as the final fallback when not already present.
+ ///
+ /// The to compute the order for.
+ /// The packages in ascending priority order - the last entry wins when more than one declares the same name.
+ ///
+ /// is the lowest-priority fallback, so it belongs at the FRONT of the ascending-priority
+ /// order, not the back - prepending it (rather than appending) is what makes every explicitly listed
+ /// package outrank it.
+ ///
+ public static IReadOnlyList EffectivePackages(UiProfile profile) =>
+ profile.Packages.Contains(Core) ? profile.Packages : [Core, .. profile.Packages];
+
+ ///
+ /// Resolves a component name against a .
+ ///
+ /// The name as written on a screen - bare (button) or package-qualified (Internal.Widgets.TrendChart).
+ /// The whose package list to resolve against.
+ /// Every active package's declared component names, keyed by package name.
+ /// The , or when nothing in scope declares the name.
+ ///
+ /// A name containing a . is package-qualified - everything before the last . is the package,
+ /// everything after is the bare name - and resolves directly against that one package, bypassing shadow
+ /// tracking and the profile's priority order entirely (an author naming the package explicitly has
+ /// already disambiguated). A name with no . is bare and resolves by walking
+ /// from highest to lowest priority; every other active package that also
+ /// declares the name is recorded in , not discarded, so a caller
+ /// can explain the pick rather than only report it.
+ ///
+ public static ComponentResolution? Resolve(string requestedName, UiProfile profile, IReadOnlyDictionary> catalog)
+ {
+ var lastDot = requestedName.LastIndexOf('.');
+ if (lastDot >= 0)
+ {
+ var qualifiedPackage = requestedName[..lastDot];
+ var qualifiedName = requestedName[(lastDot + 1)..];
+ return Declares(catalog, qualifiedPackage, qualifiedName)
+ ? new ComponentResolution(qualifiedName, qualifiedPackage, [])
+ : null;
+ }
+
+ var priority = EffectivePackages(profile);
+ var matches = new List();
+ for (var index = priority.Count - 1; index >= 0; index--)
+ {
+ if (Declares(catalog, priority[index], requestedName))
+ {
+ matches.Add(priority[index]);
+ }
+ }
+
+ return matches.Count == 0 ? null : new ComponentResolution(requestedName, matches[0], matches.Skip(1).ToList());
+ }
+
+ static bool Declares(IReadOnlyDictionary> catalog, string package, string name) =>
+ catalog.TryGetValue(package, out var components) && components.Contains(name);
+}
diff --git a/Source/JavaScript/engine/ComponentResolution.ts b/Source/JavaScript/engine/ComponentResolution.ts
new file mode 100644
index 0000000..ec5d51f
--- /dev/null
+++ b/Source/JavaScript/engine/ComponentResolution.ts
@@ -0,0 +1,22 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+/**
+ * The outcome of resolving a bare or package-qualified component name against a {@link UiProfile}'s
+ * package list.
+ */
+export interface ComponentResolution {
+ /** The bare component name within {@link package}. */
+ name: string;
+
+ /** The package the name resolved to. */
+ package: string;
+
+ /**
+ * Other active packages, in descending priority order, that also declare this name but were
+ * shadowed by {@link package} - this is what answers "why did this resolve to X and not Y". Always
+ * empty for a package-qualified reference, which resolves directly against its named package and
+ * never runs shadow tracking.
+ */
+ shadows: string[];
+}
diff --git a/Source/JavaScript/engine/for_resolveComponentName/when_resolving_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_resolveComponentName/when_resolving_against_the_shared_fixture_corpus.ts
new file mode 100644
index 0000000..1937e21
--- /dev/null
+++ b/Source/JavaScript/engine/for_resolveComponentName/when_resolving_against_the_shared_fixture_corpus.ts
@@ -0,0 +1,36 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { UiProfile } from '@cratis/scene.model';
+import { ComponentResolution, PackageCatalog, resolveComponentName } from '../index';
+
+interface FixtureCase {
+ name: string;
+ profile: { packages: string[] };
+ catalog: PackageCatalog;
+ requestedName: string;
+ expected: ComponentResolution | null;
+}
+
+interface FixtureCorpus {
+ cases: FixtureCase[];
+}
+
+const manifestPath = join(import.meta.dirname, '..', '..', '..', '..', 'package-resolution-fixtures.json');
+const corpus = JSON.parse(readFileSync(manifestPath, 'utf-8')) as FixtureCorpus;
+
+describe('when resolving against the shared fixture corpus', () => {
+ for (const fixtureCase of corpus.cases) {
+ it(`should match the expected resolution for "${fixtureCase.name}"`, () => {
+ const profile: UiProfile = { name: 'test', targetPlatform: 'web', packages: fixtureCase.profile.packages };
+ const actual = resolveComponentName(fixtureCase.requestedName, profile, fixtureCase.catalog);
+ if (fixtureCase.expected === null) {
+ (actual === undefined).should.be.true;
+ } else {
+ actual!.should.deep.equal(fixtureCase.expected);
+ }
+ });
+ }
+});
diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts
index 9e69687..2e743fb 100644
--- a/Source/JavaScript/engine/index.ts
+++ b/Source/JavaScript/engine/index.ts
@@ -5,3 +5,5 @@ export * from './Renderer';
export * from './BindingResolver';
export * from './renderElement';
export * from './elementKind';
+export * from './ComponentResolution';
+export * from './resolveComponentName';
diff --git a/Source/JavaScript/engine/resolveComponentName.ts b/Source/JavaScript/engine/resolveComponentName.ts
new file mode 100644
index 0000000..ff3bb85
--- /dev/null
+++ b/Source/JavaScript/engine/resolveComponentName.ts
@@ -0,0 +1,66 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { UiProfile } from '@cratis/scene.model';
+import { ComponentResolution } from './ComponentResolution';
+
+/** The name of the package that is always present as the final fallback, regardless of what a {@link UiProfile} lists in its own `packages`. */
+export const corePackage = 'core';
+
+/** Every active package's declared component names, keyed by package name. */
+export type PackageCatalog = Record;
+
+/**
+ * Computes a {@link UiProfile}'s effective package priority order - its own declared packages, with
+ * {@link corePackage} prepended as the final fallback when not already present. {@link corePackage} is
+ * the lowest-priority fallback, so it belongs at the front of the ascending-priority order, not the
+ * back - prepending it (rather than appending) is what makes every explicitly listed package outrank it.
+ *
+ * @returns The packages in ascending priority order - the last entry wins when more than one declares the same name.
+ */
+export function effectivePackages(profile: UiProfile): string[] {
+ return profile.packages.includes(corePackage) ? profile.packages : [corePackage, ...profile.packages];
+}
+
+/**
+ * Resolves a component name against a {@link UiProfile} - the runtime half of Cratis/Scene#3, run
+ * whenever a profile is applied. Studio's design-time tooling and Stage's build-time resolution use the
+ * C# twin of this algorithm in `Cratis.Scene.Engine`; both sides are asserted against the same shared
+ * fixture corpus so they cannot drift apart.
+ *
+ * A name containing a `.` is package-qualified - everything before the last `.` is the package,
+ * everything after is the bare name - and resolves directly against that one package, bypassing shadow
+ * tracking and the profile's priority order entirely (an author naming the package explicitly has
+ * already disambiguated). A name with no `.` is bare and resolves by walking {@link effectivePackages}
+ * from highest to lowest priority; every other active package that also declares the name is recorded in
+ * the result's `shadows`, not discarded, so a caller can explain the pick rather than only report it.
+ *
+ * @param requestedName The name as written on a screen - bare (`button`) or package-qualified (`Internal.Widgets.TrendChart`).
+ * @param profile The {@link UiProfile} whose package list to resolve against.
+ * @param catalog Every active package's declared component names, keyed by package name.
+ * @returns The {@link ComponentResolution}, or `undefined` when nothing in scope declares the name.
+ */
+export function resolveComponentName(requestedName: string, profile: UiProfile, catalog: PackageCatalog): ComponentResolution | undefined {
+ const lastDot = requestedName.lastIndexOf('.');
+ if (lastDot >= 0) {
+ const qualifiedPackage = requestedName.slice(0, lastDot);
+ const qualifiedName = requestedName.slice(lastDot + 1);
+ return declares(catalog, qualifiedPackage, qualifiedName)
+ ? { name: qualifiedName, package: qualifiedPackage, shadows: [] }
+ : undefined;
+ }
+
+ const priority = effectivePackages(profile);
+ const matches: string[] = [];
+ for (let index = priority.length - 1; index >= 0; index--) {
+ if (declares(catalog, priority[index], requestedName)) {
+ matches.push(priority[index]);
+ }
+ }
+
+ return matches.length === 0 ? undefined : { name: requestedName, package: matches[0], shadows: matches.slice(1) };
+}
+
+function declares(catalog: PackageCatalog, packageName: string, name: string): boolean {
+ return catalog[packageName]?.includes(name) ?? false;
+}
diff --git a/Source/JavaScript/react/renderer/ComponentRegistry.ts b/Source/JavaScript/react/renderer/ComponentRegistry.ts
index 0c8f720..e276408 100644
--- a/Source/JavaScript/react/renderer/ComponentRegistry.ts
+++ b/Source/JavaScript/react/renderer/ComponentRegistry.ts
@@ -14,8 +14,10 @@ export interface RegisteredComponentProps {
/**
* Maps a resolved component name (as `ExternalComponent.componentName` already carries it, post
- * `ui profile` package resolution) to the React component that renders it. Real bare-name resolution
- * against a profile's package list is Scene#3's job - this registry is keyed by the already-resolved
- * name.
+ * `ui profile` package resolution) to the React component that renders it. Bare-name resolution against a
+ * profile's package list is `resolveComponentName` in `@cratis/scene.engine` (Cratis/Scene#3) - this
+ * registry is keyed by the already-resolved name; wiring the two together (resolving an entire element
+ * tree's component names before it reaches this registry) is Stage#39/StudioIssues#160's job, not this
+ * registry's.
*/
export type ComponentRegistry = Record>;
diff --git a/package-resolution-fixtures.json b/package-resolution-fixtures.json
new file mode 100644
index 0000000..bd91ad3
--- /dev/null
+++ b/package-resolution-fixtures.json
@@ -0,0 +1,75 @@
+{
+ "description": "Shared behavior corpus for Cratis.Scene.Engine.Profiles.PackageResolver (C#) and resolveComponentName (TypeScript, @cratis/scene.engine) - both sides assert every case here independently, so the two implementations of Cratis/Scene#3's algorithm cannot drift apart.",
+ "cases": [
+ {
+ "name": "bare name declared by exactly one active package resolves to it",
+ "profile": { "packages": ["PrimeReact"] },
+ "catalog": { "core": ["table"], "PrimeReact": ["dropdown"] },
+ "requestedName": "dropdown",
+ "expected": { "name": "dropdown", "package": "PrimeReact", "shadows": [] }
+ },
+ {
+ "name": "bare name falls back to core when no listed package declares it",
+ "profile": { "packages": ["PrimeReact"] },
+ "catalog": { "core": ["table"], "PrimeReact": ["dropdown"] },
+ "requestedName": "table",
+ "expected": { "name": "table", "package": "core", "shadows": [] }
+ },
+ {
+ "name": "bare name declared by two active packages resolves to the later, higher-priority one",
+ "profile": { "packages": ["PrimeReact", "Internal.Widgets"] },
+ "catalog": { "core": ["button"], "PrimeReact": ["button"], "Internal.Widgets": ["button"] },
+ "requestedName": "button",
+ "expected": { "name": "button", "package": "Internal.Widgets", "shadows": ["PrimeReact", "core"] }
+ },
+ {
+ "name": "core listed explicitly in packages is not duplicated in the effective order",
+ "profile": { "packages": ["core", "PrimeReact"] },
+ "catalog": { "core": ["button"], "PrimeReact": ["button"] },
+ "requestedName": "button",
+ "expected": { "name": "button", "package": "PrimeReact", "shadows": ["core"] }
+ },
+ {
+ "name": "bare name nothing in scope declares is unresolved",
+ "profile": { "packages": ["PrimeReact"] },
+ "catalog": { "core": ["table"], "PrimeReact": ["dropdown"] },
+ "requestedName": "missing",
+ "expected": null
+ },
+ {
+ "name": "package-qualified name with a simple package resolves directly against it",
+ "profile": { "packages": ["PrimeReact"] },
+ "catalog": { "core": ["button"], "PrimeReact": ["button"] },
+ "requestedName": "core.button",
+ "expected": { "name": "button", "package": "core", "shadows": [] }
+ },
+ {
+ "name": "package-qualified name with a dotted package splits on the last dot",
+ "profile": { "packages": ["Internal.Widgets"] },
+ "catalog": { "core": [], "Internal.Widgets": ["TrendChart"] },
+ "requestedName": "Internal.Widgets.TrendChart",
+ "expected": { "name": "TrendChart", "package": "Internal.Widgets", "shadows": [] }
+ },
+ {
+ "name": "package-qualified name bypasses priority order entirely - resolves against the lower-priority package even when a higher one also declares the bare name",
+ "profile": { "packages": ["PrimeReact", "Internal.Widgets"] },
+ "catalog": { "core": ["button"], "PrimeReact": ["button"], "Internal.Widgets": ["button"] },
+ "requestedName": "core.button",
+ "expected": { "name": "button", "package": "core", "shadows": [] }
+ },
+ {
+ "name": "package-qualified name whose named package does not declare it is unresolved",
+ "profile": { "packages": ["PrimeReact"] },
+ "catalog": { "core": ["button"], "PrimeReact": [] },
+ "requestedName": "PrimeReact.button",
+ "expected": null
+ },
+ {
+ "name": "package-qualified name whose named package is not active at all is unresolved",
+ "profile": { "packages": ["PrimeReact"] },
+ "catalog": { "core": ["button"], "PrimeReact": ["button"] },
+ "requestedName": "Vendor.widget",
+ "expected": null
+ }
+ ]
+}