From 7f03d2eae06975bf9406605ccc7963bf02ad4846 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:02:42 +0200 Subject: [PATCH] feat(schema): typed CLI properties for extends-based formae Forma files can now extend the base module and declare CLI properties as a plain typed class: extends "@formae/forma.pkl" properties: Props class Props { /// The name to deploy name: String = "pel" port: Int(this > 0) = 8080 } Member name = flag, declared default = default, doc comment = help text, and pkl type constraints validate CLI input. Property access is statically typed, so editors resolve properties.name instead of flagging it as unresolved. Mechanics: forma.pkl's output now self-injects - on first evaluation the module amends itself with a properties instance filled from external prop: values (reflect members, read, coerce by declared type, fall back to declared defaults, validate via toTyped), then evaluates the amended module's output. Late binding carries the injected values into all resources. This mirrors how pkl:Command's runtime injects options (synthesize a module that amends the command module). The manifest renders Value from the instance so manifest and resources always agree. Backward compatible: properties is widened to Any?, legacy properties{} blocks pass through the render unchanged, amends-based formae keep working against the now-open module, and their serialized JSON is byte-identical. Stray module properties never render (output serializes FormaRender only). Not included yet: unknown-property validation throw, scaffold/testdata migration, docs. --- internal/schema/pkl/schema/forma.pkl | 24 ++++++++-- internal/schema/pkl/schema/formae.pkl | 48 ++++++++++++++++++- .../pkl/testdata/forma/extends_props_test.pkl | 39 +++++++++++++++ 3 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 internal/schema/pkl/testdata/forma/extends_props_test.pkl diff --git a/internal/schema/pkl/schema/forma.pkl b/internal/schema/pkl/schema/forma.pkl index b4f231bbe..004e2f202 100644 --- a/internal/schema/pkl/schema/forma.pkl +++ b/internal/schema/pkl/schema/forma.pkl @@ -4,12 +4,16 @@ * SPDX-License-Identifier: FSL-1.1-ALv2 */ -module Forma +open module Forma import "formae.pkl" description: formae.Description? -properties: Dynamic? +properties: Any? + +/// fill: entry point used by the self-injecting output below; constructs a +/// typed instance of the user's properties class from external `prop:` values. +function fill(clazz: Class): Typed = formae.fillProps(clazz) hidden forma: Listing /// defaultStack: returns the first defined Stack @@ -22,17 +26,27 @@ hidden defaultTarget: formae.TargetResolvable = forma.toList().filterIsInstance(formae.Target).firstOrNull?.res ?? forma.toList().filterIsInstance(formae.TargetResolvable).firstOrNull ?? throw("no default target found") +// hidden guard for the self-injecting output: the first evaluation amends this +// module with a filled `properties` instance (reading `prop:` values), the +// second renders normally with the injected values visible everywhere via +// late binding. Same mechanism pkl:Command's runtime uses ("synthesize a +// module that amends the command module and sets options"). +hidden injected: Boolean = false + // NOTE: a lot of this would be uneeded if bugs or unfinished implementation issues were resolved in PKL output { value = - if (formae.mode() == "properties") + if (!injected && properties != null && properties is Typed) + let (m = module) + ((m) { injected = true; properties = m.fill(m.properties.getClass()) }).output.value + else if (formae.mode() == "properties") new formae.FormaRender { - Properties = module.properties + Properties = formae.renderProperties(module.properties) } else new formae.FormaRender { Description = module.description - Properties = module.properties + Properties = formae.renderProperties(module.properties) Stacks = forma.toList().filterIsInstance(formae.Stack).toListing() Targets = forma.toList().filterIsInstance(formae.Target).toListing() Policies = forma.toList().filterIsInstance(formae.Policy).map((p) -> p.render()).toListing() diff --git a/internal/schema/pkl/schema/formae.pkl b/internal/schema/pkl/schema/formae.pkl index deb42fe3c..77f1ba2b0 100644 --- a/internal/schema/pkl/schema/formae.pkl +++ b/internal/schema/pkl/schema/formae.pkl @@ -432,12 +432,58 @@ open class Prop { fixed Value: (String|Boolean|Int|Float)? = value } +// ---- typed CLI properties (RFC-105) ---------------------------------------- + +/// Resolve a reflected type to its simple name, unwrapping nullables. +function typeName(t): String = + if (t is reflect.DeclaredType) t.referent.name + else if (t is reflect.NullableType) typeName(t.member) + else "unknown" + +/// fillProps constructs a typed instance of `clazz`, reading `prop:` +/// per member, coercing to the declared type, and falling back to the declared +/// default. Type constraints on the class members are validated by `toTyped`. +/// Prior art: pkl-pantry `pkl.experimental.structuredRead`. +function fillProps(clazz: Class): Typed = + new Mapping { + for (n, p in reflect.Class(clazz).properties) { + [n] = let (raw = read?("prop:\(n)")) + if (raw == null) p.defaultValue + else + let (tn = typeName(p.type)) + if (tn == "Int") raw.toInt() + else if (tn == "Float") raw.toFloat() + else if (tn == "Boolean") raw.toBoolean() + else raw + } + }.toMap().toTyped(clazz) + +/// renderProperties turns the module's `properties` value into the manifest the +/// CLI/agent consume. Typed instances (the extends shape) are reflected into +/// `{ name -> Prop }`; legacy Dynamic blocks pass through unchanged. The +/// manifest Value always comes from the INSTANCE (never a self-reading Prop), +/// so manifest and resources cannot disagree. +function renderProperties(opts: Any?): Any? = + if (opts == null) new Mapping {} + else if (!(opts is Typed)) opts + else + let (inst = opts.toDynamic().toMap()) + new Mapping { + for (name, p in reflect.Class(opts.getClass()).properties) { + [name] = new Prop { + flag = name + default = p.defaultValue as (String|Boolean|Int|Float)? + value = inst[name] as (String|Boolean|Int|Float)? + } + } + } + /// FormaeRender is an ouput class notice the casing difference class FormaRender { local self = this Description: Description? - Properties: Dynamic? + Properties: Any? Stacks: Listing Targets: Listing Policies: Listing? diff --git a/internal/schema/pkl/testdata/forma/extends_props_test.pkl b/internal/schema/pkl/testdata/forma/extends_props_test.pkl new file mode 100644 index 000000000..393438d6f --- /dev/null +++ b/internal/schema/pkl/testdata/forma/extends_props_test.pkl @@ -0,0 +1,39 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +/// RFC-105 target shape: extends + pure-schema typed properties. +extends "@formae/forma.pkl" + +import "@formae/formae.pkl" +import "@fakeaws/fakeaws.pkl" +import "@fakeaws/sqs/queue.pkl" + +properties: Props + +class Props { + /// The name to deploy + name: String = "pel" + + /// Port to listen on + port: Int(this > 0) = 8080 +} + +forma { + new formae.Stack { + label = "\(properties.name)-stack" + description = "stack for \(properties.name)" + } + + new formae.Target { + label = "default-target" + config = new fakeaws.Config { region = "us-east-1" } + } + + new queue.Queue { + label = "q" + queueName = "\(properties.name)-\(properties.port)-queue" + } +}