Skip to content

fix(extract): model Nix attrpaths (leaf name, scoped QN) and mint Variables for module-level bindings - #1305

Merged
DeusData merged 5 commits into
DeusData:mainfrom
sini:feat/nix-attrpath-and-variables
Jul 31, 2026
Merged

fix(extract): model Nix attrpaths (leaf name, scoped QN) and mint Variables for module-level bindings#1305
DeusData merged 5 commits into
DeusData:mainfrom
sini:feat/nix-attrpath-and-variables

Conversation

@sini

@sini sini commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Models Nix attrpaths, and mints Variable nodes for Nix module-level bindings.

Built on top of #1304. That PR makes definitions reachable in function-headed files at all; this
one fixes how they are named once reachable, and adds the Variable half. The two are separate
concerns and the four commits here are extracted cleanly for independent review — but the branch
carries #1304's two commits as its base, so please merge that first. The one hard dependency is
pipeline_nix_scoped_binding_calls_resolve, whose fixture is function-headed and mints nothing
without #1304.

Filing under the CONTRIBUTING carve-out for focused bug fixes, as with #1304. Commits 3–4 (the
Variable half) add a node class rather than repair one, so if you would rather see that split out or
discussed in an issue first, say the word and I will pull it.

1. Attrpath names — a merge bug and an encoding bug

A Nix binding's name is a PATH. The resolver took only its first segment
(child_by_field_name(attrpath, "attr")), and three defects followed from that one modelling gap:

source before after
setA = { dup = …; } name dup, QN proj.f.dup QN proj.f.setA.dup
setB = { dup = …; } same QN — collides, def dropped QN proj.f.setB.dup
a.b.fn = … name a name fn, QN proj.f.a.b.fn
"kebab-case" = … name "kebab-case" (quotes included) name kebab-case
"${dyn}" = … name "${dyn}" not minted

The collision is the serious one: two bindings sharing a leaf name in one file collapsed onto a
single node, and the second definition and every CALLS edge sourced from it were silently
discarded at write. On a 320-file Nix repository a conservative scan found ≥54 files affected and
≥275 bindings collapsed.

The convention adopted is the one already used for C++ namespaces — ns::serialize is name
serialize, QN proj.file.ns.serialize. For Nix: name is the leaf segment, qualified_name is
enclosing scope plus leaf. A binding whose value is an attribute set becomes a scope, mirroring
is_namespace_scope_kind's treatment of a namespace. let bindings deliberately do not scope —
they are lexical, and C++ does not qualify by block scope either.

The correctness argument is that Nix has two spellings of the same thing:

a.b.fn = z: …;                 # sugar for
a = { b = { fn = z: …; }; };   # this

Before this they produced different, both-wrong answers. nix_dotted_attrpath_qualifies_like_nested
pins that they now agree.

2. Variables

nix_var_types has declared binding since the language was added, but extract_var_names had no
Nix case and extract_variables walks only the file root's direct children — which for a
function-headed file is the lambda. So the Nix Variable count was unconditionally zero.

Scope follows the existing rule rather than a new one: extract_variables mints file scope and
never locals
, exactly as a C++ declaration inside a function body is not a Variable. For Nix,
file scope is the let bindings and the returned attrset; 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 special case in this same function already exists to avoid. Measured on a
499-file NixOS configuration: 226 Nix Variables, at most 9 in any one file. No flood.

Lambda-valued and attrset-valued bindings are skipped — the first is already a Function, the second
is a scope — so nothing is double-counted.

The constraint that shaped the implementation

Definition QNs and call-scope QNs are computed by two different functions: compute_class_qn in
extract_defs.c and a separate one in extract_unified.c. If they disagree by one segment, the CALLS
edge names a source node that was never minted and is dropped at write — no error, and every
extraction-level assertion still passes.

They are also asymmetric today: the def-side enclosing-QN gate is language-gated, the call-side is
not. So widening one without the other would have produced exactly that silent mismatch.

