From 41a474e908ada436a5b85cf12684292b8e6b8763 Mon Sep 17 00:00:00 2001 From: Einar Date: Sat, 15 Aug 2026 10:05:14 +0200 Subject: [PATCH 1/5] Add Scene.Engine project scaffolding New Cratis.Scene.Engine C# project, sibling to Model - resolution and other runtime/design-time engine logic belongs here rather than in Model's assembly, whose shape is tracked by a strict parity manifest (scene-model-shape.json) that has no room for behavioral types. Mirrors the existing TypeScript model/engine package split. --- Scene.slnx | 2 ++ Source/DotNET/Engine.Specs/Engine.Specs.csproj | 12 ++++++++++++ Source/DotNET/Engine/Engine.csproj | 11 +++++++++++ 3 files changed, 25 insertions(+) create mode 100644 Source/DotNET/Engine.Specs/Engine.Specs.csproj create mode 100644 Source/DotNET/Engine/Engine.csproj 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/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 + + + + + From 26cba968910d37560b225100ba62008a3b2495f1 Mon Sep 17 00:00:00 2001 From: Einar Date: Sat, 15 Aug 2026 10:05:20 +0200 Subject: [PATCH 2/5] Add C# package resolution algorithm PackageResolver.Resolve() implements Cratis/Scene#3: a bare name walks a ui profile's package list from highest to lowest priority (core always the lowest-priority fallback); a package-qualified name (last dot splits package from name) resolves directly against its named package, bypassing priority order and shadow tracking entirely. Every active package that also declares a bare name's resolved value is kept as Shadows rather than discarded, answering the issue's own "why did this resolve to X and not Y" question directly in the result. --- .../Engine/Profiles/ComponentResolution.cs | 17 ++++ .../DotNET/Engine/Profiles/PackageResolver.cs | 79 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 Source/DotNET/Engine/Profiles/ComponentResolution.cs create mode 100644 Source/DotNET/Engine/Profiles/PackageResolver.cs 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); +} From 6aec7262e41a4fe8f7dbe611209af8974c5c4021 Mon Sep 17 00:00:00 2001 From: Einar Date: Sat, 15 Aug 2026 10:05:23 +0200 Subject: [PATCH 3/5] Add TypeScript package resolution algorithm resolveComponentName() is the runtime twin of the C# PackageResolver, run in the browser whenever a ui profile is applied - same algorithm, same shadow-tracking result shape. --- .../JavaScript/engine/ComponentResolution.ts | 22 +++++++ Source/JavaScript/engine/index.ts | 2 + .../JavaScript/engine/resolveComponentName.ts | 66 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 Source/JavaScript/engine/ComponentResolution.ts create mode 100644 Source/JavaScript/engine/resolveComponentName.ts 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/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; +} From 215d162d205dfdaf362a05937688860dd043ab45 Mon Sep 17 00:00:00 2001 From: Einar Date: Sat, 15 Aug 2026 10:05:28 +0200 Subject: [PATCH 4/5] Add shared fixture corpus proving C#/TS resolver parity package-resolution-fixtures.json is asserted independently by both Cratis.Scene.Engine.Specs (C#) and @cratis/scene.engine's own Vitest suite, so the two implementations of the resolution algorithm cannot drift apart - the same pattern scene-model-shape.json already establishes for Model's shape. Caught a real bug before either spec was trusted: the first PackageResolver draft appended "core" to the END of the priority list, which made it the highest-priority match (walked first) instead of the intended lowest-priority fallback. --- ...lving_against_the_shared_fixture_corpus.cs | 75 +++++++++++++++++++ ...lving_against_the_shared_fixture_corpus.ts | 36 +++++++++ package-resolution-fixtures.json | 75 +++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 Source/DotNET/Engine.Specs/for_PackageResolver/when_resolving_against_the_shared_fixture_corpus.cs create mode 100644 Source/JavaScript/engine/for_resolveComponentName/when_resolving_against_the_shared_fixture_corpus.ts create mode 100644 package-resolution-fixtures.json 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/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/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 + } + ] +} From 2026d20c5294c15efde592f86f69b40c767a7cfa Mon Sep 17 00:00:00 2001 From: Einar Date: Sat, 15 Aug 2026 10:05:30 +0200 Subject: [PATCH 5/5] Cross-reference the package resolver from ComponentRegistry's docs --- Source/JavaScript/react/renderer/ComponentRegistry.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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>;