Conversation
Type annotations like `Config.StepTest` were not parsed correctly. The parser consumed only the first identifier (`Config`) and treated the dot as an unexpected token, causing function definitions with dotted return types to be silently dropped. This affected the hk builtins TestMaker helper class where functions like `checkFail`, `checkPass`, `fixPass`, and `fixFail` were all lost during parsing.
Pkl allows `;` as a property separator in object bodies (e.g., `new { file = "foo"; contains = "bar" }`). The lexer was rejecting them as unexpected characters. Now `;` is skipped along with other whitespace in `skip_whitespace_and_comments`.
Two changes work together to fix `outer.optionalProp` resolution in object amendments (the `outer.before` pattern in hk builtins): 1. Arc-wrap Lambda captures (`Value::Lambda` third field changed from `IndexMap` to `Arc<IndexMap>`). Previously, cloning a Lambda deep-copied its entire capture map, causing O(2^n) cost when successive local functions (makeTest, checkPass, checkFail, fixPass, fixFail) each captured the previous ones. With Arc the clone is O(1). 2. In `eval_amended_object`, seed `Null` into `eval_scope` for any nullable-no-default base class properties not already present. This makes `outer.before` (and similar optional properties) resolve to `Null` rather than failing with "field not found" when the property was never assigned in the class or overlay. Together these allow all 128+ hk builtins to evaluate successfully, including those using the `TestMaker` pattern with `outer.before`.
Greptile SummaryThis PR fixes three distinct bugs in pklr: outer.optionalProp now resolves to Null instead of panicking for nullable class properties with no default; Value::Lambda and Value::Object captures are Arc-wrapped for O(1) clone (eliminating O(2^n) deep-copy on large scopes); and semicolons are now treated as whitespace in the lexer so Pkl optional property separators are accepted. Confidence Score: 5/5Safe to merge; all three fixes are well-scoped and backed by regression tests. Remaining findings are P2 style notes that do not affect correctness on the described code paths. src/eval.rs nullable seeding (union-type gap is P2) Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[eval_amended_object] --> B[Build eval_scope from base + current scope]
B --> C{For each base Property}
C -->|nullable, no default, absent from scope| D[Seed Null into eval_scope]
C -->|otherwise| E[Skip]
D --> F[eval_entries with seeded scope]
E --> F
F --> G[set outer = flatten of seeded scope]
G --> H[outer.optProp resolves to Null]
Reviews (1): Last reviewed commit: "fix(eval): resolve nullable outer proper..." | Re-trigger Greptile |
| for entry in base_entries { | ||
| if let Entry::Property(prop) = entry | ||
| && prop.value.is_none() | ||
| && prop.body.is_none() | ||
| && !has_modifier(&prop.modifiers, Modifier::Local) | ||
| && eval_scope.get(&prop.name).is_none() | ||
| && matches!(prop.type_ann, Some(crate::parser::TypeExpr::Nullable(_))) | ||
| { | ||
| eval_scope.set(prop.name.clone(), Value::Null); | ||
| } |
There was a problem hiding this comment.
Nullable seeding misses union-type nullable annotations
The guard matches!(prop.type_ann, Some(TypeExpr::Nullable(_))) catches the common Prop? spelling, but Pkl also supports expressing optionality as a union, e.g. prop: (String|Null). Those produce a TypeExpr::Union variant and are skipped here, so outer.prop on such a property would still fail with "field not found". The same gap exists in the parallel seeding in eval_entries. Narrow edge case not present in the reported bug, but worth tracking.
| j: String? | ||
| k: String? | ||
| l: String? | ||
| m: String? | ||
| n: String? | ||
| o: String? | ||
| p: String? | ||
| q: String? | ||
| r: String? | ||
| s: String? | ||
| t: String? | ||
| env: Mapping<String, String> = new Mapping<String, String> {} | ||
| tests: Mapping<String, StepTest> = new Mapping<String, StepTest> {} | ||
| } | ||
|
|
||
| class Hook { | ||
| fix: Boolean? | ||
| stash: String? | ||
| env: Mapping<String, String> = new Mapping<String, String> {} | ||
| steps: Mapping<String, Step> = new Mapping<String, Step> {} | ||
| } | ||
|
|
||
| hooks: Mapping<String, Hook> = new Mapping<String, Hook> {} | ||
| "#; | ||
| let mut evaluator = pklr::Evaluator::new(); | ||
| let result = evaluator | ||
| .eval_source(src, std::path::Path::new("test_nested.pkl")) | ||
| .await; | ||
| eprintln!("eval completed, is_ok={}", result.is_ok()); | ||
| if let Err(ref e) = result { | ||
| eprintln!("error: {e}"); | ||
| } | ||
| assert!(result.is_ok()); | ||
| } | ||
|
|
||
| #[tokio::test] |
There was a problem hiding this comment.
Local-path tests silently pass when fixture is absent
Several tests depending on /tmp/hk-extracted/ do an early return when the file is missing, so cargo test -- --ignored reports them as passed even without the fixture. Consistently relying on #[ignore = "..."] (already used on some tests) and removing the early-return guard would make the skip intent explicit.
There was a problem hiding this comment.
Code Review
This pull request optimizes the evaluator by wrapping object and lambda maps in Arc for O(1) cloning and switching Scope to use Rc. It also enhances the parser to support dotted type names, updates the lexer to treat semicolons as whitespace, and implements logic to seed Null for nullable properties without defaults. Feedback highlights that the current nullability check is too restrictive and the logic is duplicated, suggesting a refactor into a more comprehensive helper method.
| for entry in entries { | ||
| if let Entry::Property(prop) = entry | ||
| && prop.value.is_none() | ||
| && prop.body.is_none() | ||
| && !has_modifier(&prop.modifiers, Modifier::Local) | ||
| && !outer_map.contains_key(&prop.name) | ||
| && matches!(prop.type_ann, Some(crate::parser::TypeExpr::Nullable(_))) | ||
| { | ||
| outer_map.insert(prop.name.clone(), Value::Null); | ||
| } | ||
| } |
There was a problem hiding this comment.
The logic for seeding Null for nullable properties is duplicated here and in eval_amended_object (lines 1247-1257). Furthermore, the current check matches!(prop.type_ann, Some(crate::parser::TypeExpr::Nullable(_))) is too restrictive. In Pkl, properties are also nullable if they are of type Any, if they are untyped (which defaults to Any), or if they are a Union type that includes Null (e.g., String | Null).
Consider refactoring this into a helper method that performs a more comprehensive nullability check to ensure consistency across the evaluator.
## 🤖 New release * `pklr`: 0.4.0 -> 0.4.1 (✓ API compatible changes) <details><summary><i><b>Changelog</b></i></summary><p> <blockquote> ## [0.4.1](v0.4.0...v0.4.1) - 2026-04-07 ### Fixed - resolve nullable outer property access and semicolons ([#54](#54)) </blockquote> </p></details> --- This PR was generated with [release-plz](https://github.com/release-plz/release-plz/). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a summary for commit f211a5c. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Includes this fix: jdx/pklr#54
Original error
Summary
Lambdacaptures so cloning a Lambda is O(1)Nullintoeval_scopefor nullable-no-default base classproperties so
outer.optPropresolves toNullinstead of erroring;as whitespace so Pkl's semicolon property separator is acceptedDetails
outer.before— field not foundhk's
helpers.pklTestMakerclass uses this pattern:When
outer.beforewas evaluated andbeforehad no default value and was not set on theTestMakerinstance,outer(a snapshot of the scope before evaluating the object body) didn't containbeforeat all, causing a "field not found: before" error.Fix: in
eval_amended_object, after buildingeval_scopefrombase_scope + current_scope, iterate over the base entries and insertNullfor any nullable-no-default property absent fromeval_scope. Sinceeval_scopebecomesouterin the childeval_entriescall,outer.beforenow resolves toNull.O(2^n) Lambda capture cloning
TestMakerdefines five local functions in sequence:makeTest,checkPass,checkFail,fixPass,fixFail. Each is a deferred lambda that captureschild_scope.flatten()at evaluation time, which includes all previously-defined lambdas. WithLambdacaptures stored as a bareIndexMap, cloning aLambdadeep-copied its entire capture map — causing each successive lambda's clone cost to double, resulting in O(2^n) total work whenscope.flatten()was called across 128 hk builtins.Fix: change
Value::Lambda's captures fromIndexMap<String, Value>toArc<IndexMap<String, Value>>. Clone is now O(1).Semicolons as property separators
Pkl allows
;as a separator between properties in object bodies (e.g.,new { file = "package.json"; contains = "..." }). The lexer was rejecting them as unexpected characters.Fix: skip
;alongside whitespace inskip_whitespace_and_comments.(
checkFailwas missing because its function definition used a dotted return typeConfig.StepTest, fixed in the previous commit. After that fix, the error becamefield not found: before.)Test plan
HK_PKL_BACKEND=pklr hk run checksucceeds in inko-tantivy