From bf8680c7cf1884dd63b7a71e638f0f40cea1d45b Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Mon, 27 Jul 2026 13:37:26 -0700 Subject: [PATCH 1/5] =?UTF-8?q?fix(extract):=20model=20Nix=20attrpaths=20?= =?UTF-8?q?=E2=80=94=20leaf=20is=20the=20name,=20the=20path=20is=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Nix binding's name is a PATH, and the resolver took only its first segment (`child_by_field_name(attrpath, "attr")`). Three defects followed from that one modelling gap: - `setA = { dup = …; }` and `setB = { dup = …; }` both minted the qualified name proj.file.dup. The second definition, and every CALLS edge sourced from it, was silently discarded at write. - `a.b.fn = …` minted a definition named `a`, colliding with every other binding whose path began `a`. - `"kebab-case" = …` minted a name with the quote characters in it, so every consumer keying on the name had to know to re-quote. Adopts the convention already used for C++ namespaces — name is the leaf segment, qualified_name is enclosing scope plus leaf, so `ns::serialize` is name `serialize` and QN proj.file.ns.serialize. For Nix that means: - the resolver returns the LAST attrpath segment; - leading segments become scope, so `a.b.fn = …` and the nested spelling `a = { b = { fn = …; }; }` produce the same QN, as they must — one is sugar for the other; - a binding whose value is an attribute set is a scope, mirroring is_namespace_scope_kind's treatment of a C++ namespace. `let` bindings are deliberately NOT scopes: they are lexical, and C++ does not qualify by block scope either; - a quoted segment is unquoted, and an interpolated one (`"${x}" = …`) mints nothing at all, since it has no statically knowable name — the same call the Makefile dot-prefix guard makes. The two scope sources compose: `setA = { a.b.fn = …; }` is proj.file.setA.a.b.fn. Definition QNs and call-scope QNs are computed by two different functions — extract_defs.c's compute_class_qn and extract_unified.c's own. If they disagree by one segment the CALLS edge names a source node that was never minted and is dropped at write, with no error and no failing extraction assertion. Rather than implement the rule twice, the Nix computation lives in helpers.c and both call it, which makes that class of drift impossible instead of merely unlikely. Note also that the def-side enclosing-QN gate is language-gated while the call-side is not, so widening one without the other would have produced exactly that mismatch. Signed-off-by: Jason Bowman --- internal/cbm/extract_defs.c | 73 +++++++++++--- internal/cbm/extract_unified.c | 35 ++++++- internal/cbm/helpers.c | 172 ++++++++++++++++++++++++++++++++- internal/cbm/helpers.h | 41 +++++++- 4 files changed, 303 insertions(+), 18 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index dbc1d46b11..32298530a2 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -821,15 +821,22 @@ TSNode cbm_resolve_func_name(TSNode node, CBMLanguage lang) { /* Nix: a named function is a `function_expression` (lambda `x: body`) with * no name of its own — the binding name lives on the enclosing `binding`'s * `attrpath` field (`name = x: ...`). Resolve through the parent binding to - * the attrpath's `attr` identifier so `addOne = x: ...` mints a Function - * def. A lambda whose parent is not a binding (e.g. an inline `map (x: x)` - * argument) resolves null and stays out of func_types. */ + * the attrpath's LAST `attr` so `addOne = x: ...` mints a Function def. A + * lambda whose parent is not a binding (e.g. an inline `map (x: x)` + * argument) resolves null and stays out of func_types. + * + * The last segment, not the first: an attrpath is a PATH, and `a.b.fn = …` + * is sugar for `a = { b = { fn = …; }; };`. Both spellings must mint the + * same name (`fn`) and the same QN (`proj.mod.a.b.fn`) — the leading + * segments are scope, supplied by cbm_nix_attrpath_scope. Taking the first + * segment named `a.b.fn` "a", which collided with every other binding + * whose path began `a`. */ if (lang == CBM_LANG_NIX && strcmp(kind, "function_expression") == 0) { TSNode parent = ts_node_parent(node); if (!ts_node_is_null(parent) && strcmp(ts_node_type(parent), "binding") == 0) { TSNode attrpath = ts_node_child_by_field_name(parent, TS_FIELD("attrpath")); if (!ts_node_is_null(attrpath)) { - TSNode attr = ts_node_child_by_field_name(attrpath, TS_FIELD("attr")); + TSNode attr = cbm_nix_attrpath_last_attr(attrpath); return ts_node_is_null(attr) ? attrpath : attr; } } @@ -3265,7 +3272,7 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec return; } - char *name = cbm_func_name_node_text(a, name_node, ctx->source); + char *name = cbm_func_name_node_text(a, name_node, ctx->source, ctx->language); if (!name || !name[0] || strcmp(name, "function") == 0) { return; } @@ -3292,17 +3299,33 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec } } + /* Nix `"${foo}" = x: …`: an interpolated attrpath segment has no statically + * knowable name. Minting it would produce a def literally named `"${foo}"` + * that nothing can ever look up or resolve a call against. An absent node is + * the honest answer — same reasoning as the Makefile guard above. */ + if (ctx->language == CBM_LANG_NIX && cbm_nix_attr_is_interpolated(name_node)) { + return; + } + TSNode func_node = unwrap_template_inner(node, ctx->language); CBMDefinition def; memset(&def, 0, sizeof(def)); def.name = name; + /* Nix: a binding's name is a path. The leaf is the name; the leading segments + * are scope, so `a.b.fn = …` gets the same QN as `a = { b = { fn = …; }; }`. + * Without this every binding whose path shares a leaf name collapsed onto one + * node, silently discarding the later definition and its CALLS edges. */ + const char *qn_name = name; + if (ctx->language == CBM_LANG_NIX) { + qn_name = cbm_nix_qn_name(a, node, ctx->source, name); + } /* Java/Go derive the module from the containing directory (package), so the * filename stem is NOT baked into the QN (Go func in myapp/db/conn.go -> * proj.myapp.db.Func, not proj.myapp.db.conn.Func). Other langs unchanged. */ def.qualified_name = - cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, name, ctx->language); + cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, qn_name, ctx->language); /* A free function declared inside a namespace (C++/C#/PHP) is qualified by * the namespace scope the def walk carries (enclosing_class_qn was extended * by is_namespace_scope_kind), so `ns::serialize` is `proj.file.ns.serialize` @@ -3310,10 +3333,19 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * resolution (ADL, namespace-function lookup) can never see it. Class methods * never reach here (they go through extract_class_methods), so a set * enclosing scope here is always a namespace. The out-of-line method path - * below overrides this for `Ns::Cls::method` definitions. */ + * below overrides this for `Ns::Cls::method` definitions. + * + * Nix joins this list for the same reason: a binding inside an attrset is + * scoped by it, so `setA = { fn = ...; }` is proj.file.setA.fn. Uses qn_name, + * not name, so an attrpath's own leading segments compose with the enclosing + * scope. Note the call-scope side (compute_func_qn in extract_unified.c) is + * NOT language-gated — it qualifies whenever a scope is pushed — so this gate + * and the scope-push rule must move together, or a call QN names a def QN that + * was never minted and the edge is dropped at write. */ if (ctx->enclosing_class_qn && - (ctx->language == CBM_LANG_CPP || ctx->language == CBM_LANG_CUDA)) { - def.qualified_name = cbm_arena_sprintf(a, "%s.%s", ctx->enclosing_class_qn, name); + (ctx->language == CBM_LANG_CPP || ctx->language == CBM_LANG_CUDA || + ctx->language == CBM_LANG_NIX)) { + def.qualified_name = cbm_arena_sprintf(a, "%s.%s", ctx->enclosing_class_qn, qn_name); } def.label = "Function"; def.file_path = ctx->rel_path; @@ -4309,7 +4341,7 @@ static void push_method_def(CBMExtractCtx *ctx, TSNode child, TSNode class_node, const char *class_qn, const CBMLangSpec *spec, TSNode name_node) { CBMArena *a = ctx->arena; - char *name = cbm_func_name_node_text(a, name_node, ctx->source); + char *name = cbm_func_name_node_text(a, name_node, ctx->source, ctx->language); if (!name || !name[0]) { return; } @@ -6306,14 +6338,31 @@ static bool is_template_class_node(TSNode node, CBMLanguage lang) { * namespace emits no def of its own — it only extends the enclosing scope for * its members. C#/PHP need the same treatment paired with their LSP resolvers * (a def-only change breaks their existing namespace handling), done separately. */ -static bool is_namespace_scope_kind(CBMLanguage lang, const char *kind) { +static bool is_namespace_scope_kind(CBMLanguage lang, const char *kind, TSNode node) { if (lang == CBM_LANG_CPP || lang == CBM_LANG_CUDA) { return strcmp(kind, "namespace_definition") == 0; } + /* Nix: a binding whose value is an attribute set is a named scope that is not + * itself a definition — the same shape as a C++ namespace. `setA = { fn = …; }` + * makes `fn` proj.file.setA.fn, so two attrsets can each hold a `fn` without + * the second silently overwriting the first. + * + * Deliberately NOT `let` bindings: those are lexical, and C++ does not qualify + * by block scope either. Takes the node because the decision depends on the + * binding's VALUE, which the kind string alone cannot express. */ + if (lang == CBM_LANG_NIX && strcmp(kind, "binding") == 0) { + return cbm_nix_binding_is_attrset_scope(node); + } return false; } static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const char *saved_enclosing) { + /* Nix scopes are `binding` nodes, which carry an `attrpath` rather than a + * `name` field. Shared with the unified extractor's own compute_class_qn so + * the def QN and the call-scope QN cannot drift. */ + if (ctx->language == CBM_LANG_NIX) { + return cbm_nix_binding_scope_qn(ctx, node, saved_enclosing); + } TSNode name_node = ts_node_child_by_field_name(node, TS_FIELD("name")); if (ts_node_is_null(name_node) && ctx->language == CBM_LANG_OBJC) { name_node = cbm_find_child_by_kind(node, "identifier"); @@ -6865,7 +6914,7 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, * is walked normally — functions AND classes, unlike a class body which * routes methods through extract_class_methods. Do NOT emit a def or run * the class/func paths on the namespace node itself. */ - if (is_namespace_scope_kind(ctx->language, kind)) { + if (is_namespace_scope_kind(ctx->language, kind, node)) { const char *new_enclosing = compute_class_qn(ctx, node, frame.enclosing_class_qn); wd_push_children_reverse(&s, node, new_enclosing); continue; diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index d6f2380e2a..5821a2eefd 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -623,7 +623,7 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan return NULL; } - char *name = cbm_func_name_node_text(ctx->arena, name_node, ctx->source); + char *name = cbm_func_name_node_text(ctx->arena, name_node, ctx->source, ctx->language); if (!name || !name[0]) { return NULL; } @@ -644,12 +644,24 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan } } + /* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def + * extractor bakes it into the def QN. Compose it identically here — otherwise + * an in-body call sources to a QN one or more segments short of the def, and + * the edge is dropped at write. */ + const char *qn_name = name; + if (ctx->language == CBM_LANG_NIX) { + qn_name = cbm_nix_qn_name(ctx->arena, node, ctx->source, name); + if (!qn_name || !qn_name[0]) { + return NULL; + } + } + if (state->enclosing_class_qn) { - return cbm_arena_sprintf(ctx->arena, "%s.%s", state->enclosing_class_qn, name); + return cbm_arena_sprintf(ctx->arena, "%s.%s", state->enclosing_class_qn, qn_name); } /* Java/Go: directory-based module so this enclosing-func QN matches the def * QN and the LSP caller_qn (the lsp_resolve join keys on exact equality). */ - return cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, name, + return cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, qn_name, ctx->language); } @@ -658,6 +670,13 @@ static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const WalkS if (ctx->language == CBM_LANG_OBJECTSCRIPT_UDL) { return objectscript_get_class_name(ctx, node); } + /* Nix: an attrset-valued `binding` is a named scope. Same shared helper the def + * extractor's compute_class_qn uses — these are two separate functions, and a + * one-segment disagreement between them drops every CALLS edge sourced from a + * nested binding. */ + if (ctx->language == CBM_LANG_NIX) { + return cbm_nix_binding_scope_qn(ctx, node, state ? state->enclosing_class_qn : NULL); + } TSNode name_node = ts_node_child_by_field_name(node, TS_FIELD("name")); /* Newer tree-sitter-kotlin: class/object name is a type_identifier child. */ if (ts_node_is_null(name_node) && ctx->language == CBM_LANG_KOTLIN) { @@ -1418,6 +1437,16 @@ static void push_boundary_scopes(CBMExtractCtx *ctx, TSNode node, const CBMLangS state->os_type_map.class_base_count = 0; } } + } else if (ctx->language == CBM_LANG_NIX && strcmp(ts_node_type(node), "binding") == 0 && + cbm_nix_binding_is_attrset_scope(node)) { + /* Nix: a binding whose value is an attribute set is a named scope, exactly + * as is_namespace_scope_kind treats it on the def side. Pushing it here is + * what makes an in-body call source to `proj.file.setA.fn` rather than the + * bare `proj.file.fn` that no node carries once defs are attrpath-qualified. */ + const char *cqn = compute_class_qn(ctx, node, state); + if (cqn) { + push_scope(state, SCOPE_CLASS, depth, cqn); + } } else if (ctx->language == CBM_LANG_RUST && strcmp(ts_node_type(node), "impl_item") == 0) { TSNode type_node = ts_node_child_by_field_name(node, TS_FIELD("type")); if (!ts_node_is_null(type_node)) { diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 3cea43ab30..c1e1613a8c 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -832,7 +832,7 @@ TSNode cbm_resolve_c_declarator_name_node(TSNode func_node) { // space. Without this the conversion operator is indexed as "operator bool() // const", and a member lookup for "operator bool" (the implicit call in // `if (obj)`) misses. -char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source) { +char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source, CBMLanguage lang) { char *text = cbm_node_text(a, name_node, source); if (text && strcmp(ts_node_type(name_node), "operator_cast") == 0) { char *paren = strchr(text, '('); @@ -843,9 +843,177 @@ char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source) *paren = '\0'; } } + /* Nix quoted attrpath segment: `"kebab-case" = a: a;` and `services."my.svc" = …` + * are ordinary names that merely need quoting in source. The node text carries + * the delimiters, so without this the def is named `"kebab-case"` — quotes and + * all — and every consumer keying on the name (search, CALLS resolution, the + * LSP join) would have to know to re-quote. Stripped here rather than in the + * resolver so the def name and the call-scope QN, which both route through this + * function, cannot disagree. */ + if (text && lang == CBM_LANG_NIX) { + cbm_nix_strip_attr_quotes(text); + } return text; } +/* ── Nix attrpath helpers ─────────────────────────────────── + * A Nix binding's name is a PATH (`a.b.c = …`), whose segments may be quoted or + * interpolated. These render it the way the rest of the extractor expects: leaf + * segment as the name, leading segments as scope. Shared by the defs and unified + * (call-scope) extractors so both compute the same QN — if they disagree, a CALLS + * edge names a source node that does not exist and is dropped at write, which is + * precisely how the function-header bug manifested. */ + +/* Bound on the interpolation scan below. An attrpath segment is an identifier or a + * string, so its subtree is shallow; this only has to stop a pathological input. */ +enum { NIX_ATTR_SCAN_MAX = 32 }; + +/* Strip one matching pair of surrounding double quotes, in place. */ +void cbm_nix_strip_attr_quotes(char *text) { + if (!text) { + return; + } + size_t len = strlen(text); + if (len >= CBM_QUOTE_PAIR && text[0] == '"' && text[len - SKIP_ONE] == '"') { + memmove(text, text + SKIP_ONE, len - PAIR_LEN); + text[len - PAIR_LEN] = '\0'; + } +} + +/* True when a segment contains a `${...}` interpolation, and therefore has no + * statically knowable name. Iterative and bounded — the lint forbids unlisted + * recursion, and an attrpath segment is shallow by construction. */ +bool cbm_nix_attr_is_interpolated(TSNode attr) { + if (ts_node_is_null(attr)) { + return false; + } + TSNode stack[NIX_ATTR_SCAN_MAX]; + int top = 0; + stack[top++] = attr; + while (top > 0) { + TSNode cur = stack[--top]; + if (strcmp(ts_node_type(cur), "interpolation") == 0) { + return true; + } + uint32_t n = ts_node_named_child_count(cur); + for (uint32_t i = 0; i < n && top < NIX_ATTR_SCAN_MAX; i++) { + stack[top++] = ts_node_named_child(cur, i); + } + } + return false; +} + +/* The leaf segment of an attrpath — the name. `attr` is a FIELD in this grammar, + * not a node type (segments are identifier / string_expression / interpolation), + * so iterate named children rather than matching on a type string. */ +TSNode cbm_nix_attrpath_last_attr(TSNode attrpath) { + TSNode last = {0}; + if (ts_node_is_null(attrpath)) { + return last; + } + uint32_t n = ts_node_named_child_count(attrpath); + if (n == 0) { + return last; + } + return ts_node_named_child(attrpath, n - SKIP_ONE); +} + +/* The scope prefix of an attrpath: every segment except the leaf, quote-stripped + * and dot-joined. `a.b.fn = …` yields "a.b" so it qualifies identically to the + * nested spelling `a = { b = { fn = …; }; }`. Returns NULL for a single-segment + * path (no scope) or when a leading segment is interpolated (not nameable). */ +const char *cbm_nix_attrpath_scope(CBMArena *a, TSNode attrpath, const char *source) { + if (ts_node_is_null(attrpath)) { + return NULL; + } + uint32_t n = ts_node_named_child_count(attrpath); + if (n <= SKIP_ONE) { + return NULL; + } + const char *scope = NULL; + for (uint32_t i = 0; i + SKIP_ONE < n; i++) { + TSNode seg = ts_node_named_child(attrpath, i); + if (cbm_nix_attr_is_interpolated(seg)) { + return NULL; + } + char *seg_text = cbm_node_text(a, seg, source); + if (!seg_text || !seg_text[0]) { + return NULL; + } + cbm_nix_strip_attr_quotes(seg_text); + scope = scope ? cbm_arena_sprintf(a, "%s.%s", scope, seg_text) : seg_text; + } + return scope; +} + +/* True when a Nix `binding`'s value is an attribute set, i.e. the binding names a + * scope rather than defining a value. Deliberately excludes `let` bindings and + * lambda-valued bindings: the former are lexical (C++ does not qualify by block + * scope either), the latter are definitions in their own right. */ +bool cbm_nix_binding_is_attrset_scope(TSNode node) { + if (ts_node_is_null(node) || strcmp(ts_node_type(node), "binding") != 0) { + return false; + } + TSNode value = ts_node_child_by_field_name(node, TS_FIELD("expression")); + if (ts_node_is_null(value)) { + return false; + } + const char *vk = ts_node_type(value); + return strcmp(vk, "attrset_expression") == 0 || strcmp(vk, "rec_attrset_expression") == 0; +} + +/* The scope QN contributed by a Nix `binding` whose value is an attribute set — + * `setA = { … }` contributes "…file.setA", and a dotted `a.b = { … }` contributes + * "…file.a.b". Returns saved_enclosing unchanged when the binding cannot name a + * scope (empty or interpolated attrpath). + * + * Lives here, called by BOTH extract_defs.c and extract_unified.c, because those + * two files carry separate compute_class_qn implementations. A def QN and a + * call-scope QN that disagree by even one segment make the CALLS edge name a + * source node that was never minted, and it is silently dropped at write. Sharing + * the computation makes that class of drift impossible rather than merely + * unlikely. */ +const char *cbm_nix_binding_scope_qn(CBMExtractCtx *ctx, TSNode node, const char *saved_enclosing) { + if (!ctx || ts_node_is_null(node) || strcmp(ts_node_type(node), "binding") != 0) { + return saved_enclosing; + } + TSNode attrpath = ts_node_child_by_field_name(node, TS_FIELD("attrpath")); + TSNode leaf = cbm_nix_attrpath_last_attr(attrpath); + if (ts_node_is_null(leaf) || cbm_nix_attr_is_interpolated(leaf)) { + return saved_enclosing; + } + char *leaf_text = cbm_node_text(ctx->arena, leaf, ctx->source); + if (!leaf_text || !leaf_text[0]) { + return saved_enclosing; + } + cbm_nix_strip_attr_quotes(leaf_text); + const char *scope = cbm_nix_attrpath_scope(ctx->arena, attrpath, ctx->source); + const char *rel = scope ? cbm_arena_sprintf(ctx->arena, "%s.%s", scope, leaf_text) : leaf_text; + if (saved_enclosing) { + return cbm_arena_sprintf(ctx->arena, "%s.%s", saved_enclosing, rel); + } + return cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, rel, ctx->language); +} + +/* The QN-relative name of a Nix binding: its attrpath scope joined to its leaf + * name. `a.b.fn = …` yields "a.b.fn"; a bare `fn = …` yields "fn". Callers prepend + * either the enclosing attrset scope or the module QN, so the two scope sources — + * a dotted attrpath and an enclosing attrset — compose. Takes the already-resolved + * leaf name rather than re-deriving it, so it cannot disagree with the name the + * def was minted under. */ +const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, const char *name) { + if (!name) { + return NULL; + } + TSNode parent = ts_node_parent(func_node); + if (ts_node_is_null(parent) || strcmp(ts_node_type(parent), "binding") != 0) { + return name; + } + TSNode attrpath = ts_node_child_by_field_name(parent, TS_FIELD("attrpath")); + const char *scope = cbm_nix_attrpath_scope(a, attrpath, source); + return scope ? cbm_arena_sprintf(a, "%s.%s", scope, name) : name; +} + static const char *func_node_name(CBMArena *a, TSNode func_node, const char *source, CBMLanguage lang) { // Wolfram: set_delayed_top/set_top/set_delayed/set — LHS is apply(user_symbol("f"), ...) @@ -884,7 +1052,7 @@ static const char *func_node_name(CBMArena *a, TSNode func_node, const char *sou if (strcmp(ts_node_type(func_node), "function_definition") == 0) { TSNode dn = cbm_resolve_c_declarator_name_node(func_node); if (!ts_node_is_null(dn)) { - return cbm_func_name_node_text(a, dn, source); + return cbm_func_name_node_text(a, dn, source, lang); } } return NULL; diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 6fb136294f..4ab8b1e543 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -62,7 +62,46 @@ TSNode cbm_resolve_c_declarator_name_node(TSNode func_node); // C++ conversion-operator's `operator_cast` node (which spans the full // "operator bool() const") down to "operator bool". Shared by the defs and // unified extractors so the def name and call-scope QN agree. -char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source); +// Also strips the surrounding quotes from a Nix quoted attrpath segment, so +// `"kebab-case" = a: a;` is named kebab-case rather than "kebab-case". Takes the +// language for that reason; every caller must pass ctx->language. +char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source, CBMLanguage lang); + +// ── Nix attrpath helpers ── +// A Nix binding's name is a PATH (`a.b.c = …`) whose segments may be quoted or +// interpolated. Shared by the defs and unified (call-scope) extractors so both +// derive the same name and the same scope prefix — divergence makes a CALLS edge +// name a source node that does not exist, and it is dropped at write. + +// Strip one matching pair of surrounding double quotes, in place. +void cbm_nix_strip_attr_quotes(char *text); + +// True when an attrpath segment contains a `${...}` interpolation and therefore +// has no statically knowable name. +bool cbm_nix_attr_is_interpolated(TSNode attr); + +// The leaf segment of an attrpath — the name. Null node for an empty attrpath. +TSNode cbm_nix_attrpath_last_attr(TSNode attrpath); + +// The scope prefix of an attrpath: all segments but the leaf, quote-stripped and +// dot-joined, so `a.b.fn = …` qualifies identically to `a = { b = { fn = …; }; }`. +// NULL for a single-segment path, or when a leading segment is interpolated. +const char *cbm_nix_attrpath_scope(CBMArena *a, TSNode attrpath, const char *source); + +// True when a Nix `binding`'s value is an attribute set — the binding names a +// scope rather than defining a value. Excludes let-bindings and lambda values. +bool cbm_nix_binding_is_attrset_scope(TSNode node); + +// The scope QN contributed by a Nix `binding` whose value is an attribute set. +// Called by BOTH extract_defs.c and extract_unified.c, which carry separate +// compute_class_qn implementations — sharing this makes a def/call-scope QN +// mismatch (which silently drops the CALLS edge) structurally impossible. +const char *cbm_nix_binding_scope_qn(CBMExtractCtx *ctx, TSNode node, const char *saved_enclosing); + +// The QN-relative name of a Nix binding — its attrpath scope joined to `name`. +// Callers prepend the enclosing attrset scope (or the module QN), so a dotted +// attrpath and an enclosing attrset compose into one qualified name. +const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, const char *name); // Resolve a function/method definition node's NAME node across all ~130 grammars // (generic `name` field, arrow→declarator, C/C++ declarator chain, plus the many From b5209af7a3772d828d8cec0249feaa3ec929da74 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Mon, 27 Jul 2026 13:37:48 -0700 Subject: [PATCH 2/5] test(nix): cover attrpath qualification, quoting, and interpolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four extraction cases and one pipeline case, each asserting a distinct claim so a regression names what it broke: nix_attrset_scope_disambiguates_leaf_names two attrsets each holding `dup` produce two definitions with distinct QNs. Unqualified they shared one QN and the second was discarded. nix_dotted_attrpath_qualifies_like_nested `wrap.deep.fn = …` and `wrap = { deep = { fn = …; }; }` produce the SAME QN. This is the equality that makes attrpath qualification a correctness fix rather than a preference — the two are the same expression. nix_quoted_attr_name_strips_quotes `"kebab-case"` is named kebab-case, and asserts the quoted form is absent rather than merely that the bare form is present. nix_interpolated_attr_mints_no_def `"${dynamic}" = …` mints nothing, and the sibling binding still does — so the test cannot pass by extracting nothing at all. pipeline_nix_scoped_binding_calls_resolve (tests/test_pipeline.c) a call inside a scoped binding reaches its target through the store, for both routes into a qualified name: an enclosing attrset and a dotted attrpath. The pipeline case has to live at that level. Definition QNs and call-scope QNs come from two separate functions, and if they disagree the edge is dropped at write with no error — every extraction-level assertion still passes while the graph quietly loses edges. Only the store shows it. Adds has_def_qn alongside the existing has_def. find_def_by_name returns the first match by NAME and so cannot tell two same-named definitions in different scopes apart, which is exactly what these tests exist to separate. Extraction and pipeline suites: 491 passed, 0 failed. Signed-off-by: Jason Bowman --- tests/test_extraction.c | 87 +++++++++++++++++++++++++++++++++++++++++ tests/test_pipeline.c | 56 ++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 9b06e17267..120ee72f8e 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -53,6 +53,18 @@ static int __attribute__((unused)) has_import(CBMFileResult *r, const char *path } /* Count definitions with a given label. */ +/* Check for a definition with the given qualified name. Distinct from + * find_def_by_name, which returns the first match by NAME and so cannot tell two + * same-named definitions in different scopes apart — exactly the case that + * attrpath qualification exists to separate. */ +static int has_def_qn(CBMFileResult *r, const char *qn) { + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].qualified_name && strcmp(r->defs.items[i].qualified_name, qn) == 0) + return 1; + } + return 0; +} + static int count_defs_with_label(CBMFileResult *r, const char *label) { int count = 0; for (int i = 0; i < r->defs.count; i++) { @@ -1222,6 +1234,77 @@ TEST(nix_defs_survive_curried_header) { PASS(); } +/* A Nix binding's name is a PATH, and the extractor previously took only its first + * segment. Convention here matches C++ namespaces — `ns::serialize` is name + * `serialize`, QN `proj.file.ns.serialize` — so a Nix binding is name = leaf + * segment, QN = enclosing scope + leaf. */ +TEST(nix_attrset_scope_disambiguates_leaf_names) { + CBMFileResult *r = + extract("{\n setA = { dup = x: x + 1; };\n setB = { dup = y: y + 2; };\n}\n", + CBM_LANG_NIX, "t", "collide.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* Both survive. Unqualified, these shared one QN, so the second definition — + * and every CALLS edge sourced from it — was silently discarded at write. */ + ASSERT(count_defs_with_label(r, "Function") == 2); + ASSERT(has_def_qn(r, "t.collide.setA.dup")); + ASSERT(has_def_qn(r, "t.collide.setB.dup")); + cbm_free_result(r); + PASS(); +} + +/* `a.b.fn = …` is sugar for `a = { b = { fn = …; }; }`. Both spellings must yield + * the same name and the same QN; the leading segments are scope, not name. */ +TEST(nix_dotted_attrpath_qualifies_like_nested) { + CBMFileResult *r = extract("{\n wrap.deep.fn = z: z + 1;\n}\n", CBM_LANG_NIX, "t", "d.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "fn")); + ASSERT(has_def_qn(r, "t.d.wrap.deep.fn")); + + CBMFileResult *n = + extract("{\n wrap = { deep = { fn = z: z + 1; }; };\n}\n", CBM_LANG_NIX, "t", "d.nix"); + ASSERT_NOT_NULL(n); + ASSERT_FALSE(n->has_error); + /* The equality that makes this a correctness fix rather than a preference. */ + ASSERT(has_def_qn(n, "t.d.wrap.deep.fn")); + cbm_free_result(n); + cbm_free_result(r); + PASS(); +} + +/* A quoted segment is an ordinary name that merely needs quoting in source. The + * delimiters are not part of it, and leaving them in means every consumer keying + * on the name has to know to re-quote. */ +TEST(nix_quoted_attr_name_strips_quotes) { + CBMFileResult *r = extract("{\n \"kebab-case\" = a: a;\n svc.\"my.name\" = b: b;\n}\n", + CBM_LANG_NIX, "t", "q.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "kebab-case")); + ASSERT_FALSE(has_def_any(r, "\"kebab-case\"")); + ASSERT(has_def_qn(r, "t.q.kebab-case")); + /* A quoted segment may itself contain dots; they are part of the name, not + * path separators, but the QN is a dotted string either way. */ + ASSERT(has_def(r, "Function", "my.name")); + cbm_free_result(r); + PASS(); +} + +/* `"${x}" = …` has no statically knowable name. Minting it produces a def named + * `"${x}"` that nothing can look up or resolve a call against, so mint nothing — + * the same call the Makefile dot-prefix guard makes. */ +TEST(nix_interpolated_attr_mints_no_def) { + CBMFileResult *r = + extract("{\n \"${dynamic}\" = a: a;\n fixed = b: b;\n}\n", CBM_LANG_NIX, "t", "i.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "fixed")); + ASSERT(count_defs_with_label(r, "Function") == 1); + cbm_free_result(r); + PASS(); +} + /* Descending past the header must not mint a second def from a curried lambda's inner * arm: `iota = a: b: ...` is one named function, not two. The inner `b:` has a * function_expression parent, resolves no name, and must stay out. */ @@ -4986,6 +5069,10 @@ SUITE(extraction) { RUN_TEST(nix_defs_survive_function_header_let); RUN_TEST(nix_defs_survive_function_header_attrset); RUN_TEST(nix_defs_survive_curried_header); + RUN_TEST(nix_attrset_scope_disambiguates_leaf_names); + RUN_TEST(nix_dotted_attrpath_qualifies_like_nested); + RUN_TEST(nix_quoted_attr_name_strips_quotes); + RUN_TEST(nix_interpolated_attr_mints_no_def); RUN_TEST(nix_curried_lambda_mints_one_def); RUN_TEST(fortran_function); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 2685b425d9..ce198e7790 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -851,6 +851,61 @@ static bool cross_file_call_exists(cbm_store_t *s, const char *project, const ch return found; } +/* Nix attrpath qualification, end to end. A call inside a scoped binding must + * source to the QUALIFIED definition. + * + * This has to be a pipeline test rather than an extraction one. Definition QNs + * (extract_defs.c) and call-scope QNs (extract_unified.c) are computed by two + * separate functions. If they disagree by even one segment, the CALLS edge names + * a source node that was never minted and is dropped at write — with no error, + * and with every extraction-level assertion still passing. Only the store sees it. + * + * Both callers below are scoped, one by an enclosing attrset and one by a dotted + * attrpath, so this covers both routes into the qualified name. */ +TEST(pipeline_nix_scoped_binding_calls_resolve) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + + char nix_path[512]; + snprintf(nix_path, sizeof(nix_path), "%s/lib.nix", g_tmpdir); + FILE *nf = fopen(nix_path, "w"); + if (!nf) { + teardown_test_repo(); + FAIL("failed to write nix fixture"); + } + fprintf(nf, "{ prelude }:\n" + "let\n" + " nixTarget = t: t + 1;\n" + "in\n" + "{\n" + " setA = { nixCaller = x: nixTarget x; };\n" + " outer.inner.nixDeepCaller = y: nixTarget y;\n" + "}\n"); + fclose(nf); + + char nix_db[512]; + snprintf(nix_db, sizeof(nix_db), "%s/test_nix_calls.db", g_tmpdir); + + cbm_pipeline_t *np = cbm_pipeline_new(g_tmpdir, nix_db, CBM_MODE_FULL); + ASSERT_NOT_NULL(np); + ASSERT_EQ(cbm_pipeline_run(np), 0); + + cbm_store_t *ns = cbm_store_open_path(nix_db); + ASSERT_NOT_NULL(ns); + const char *nix_project = cbm_pipeline_project_name(np); + + /* Scoped by an enclosing attrset: the def QN is proj.lib.setA.nixCaller. */ + ASSERT(cross_file_call_exists(ns, nix_project, "nixCaller", "nixTarget")); + /* Scoped by a dotted attrpath: proj.lib.outer.inner.nixDeepCaller. */ + ASSERT(cross_file_call_exists(ns, nix_project, "nixDeepCaller", "nixTarget")); + + cbm_store_close(ns); + cbm_pipeline_free(np); + teardown_test_repo(); + PASS(); +} + /* Regression: incremental re-index of an edited file must NOT drop inbound * cross-file CALLS edges whose source lives in an UNCHANGED file. * @@ -7363,6 +7418,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_complexity_transitive_loop_depth); /* Calls pass */ RUN_TEST(pipeline_calls_resolution); + RUN_TEST(pipeline_nix_scoped_binding_calls_resolve); RUN_TEST(pipeline_incremental_preserves_cross_file_calls); RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges); From 0bd65aa533784b35dfff34ab0d2544087f7a5a5f Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Mon, 27 Jul 2026 13:53:22 -0700 Subject: [PATCH 3/5] feat(extract): mint Variable nodes for Nix module-level bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nix_var_types` has declared `binding` since the language was added, but no Nix case existed in extract_var_names and no path reached a binding in the first place, so the Variable count for Nix was unconditionally zero. Two things were in the way. extract_variables iterates only the file root's DIRECT children, and a Nix file's root child is its header lambda (`{ pkgs, lib, ... }:`), so the generic loop saw nothing — the same shape as the definition bug. And extract_var_names had no Nix branch, so a binding could not have been named even if reached. Scope follows the rule the other languages already use rather than inventing one. extract_variables mints FILE scope and never locals: a C++ declaration inside a function body is not a Variable. For Nix, file scope is the `let` bindings and the returned attrset — a let binding is file scope in the same sense a C++ file-static is, and the attrset is the exported surface. A binding in a deeper attrset is not. That bound is load-bearing. Every Nix binding's parent is a binding_set at any depth, so admitting them all would mint a node per `enable = true` in a NixOS module's settings tree — the per-leaf flood the Helm values.yaml case in this same function already exists to avoid. Bindings whose value is a lambda or an attribute set are skipped: the first is already minted as a Function by the def walk, the second is a scope (is_namespace_scope_kind), so minting either here would double-count it. Names follow the attrpath convention from the previous commit — leaf as the name, whole path in the qualified name, so `services.nginx.enable = true` is name `enable`, QN proj.file.services.nginx.enable. push_var_def grows a _qn variant for that; the plain form delegates to it and is unchanged for every other language. Extraction and pipeline suites: 494 passed, 0 failed. Signed-off-by: Jason Bowman --- internal/cbm/extract_defs.c | 120 +++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 3 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 32298530a2..06057e28ba 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -31,6 +31,10 @@ enum { EXPORT_ANCESTOR_DEPTH = 4, FUNC_PARENT_CLIMB_LIMIT = 4, /* fun_expr -> term -> uni_term -> let_binding (Nickel) */ + /* Nix header lambdas to descend before the file's body: `{ pkgs, ... }:` is one, + * the nixpkgs overlay `final: prev:` is two. Bounded so a pathological chain + * cannot spin. */ + NIX_HEADER_HOP_MAX = 8, DECORATOR_SCAN_LIMIT = 3, C_RETURN_WALK_DEPTH = 5, VAR_RECURSION_LIMIT = 8, @@ -188,6 +192,7 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec); static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, int depth_unused); static void extract_variables(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec); +static void extract_var_names(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec); static void extract_class_variables(CBMExtractCtx *ctx, TSNode class_node, const CBMLangSpec *spec); static void extract_rust_impl(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec); static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const char *class_qn, @@ -4751,7 +4756,12 @@ static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSp // --- Variable extraction --- // Helper to push a Variable definition -static void push_var_def(CBMExtractCtx *ctx, const char *name, TSNode node) { +/* `qn_name` is the name as it should appear in the qualified name, which differs + * from `name` only where a language scopes a variable below the module — Nix, + * whose binding names are attrpaths (`a.b.c = …` is name `c`, QN suffix `a.b.c`). + * Pass NULL to use `name` for both. */ +static void push_var_def_qn(CBMExtractCtx *ctx, const char *name, const char *qn_name, + TSNode node) { if (!name || !name[0] || strcmp(name, "_") == 0) { return; } @@ -4761,8 +4771,8 @@ static void push_var_def(CBMExtractCtx *ctx, const char *name, TSNode node) { def.name = name; /* Java/Go: directory-based module (package), so a Go package-level var in * myapp/db/conn.go is proj.myapp.db.Var, matching its siblings. */ - def.qualified_name = - cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, name, ctx->language); + def.qualified_name = cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, + qn_name ? qn_name : name, ctx->language); def.label = "Variable"; def.file_path = ctx->rel_path; def.start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; @@ -4771,6 +4781,10 @@ static void push_var_def(CBMExtractCtx *ctx, const char *name, TSNode node) { cbm_defs_push(&ctx->result->defs, a, def); } +static void push_var_def(CBMExtractCtx *ctx, const char *name, TSNode node) { + push_var_def_qn(ctx, name, NULL, node); +} + // Helper: extract name from a declarator chain (C/C++/ObjC) // declaration > init_declarator > declarator (may be pointer_declarator > identifier) static const char *extract_c_declarator_name(CBMArena *a, TSNode decl, const char *source) { @@ -5447,10 +5461,94 @@ static void extract_vars_config(CBMExtractCtx *ctx, TSNode node, CBMArena *a, co /* ── Variable name extraction dispatcher ────────────────────────── */ +/* Nix: a module-level `binding` whose value is neither a lambda nor an attribute + * set. Both of those are already represented — a lambda-valued binding is minted + * as a Function by the def walk, and an attrset-valued one is a scope + * (is_namespace_scope_kind) — so minting either again here would double-count it. + * + * The name is the attrpath's leaf and the QN carries the whole path, matching how + * the def walk names functions; `services.nginx.enable = true` is name `enable`, + * QN proj.file.services.nginx.enable. */ +static void extract_vars_nix(CBMExtractCtx *ctx, TSNode node, CBMArena *a) { + if (strcmp(ts_node_type(node), "binding") != 0) { + return; + } + TSNode value = ts_node_child_by_field_name(node, TS_FIELD("expression")); + if (ts_node_is_null(value)) { + return; + } + if (strcmp(ts_node_type(value), "function_expression") == 0) { + return; /* already a Function */ + } + if (cbm_nix_binding_is_attrset_scope(node)) { + return; /* a scope, not a value */ + } + TSNode attrpath = ts_node_child_by_field_name(node, TS_FIELD("attrpath")); + TSNode leaf = cbm_nix_attrpath_last_attr(attrpath); + if (ts_node_is_null(leaf) || cbm_nix_attr_is_interpolated(leaf)) { + return; + } + char *name = cbm_node_text(a, leaf, ctx->source); + if (!name || !name[0]) { + return; + } + cbm_nix_strip_attr_quotes(name); + const char *scope = cbm_nix_attrpath_scope(a, attrpath, ctx->source); + const char *qn_name = scope ? cbm_arena_sprintf(a, "%s.%s", scope, name) : name; + push_var_def_qn(ctx, name, qn_name, node); +} + +/* Mint every direct binding of one Nix binding container. */ +static void extract_nix_binding_set(CBMExtractCtx *ctx, TSNode set, const CBMLangSpec *spec) { + if (ts_node_is_null(set)) { + return; + } + uint32_t n = ts_node_named_child_count(set); + for (uint32_t i = 0; i < n; i++) { + TSNode child = ts_node_named_child(set, i); + if (strcmp(ts_node_type(child), "binding") == 0) { + extract_var_names(ctx, child, spec); + } + } +} + +/* Walk past a Nix file's header lambda(s) to the container(s) holding its + * file-scope bindings, minting each. Handles the curried header (`final: prev:`) + * and both containers of a `let … in { … }` file: the let's own bindings are file + * scope in the same sense a C++ file-static is, and the returned attrset's are the + * exported surface. Anything deeper is nested and deliberately skipped. */ +static void extract_nix_module_vars(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) { + TSNode cur = ts_node_named_child_count(root) > 0 ? ts_node_named_child(root, 0) : root; + /* Descend header lambdas: `{ pkgs, ... }: `, `final: prev: `. */ + for (int hop = 0; hop < NIX_HEADER_HOP_MAX && !ts_node_is_null(cur) && + strcmp(ts_node_type(cur), "function_expression") == 0; + hop++) { + cur = ts_node_child_by_field_name(cur, TS_FIELD("body")); + } + if (ts_node_is_null(cur)) { + return; + } + if (strcmp(ts_node_type(cur), "let_expression") == 0) { + extract_nix_binding_set(ctx, cbm_find_child_by_kind(cur, "binding_set"), spec); + cur = ts_node_child_by_field_name(cur, TS_FIELD("body")); + if (ts_node_is_null(cur)) { + return; + } + } + const char *k = ts_node_type(cur); + if (strcmp(k, "attrset_expression") == 0 || strcmp(k, "rec_attrset_expression") == 0) { + extract_nix_binding_set(ctx, cbm_find_child_by_kind(cur, "binding_set"), spec); + } +} + static void extract_var_names(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) { (void)spec; CBMArena *a = ctx->arena; const char *kind = ts_node_type(node); + if (ctx->language == CBM_LANG_NIX) { + extract_vars_nix(ctx, node, a); + return; + } switch (ctx->language) { /* Mainstream + C-family + Rust */ @@ -5684,6 +5782,22 @@ static void extract_variables(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec return; } + /* Nix: the file's top level sits behind its header lambda(s), so the root's + * only child is a function_expression and the generic loop below would see + * nothing. Resolve past the header to the binding container(s) that actually + * constitute file scope, and mint only THEIR direct bindings. + * + * That bound is the point. Every Nix binding's parent is a binding_set at any + * depth, so admitting them all would mint a node per `enable = true` in a + * NixOS module's settings tree — the per-leaf flood the Helm values.yaml case + * above exists to avoid. C++ mints file-scope declarations and never locals; + * this is the same rule applied to a language whose file scope is behind a + * lambda. */ + if (ctx->language == CBM_LANG_NIX) { + extract_nix_module_vars(ctx, root, spec); + return; + } + // `root` is the file's root node (the sole caller passes ctx->root), so it // is the parent of every top-level child and the module-level check is // invariant across the loop — hoist it out (and it's now O(1) via _p). From 7f9feb6b63c090925fcf33da5cf093d33c28cf2b Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Mon, 27 Jul 2026 13:53:22 -0700 Subject: [PATCH 4/5] test(nix): cover Variable extraction, scope bound, and the Function/Variable split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nix_module_level_bindings_mint_variables a `let` binding and an attrset binding both mint Variables, and the QN carries the attrpath (services.nginx.enable). nix_nested_bindings_are_not_module_level a binding two attrsets deep mints nothing, and neither `deep` nor `nested` becomes a Variable since both are scopes. nix_lambda_binding_is_function_not_variable a lambda-valued binding is a Function and NOT a Variable; a scalar binding is the reverse. Every absence assertion is paired with a positive one on the same predicate in the same result — `topLevel` in the nesting test, `val` in the split test — so none of them can pass by extracting nothing at all. Several false "clean" results in this area came from predicates that could not have matched. Signed-off-by: Jason Bowman --- tests/test_extraction.c | 72 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 120ee72f8e..1a7e479ce0 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1305,6 +1305,75 @@ TEST(nix_interpolated_attr_mints_no_def) { PASS(); } +/* Nix Variables. `nix_var_types` has always declared `binding`, but no Nix name + * resolver existed, so the count was unconditionally zero. + * + * Scope follows the rule every other language uses: extract_variables mints FILE + * scope and never locals — a C++ declaration inside a function body is not a + * Variable. For Nix, file scope is the `let` bindings and the returned attrset; + * anything in a deeper attrset is not. Without that bound a NixOS module's + * settings tree would mint a node per `enable = true`. */ +TEST(nix_module_level_bindings_mint_variables) { + CBMFileResult *r = extract("{ pkgs, lib, ... }:\n" + "let\n" + " privateConst = 42;\n" + "in\n" + "{\n" + " exported = \"value\";\n" + " services.nginx.enable = true;\n" + "}\n", + CBM_LANG_NIX, "t", "mod.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* A let binding is file scope in the same sense a C++ file-static is. */ + ASSERT(has_def(r, "Variable", "privateConst")); + ASSERT(has_def(r, "Variable", "exported")); + /* The QN carries the attrpath, exactly as it does for functions. */ + ASSERT(has_def(r, "Variable", "enable")); + ASSERT(has_def_qn(r, "t.mod.services.nginx.enable")); + cbm_free_result(r); + PASS(); +} + +/* The flood guard. Each absence assertion is paired with a positive one on the + * same predicate in the same result, so none can pass by extracting nothing. */ +TEST(nix_nested_bindings_are_not_module_level) { + CBMFileResult *r = extract("{ pkgs }:\n" + "{\n" + " topLevel = 1;\n" + " deep = {\n" + " nested = {\n" + " shouldNotAppear = 2;\n" + " };\n" + " };\n" + "}\n", + CBM_LANG_NIX, "t", "deep.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Variable", "topLevel")); /* positive control */ + ASSERT_FALSE(has_def_any(r, "shouldNotAppear")); + /* `deep` is an attrset — a scope, not a value — so not a Variable either. */ + ASSERT_FALSE(has_def(r, "Variable", "deep")); + ASSERT_FALSE(has_def(r, "Variable", "nested")); + cbm_free_result(r); + PASS(); +} + +/* A lambda-valued binding is already minted as a Function by the def walk. Minting + * it again here would double-count every helper in the ecosystem. */ +TEST(nix_lambda_binding_is_function_not_variable) { + CBMFileResult *r = + extract("{\n fn = x: x + 1;\n val = 7;\n}\n", CBM_LANG_NIX, "t", "mix.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "fn")); + ASSERT_FALSE(has_def(r, "Variable", "fn")); + ASSERT(has_def(r, "Variable", "val")); /* positive control */ + ASSERT_FALSE(has_def(r, "Function", "val")); + cbm_free_result(r); + PASS(); +} + /* Descending past the header must not mint a second def from a curried lambda's inner * arm: `iota = a: b: ...` is one named function, not two. The inner `b:` has a * function_expression parent, resolves no name, and must stay out. */ @@ -5073,6 +5142,9 @@ SUITE(extraction) { RUN_TEST(nix_dotted_attrpath_qualifies_like_nested); RUN_TEST(nix_quoted_attr_name_strips_quotes); RUN_TEST(nix_interpolated_attr_mints_no_def); + RUN_TEST(nix_module_level_bindings_mint_variables); + RUN_TEST(nix_nested_bindings_are_not_module_level); + RUN_TEST(nix_lambda_binding_is_function_not_variable); RUN_TEST(nix_curried_lambda_mints_one_def); RUN_TEST(fortran_function); From 4397833d6460e708309535f36729079cbb09869c Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Mon, 27 Jul 2026 15:33:33 -0700 Subject: [PATCH 5/5] test(nix): update the label golden now that bindings mint Variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LABEL_GOLDENS pins the node labels each grammar emits. The nix case is `{ foo = 1; bar = 2; }` — two module-level scalar bindings — and its golden was Module:1, recorded when no Nix binding could be named and none was minted. Both are Variables now, so the golden is Module:1,Variable:2. The mismatch CI reported is the intended behaviour change, not a regression. Extraction, pipeline, grammar_labels, grammar_regression and lang_contract: 534 passed, 0 failed. Signed-off-by: Jason Bowman --- tests/test_grammar_labels.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_grammar_labels.c b/tests/test_grammar_labels.c index b592e32857..bae856faba 100644 --- a/tests/test_grammar_labels.c +++ b/tests/test_grammar_labels.c @@ -200,7 +200,7 @@ static const LabelGolden LABEL_GOLDENS[] = { {"gn", "Module:1"}, {"just", "Function:1,Module:1"}, {"hcl", "Class:1,Module:1"}, - {"nix", "Module:1"}, + {"nix", "Module:1,Variable:2"}, {"gomod", "Module:1"}, {"gotemplate", "Module:1"}, {"graphql", "Class:1,Field:1,Module:1"},