Rather than implement the rule twice, the Nix computation lives in helpers.c and both sides call
it, which makes that class of drift impossible rather than merely unlikely.
pipeline_nix_scoped_binding_calls_resolve guards it end to end through the store, because no
extraction-level test can see a dropped edge.

Impact

Cumulative with #1304, same binary and mode=full throughout, Function / Variable / CALLS:

repo main with #1304 with this PR
library (76 .nix) 36 / 0 / 24 390 / 0 / 287 463 / 132 / 292
framework (320 .nix) 48 / 0 / 56 1900 / 0 / 1443 2213 / 59 / 1461
system config (499 .nix) 293 / 0 / 204 654 / 0 / 325 684 / 226 / 327

Variable counts are Nix-sourced only; the system config also carries ~7.4k pre-existing JSON
Variables, unchanged by this.

The framework repository's Function count rising 1900 → 2213 with no new files is the collision fix
alone — 313 definitions that previously shared a qualified name with a sibling and were discarded.
That independently corroborates the ≥275 estimate from the static scan.

Tests

Seven new cases. Extraction: leaf-name disambiguation across two attrsets; dotted-vs-nested QN
equality; quote stripping (asserting the quoted form is absent, not merely that the bare form is
present); interpolated attrpath minting nothing; module-level Variables including the attrpath QN;
nested bindings minting nothing; and the Function/Variable split in both directions.

Every absence assertion is paired with a positive one on the same predicate in the same result, so
none can pass by extracting nothing at all.

Pipeline: pipeline_nix_scoped_binding_calls_resolve covers both routes into a qualified name — an
enclosing attrset and a dotted attrpath — asserting each call still reaches its target through the
store.

Extraction and pipeline suites: 494 passed, 0 failed.

Checklist

@sini
sini requested a review from DeusData as a code owner July 27, 2026 20:56
@sini
sini force-pushed the feat/nix-attrpath-and-variables branch from 6fc96a0 to ebfc9ea Compare July 28, 2026 00:08
@DeusData DeusData added bug Something isn't working parsing/quality Graph extraction bugs, false positives, missing edges priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. labels Jul 28, 2026
@DeusData DeusData added this to the 0.9.1-rc milestone Jul 28, 2026
@DeusData

DeusData commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Thank you for the contribution and for separating the Nix attrpath and Variable work while clearly documenting its dependency on #1304. This is now triaged as a high-priority parsing-quality bug for 0.9.1-rc. Our community PR queue is currently quite full, so it may take a little time before we can complete the review and, if approved, merge it. We are doing our best to support community contributions and will return with code-grounded feedback as capacity opens.

@DeusData

Copy link
Copy Markdown
Owner

I merged #1304 a moment ago, which means this branch now conflicts — it carried those two commits, and they have since landed on main through a merge commit. A rebase onto current main should drop them cleanly and leave just the attrpath and Variable work.

The review itself is done and positive. What I checked, since the whole design rests on it: definition QNs and call-scope QNs both route through the same shared helpers (cbm_nix_binding_scope_qn, cbm_nix_qn_name, cbm_nix_attrpath_scope, with the quote-strip inside cbm_func_name_node_text), and I traced both the enclosing-attrset route and the dotted-attrpath route end to end — they compose to identical QNs. That matters because before this PR no Nix scope was pushed on the call side at all, so the definition and call gates genuinely move together here rather than drifting apart later.

A few things I appreciated specifically: the quote test asserts the quoted form is absent rather than merely checking the bare form is present; every absence assertion is paired with a positive control, so none of them can pass vacuously; and pipeline_nix_scoped_binding_calls_resolve is the only test class that could catch a silently dropped CALLS edge, which is exactly the failure this design risks.

