Documentation
¶
Overview ¶
Package kcleval evaluates ONE KCL file of a project and selects fields out of the result, so nothing outside forge ever has to run `kcl` itself.
The gap this closes ¶
Since forge started supplying the `forge` KCL module from the binary (docs/adr/0003-kcl-module-from-the-binary.md), a plain `kcl run` on a file that says `import forge` CANNOT resolve it: there is no kcl.mod dependency to fetch and no project-local copy to read. That is deliberate — the binary rendering a project IS the module version it renders against — and it is also why a consumer with a legitimate need to read a value out of project KCL had nowhere to go.
forge's answer was `forge env render <env>`, which evaluates an ENVIRONMENT and prints Kubernetes objects. A project also keeps plain declarative constants in library files — control-plane's `deploy/kcl/lib/kata_pool.k` declares GKE node-pool inputs, its `lib/daemon_placement.k` declares which cluster each env's daemon pods land in — and those are read by shell scripts and Go tests, not deployed. There was no forge command that evaluated an arbitrary FILE and handed back one field.
So consumers reconstructed forge's own render setup from outside: a control-plane shell library made forge materialize its module into FORGE_KCL_MODULE_CACHE, dug the directory out, and ran `kcl run <file> -S <field> -E forge=<dir>`. That works right up until any part of forge's render setup changes — the cache layout, the module name, the plugin namespace — at which point it breaks in a consumer's CI with an error about KCL, naming nothing that would lead anyone back here. It also requires `kcl` on PATH, which forge itself has not needed since it embedded the runtime.
What this is NOT ¶
Not an env render. Nothing here resolves an image tag, reads build state, routes objects to clusters, or templates a helm chart — see internal/cli/env_render.go for that. The unit is a file and the output is the file's own values.
Not a port claim. Evaluation arms the read-only halves of the render context (internal/cli.armReadOnlyKCLContext): `fp.allocate_port` resolves keys to blocks they already hold and `fp.resolve_port` reads its store without writing it, so evaluating a file can neither hand out a port block nor fail at the dev_stack ceiling. A read-only command never claims a port block.
Why selection happens HERE and not in kcl ¶
kcl's own `-S` renders a selected LIST as one YAML document per element, which is unusable without knowing how many elements to expect; control-plane's check-kata-pool-staleness.sh carries a hand-written workaround for exactly this, and a comment explaining that an empty result passes a tier name through as data. Selecting in Go against the parsed JSON gives back the value KCL actually produced — an array stays one array — and it is the same traversal for a scalar, a list and a schema instance.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrNoSuchField = errors.New("no such field")
ErrNoSuchField is returned when a selector names a field the evaluated document does not have. It is a distinct error because a caller reading an optional field wants to tell "absent" from "the file does not evaluate", and because a typo'd selector must not read as an empty value — the failure mode that made a kata-pool script pass a tier name through as data.
Functions ¶
func Formats ¶
func Formats() []string
Formats are the accepted --format values, for the flag's help and its refusal message.
func Render ¶
Render turns a Result into the bytes to print.
Why FormatRaw exists ¶
It is the format that removes the reason anyone reached for `kcl` directly. `kcl run -S <field>` prints YAML, and YAML QUOTES a scalar that would otherwise parse as something else — so every caller wanting a plain string wrote a cleanup pipeline. control-plane's build-workspace-sbd.sh carries six of them:
BUILDER_REPO="$(kcl run -S sbd.builder_repo "${KCL}" | tr -d "\n'\"")"
That `tr` deletes every newline, single quote and double quote ANYWHERE in the value, so a value legitimately containing one comes back silently corrupted, and the pipeline is why nobody noticed the quoting rule differed per value. FormatRaw prints the scalar's own text and nothing else — no quotes, no trailing newline — so `$(...)` captures exactly the value:
BUILDER_REPO="$(forge kcl eval "${KCL}" -S sbd.builder_repo --format raw)"
A number prints as KCL's own text: `-S sbd.disk_size_gb` must produce `200` for a caller passing it to `gcloud --boot-disk-size`, and `200` is what the KCL says. The document keeps numbers as json.Number rather than float64, so nothing is reformatted on the way through.
It refuses a composite rather than falling back to JSON. A caller asking for raw wants a value it can interpolate into a command line; handing it `{"a":1}` would interpolate cleanly and mean nothing, and the failure would surface as whatever that command did with a JSON blob. Naming the problem at the boundary is the whole value of a typed refusal here.
func Select ¶
Select walks a dotted path through a decoded KCL document.
A numeric segment indexes a list, so `pools.0.name` reaches into one, and a map key that happens to be numeric still wins over the list reading — checked in that order because a map is the common case and a KCL map may legitimately be keyed "0".
Types ¶
type Format ¶
type Format string
Format is how a Result is printed.
const ( // FormatJSON is the default: the value as JSON, indented. FormatJSON Format = "json" // FormatYAML is the value as YAML, which is what kcl's own `-S` prints. FormatYAML Format = "yaml" // FormatRaw is a scalar's own text, with no quoting and no trailing // newline. See Render. FormatRaw Format = "raw" )
type Object ¶ added in v1.0.0
type Object []Member
Object is a KCL dict or schema instance with its keys in the order KCL declared them.
It exists because the render arrives as JSON in declared order and a Go map has none: decoding into map[string]any alphabetised every `--format json` document, so a consumer comparing committed JSON byte for byte could not use forge's output and needed a second `kcl run` pass. KCL cannot repeat a key, so a slice of members loses nothing a map held.
A decoded document holds only Object, []any, string, json.Number, bool and nil. Numbers stay json.Number — KCL's own text — so `1.0` and `1e+20` print as KCL wrote them rather than as a float64 would.
type Request ¶
type Request struct {
// ProjectDir is the project root (the directory holding forge.yaml). The
// kcl.mod migration checks read it, and File must resolve inside it.
ProjectDir string
// File is the .k file to evaluate, relative to ProjectDir or absolute.
File string
// Selectors are dotted field paths to take out of the result, in the
// order given. Empty selects the whole document.
Selectors []string
// Options are `key=value` top-level option bindings, as `-D` passes
// them to kcl.
Options []string
// ProcessRooted is for an isolated CLI child already started with its cwd
// at ProjectDir. It avoids even a temporary process-global chdir.
ProcessRooted bool
}
Request is one evaluation: a file, the selections to take out of it, and the `-D` option bindings to evaluate it under.
type Result ¶
type Result struct {
// Value is the selected value: the whole document when no selector was
// given, the selected value for exactly one, and an object keyed by
// selector for several. It is built from Object, []any, string,
// json.Number, bool and nil, so key order and number text survive.
Value any
// Scalar reports whether Value is a single non-composite value, which is
// what --format raw is allowed to print.
Scalar bool
}
Result is what an evaluation produced.
func Eval ¶
Eval evaluates req.File and applies req.Selectors.
The evaluation runs with the PROJECT ROOT as the working directory, exactly as `env render` does, so one project has one evaluation semantics regardless of which file is being evaluated or where the caller stands. Two things follow, and they are the two the shell scripts this replaces had to know about:
- A relative `lib.*` import resolves from the file's own kcl.mod package, because that is how kpm resolves a package — not from the cwd. The scripts' `cd deploy/kcl` was never what made their imports work.
- A `file.read("deploy/...")` resolves from the project root, because the KCL runtime reads relative paths against the process cwd. THAT is what the `cd` was load-bearing for, and getting it wrong fails with "No such file or directory" naming a path that plainly exists.
A caller in a subdirectory therefore gets the same answer as one at the project root — the property `forge -C <dir>` was missing (see kclrender.Run's chdir note).