Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions internal/cbm/extract_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
31 changes: 8 additions & 23 deletions internal/cbm/helpers.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 16 additions & 6 deletions internal/cbm/lang_specs.c
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 14 additions & 2 deletions scripts/run-test-wave.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
SKIPPED = re.compile(r"(?:^|, )(?P<skipped>[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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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"
)
Expand All @@ -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
Expand Down
80 changes: 59 additions & 21 deletions tests/repro/repro_grammar_config.c
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────────────
Expand Down
Loading