From 4e8d01d98e6f3e8a31d6720e835393d4e0af9904 Mon Sep 17 00:00:00 2001 From: Tony Thayer-Osborne Date: Mon, 3 Aug 2026 10:48:24 -0700 Subject: [PATCH 1/3] feat(pkl): extract calls, branches, throws and decorators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pkl was wired end-to-end but shallow: on a real-world 172-file Pkl corpus the graph had 386 IMPORTS edges and 0 CALLS edges, because pkl_call_types was empty_types. Pkl has no dedicated call node. `unqualifiedAccessExpr` and `qualifiedAccessExpr` are the same node whether they are a call (`helper(a)`) or a bare property read (`host`) — the only discriminator is an `argumentList` child. extract_pkl_callee gates on that child, and is dispatched from extract_callee_name with an unconditional return: falling through to field-based or generic first-identifier resolution would mint a CALLS edge for every property read in every Pkl file, since a bare access expr's first child is an identifier. Callee resolution: helper(a) -> "helper" utils.fallback(a) -> "utils.fallback" (module-qualified; cbm.c shortens to the last dotted segment) s.trim().toLowerCase() -> "toLowerCase" (a receiver that is itself a call has parens in its text and is not prefixed) new Server { ... } -> "Server" (links to the class def) Also fills the other empty slots in the Pkl spec, all confirmed against real parse trees rather than guessed: typeAlias (class), importGlobClause / importExpr (import), ifExpr / whenGenerator / forGenerator (branch), throwExpr (throw), annotation (decorator). forGenerator is additionally registered in cbm_is_loop_node_type so `for (x in xs)` counts toward loop-nesting depth; the name is Pkl-unique so no other grammar collides. The Pkl repro test moves from the structural battery (dims 1-5) to the full callable battery (dims 1-8) and adds an inline negative assertion that bare property reads do NOT become CALLS edges — the regression the argumentList gate exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Tony Thayer-Osborne --- internal/cbm/extract_calls.c | 68 +++++++++++++++++++++++++ internal/cbm/helpers.c | 31 +++--------- internal/cbm/lang_specs.c | 22 +++++--- tests/repro/repro_grammar_config.c | 80 ++++++++++++++++++++++-------- 4 files changed, 151 insertions(+), 50 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index f7ee7bafa..4af481b76 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -786,6 +786,65 @@ static char *extract_nickel_callee(CBMArena *a, TSNode node, const char *source, return NULL; } +// Pkl: `unqualifiedAccessExpr` / `qualifiedAccessExpr` are the same node whether +// they are a call (`helper(a)`) or a bare property read (`host`) — the only +// discriminator is an `argumentList` child, so both are gated on it. For a +// qualified call the method name is the `identifier` child that is not the +// `receiver`; the receiver is prefixed only when it is itself a plain name +// (`utils.fallback(a)` -> "utils.fallback", module-qualified, which cbm.c +// shortens to the last dotted segment when resolving). A receiver that is itself +// a call must NOT be prefixed: `s.trim().toLowerCase()` -> "toLowerCase", since +// the receiver's text carries parens and would never resolve. +// `newExpr` resolves to its `declaredType` so `new Server {}` links to the class. +static char *extract_pkl_callee(CBMArena *a, TSNode node, const char *source, const char *nk) { + if (strcmp(nk, "newExpr") == 0) { + // `new { ... }` with an inferred type has no declaredType child. + TSNode dt = cbm_find_child_by_kind(node, "declaredType"); + return ts_node_is_null(dt) ? NULL : cbm_node_text(a, dt, source); + } + + bool qualified = strcmp(nk, "qualifiedAccessExpr") == 0; + if (!qualified && strcmp(nk, "unqualifiedAccessExpr") != 0) { + return NULL; + } + // No argument list -> property read, not a call. + if (ts_node_is_null(cbm_find_child_by_kind(node, "argumentList"))) { + return NULL; + } + + TSNode recv = ts_node_child_by_field_name(node, TS_FIELD("receiver")); + TSNode name = (TSNode){0}; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode child = ts_node_named_child(node, i); + if (!ts_node_is_null(recv) && ts_node_eq(child, recv)) { + continue; + } + if (strcmp(ts_node_type(child), "identifier") == 0) { + name = child; + break; + } + } + if (ts_node_is_null(name)) { + return NULL; + } + char *mn = cbm_node_text(a, name, source); + if (!mn || !mn[0]) { + return NULL; + } + if (!qualified || ts_node_is_null(recv)) { + return mn; + } + if (strcmp(ts_node_type(recv), "unqualifiedAccessExpr") == 0 && + ts_node_is_null(cbm_find_child_by_kind(recv, "argumentList"))) { + char *rt = cbm_node_text(a, recv, source); + if (rt && rt[0]) { + return cbm_arena_sprintf(a, "%s.%s", rt, mn); + } + } + return mn; +} + // Typst: a `call` node's callee is its `item` field (an ident), matching the // def-side resolution of `#let greet(name) = ...`. static char *extract_typst_callee(CBMArena *a, TSNode node, const char *source, const char *nk) { @@ -1350,6 +1409,15 @@ static char *extract_callee_name(CBMArena *a, TSNode node, const char *source, C } } + /* Pkl: resolve here and return unconditionally — the access-expr call node + * types double as plain property reads, so falling through to field-based or + * generic first-identifier resolution would mint a CALLS edge for every + * property read (a bare `host` has an `identifier` first child, which the + * generic fallback would happily emit). NULL here means "not a call". */ + if (lang == CBM_LANG_PKL) { + return extract_pkl_callee(a, node, source, ts_node_type(node)); + } + // Helm / Go templates: resolve `include "x"` / `template "x"` to the // referenced named template so it links to the define'd Function (#338). if (lang == CBM_LANG_GOTEMPLATE) { diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 027c82a21..a57aa62a9 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -523,29 +523,14 @@ int cbm_count_branching(TSNode node, const char **branching_types) { // Loop node-type names across tree-sitter grammars, for loop-nesting depth. bool cbm_is_loop_node_type(const char *kind) { - static const char *const loops[] = {"for_statement", - "while_statement", - "do_statement", - "do_while_statement", - "for_in_statement", - "for_of_statement", - "for_each_statement", - "foreach_statement", - "enhanced_for_statement", - "for_range_loop", - "c_style_for_statement", - "for_expression", - "while_expression", - "loop_expression", - "while_let_expression", - "repeat_statement", - "repeat_while_statement", - "until", - "while_modifier", - "until_modifier", - "for", - "while", - NULL}; + static const char *const loops[] = { + "for_statement", "while_statement", "do_statement", "do_while_statement", + "for_in_statement", "for_of_statement", "for_each_statement", "foreach_statement", + "enhanced_for_statement", "for_range_loop", "c_style_for_statement", "for_expression", + "while_expression", "loop_expression", "while_let_expression", "repeat_statement", + "repeat_while_statement", + // Pkl: `for (x in xs) { ... }` inside an object body. + "forGenerator", "until", "while_modifier", "until_modifier", "for", "while", NULL}; for (const char *const *l = loops; *l; l++) { if (strcmp(kind, *l) == 0) { return true; diff --git a/internal/cbm/lang_specs.c b/internal/cbm/lang_specs.c index bcc4b5548..9b3bc1f9b 100644 --- a/internal/cbm/lang_specs.c +++ b/internal/cbm/lang_specs.c @@ -1557,11 +1557,21 @@ static const char *tlaplus_branch_types[] = {"if_then_else", "case", NULL}; static const char *tlaplus_var_types[] = {"variable_declaration", NULL}; static const char *tlaplus_module_types[] = {"source_file", NULL}; static const char *pkl_func_types[] = {"classMethod", "objectMethod", NULL}; -static const char *pkl_class_types[] = {"clazz", NULL}; -static const char *pkl_import_types[] = {"importClause", "extendsOrAmendsClause", "extends", - "import", NULL}; +static const char *pkl_class_types[] = {"clazz", "typeAlias", NULL}; +static const char *pkl_import_types[] = { + "importClause", "importGlobClause", "importExpr", "extendsOrAmendsClause", + "extends", "import", NULL}; static const char *pkl_var_types[] = {"classProperty", "objectProperty", NULL}; static const char *pkl_module_types[] = {"module", NULL}; +/* Both access exprs double as plain property reads; extract_pkl_callee keeps + * only the ones carrying an argumentList. `newExpr` resolves to its type. */ +static const char *pkl_call_types[] = {"unqualifiedAccessExpr", "qualifiedAccessExpr", "newExpr", + NULL}; +/* Control-flow only, matching every other spec (short-circuit operators are + * deliberately excluded). `forGenerator` is also a loop — see helpers.c. */ +static const char *pkl_branch_types[] = {"ifExpr", "whenGenerator", "forGenerator", NULL}; +static const char *pkl_throw_types[] = {"throwExpr", NULL}; +static const char *pkl_decorator_types[] = {"annotation", NULL}; static const char *gomod_var_types[] = {"require_directive", "replace_directive", NULL}; static const char *gomod_import_types[] = {"require", NULL}; static const char *gomod_module_types[] = {"source_file", NULL}; @@ -2562,9 +2572,9 @@ static const CBMLangSpec lang_specs[CBM_LANG_COUNT] = { // CBM_LANG_PKL [CBM_LANG_PKL] = {CBM_LANG_PKL, pkl_func_types, pkl_class_types, empty_types, pkl_module_types, - empty_types, pkl_import_types, empty_types, empty_types, pkl_var_types, - empty_types, empty_types, NULL, empty_types, NULL, NULL, tree_sitter_pkl, - NULL}, + pkl_call_types, pkl_import_types, empty_types, pkl_branch_types, + pkl_var_types, empty_types, pkl_throw_types, NULL, pkl_decorator_types, NULL, + NULL, tree_sitter_pkl, NULL}, // CBM_LANG_GOMOD [CBM_LANG_GOMOD] = {CBM_LANG_GOMOD, empty_types, empty_types, empty_types, gomod_module_types, diff --git a/tests/repro/repro_grammar_config.c b/tests/repro/repro_grammar_config.c index 9b143cfe3..c76f811b1 100644 --- a/tests/repro/repro_grammar_config.c +++ b/tests/repro/repro_grammar_config.c @@ -45,7 +45,8 @@ * 6. calls-extracted : inv_has_call(r, callee) == 1. * Only asserted for languages that have non-empty * call_types: HCL (function_call), NICKEL (infix_expr), - * JSONNET (functioncall), STARLARK (call). + * JSONNET (functioncall), STARLARK (call), + * PKL (unqualified/qualifiedAccessExpr, newExpr). * * FULL-PIPELINE (rh_index_files -> cbm_store_t*, via inv_count_* store helpers): * 7. callable-sourcing : inv_count_calls_by_source(store,project,&mod,&call). @@ -85,11 +86,15 @@ * Dims 1-5 ("Class"). No calls. * XML -- class_types = element -> "Class". Dims 1-5 ("Class"). No calls. * PROPERTIES -- var_types = property -> "Variable". Dims 1-5 ("Variable"). No calls. - * PKL -- func_types = classMethod/objectMethod -> "Function"; - * class_types = clazz -> "Class"; var_types = classProperty/objectProperty. - * call_types = empty_types. Dims 1-5 ("Function", "Class"). No call dim. * * LANGUAGES WITH CALLABLES (dims 1-6 + R, and pipeline dims 7-8 where applicable): + * PKL -- func_types = classMethod/objectMethod -> "Function"; + * class_types = clazz/typeAlias -> "Class"; + * var_types = classProperty/objectProperty; + * call_types = unqualifiedAccessExpr/qualifiedAccessExpr/newExpr. + * Dims 1-8. The access-expr call types double as property reads, + * so extract_pkl_callee gates them on an `argumentList` child; + * the test adds an inline negative assertion for that gate. * HCL -- class_types = block -> "Class"; var_types = attribute; * call_types = function_call. Dims 1-6. No func_types so no pipeline * dim 7 (calls would be module-sourced with no Function anchor). @@ -766,39 +771,72 @@ TEST(repro_grammar_config_ron) { /* ── PKL ────────────────────────────────────────────────────────────────────── * Idiomatic PKL (Apple Pkl) module with a class definition - * (pkl_class_types = {"clazz"} -> "Class"), a method inside it + * (pkl_class_types = {"clazz", "typeAlias"} -> "Class"), methods inside it * (pkl_func_types = {"classMethod", "objectMethod"} -> "Function"), and * class properties (pkl_var_types = {"classProperty", "objectProperty"}). - * pkl_call_types = empty_types so no call extraction occurs. - * - * Dims asserted: 1-5 + R ("Class" for the class def, "Function" for the method). - * Dims 6-8 SKIPPED: call_types = empty_types in spec. - * Expected GREEN: dims 1-5. Dim 5 RED would indicate clazz->Class or - * classMethod->Function mapping is broken in the PKL grammar walker. + * pkl_call_types = {"unqualifiedAccessExpr", "qualifiedAccessExpr", "newExpr"}. + * + * Dims asserted: 1-8 (full battery) + R. + * Dim 6 GREEN: `makeUrl(host, port)` inside url() extracts callee "makeUrl". + * Dim 7 GREEN: every call site in the fixture is inside a classMethod body, so + * no CALLS edge is Module-sourced. (Real-world Pkl does call at module level; + * the fixture deliberately avoids it because dim 7 treats Module-sourced + * in-body calls as the enclosing-func gap.) + * Dim 8 GREEN: makeUrl and Server are both defined in-file, so neither the + * unqualified call nor the newExpr constructor edge dangles. + * + * PKL-SPECIFIC REGRESSION (asserted inline below): `unqualifiedAccessExpr` and + * `qualifiedAccessExpr` are the same node for a call and for a bare property + * read, so the interpolated `host` / `port` reads inside makeUrl must NOT be + * emitted as CALLS. extract_pkl_callee gates on an `argumentList` child; without + * that gate every property read in every Pkl file becomes a call edge. */ TEST(repro_grammar_config_pkl) { static const char src[] = "module cbm.Config\n" "\n" - "function makeUrl(host: String, port: Int): String = \"http://\\(host):\\(port)\"\n" + "typealias Port = Int\n" + "\n" + "function makeUrl(host: String, port: Port): String = \"http://\\(host):\\(port)\"\n" "\n" "class Server {\n" " host: String = \"localhost\"\n" - " port: Int = 8080\n" + " port: Port = 8080\n" " tls: Boolean = false\n" "\n" - " function url(): String = \"http://\\(host):\\(port)\"\n" - "}\n" + " function url(): String = makeUrl(host, port)\n" "\n" - "server = new Server {\n" - " host = \"0.0.0.0\"\n" - " port = 9000\n" + " function clone(): Server = new Server { host = host }\n" "}\n"; static const char bad[] = "module cbm.Config\nclass Server {\n host:"; - if (config_struct_battery("PKL", src, CBM_LANG_PKL, "config.pkl", - "Class", "Function") != 0) + if (config_callable_battery("PKL", src, CBM_LANG_PKL, "config.pkl", + "Function", "makeUrl") != 0) + return 1; + + /* Bare property reads must not be calls (see PKL-SPECIFIC REGRESSION above). */ + CBMFileResult *pr = inv_rx(src, CBM_LANG_PKL, "config.pkl"); + if (!pr) { + printf(" %sFAIL%s [PKL] inv_rx returned NULL\n", tf_red(), tf_reset()); + return 1; + } + int bogus = 0; + for (int i = 0; i < pr->calls.count; i++) { + const char *cn = pr->calls.items[i].callee_name; + if (cn && (strcmp(cn, "host") == 0 || strcmp(cn, "port") == 0 || + strcmp(cn, "tls") == 0)) { + bogus++; + } + } + cbm_free_result(pr); + if (bogus != 0) { + printf(" %sFAIL%s [PKL] property-read-not-call: %d bare property read(s) " + "emitted as a CALLS edge\n", tf_red(), tf_reset(), bogus); + return 1; + } + + if (config_robustness("PKL", bad, CBM_LANG_PKL, "config.pkl") != 0) return 1; - return config_robustness("PKL", bad, CBM_LANG_PKL, "config.pkl"); + return config_pipeline_battery("PKL", "config.pkl", src); } /* ── NICKEL ─────────────────────────────────────────────────────────────────── From a700bff1e8d5b7e070ff378759815792e9cba3cd Mon Sep 17 00:00:00 2001 From: Tony Thayer-Osborne Date: Mon, 3 Aug 2026 16:25:10 -0700 Subject: [PATCH 2/3] fix(ci): stop kill-grace from bounding Windows helper spawns The Windows shard failed Step 0i (parallel suite scheduler contract) with "cleanup failed: hang_after_summary: leader exited leaving live descendants" while its sibling shard passed the same contract. Nothing in the wave was actually leaking a process. run-test-wave.py passed --kill-grace as the subprocess timeout for the two external Windows helpers: taskkill.exe /T /F and the powershell.exe Get-CimInstance descendant probe. Those are different quantities. kill_grace budgets how long a doomed process may take to die; the helpers also have to pay process spawn plus, for PowerShell, CIM startup. The contract fixtures run with --kill-grace 1, and one second is not reliably enough to launch either helper on a loaded runner. The two timeouts then compounded. A timed-out taskkill is reported as "could not prove process-tree cleanup", which raises out of the wave loop; the finally-block cleanup re-enters with the leader already dead, so it falls to the descendant probe, which times out on the same one-second budget and takes its "cannot prove absence -> assume the worst" branch. Phantom descendants, red wave. Give helper invocations their own floor, max(kill_grace, 30). kill_grace still governs every actual death wait, so nothing fails open: the timeout-race contract still refuses with rc=2 over a genuinely surviving descendant, now because PowerShell answered rather than because it timed out. The production path already passed --kill-grace 15 and is unchanged in behaviour. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Tony Thayer-Osborne --- scripts/run-test-wave.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/run-test-wave.py b/scripts/run-test-wave.py index 44a16c23c..b3998f541 100755 --- a/scripts/run-test-wave.py +++ b/scripts/run-test-wave.py @@ -27,6 +27,14 @@ SKIPPED = re.compile(r"(?:^|, )(?P[0-9]+) skipped") SLOW_SUITES = frozenset(("incremental", "store_arch", "daemon_runtime")) POLL_SECONDS = 0.05 +# Floor for how long an external Windows helper (taskkill.exe, powershell.exe) +# may take to answer. This is deliberately NOT --kill-grace: that flag budgets +# how long a doomed process may take to die, while this budgets process spawn +# plus CIM startup on a loaded runner, which routinely exceeds a second. Wiring +# the two together made a small kill grace flake the whole wave -- taskkill +# timing out is reported as "could not prove cleanup", and the descendant probe +# that runs afterwards fails closed on its own timeout. +WINDOWS_HELPER_TIMEOUT_SECONDS = 30 @dataclass @@ -123,6 +131,10 @@ def start_suite( ) +def windows_helper_timeout(kill_grace: int) -> int: + return max(kill_grace, WINDOWS_HELPER_TIMEOUT_SECONDS) + + def windows_descendants(pid: int, timeout: int) -> bool: """True if any live process still claims `pid` as its parent. @@ -167,7 +179,7 @@ def terminate_process_tree(active: ActiveSuite, kill_grace: int) -> None: # how a deliberately-hanging fixture suite reddened a release run. # taskkill /T cannot walk a tree from a dead PID, so prove cleanup # the only way still available -- nothing is parented to it. - if windows_descendants(process.pid, kill_grace): + if windows_descendants(process.pid, windows_helper_timeout(kill_grace)): raise RuntimeError( f"suite {active.name!r} leader exited leaving live descendants" ) @@ -185,7 +197,7 @@ def terminate_process_tree(active: ActiveSuite, kill_grace: int) -> None: stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - timeout=kill_grace, + timeout=windows_helper_timeout(kill_grace), ) except (OSError, subprocess.TimeoutExpired): completed = None From e484924675a803c60742989a206bb1b41532f6a7 Mon Sep 17 00:00:00 2001 From: Tony Thayer-Osborne Date: Wed, 5 Aug 2026 13:26:05 -0700 Subject: [PATCH 3/3] fix(pkl): register call nodes in the ledgers and bind Pkl declarations Merging main brought in three contracts this branch violated. repro_call_node_manifest requires a table row for every live call_node_types entry, so Pkl's unqualifiedAccessExpr/qualifiedAccessExpr (direct) and newExpr (constructor) each get one, with the partition totals moved to match. The historical-snapshot column is the audited ledger, not an archaeology record -- the suite hard-fails on a row that claims it was absent -- so a newly registered kind joins the snapshot with the change that registers it. repro_language_registry then requires every call-capable language to own exactly one call-argument matrix row, which turned out to be a real extractor gap rather than a bookkeeping entry. Pkl's grammar labels no production with a `name` field, so the generic declared-container rule never recognised a Pkl binding: method names, parameter names, and property names were each re-emitted as an ordinary read of themselves, which Go and Nickel do not do. Add a Pkl occurrence policy that binds the declared identifier of methodHeader, typedIdentifier, classProperty, objectProperty, clazz, and typeAlias, resolved against the nearest such container so annotations, defaults, and bodies stay reads. Recording the binding also supplies the lexical-shadow proof the matrix row asserts. Separately, a700bff1 gave the Windows helpers their own timeout floor but left the harness contract allowing the scheduler only eight seconds to finish refusing -- a path that can spend that floor twice, once proving descendants and once in the cleanup re-entry. The Windows shard died on subprocess.TimeoutExpired, taking shard-completeness with it. Derive the budget from the scheduler's own constant instead of restating it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Tony Thayer-Osborne --- internal/cbm/extract_usages.c | 28 ++++++++++++++++++++++ tests/repro/repro_call_argument_matrix_b.c | 17 ++++++++++--- tests/repro/repro_call_node_manifest.c | 18 ++++++++++---- tests/repro/repro_language_registry.c | 12 +++++----- tests/test_parallel_harness_contract.sh | 26 +++++++++++++++++++- 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/internal/cbm/extract_usages.c b/internal/cbm/extract_usages.c index fd1a8ab42..1bf3a568a 100644 --- a/internal/cbm/extract_usages.c +++ b/internal/cbm/extract_usages.c @@ -404,6 +404,7 @@ typedef enum { CBM_OCCURRENCE_VHDL_INTERFACE, CBM_OCCURRENCE_PINE_FUNCTION, CBM_OCCURRENCE_LLVM_FUNCTION, + CBM_OCCURRENCE_PKL_DECLARATION, } CBMOccurrencePolicy; typedef struct { @@ -522,6 +523,7 @@ static const CBMOccurrenceSpec occurrence_specs[CBM_LANG_COUNT] = { [CBM_LANG_PINE] = {NULL, NULL, CBM_OCCURRENCE_PINE_FUNCTION, false}, [CBM_LANG_PUPPET] = {NULL, NULL, CBM_OCCURRENCE_STANDARD, true}, [CBM_LANG_LLVM_IR] = {llvm_binding_nodes, NULL, CBM_OCCURRENCE_LLVM_FUNCTION, false}, + [CBM_LANG_PKL] = {NULL, NULL, CBM_OCCURRENCE_PKL_DECLARATION, false}, [CBM_LANG_MESON] = {NULL, meson_write_nodes, CBM_OCCURRENCE_STANDARD, true}, [CBM_LANG_GN] = {NULL, gn_write_nodes, CBM_OCCURRENCE_STANDARD, true}, [CBM_LANG_LINKERSCRIPT] = {NULL, linkerscript_write_nodes, CBM_OCCURRENCE_STANDARD, true}, @@ -1015,6 +1017,30 @@ static bool is_pine_function_binding(TSNode node) { return ancestor_field_binds(node, "function_declaration_statement", fields); } +/* Pkl declares names positionally: no production labels the declared identifier + * with a `name` field, so the generic declared-container rule never binds them + * and every method name, parameter name, and property name would be re-emitted + * as an ordinary read of itself. Each container below holds its declared name + * as named child 0; annotations, defaults, and bodies follow it and stay reads. + * Resolve against the NEAREST container so a nested declaration's own name is + * the only occurrence its parent can bind. */ +static bool is_pkl_declaration_binding(TSNode node) { + static const char *const declaration_kinds[] = {"methodHeader", + "typedIdentifier", + "classProperty", + "objectProperty", + "clazz", + "typeAlias", + NULL}; + for (TSNode parent = ts_node_parent(node); !ts_node_is_null(parent); + parent = ts_node_parent(parent)) { + if (kind_in_exact_set(ts_node_type(parent), declaration_kinds)) { + return named_child_contains(parent, 0, node); + } + } + return false; +} + static bool is_policy_binding(CBMExtractCtx *ctx, TSNode node, const CBMOccurrenceSpec *occurrence) { switch (occurrence->policy) { @@ -1071,6 +1097,8 @@ static bool is_policy_binding(CBMExtractCtx *ctx, TSNode node, return is_vhdl_interface_binding(node); case CBM_OCCURRENCE_PINE_FUNCTION: return is_pine_function_binding(node); + case CBM_OCCURRENCE_PKL_DECLARATION: + return is_pkl_declaration_binding(node); case CBM_OCCURRENCE_LLVM_FUNCTION: for (TSNode parent = ts_node_parent(node); !ts_node_is_null(parent); parent = ts_node_parent(parent)) { diff --git a/tests/repro/repro_call_argument_matrix_b.c b/tests/repro/repro_call_argument_matrix_b.c index 8b701ea0a..25e085380 100644 --- a/tests/repro/repro_call_argument_matrix_b.c +++ b/tests/repro/repro_call_argument_matrix_b.c @@ -752,6 +752,12 @@ static const char TLAPLUS_BOUNDED_QUANTIFICATION[] = "Guard(values) == \\A item \\in values : item = item\n" "====\n"; +/* Pkl access expressions double as plain property reads, so the bare fixture + * also proves the un-applied reference is not promoted to a call. */ +static const char PKL_INSIDE[] = "function accept(value: Int): Int = value\n" + "function run(watched: Int): Int = accept(watched)\n"; +static const char PKL_BARE[] = "function run(watched: Int): Int = watched\n"; + static const char APEX_INSIDE[] = "public class Sample {\n" " private static Integer accept(Integer value) {\n" " return value;\n" @@ -958,6 +964,9 @@ static const RoutineArgumentCase LLVM_IR_CASE = ROUTINE_ARGUMENT_CASE( static const RoutineArgumentCase TLAPLUS_CASE = ROUTINE_ARGUMENT_CASE( "TLAPLUS", CBM_LANG_TLAPLUS, "Sample.tla", TLAPLUS_INSIDE, TLAPLUS_BARE, "bound_op", "Guard", "Accept", "values", 1, 1, 0, "TLA+ operator application with a value argument"); +static const RoutineArgumentCase PKL_CASE = ROUTINE_ARGUMENT_CASE( + "PKL", CBM_LANG_PKL, "sample.pkl", PKL_INSIDE, PKL_BARE, "unqualifiedAccessExpr", "run", + "accept", "watched", 1, 1, 0, "native Pkl method application and property-read vocabulary"); static const RoutineArgumentCase APEX_CASE = ROUTINE_ARGUMENT_CASE( "APEX", CBM_LANG_APEX, "Sample.cls", APEX_INSIDE, APEX_BARE, "method_invocation", "run", "accept", "watched", 1, 1, 0, "native method application"); @@ -1166,6 +1175,7 @@ DEFINE_ROUTINE_ARGUMENT_TEST(func, FUNC_CASE) DEFINE_ROUTINE_ARGUMENT_TEST(puppet, PUPPET_CASE) DEFINE_ROUTINE_ARGUMENT_TEST(slang, SLANG_CASE) DEFINE_ROUTINE_ARGUMENT_TEST(llvm_ir, LLVM_IR_CASE) +DEFINE_ROUTINE_ARGUMENT_TEST(pkl, PKL_CASE) DEFINE_ROUTINE_ARGUMENT_TEST(apex, APEX_CASE) DEFINE_ROUTINE_ARGUMENT_TEST(pine, PINE_CASE) DEFINE_ROUTINE_ARGUMENT_TEST(qml, QML_CASE) @@ -1243,15 +1253,15 @@ TEST(repro_call_argument_matrix_b_domain_bitbake) { } enum { - ROUTINE_ARGUMENT_LANGUAGE_COUNT = 36, + ROUTINE_ARGUMENT_LANGUAGE_COUNT = 37, MODULE_ARGUMENT_LANGUAGE_COUNT = 4, DOMAIN_CONTROL_LANGUAGE_COUNT = 6, MATRIX_LANGUAGE_COUNT = ROUTINE_ARGUMENT_LANGUAGE_COUNT + MODULE_ARGUMENT_LANGUAGE_COUNT + DOMAIN_CONTROL_LANGUAGE_COUNT, }; -_Static_assert(MATRIX_LANGUAGE_COUNT == 46, - "RACKET..OBJECTSCRIPT_ROUTINE call-capable matrix must contain exactly 46 " +_Static_assert(MATRIX_LANGUAGE_COUNT == 47, + "RACKET..OBJECTSCRIPT_ROUTINE call-capable matrix must contain exactly 47 " "language rows"); #define MATRIX_B_LANGUAGE_ROWS(X) \ @@ -1283,6 +1293,7 @@ _Static_assert(MATRIX_LANGUAGE_COUNT == 46, X(repro_call_argument_matrix_b_routine_slang, SLANG_CASE.identity.language) \ X(repro_call_argument_matrix_b_routine_llvm_ir, LLVM_IR_CASE.identity.language) \ X(repro_call_argument_matrix_b_routine_tlaplus, TLAPLUS_CASE.identity.language) \ + X(repro_call_argument_matrix_b_routine_pkl, PKL_CASE.identity.language) \ X(repro_call_argument_matrix_b_routine_apex, APEX_CASE.identity.language) \ X(repro_call_argument_matrix_b_routine_pine, PINE_CASE.identity.language) \ X(repro_call_argument_matrix_b_routine_qml, QML_CASE.identity.language) \ diff --git a/tests/repro/repro_call_node_manifest.c b/tests/repro/repro_call_node_manifest.c index 822176a4c..bbedb6410 100644 --- a/tests/repro/repro_call_node_manifest.c +++ b/tests/repro/repro_call_node_manifest.c @@ -16,6 +16,11 @@ * TLA+ bound_op is historical direct-call metadata. Bounded quantification is * not a historical ledger row; repro_call_argument_matrix_b is its separate * negative behavior guard. + * + * The historical-snapshot column is the audited ledger, not an archaeology + * record: every registered call node owns a row, so a newly registered kind + * (Pkl's access/new expressions) joins the snapshot with the change that + * registers it. */ #include "test_framework.h" #include "lang_specs.h" @@ -54,10 +59,10 @@ typedef struct { } CallNodeManifestEntry; enum { - EXPECTED_HISTORICAL_CALL_NODE_TOTAL = 219, - EXPECTED_ACTIVE_PRIMARY_TOTAL = 189, - EXPECTED_DIRECT_CALLS = 155, - EXPECTED_CONSTRUCTOR_CALLS = 20, + EXPECTED_HISTORICAL_CALL_NODE_TOTAL = 222, + EXPECTED_ACTIVE_PRIMARY_TOTAL = 192, + EXPECTED_DIRECT_CALLS = 157, + EXPECTED_CONSTRUCTOR_CALLS = 21, EXPECTED_OPERATOR_CALLS = 12, EXPECTED_IMPLICIT_CALLS = 2, EXPECTED_DSL_INVOCATIONS = 14, @@ -65,7 +70,7 @@ enum { EXPECTED_CALLEE_WRAPPERS = 8, EXPECTED_ARGUMENT_WRAPPERS = 1, EXPECTED_CONTROL_OR_DOCUMENT_NONCALLS = 6, - EXPECTED_PRIMARY_OWNERS = 189, + EXPECTED_PRIMARY_OWNERS = 192, EXPECTED_SYNTHETIC_OWNERS = 10, EXPECTED_NO_CALL_EDGE_OWNERS = 20, EXPECTED_PRIMARY_OPERATOR_CALLS = 4, @@ -296,6 +301,9 @@ static const CallNodeManifestEntry CALL_NODE_MANIFEST[] = { ENTRY(CBM_LANG_TLAPLUS, "function_evaluation", DIRECT_CALL, PRIMARY_EXTRACTOR, true, true), ENTRY(CBM_LANG_TLAPLUS, "call", DIRECT_CALL, PRIMARY_EXTRACTOR, true, true), ENTRY(CBM_LANG_TLAPLUS, "bound_op", DIRECT_CALL, PRIMARY_EXTRACTOR, true, true), + ENTRY(CBM_LANG_PKL, "unqualifiedAccessExpr", DIRECT_CALL, PRIMARY_EXTRACTOR, true, true), + ENTRY(CBM_LANG_PKL, "qualifiedAccessExpr", DIRECT_CALL, PRIMARY_EXTRACTOR, true, true), + ENTRY(CBM_LANG_PKL, "newExpr", CONSTRUCTOR_CALL, PRIMARY_EXTRACTOR, true, true), ENTRY(CBM_LANG_APEX, "method_invocation", DIRECT_CALL, PRIMARY_EXTRACTOR, true, true), ENTRY(CBM_LANG_PINE, "call", DIRECT_CALL, PRIMARY_EXTRACTOR, true, true), ENTRY(CBM_LANG_MOJO, "call", DIRECT_CALL, PRIMARY_EXTRACTOR, true, true), diff --git a/tests/repro/repro_language_registry.c b/tests/repro/repro_language_registry.c index a779a1ba0..7547856b3 100644 --- a/tests/repro/repro_language_registry.c +++ b/tests/repro/repro_language_registry.c @@ -195,7 +195,7 @@ static const LanguageCapabilityEntry LANGUAGE_CAPABILITIES[CBM_LANG_COUNT] = { NO_CALL(SMITHY), NO_CALL(WIT), CALL_WITH_REFERENCE_VOCAB(TLAPLUS), - NO_CALL(PKL), + CALL_WITH_REFERENCE_VOCAB(PKL), NO_CALL(GOMOD), CALL_WITH_REFERENCE_VOCAB(APEX), NO_CALL(SOQL), @@ -298,8 +298,8 @@ TEST(repro_language_capability_ledger_covers_every_enum) { } } - if (counts[CAP_CALL_WITH_REFERENCE_VOCAB] != 86 || - counts[CAP_CALL_WITHOUT_REFERENCE_VOCAB] != 25 || counts[CAP_NO_CALL] != 50 || + if (counts[CAP_CALL_WITH_REFERENCE_VOCAB] != 87 || + counts[CAP_CALL_WITHOUT_REFERENCE_VOCAB] != 25 || counts[CAP_NO_CALL] != 49 || counts[CAP_TRANSFORM_ONLY] != 1 || counts[CAP_UNSUPPORTED] != 1) { fprintf(stderr, " [language-registry] invariant=capability_partition call_ref_vocab=%d ref_gap=%d " @@ -316,9 +316,9 @@ TEST(repro_language_capability_ledger_covers_every_enum) { TEST(repro_call_argument_matrices_equal_call_capability_ledger) { enum { EXPECTED_MATRIX_A_ROWS = 67, - EXPECTED_MATRIX_B_ROWS = 46, - EXPECTED_CALL_CAPABLE_LANGUAGES = 111, - EXPECTED_NON_CALL_LANGUAGES = 52, + EXPECTED_MATRIX_B_ROWS = 47, + EXPECTED_CALL_CAPABLE_LANGUAGES = 112, + EXPECTED_NON_CALL_LANGUAGES = 51, EXPECTED_NON_CALL_DOMAIN_CONTROLS = 2, }; CBMLanguage matrix_a_ids[CBM_LANG_COUNT]; diff --git a/tests/test_parallel_harness_contract.sh b/tests/test_parallel_harness_contract.sh index a658dc56a..511fb25a0 100755 --- a/tests/test_parallel_harness_contract.sh +++ b/tests/test_parallel_harness_contract.sh @@ -256,6 +256,7 @@ python3 - "$scheduler" "$fixture" "$(command -v python3)" <<'PY' from __future__ import annotations import ctypes +import importlib.util import os import pathlib import signal @@ -275,6 +276,29 @@ release = barrier / "timeout_exit_race.release" descendant_path = fixture / "descendant.pid" +def scheduler_wait_budget() -> int: + """Seconds to allow the scheduler to finish refusing. + + On POSIX the refusal is signal-driven and lands well inside --kill-grace. + On Windows it costs external helper spawns (taskkill.exe, and powershell.exe + for the descendant probe), which the scheduler deliberately budgets with its + own floor rather than --kill-grace. Read that floor from the scheduler + instead of restating it: hard-coding a budget here silently turns a slow + runner into a harness failure the moment the two numbers drift apart. The + refusal path can spend the floor twice -- once proving descendants, once in + the cleanup re-entry -- so allow both plus interpreter startup. + """ + if os.name != "nt": + return 8 + spec = importlib.util.spec_from_file_location("cbm_run_test_wave", scheduler) + module = importlib.util.module_from_spec(spec) + # Register before exec: @dataclass resolves annotations through + # sys.modules[cls.__module__], which is None for an unregistered module. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module.WINDOWS_HELPER_TIMEOUT_SECONDS * 2 + 10 + + def process_state(pid: int) -> str: if os.name == "nt": handle = ctypes.windll.kernel32.OpenProcess(0x101000, False, pid) @@ -370,7 +394,7 @@ try: raise SystemExit("FAIL: scheduler did not observe the forced leader exit") time.sleep(0.02) release.write_text("release\n", encoding="utf-8") - stdout, stderr = process.communicate(timeout=8) + stdout, stderr = process.communicate(timeout=scheduler_wait_budget()) if os.name == "nt": # Assert the PROPERTY, not the wording. This used to require the phrase