diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index be739d717..34f1baf2b 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -1014,6 +1014,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) { @@ -1638,6 +1697,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/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/internal/cbm/helpers.c b/internal/cbm/helpers.c index 3042f606e..879592103 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -536,29 +536,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 2bc1fe1e7..25264d876 100644 --- a/internal/cbm/lang_specs.c +++ b/internal/cbm/lang_specs.c @@ -1546,11 +1546,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}; @@ -2553,9 +2563,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/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 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_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 ─────────────────────────────────────────────────────────────────── 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