test(nix): cover attrpath qualification, quoting, and interpolation

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>
This commit is contained in:
Jason Bowman
2026-07-30 17:45:25 -07:00
parent bf8680c7cf
commit b5209af7a3
2 changed files with 143 additions and 0 deletions
+87
View File
@@ -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);
+56
View File
@@ -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);