From b5209af7a3772d828d8cec0249feaa3ec929da74 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Mon, 27 Jul 2026 13:37:48 -0700 Subject: [PATCH] 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 9b06e1726..120ee72f8 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 2685b425d..ce198e779 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);