On the Variable half you offered to split: I am keeping it. nix_var_types has declared binding since the language landed, so this completes intended-but-dead functionality rather than adding a foreign concept, and the file-scope-only rule plus your measurements (226 variables across a 499-file config, at most 9 per file) show it will not flood the graph.

Two asymmetries worth recording for whoever reads this later, neither blocking: deep.nested.x = 2 at top level mints a Variable while the nested-attrset spelling does not — defensible under a lexical file-scope rule, but surprising; and a let … in let … in { … } chain skips the second let. Both are fine as follow-ups.

Rebase and it goes in. Thank you for the care in both of these — self-reporting your own limitations is rarer than it should be.

sini added 5 commits July 30, 2026 17:45
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 <jason@json64.dev>
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 <jason@json64.dev>
`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 <jason@json64.dev>
…ariable split

  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 <jason@json64.dev>
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 <jason@json64.dev>
@sini
sini force-pushed the feat/nix-attrpath-and-variables branch from ebfc9ea to 4397833 Compare July 31, 2026 01:00
@sini

sini commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto 63a244a8. Both #1304 commits (30622fe1, ecde3188) were dropped automatically as already-applied — no conflicts, nothing to resolve by hand. What remains is the five commits of attrpath and Variable work, and the diff is byte-identical to what you reviewed: 7 files, +636/−22.

On the two asymmetries you recorded: leaving both as-is for follow-up, as you called them. For the record on the first one — deep.nested.x = 2 at top level minting a Variable while the nested-attrset spelling does not — that falls out of the file-scope rule being lexical: the dotted spelling is one binding whose leaf sits at file scope, whereas the nested spelling puts the leaf inside an attrset expression. Same value, different binder depth. I agree it is surprising, and it is the kind of thing that wants a stated rule rather than a fix.

One disclosure on my local run, since none of it is in the paths this PR touches. extraction (270/270), pipeline (227/227), and grammar_labels (2/2) are green. Two unrelated suites are red on my machine: daemon_runtime at 41 pass / 1 fail, and cli aborting under LeakSanitizer on a 26-byte strdup leak from cli_activation_save_env at tests/test_cli.c:509 — a test-local helper, not production code. I have not run these against main to confirm they are pre-existing, so I am flagging rather than claiming.

@DeusData
DeusData merged commit 8f06867 into DeusData:main Jul 31, 2026
28 checks passed
@DeusData

Copy link
Copy Markdown
Owner

Merged as 8f06867d7. The rebase came through exactly as you described — 5 commits, 7 files, +636/−22, byte-identical to what was reviewed, and 28/28 green. Thank you for the care in both of these.

On the two red suites you flagged: neither is yours, and you were right to flag rather than claim.

I checked tests/test_cli.c against origin/main at the commit you rebased onto — it is byte-identical, so nothing in this PR can have caused that leak. The mechanism is worth knowing: cli_activation_save_env (tests/test_cli.c:506) strdups HOME and CBM_CACHE_DIR, and the matching free lives at the end of cli_activation_restore_env (:526). Every one of those tests runs a long stretch of assertions between the two calls, so if any assertion fires early, the test aborts before the restore and the strdup leaks. In other words the 26-byte leak is a symptom of a failing assertion, not the failure itself — LSan is reporting the consequence and the actual defect is whatever assert fired first. If you still have the output, the assert line above the leak report is the interesting one.

daemon_runtime at 41/1 is likewise nowhere near the Nix extraction paths. Both are ours to look at.

And your explanation of the first asymmetry is better than my description of it. "The dotted spelling is one binding whose leaf sits at file scope, whereas the nested spelling puts the leaf inside an attrset expression — same value, different binder depth" is exactly right, and I agree with your conclusion: this wants a stated rule rather than a fix, because the alternative is a scope model that is no longer lexical. I have recorded it that way.

Thank you again — self-reporting your own red suites, unprompted and without hedging, is rarer than it should be.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working parsing/quality Graph extraction bugs, false positives, missing edges priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants