From 41f73838c75bca6f20d2c3d966f5edf7abb3d859 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 18:55:02 +0200 Subject: [PATCH 1/7] fix(store): bound the overview entries so Django-sized graphs render get_architecture(system_structure) returned status "limited" for Django 5.2.7 (52,402 nodes, 274,549 edges, below both graph budgets). The overview listed one entry with a full representative symbol for each of its 12,700 components. That section alone grew the mutable document to 16.7 MB, the capped components and dependency witnesses took it to 29.4 MB, and serializing the result then crossed the 32 MB response budget, so the whole projection fell back to the empty limited render. List at most 4096 overview entries, chosen by the same rank that picks the displayed components, so every displayed component keeps its entry. Groups still carry every component id and all counts, and the omission is explicit: overview.complete is false and overview.limits reports omitted_components. Graphs with up to 4096 components are unchanged apart from that new zero count. Django now renders "ready" with 12,700 components, 256 displayed, 2048 dependencies and 4096 overview entries in a 4.1 MB result (peak 23.2 MB of the budget, below the 8 MiB job result cap). Signed-off-by: Bernhard Jackiewicz --- src/store/architecture_projection.c | 47 +++++++++++++------ tests/test_architecture_projection.c | 67 ++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 14 deletions(-) diff --git a/src/store/architecture_projection.c b/src/store/architecture_projection.c index 6c8751749..b2df238ba 100644 --- a/src/store/architecture_projection.c +++ b/src/store/architecture_projection.c @@ -19,7 +19,12 @@ enum { AP_ARGUMENT_LIMIT = 8, AP_SIGNATURE_BYTES = 4096, AP_VALUE_BYTES = 1024, - AP_PARAMETER_LIMIT = 64 + AP_PARAMETER_LIMIT = 64, + /* Overview entries carry a full representative symbol (about 1.3 KB of + * mutable JSON each). Django 5.2.7 has 12,700 components: listing all of + * them took half of AP_JSON_BUDGET before any other section. Keep it at + * least the max_components ceiling (512) so displayed parts stay listed. */ + AP_OVERVIEW_COMPONENTS = 4096 }; enum { AP_EVIDENCE_CONTEXT, AP_EVIDENCE_CORRIDOR, AP_EVIDENCE_PATH, AP_EVIDENCE_SCOPES }; @@ -68,7 +73,7 @@ static const char *const ap_types[] = { "IMPLEMENTS", "INHERITS", "CONFIGURES", "WRITES", "READS", "USAGE", "PUBLISHES", "SUBSCRIBES", "DEFINES", "DEFINES_METHOD", "CONTAINS", "EMITS", "LISTENS_ON", "HANDLES", "DATA_FLOWS", "INFRA_MAPS"}; -enum { AP_DEFINES = 14, AP_METHOD = 15, AP_CONTAINS = 16 }; +enum { AP_DEFINES = 14, AP_METHOD = 15, AP_CONTAINS = 16, AP_HANDLES = 19 }; typedef struct { int64_t id; @@ -89,6 +94,7 @@ typedef struct { typedef struct { int root, members, files, atoms, representative, degree, displayed; int overview_group; + bool listed; char *directory; int samples[AP_REPRESENTATIVES], sample_count; } ap_component; @@ -1278,7 +1284,7 @@ static int ap_overview(ap_context *c, yyjson_mut_doc *doc, yyjson_mut_val *root) yyjson_mut_obj_add_val(doc, overview, "connections", connections); yyjson_mut_obj_add_val(doc, overview, "totals", totals); yyjson_mut_obj_add_val(doc, overview, "limits", limits); - int connection_count = 0, shown = 0; + int connection_count = 0, shown = 0, omitted_parts = 0; if (!c->limited) { for (int g = 0; g < c->group_count; g++) { ap_overview_group *group = &c->groups[g]; @@ -1309,8 +1315,14 @@ static int ap_overview(ap_context *c, yyjson_mut_doc *doc, yyjson_mut_val *root) if (ap_stopped(c)) return CBM_STORE_ERR; } + /* Groups above keep every component id and count; only the detailed + * entries are bounded, in display rank order. */ for (int i = 0; i < c->component_count; i++) { ap_component *part = &c->components[i]; + if (!part->listed) { + omitted_parts++; + continue; + } yyjson_mut_val *value = yyjson_mut_obj(doc), *reps = yyjson_mut_arr(doc); char id[48]; ap_component_id(c, i, id); @@ -1369,12 +1381,14 @@ static int ap_overview(ap_context *c, yyjson_mut_doc *doc, yyjson_mut_val *root) } free(edges); } - yyjson_mut_obj_add_bool(doc, overview, "complete", !c->limited && shown == connection_count); + yyjson_mut_obj_add_bool(doc, overview, "complete", + !c->limited && shown == connection_count && !omitted_parts); yyjson_mut_obj_add_int(doc, totals, "groups", c->limited ? 0 : c->group_count); yyjson_mut_obj_add_int(doc, totals, "components", c->limited ? 0 : c->component_count); yyjson_mut_obj_add_int(doc, totals, "accounted_nodes", c->limited ? 0 : c->accounted); yyjson_mut_obj_add_int(doc, totals, "connections", connection_count); yyjson_mut_obj_add_int(doc, limits, "omitted_connections", connection_count - shown); + yyjson_mut_obj_add_int(doc, limits, "omitted_components", omitted_parts); return CBM_STORE_OK; } @@ -1732,30 +1746,35 @@ static int ap_render(ap_context *c, const char *project, char **out_json) { bool paths_truncated = false; int rc = CBM_STORE_OK; ap_dep *deps = NULL; - if (ap_overview(c, doc, root) != CBM_STORE_OK) { - rc = CBM_STORE_ERR; - goto done; - } if (!c->limited) { /* Prefer the largest connected candidates, retaining deterministic IDs. - * Never relabel discarded components as a single invented component. */ + * Never relabel discarded components as a single invented component. + * The same rank bounds the overview entries, which therefore always + * include every displayed component. */ ap_rank *ranks = malloc(((size_t)c->component_count + 1) * sizeof(*ranks)); if (!ranks) { rc = CBM_STORE_ERR; goto done; } - for (int i = 0; i < c->component_count; i++) { - c->components[i].displayed = -1; + for (int i = 0; i < c->component_count; i++) ranks[i] = (ap_rank){i, c->components[i].members, c->components[i].degree}; - } qsort(ranks, (size_t)c->component_count, sizeof(*ranks), ap_rank_compare); - for (int pick = 0; pick < c->options.max_components && pick < c->component_count; pick++) - c->components[ranks[pick].index].displayed = shown++; + for (int pick = 0; pick < c->component_count; pick++) { + ap_component *part = &c->components[ranks[pick].index]; + part->displayed = pick < c->options.max_components ? shown++ : -1; + part->listed = pick < AP_OVERVIEW_COMPONENTS; + } free(ranks); if (ap_stopped(c)) { rc = CBM_STORE_ERR; goto done; } + } + if (ap_overview(c, doc, root) != CBM_STORE_OK) { + rc = CBM_STORE_ERR; + goto done; + } + if (!c->limited) { for (int i = 0; i < c->component_count; i++) { ap_component *p = &c->components[i]; if (p->displayed < 0) diff --git a/tests/test_architecture_projection.c b/tests/test_architecture_projection.c index 4e93ed30a..f169fe339 100644 --- a/tests/test_architecture_projection.c +++ b/tests/test_architecture_projection.c @@ -1140,6 +1140,72 @@ TEST(projection_repeated_behavior_evidence_preserves_base_graph_under_budget) { PASS(); } +/* Shaped like Django 5.2.7 (12,700 mostly single-symbol components, far below + * the node and edge budgets): one full overview entry per component used to + * exceed the 32 MB response budget, so the whole projection came back + * limited and empty. The overview now lists a bounded, ranked subset and + * still accounts for every component through its groups and totals. */ +TEST(projection_many_small_components_stay_within_response_budget) { + cbm_store_t *store = projection_store(); + ASSERT_NOT_NULL(store); + enum { parts = 14000 }; + char padding[161]; + memset(padding, 'x', sizeof(padding) - 1); + padding[sizeof(padding) - 1] = '\0'; + ASSERT_EQ(cbm_store_begin(store), CBM_STORE_OK); + for (int i = 0; i < parts; i++) { + char qn[256], file[256]; + snprintf(qn, sizeof(qn), "projection.area%d.%s%05d.Part", i % 8, padding, i); + snprintf(file, sizeof(file), "src/area%d/%.*s%05d.py", i % 8, 120, padding, i); + ASSERT_TRUE(projection_node(store, "Class", qn, file, false) > 0); + } + ASSERT_EQ(cbm_store_commit(store), CBM_STORE_OK); + char *json = NULL; + ASSERT_EQ(cbm_store_architecture_projection(store, "projection", NULL, &json), CBM_STORE_OK); + ASSERT_NULL(strstr(json, "exceeded its memory budget")); + /* architecture_jobs.c rejects results above 8 MiB. */ + ASSERT_TRUE(strlen(json) < 8 * 1024 * 1024); + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + ASSERT_NOT_NULL(doc); + ASSERT_STR_EQ(yyjson_get_str(projection_field(doc, "status")), "ready"); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(projection_field(doc, "totals"), "components")), parts); + yyjson_val *displayed = projection_field(doc, "components"); + ASSERT_EQ(yyjson_arr_size(displayed), 256); + yyjson_val *overview = projection_field(doc, "overview"), + *listed = yyjson_obj_get(overview, "components"), + *groups = yyjson_obj_get(overview, "groups"); + size_t listed_count = yyjson_arr_size(listed); + ASSERT_TRUE(listed_count >= 512); + ASSERT_TRUE(listed_count < parts); + ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(overview, "complete"))); + ASSERT_EQ( + yyjson_get_int(yyjson_obj_get(yyjson_obj_get(overview, "limits"), "omitted_components")), + parts - (int)listed_count); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(yyjson_obj_get(overview, "totals"), "components")), + parts); + /* Every displayed component keeps its overview entry. */ + for (size_t i = 0; i < yyjson_arr_size(displayed); i++) { + const char *id = yyjson_get_str(yyjson_obj_get(yyjson_arr_get(displayed, i), "id")); + bool found = false; + for (size_t j = 0; j < listed_count && !found; j++) + found = !strcmp(id, yyjson_get_str(yyjson_obj_get(yyjson_arr_get(listed, j), "id"))); + ASSERT_TRUE(found); + } + size_t membership = 0; + int members = 0; + for (size_t i = 0; i < yyjson_arr_size(groups); i++) { + yyjson_val *group = yyjson_arr_get(groups, i); + membership += yyjson_arr_size(yyjson_obj_get(group, "component_ids")); + members += yyjson_get_int(yyjson_obj_get(group, "member_count")); + } + ASSERT_EQ(membership, parts); + ASSERT_EQ(members, parts); + yyjson_doc_free(doc); + free(json); + cbm_store_close(store); + PASS(); +} + SUITE(architecture_projection) { RUN_TEST(projection_accounts_for_isolated_nodes_and_files); RUN_TEST(projection_preserves_typed_edges_and_contiguous_paths); @@ -1165,4 +1231,5 @@ SUITE(architecture_projection) { RUN_TEST(projection_missing_corrupt_and_noncall_arguments_remain_unknown); RUN_TEST(projection_oversized_evidence_is_omitted_without_truncating_values); RUN_TEST(projection_repeated_behavior_evidence_preserves_base_graph_under_budget); + RUN_TEST(projection_many_small_components_stay_within_response_budget); } From 648daf71e4319b9b86b725cee45e7934dbfe1ad2 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 19:09:18 +0200 Subject: [PATCH 2/7] fix(store): classify Python test files by name in the architecture projection ap_test_path missed Django's app convention tests.py and treated every file below a singular test/ directory as test code, so django/test/, Django's public testing API, became test components. Python discovers tests by file name, so recognize tests.py and conftest.py next to the existing test_*.py and *_test.py rules, and stop a singular test/ directory from being the only signal for a .py file without a test name. The indexed is_test flag of such a file comes from the same directory rule at extraction time, so it is set aside there as well; otherwise the class (unflagged) and its methods (flagged) would get different roles and split into separate components. tests/, __tests__/ and test/ for other languages keep their meaning. Signed-off-by: Bernhard Jackiewicz --- src/store/architecture_projection.c | 32 +++++++++++---- tests/test_architecture_projection.c | 60 ++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src/store/architecture_projection.c b/src/store/architecture_projection.c index b2df238ba..8b25e5d43 100644 --- a/src/store/architecture_projection.c +++ b/src/store/architecture_projection.c @@ -193,23 +193,38 @@ static bool ap_cluster_edge(int type) { } /* Conservative source conventions supplement the positive indexed is_test - * flag. Non-test classification means no test evidence, not production use. */ -static bool ap_test_path(const char *path) { + * flag. Non-test classification means no test evidence, not production use. + * Python discovers tests by file name (test_*.py, *_test.py, tests.py, + * conftest.py). A singular test/ directory there is often a shipped package + * (django/test/ is Django's public testing API), so for a .py file without a + * test name it is no signal on its own. *package then tells the caller that + * the indexed flag only repeats that directory rule (cbm_is_test_file) and + * must not decide alone either; it keeps one role per file. tests/ and + * __tests__/ stay directory signals, and test/ stays one for other files. */ +static bool ap_test_path(const char *path, bool *package) { const char *base = path; + bool directory = false, singular = false; for (const char *p = path;; p++) { if (*p == '/' || *p == '\\' || !*p) { size_t len = (size_t)(p - base); - if ((len == 4 && !strncmp(base, "test", len)) || - (len == 5 && !strncmp(base, "tests", len)) || + if ((len == 5 && !strncmp(base, "tests", len)) || (len == 9 && !strncmp(base, "__tests__", len))) - return true; + directory = true; + else if (len == 4 && !strncmp(base, "test", len)) + singular = true; if (!*p) break; base = p + 1; } } - return !strncmp(base, "test_", 5) || strstr(base, "_test.") || strstr(base, ".test.") || - strstr(base, ".spec."); + size_t len = strlen(base); + bool python = len > 3 && !strcmp(base + len - 3, ".py"); + if (directory || !strncmp(base, "test_", 5) || strstr(base, "_test.") || + strstr(base, ".test.") || strstr(base, ".spec.") || + (python && (!strcmp(base, "tests.py") || !strcmp(base, "conftest.py")))) + return true; + *package = singular && python; + return singular && !python; } static int ap_counts(ap_context *c, const char *project) { @@ -269,7 +284,8 @@ static int ap_load(ap_context *c, const char *project) { n->end = sqlite3_column_int(stmt, 6); n->entry = sqlite3_column_int(stmt, 7) != 0; n->test_property = sqlite3_column_int(stmt, 8) != 0; - n->test = n->test_property || ap_test_path(n->file); + bool package = false; + n->test = ap_test_path(n->file, &package) || (n->test_property && !package); n->structural = !strcmp(n->label, "Project") || !strcmp(n->label, "Folder"); n->declared = !strcmp(n->label, "Module") || !strcmp(n->label, "Package") || !strcmp(n->label, "Namespace") || !strcmp(n->label, "Class") || diff --git a/tests/test_architecture_projection.c b/tests/test_architecture_projection.c index f169fe339..972fe1cdf 100644 --- a/tests/test_architecture_projection.c +++ b/tests/test_architecture_projection.c @@ -1206,6 +1206,65 @@ TEST(projection_many_small_components_stay_within_response_budget) { PASS(); } +static yyjson_val *projection_component_of(yyjson_doc *doc, const char *qn) { + yyjson_val *parts = projection_field(doc, "components"); + for (size_t i = 0; i < yyjson_arr_size(parts); i++) { + yyjson_val *part = yyjson_arr_get(parts, i); + yyjson_val *rep = yyjson_arr_get(yyjson_obj_get(part, "representatives"), 0); + if (!strcmp(yyjson_get_str(yyjson_obj_get(rep, "qualified_name")), qn)) + return part; + } + return NULL; +} + +TEST(projection_python_test_files_follow_name_conventions) { + cbm_store_t *store = projection_store(); + ASSERT_NOT_NULL(store); + projection_node(store, "Class", "projection.app.tests.ViewTests", "app/tests.py", false); + projection_node(store, "Function", "projection.app.models_test.check", "app/models_test.py", + false); + projection_node(store, "Function", "projection.conftest.fixture", "test/conftest.py", false); + projection_node(store, "Function", "projection.check", "test/check.c", false); + projection_node(store, "Function", "projection.app.views.index", "app/views.py", false); + /* django/test/ ships Django's testing API. The extractor flags its + * functions from the directory alone; the class carries no flag. */ + int64_t client = projection_node(store, "Class", "projection.django.test.client.Client", + "django/test/client.py", false); + cbm_node_t method = {.project = "projection", + .label = "Method", + .name = "get", + .qualified_name = "projection.django.test.client.Client.get", + .file_path = "django/test/client.py", + .properties_json = "{\"is_test\":true}"}; + int64_t get = cbm_store_upsert_node(store, &method); + projection_edge(store, client, get, "DEFINES_METHOD"); + char *json = NULL; + ASSERT_EQ(cbm_store_architecture_projection(store, "projection", NULL, &json), CBM_STORE_OK); + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + ASSERT_NOT_NULL(doc); + const char *tests[] = {"projection.app.tests.ViewTests", "projection.app.models_test.check", + "projection.conftest.fixture", "projection.check"}; + for (size_t i = 0; i < sizeof(tests) / sizeof(tests[0]); i++) { + yyjson_val *part = projection_component_of(doc, tests[i]); + ASSERT_NOT_NULL(part); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(part, "role")), "test"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(part, "role_basis")), "test_path_convention"); + } + yyjson_val *views = projection_component_of(doc, "projection.app.views.index"); + ASSERT_NOT_NULL(views); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(views, "role")), "non_test"); + /* One role per file: the class and its flagged method stay one part. */ + yyjson_val *api = projection_component_of(doc, "projection.django.test.client.Client"); + ASSERT_NOT_NULL(api); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(api, "role")), "non_test"); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(api, "member_count")), 2); + ASSERT_EQ(yyjson_arr_size(projection_field(doc, "components")), 6); + yyjson_doc_free(doc); + free(json); + cbm_store_close(store); + PASS(); +} + SUITE(architecture_projection) { RUN_TEST(projection_accounts_for_isolated_nodes_and_files); RUN_TEST(projection_preserves_typed_edges_and_contiguous_paths); @@ -1232,4 +1291,5 @@ SUITE(architecture_projection) { RUN_TEST(projection_oversized_evidence_is_omitted_without_truncating_values); RUN_TEST(projection_repeated_behavior_evidence_preserves_base_graph_under_budget); RUN_TEST(projection_many_small_components_stay_within_response_budget); + RUN_TEST(projection_python_test_files_follow_name_conventions); } From 1c36129bfc9cb1845553906e16beeafd7b4ad637 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 19:17:12 +0200 Subject: [PATCH 3/7] fix(store): count route handlers as architecture entry points The projection took entry points only from the indexed is_entry_point flag, which the extractor sets for main and JS/TS exports. Python web code never calls main, so Django's Behavior list stayed empty even once the projection rendered. Mark a Function or Method as an entry point when it has a HANDLES edge to a Route node, the same route evidence atlas_flows.c already scores. Handlers in test code stay out: they are fixtures, and the projection keeps test and production roles apart everywhere else. The index and the extractor are unchanged. For Django 5.2.7 this adds four production handlers to main (five entry points, 12 paths instead of 4). The other 130 indexed handlers are test methods and stay excluded. Signed-off-by: Bernhard Jackiewicz --- src/store/architecture_projection.c | 7 +++++ tests/test_architecture_projection.c | 41 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/store/architecture_projection.c b/src/store/architecture_projection.c index 8b25e5d43..c79a766e1 100644 --- a/src/store/architecture_projection.c +++ b/src/store/architecture_projection.c @@ -314,6 +314,13 @@ static int ap_load(ap_context *c, const char *project) { if (source < 0 || target < 0) continue; c->edges[c->m++] = (ap_edge){sqlite3_column_int64(stmt, 0), source, target, type}; + /* Web code enters through route handlers, not main: HANDLES runs from + * a Function/Method to its Route (atlas_flows.c scores them alike). + * Handlers in test code are fixtures, not entry points. */ + ap_node *handler = &c->nodes[source]; + if (type == AP_HANDLES && !strcmp(c->nodes[target].label, "Route") && !handler->test && + (!strcmp(handler->label, "Function") || !strcmp(handler->label, "Method"))) + handler->entry = true; if (ap_dependency(type)) { c->nodes[source].degree++; c->nodes[target].degree++; diff --git a/tests/test_architecture_projection.c b/tests/test_architecture_projection.c index 972fe1cdf..9e2414426 100644 --- a/tests/test_architecture_projection.c +++ b/tests/test_architecture_projection.c @@ -1265,6 +1265,46 @@ TEST(projection_python_test_files_follow_name_conventions) { PASS(); } +/* Django views are never named main; their HANDLES edge is the entry evidence. */ +TEST(projection_route_handlers_are_entry_points) { + cbm_store_t *store = projection_store(); + ASSERT_NOT_NULL(store); + int64_t route = projection_node(store, "Route", "projection.__route__GET__/orders", "", false); + int64_t view = + projection_node(store, "Function", "projection.shop.views.orders", "shop/views.py", false); + int64_t method = + projection_node(store, "Method", "projection.shop.api.Orders.get", "shop/api.py", false); + int64_t fixture = + projection_node(store, "Function", "projection.tests.urls.orders", "tests/urls.py", false); + int64_t query = + projection_node(store, "Function", "projection.shop.query.orders", "shop/query.py", false); + projection_node(store, "Function", "projection.shop.views.unrouted", "shop/views.py", false); + projection_edge(store, view, route, "HANDLES"); + projection_edge(store, method, route, "HANDLES"); + projection_edge(store, fixture, route, "HANDLES"); + projection_edge(store, view, query, "CALLS"); + char *json = NULL; + ASSERT_EQ(cbm_store_architecture_projection(store, "projection", NULL, &json), CBM_STORE_OK); + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *entries = projection_field(doc, "entrypoints"); + ASSERT_EQ(yyjson_arr_size(entries), 2); + ASSERT_EQ(yyjson_get_sint(yyjson_obj_get(yyjson_arr_get(entries, 0), "id")), view); + ASSERT_EQ(yyjson_get_sint(yyjson_obj_get(yyjson_arr_get(entries, 1), "id")), method); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(projection_field(doc, "totals"), "entrypoints")), 2); + yyjson_val *paths = projection_field(doc, "paths"); + ASSERT_EQ(yyjson_arr_size(paths), 1); + yyjson_val *path = yyjson_arr_get(paths, 0); + ASSERT_EQ(yyjson_get_sint(yyjson_obj_get(path, "entrypoint_id")), view); + ASSERT_EQ( + yyjson_get_sint(yyjson_obj_get(yyjson_arr_get(yyjson_obj_get(path, "nodes"), 1), "id")), + query); + yyjson_doc_free(doc); + free(json); + cbm_store_close(store); + PASS(); +} + SUITE(architecture_projection) { RUN_TEST(projection_accounts_for_isolated_nodes_and_files); RUN_TEST(projection_preserves_typed_edges_and_contiguous_paths); @@ -1292,4 +1332,5 @@ SUITE(architecture_projection) { RUN_TEST(projection_repeated_behavior_evidence_preserves_base_graph_under_budget); RUN_TEST(projection_many_small_components_stay_within_response_budget); RUN_TEST(projection_python_test_files_follow_name_conventions); + RUN_TEST(projection_route_handlers_are_entry_points); } From 7c81674f9b2b51e513fa09b5c9b4dcd122d3a6e3 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 19:51:41 +0200 Subject: [PATCH 4/7] fix(store): skip test-registered routes and rank indexed entry points first On Django every handler that the route rule added came from a test-client call such as self.client.get("/get_view/"), which the extractor indexes as a route registration: the Route's only CALLS edges come from tests/. The four false entry points took 11 of the 12 default path slots, and main dropped from 4 paths to 1. A handler now counts only when its Route is registered from non-test code or has no registration call at all (decorator routes). Indexed is_entry_point nodes come before handlers, so paths serve main first. The label checks move to load time. Django: entry points main (4 paths) and delete_selected (7 paths) instead of five with main at 1 path. Signed-off-by: Bernhard Jackiewicz --- src/store/architecture_projection.c | 58 ++++++++++++++++++++-------- tests/test_architecture_projection.c | 44 ++++++++++++++++----- 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/src/store/architecture_projection.c b/src/store/architecture_projection.c index c79a766e1..5d76944bc 100644 --- a/src/store/architecture_projection.c +++ b/src/store/architecture_projection.c @@ -73,13 +73,13 @@ static const char *const ap_types[] = { "IMPLEMENTS", "INHERITS", "CONFIGURES", "WRITES", "READS", "USAGE", "PUBLISHES", "SUBSCRIBES", "DEFINES", "DEFINES_METHOD", "CONTAINS", "EMITS", "LISTENS_ON", "HANDLES", "DATA_FLOWS", "INFRA_MAPS"}; -enum { AP_DEFINES = 14, AP_METHOD = 15, AP_CONTAINS = 16, AP_HANDLES = 19 }; +enum { AP_CALLS = 1, AP_DEFINES = 14, AP_METHOD = 15, AP_CONTAINS = 16, AP_HANDLES = 19 }; typedef struct { int64_t id; char *name, *qn, *file, *label; int line, end, atom, component, degree; - bool entry, structural, declared, test, test_property; + bool entry, handler, route, callable, structural, declared, test, test_property; } ap_node; typedef struct { int64_t id; @@ -254,6 +254,35 @@ static int ap_counts(ap_context *c, const char *project) { return CBM_STORE_OK; } +/* Web code enters through route handlers, not main: HANDLES runs from a + * Function/Method to its Route (atlas_flows.c scores them alike). A CALLS edge + * into a Route is the call that registered it, as pass_route_nodes.c reads it. + * Handlers in test code are fixtures, and so are the handlers of routes that + * only test code registers: test-client calls like self.client.get("/x/") are + * indexed as registrations. Routes without a registration call (decorators) + * keep their handlers. Indexed entry points stay ahead of handlers. */ +static int ap_route_handlers(ap_context *c) { + enum { AP_PRODUCTION_REGISTRAR = 1, AP_TEST_REGISTRAR = 2 }; + unsigned char *registrars = calloc((size_t)c->n + 1, sizeof(*registrars)); + if (!registrars) + return CBM_STORE_ERR; + for (int i = 0; i < c->m; i++) { + const ap_edge *e = &c->edges[i]; + if (e->type == AP_CALLS && c->nodes[e->target].route) + registrars[e->target] |= + c->nodes[e->source].test ? AP_TEST_REGISTRAR : AP_PRODUCTION_REGISTRAR; + } + for (int i = 0; i < c->m; i++) { + const ap_edge *e = &c->edges[i]; + ap_node *handler = &c->nodes[e->source]; + if (e->type == AP_HANDLES && c->nodes[e->target].route && handler->callable && + !handler->test && !handler->entry && registrars[e->target] != AP_TEST_REGISTRAR) + handler->handler = true; + } + free(registrars); + return CBM_STORE_OK; +} + static int ap_load(ap_context *c, const char *project) { c->nodes = calloc((size_t)c->total_nodes + 1, sizeof(*c->nodes)); c->edges = calloc((size_t)c->total_edges + 1, sizeof(*c->edges)); @@ -290,6 +319,8 @@ static int ap_load(ap_context *c, const char *project) { n->declared = !strcmp(n->label, "Module") || !strcmp(n->label, "Package") || !strcmp(n->label, "Namespace") || !strcmp(n->label, "Class") || !strcmp(n->label, "Struct") || !strcmp(n->label, "Interface"); + n->route = !strcmp(n->label, "Route"); + n->callable = !strcmp(n->label, "Function") || !strcmp(n->label, "Method"); n->atom = c->n - 1; n->component = -1; } @@ -314,13 +345,6 @@ static int ap_load(ap_context *c, const char *project) { if (source < 0 || target < 0) continue; c->edges[c->m++] = (ap_edge){sqlite3_column_int64(stmt, 0), source, target, type}; - /* Web code enters through route handlers, not main: HANDLES runs from - * a Function/Method to its Route (atlas_flows.c scores them alike). - * Handlers in test code are fixtures, not entry points. */ - ap_node *handler = &c->nodes[source]; - if (type == AP_HANDLES && !strcmp(c->nodes[target].label, "Route") && !handler->test && - (!strcmp(handler->label, "Function") || !strcmp(handler->label, "Method"))) - handler->entry = true; if (ap_dependency(type)) { c->nodes[source].degree++; c->nodes[target].degree++; @@ -328,7 +352,7 @@ static int ap_load(ap_context *c, const char *project) { } } sqlite3_finalize(stmt); - return step == SQLITE_DONE ? CBM_STORE_OK : CBM_STORE_ERR; + return step == SQLITE_DONE ? ap_route_handlers(c) : CBM_STORE_ERR; } /* Ownership is grounded in declared module/type names and explicit definition @@ -1873,11 +1897,14 @@ static int ap_render(ap_context *c, const char *project, char **out_json) { rc = CBM_STORE_ERR; goto done; } - for (int i = 0; i < c->n; i++) { - ap_node *node = &c->nodes[i]; - if (node->component < 0) - continue; - if (node->entry) { + /* Indexed entry points first, then route handlers, each in id order: + * paths take their slots in this order, so many handlers cannot crowd + * main out of the default paths. */ + for (int pass = 0; pass < 2; pass++) + for (int i = 0; i < c->n; i++) { + ap_node *node = &c->nodes[i]; + if (node->component < 0 || !(pass ? node->handler : node->entry)) + continue; entry_total++; if (entry_count < AP_ENTRYPOINTS) { entry_indexes[entry_count++] = i; @@ -1887,7 +1914,6 @@ static int ap_render(ap_context *c, const char *project, char **out_json) { c->evidence_scope = AP_EVIDENCE_CONTEXT; } } - } c->evidence_scope = AP_EVIDENCE_PATH; if (c->options.target_node_id) { /* The target query produces exact endpoint witnesses below. */ diff --git a/tests/test_architecture_projection.c b/tests/test_architecture_projection.c index 9e2414426..1468b3323 100644 --- a/tests/test_architecture_projection.c +++ b/tests/test_architecture_projection.c @@ -1265,7 +1265,9 @@ TEST(projection_python_test_files_follow_name_conventions) { PASS(); } -/* Django views are never named main; their HANDLES edge is the entry evidence. */ +/* Django views are never named main; their HANDLES edge is the entry evidence. + * Test-client calls like self.client.get("/probe/") are indexed as route + * registrations, so a route that only test code registers proves nothing. */ TEST(projection_route_handlers_are_entry_points) { cbm_store_t *store = projection_store(); ASSERT_NOT_NULL(store); @@ -1279,25 +1281,49 @@ TEST(projection_route_handlers_are_entry_points) { int64_t query = projection_node(store, "Function", "projection.shop.query.orders", "shop/query.py", false); projection_node(store, "Function", "projection.shop.views.unrouted", "shop/views.py", false); + int64_t cart_route = + projection_node(store, "Route", "projection.__route__GET__/cart", "", false); + int64_t urls = + projection_node(store, "Function", "projection.shop.urls.register", "shop/urls.py", false); + int64_t cart = + projection_node(store, "Function", "projection.shop.views.cart", "shop/views.py", false); + int64_t probe_route = + projection_node(store, "Route", "projection.__route__GET__/probe", "", false); + int64_t probe = projection_node(store, "Method", "projection.tests.test_views.Tests.test_probe", + "tests/test_views.py", false); + int64_t text = projection_node(store, "Method", "projection.shop.models.Order.text", + "shop/models.py", false); + /* Created last, so only the ranking can put the indexed entry first. */ + int64_t launcher = + projection_node(store, "Function", "projection.manage.main", "manage.py", true); + int64_t setup = + projection_node(store, "Function", "projection.manage.setup", "manage.py", false); projection_edge(store, view, route, "HANDLES"); projection_edge(store, method, route, "HANDLES"); projection_edge(store, fixture, route, "HANDLES"); projection_edge(store, view, query, "CALLS"); + projection_edge(store, urls, cart_route, "CALLS"); + projection_edge(store, cart, cart_route, "HANDLES"); + projection_edge(store, probe, probe_route, "CALLS"); + projection_edge(store, text, probe_route, "HANDLES"); + projection_edge(store, launcher, setup, "CALLS"); char *json = NULL; ASSERT_EQ(cbm_store_architecture_projection(store, "projection", NULL, &json), CBM_STORE_OK); yyjson_doc *doc = yyjson_read(json, strlen(json), 0); ASSERT_NOT_NULL(doc); yyjson_val *entries = projection_field(doc, "entrypoints"); - ASSERT_EQ(yyjson_arr_size(entries), 2); - ASSERT_EQ(yyjson_get_sint(yyjson_obj_get(yyjson_arr_get(entries, 0), "id")), view); - ASSERT_EQ(yyjson_get_sint(yyjson_obj_get(yyjson_arr_get(entries, 1), "id")), method); - ASSERT_EQ(yyjson_get_int(yyjson_obj_get(projection_field(doc, "totals"), "entrypoints")), 2); + const int64_t expected[] = {launcher, view, method, cart}; + ASSERT_EQ(yyjson_arr_size(entries), sizeof(expected) / sizeof(expected[0])); + for (size_t i = 0; i < sizeof(expected) / sizeof(expected[0]); i++) + ASSERT_EQ(yyjson_get_sint(yyjson_obj_get(yyjson_arr_get(entries, i), "id")), expected[i]); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(projection_field(doc, "totals"), "entrypoints")), 4); yyjson_val *paths = projection_field(doc, "paths"); - ASSERT_EQ(yyjson_arr_size(paths), 1); - yyjson_val *path = yyjson_arr_get(paths, 0); - ASSERT_EQ(yyjson_get_sint(yyjson_obj_get(path, "entrypoint_id")), view); + ASSERT_EQ(yyjson_arr_size(paths), 2); + yyjson_val *first = yyjson_arr_get(paths, 0), *second = yyjson_arr_get(paths, 1); + ASSERT_EQ(yyjson_get_sint(yyjson_obj_get(first, "entrypoint_id")), launcher); + ASSERT_EQ(yyjson_get_sint(yyjson_obj_get(second, "entrypoint_id")), view); ASSERT_EQ( - yyjson_get_sint(yyjson_obj_get(yyjson_arr_get(yyjson_obj_get(path, "nodes"), 1), "id")), + yyjson_get_sint(yyjson_obj_get(yyjson_arr_get(yyjson_obj_get(second, "nodes"), 1), "id")), query); yyjson_doc_free(doc); free(json); From 7c7dce8300b8ab258258ee725367ded65a5c081a Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 20:02:01 +0200 Subject: [PATCH 5/7] fix(store): give every overview group its share of listed entries The 4096 overview entries were picked by one global rank, so groups whose components are all small got none. On Django the 785-component @no-source group, js_tests and the .github test group listed zero entries, and the UI can neither open such a group nor offer its components in the picker. Displayed components are listed first, then each group's own top-ranked components up to 16, and the global rank fills the rest of the 4096. With at most 64 groups the shares always fit. The new overview field is renamed to omitted_component_entries: it counts overview entries left out, which the root limits.omitted_components (components not displayed) does not. Signed-off-by: Bernhard Jackiewicz --- src/store/architecture_projection.c | 55 ++++++++++++++++++++-------- tests/test_architecture_projection.c | 43 +++++++++++++++++----- 2 files changed, 74 insertions(+), 24 deletions(-) diff --git a/src/store/architecture_projection.c b/src/store/architecture_projection.c index 5d76944bc..a83e45004 100644 --- a/src/store/architecture_projection.c +++ b/src/store/architecture_projection.c @@ -23,8 +23,10 @@ enum { /* Overview entries carry a full representative symbol (about 1.3 KB of * mutable JSON each). Django 5.2.7 has 12,700 components: listing all of * them took half of AP_JSON_BUDGET before any other section. Keep it at - * least the max_components ceiling (512) so displayed parts stay listed. */ - AP_OVERVIEW_COMPONENTS = 4096 + * least the max_components ceiling (512) plus 64 groups * AP_GROUP_ENTRIES + * so displayed parts and every group's share always fit. */ + AP_OVERVIEW_COMPONENTS = 4096, + AP_GROUP_ENTRIES = 16 }; enum { AP_EVIDENCE_CONTEXT, AP_EVIDENCE_CORRIDOR, AP_EVIDENCE_PATH, AP_EVIDENCE_SCOPES }; @@ -1312,6 +1314,29 @@ done: return rc; } +/* Overview entries are bounded; groups still carry every id and count. The + * displayed components are listed first, then each group's own top-ranked + * components up to AP_GROUP_ENTRIES, so that every group can be opened, and + * the global rank fills the remaining entries. */ +static void ap_list_overview(ap_context *c, const ap_rank *ranks) { + enum { AP_LIST_DISPLAYED, AP_LIST_GROUPS, AP_LIST_RANK, AP_LIST_PASSES }; + int group_entries[sizeof(c->groups) / sizeof(c->groups[0])] = {0}; + int listed = 0; + for (int i = 0; i < c->component_count; i++) + c->components[i].listed = false; + for (int pass = 0; pass < AP_LIST_PASSES; pass++) + for (int pick = 0; pick < c->component_count && listed < AP_OVERVIEW_COMPONENTS; pick++) { + ap_component *part = &c->components[ranks[pick].index]; + int *entries = &group_entries[part->overview_group]; + if (part->listed || (pass == AP_LIST_DISPLAYED && part->displayed < 0) || + (pass == AP_LIST_GROUPS && *entries >= AP_GROUP_ENTRIES)) + continue; + part->listed = true; + (*entries)++; + listed++; + } +} + static const char *ap_component_basis(const ap_context *c, int i) { const ap_component *p = &c->components[i]; return p->atoms > 1 ? "interaction_community" @@ -1331,7 +1356,8 @@ static int ap_overview(ap_context *c, yyjson_mut_doc *doc, yyjson_mut_val *root) yyjson_mut_obj_add_val(doc, overview, "connections", connections); yyjson_mut_obj_add_val(doc, overview, "totals", totals); yyjson_mut_obj_add_val(doc, overview, "limits", limits); - int connection_count = 0, shown = 0, omitted_parts = 0; + int connection_count = 0, shown = 0; + int omitted_entries = 0; if (!c->limited) { for (int g = 0; g < c->group_count; g++) { ap_overview_group *group = &c->groups[g]; @@ -1363,11 +1389,11 @@ static int ap_overview(ap_context *c, yyjson_mut_doc *doc, yyjson_mut_val *root) return CBM_STORE_ERR; } /* Groups above keep every component id and count; only the detailed - * entries are bounded, in display rank order. */ + * entries are bounded (ap_list_overview). */ for (int i = 0; i < c->component_count; i++) { ap_component *part = &c->components[i]; if (!part->listed) { - omitted_parts++; + omitted_entries++; continue; } yyjson_mut_val *value = yyjson_mut_obj(doc), *reps = yyjson_mut_arr(doc); @@ -1429,13 +1455,13 @@ static int ap_overview(ap_context *c, yyjson_mut_doc *doc, yyjson_mut_val *root) free(edges); } yyjson_mut_obj_add_bool(doc, overview, "complete", - !c->limited && shown == connection_count && !omitted_parts); + !c->limited && shown == connection_count && !omitted_entries); yyjson_mut_obj_add_int(doc, totals, "groups", c->limited ? 0 : c->group_count); yyjson_mut_obj_add_int(doc, totals, "components", c->limited ? 0 : c->component_count); yyjson_mut_obj_add_int(doc, totals, "accounted_nodes", c->limited ? 0 : c->accounted); yyjson_mut_obj_add_int(doc, totals, "connections", connection_count); yyjson_mut_obj_add_int(doc, limits, "omitted_connections", connection_count - shown); - yyjson_mut_obj_add_int(doc, limits, "omitted_components", omitted_parts); + yyjson_mut_obj_add_int(doc, limits, "omitted_component_entries", omitted_entries); return CBM_STORE_OK; } @@ -1796,21 +1822,20 @@ static int ap_render(ap_context *c, const char *project, char **out_json) { if (!c->limited) { /* Prefer the largest connected candidates, retaining deterministic IDs. * Never relabel discarded components as a single invented component. - * The same rank bounds the overview entries, which therefore always - * include every displayed component. */ + * The same rank picks the overview entries, before they render. */ ap_rank *ranks = malloc(((size_t)c->component_count + 1) * sizeof(*ranks)); if (!ranks) { rc = CBM_STORE_ERR; goto done; } - for (int i = 0; i < c->component_count; i++) + for (int i = 0; i < c->component_count; i++) { + c->components[i].displayed = -1; ranks[i] = (ap_rank){i, c->components[i].members, c->components[i].degree}; - qsort(ranks, (size_t)c->component_count, sizeof(*ranks), ap_rank_compare); - for (int pick = 0; pick < c->component_count; pick++) { - ap_component *part = &c->components[ranks[pick].index]; - part->displayed = pick < c->options.max_components ? shown++ : -1; - part->listed = pick < AP_OVERVIEW_COMPONENTS; } + qsort(ranks, (size_t)c->component_count, sizeof(*ranks), ap_rank_compare); + for (int pick = 0; pick < c->options.max_components && pick < c->component_count; pick++) + c->components[ranks[pick].index].displayed = shown++; + ap_list_overview(c, ranks); free(ranks); if (ap_stopped(c)) { rc = CBM_STORE_ERR; diff --git a/tests/test_architecture_projection.c b/tests/test_architecture_projection.c index 1468b3323..be56cc568 100644 --- a/tests/test_architecture_projection.c +++ b/tests/test_architecture_projection.c @@ -1144,21 +1144,30 @@ TEST(projection_repeated_behavior_evidence_preserves_base_graph_under_budget) { * the node and edge budgets): one full overview entry per component used to * exceed the 32 MB response budget, so the whole projection came back * limited and empty. The overview now lists a bounded, ranked subset and - * still accounts for every component through its groups and totals. */ + * still accounts for every component through its groups and totals. A group + * whose components all rank last, like Django's @no-source, still gets its + * own share of entries so that it can be opened. */ TEST(projection_many_small_components_stay_within_response_budget) { cbm_store_t *store = projection_store(); ASSERT_NOT_NULL(store); - enum { parts = 14000 }; + enum { bulk = 14000, late = 20, parts = bulk + late, entries = 4096, share = 16 }; char padding[161]; memset(padding, 'x', sizeof(padding) - 1); padding[sizeof(padding) - 1] = '\0'; ASSERT_EQ(cbm_store_begin(store), CBM_STORE_OK); - for (int i = 0; i < parts; i++) { + for (int i = 0; i < bulk; i++) { char qn[256], file[256]; snprintf(qn, sizeof(qn), "projection.area%d.%s%05d.Part", i % 8, padding, i); snprintf(file, sizeof(file), "src/area%d/%.*s%05d.py", i % 8, 120, padding, i); ASSERT_TRUE(projection_node(store, "Class", qn, file, false) > 0); } + /* Equal rank otherwise, so the later ids of this group rank last. */ + for (int i = 0; i < late; i++) { + char qn[64], file[64]; + snprintf(qn, sizeof(qn), "projection.late.Part%02d", i); + snprintf(file, sizeof(file), "late/part%02d.py", i); + ASSERT_TRUE(projection_node(store, "Class", qn, file, false) > 0); + } ASSERT_EQ(cbm_store_commit(store), CBM_STORE_OK); char *json = NULL; ASSERT_EQ(cbm_store_architecture_projection(store, "projection", NULL, &json), CBM_STORE_OK); @@ -1175,12 +1184,11 @@ TEST(projection_many_small_components_stay_within_response_budget) { *listed = yyjson_obj_get(overview, "components"), *groups = yyjson_obj_get(overview, "groups"); size_t listed_count = yyjson_arr_size(listed); - ASSERT_TRUE(listed_count >= 512); - ASSERT_TRUE(listed_count < parts); + ASSERT_EQ(listed_count, entries); ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(overview, "complete"))); - ASSERT_EQ( - yyjson_get_int(yyjson_obj_get(yyjson_obj_get(overview, "limits"), "omitted_components")), - parts - (int)listed_count); + ASSERT_EQ(yyjson_get_int( + yyjson_obj_get(yyjson_obj_get(overview, "limits"), "omitted_component_entries")), + parts - entries); ASSERT_EQ(yyjson_get_int(yyjson_obj_get(yyjson_obj_get(overview, "totals"), "components")), parts); /* Every displayed component keeps its overview entry. */ @@ -1195,8 +1203,25 @@ TEST(projection_many_small_components_stay_within_response_budget) { int members = 0; for (size_t i = 0; i < yyjson_arr_size(groups); i++) { yyjson_val *group = yyjson_arr_get(groups, i); - membership += yyjson_arr_size(yyjson_obj_get(group, "component_ids")); + yyjson_val *ids = yyjson_obj_get(group, "component_ids"); + membership += yyjson_arr_size(ids); members += yyjson_get_int(yyjson_obj_get(group, "member_count")); + /* Equal ranks fall back to component order, so each group must list + * a prefix of its ids, and at least its share of them. */ + const char *group_id = yyjson_get_str(yyjson_obj_get(group, "id")); + size_t count = yyjson_arr_size(ids), shown = 0, at, maximum; + yyjson_val *entry; + yyjson_arr_foreach(listed, at, maximum, entry) { + if (strcmp(group_id, yyjson_get_str(yyjson_obj_get(entry, "group_id")))) + continue; + ASSERT_TRUE(shown < count); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(entry, "id")), + yyjson_get_str(yyjson_arr_get(ids, shown))); + shown++; + } + ASSERT_TRUE(shown >= (count < share ? count : (size_t)share)); + if (!strcmp(yyjson_get_str(yyjson_obj_get(group, "label")), "late")) + ASSERT_EQ(shown, share); } ASSERT_EQ(membership, parts); ASSERT_EQ(members, parts); From 028b341fc9ea31d3414f5c74ca49fa9fe5e90d68 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 20:11:35 +0200 Subject: [PATCH 6/7] fix(store): keep a top-level test/ directory as a test signal for Python The Python exemption for a singular test/ directory was meant for shipped packages like django/test/, but it also applied to a repository's own test/ suite. Helpers such as test/helpers.py or SQLAlchemy's test/orm/_fixtures.py became production components, their indexed is_test flag was ignored, and a route handler there would count as an entry point. Only a nested test/ is now exempt; a top-level test/ stays a test directory for every language, together with its indexed flag. The segment and suffix checks use small helpers instead of hand-counted lengths. Django and the cbm control repository are unchanged. Signed-off-by: Bernhard Jackiewicz --- src/store/architecture_projection.c | 49 ++++++++++++++++------------ tests/test_architecture_projection.c | 9 +++-- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/store/architecture_projection.c b/src/store/architecture_projection.c index a83e45004..fab0137f4 100644 --- a/src/store/architecture_projection.c +++ b/src/store/architecture_projection.c @@ -194,39 +194,48 @@ static bool ap_cluster_edge(int type) { return type == 1 || type == 5 || type == 11; } +static bool ap_named(const char *text, size_t length, const char *name) { + return length == strlen(name) && !strncmp(text, name, length); +} + +static bool ap_suffix(const char *text, const char *suffix) { + size_t length = strlen(text), tail = strlen(suffix); + return length > tail && !strcmp(text + length - tail, suffix); +} + /* Conservative source conventions supplement the positive indexed is_test * flag. Non-test classification means no test evidence, not production use. - * Python discovers tests by file name (test_*.py, *_test.py, tests.py, - * conftest.py). A singular test/ directory there is often a shipped package - * (django/test/ is Django's public testing API), so for a .py file without a - * test name it is no signal on its own. *package then tells the caller that - * the indexed flag only repeats that directory rule (cbm_is_test_file) and - * must not decide alone either; it keeps one role per file. tests/ and - * __tests__/ stay directory signals, and test/ stays one for other files. */ + * tests/ and __tests__/ are test directories anywhere, and so is a top-level + * test/. Python discovers tests by file name (test_*.py, *_test.py, tests.py, + * conftest.py); a nested test/ there is often a shipped package (django/test/ + * is Django's public testing API), so for a .py file without a test name it + * is no signal on its own. *package then tells the caller that the indexed + * flag only repeats that directory rule (cbm_is_test_file) and must not + * decide alone either; it keeps one role per file. */ static bool ap_test_path(const char *path, bool *package) { const char *base = path; - bool directory = false, singular = false; + bool nested = false; for (const char *p = path;; p++) { if (*p == '/' || *p == '\\' || !*p) { size_t len = (size_t)(p - base); - if ((len == 5 && !strncmp(base, "tests", len)) || - (len == 9 && !strncmp(base, "__tests__", len))) - directory = true; - else if (len == 4 && !strncmp(base, "test", len)) - singular = true; + bool singular = ap_named(base, len, "test"); + if ((singular && base == path) || ap_named(base, len, "tests") || + ap_named(base, len, "__tests__")) + return true; + nested = nested || singular; if (!*p) break; base = p + 1; } } - size_t len = strlen(base); - bool python = len > 3 && !strcmp(base + len - 3, ".py"); - if (directory || !strncmp(base, "test_", 5) || strstr(base, "_test.") || - strstr(base, ".test.") || strstr(base, ".spec.") || - (python && (!strcmp(base, "tests.py") || !strcmp(base, "conftest.py")))) + if (ap_suffix(base, ".py")) { + if (!strcmp(base, "tests.py") || !strcmp(base, "conftest.py")) + return true; + *package = nested; + } else if (nested) return true; - *package = singular && python; - return singular && !python; + return !strncmp(base, "test_", 5) || strstr(base, "_test.") || strstr(base, ".test.") || + strstr(base, ".spec."); } static int ap_counts(ap_context *c, const char *project) { diff --git a/tests/test_architecture_projection.c b/tests/test_architecture_projection.c index be56cc568..088990a9f 100644 --- a/tests/test_architecture_projection.c +++ b/tests/test_architecture_projection.c @@ -1248,8 +1248,10 @@ TEST(projection_python_test_files_follow_name_conventions) { projection_node(store, "Class", "projection.app.tests.ViewTests", "app/tests.py", false); projection_node(store, "Function", "projection.app.models_test.check", "app/models_test.py", false); - projection_node(store, "Function", "projection.conftest.fixture", "test/conftest.py", false); + projection_node(store, "Function", "projection.app.conftest.fixture", "app/conftest.py", false); projection_node(store, "Function", "projection.check", "test/check.c", false); + /* A top-level test/ is a test suite, also for helpers without a test name. */ + projection_node(store, "Function", "projection.test.helpers.build", "test/helpers.py", false); projection_node(store, "Function", "projection.app.views.index", "app/views.py", false); /* django/test/ ships Django's testing API. The extractor flags its * functions from the directory alone; the class carries no flag. */ @@ -1268,7 +1270,8 @@ TEST(projection_python_test_files_follow_name_conventions) { yyjson_doc *doc = yyjson_read(json, strlen(json), 0); ASSERT_NOT_NULL(doc); const char *tests[] = {"projection.app.tests.ViewTests", "projection.app.models_test.check", - "projection.conftest.fixture", "projection.check"}; + "projection.app.conftest.fixture", "projection.check", + "projection.test.helpers.build"}; for (size_t i = 0; i < sizeof(tests) / sizeof(tests[0]); i++) { yyjson_val *part = projection_component_of(doc, tests[i]); ASSERT_NOT_NULL(part); @@ -1283,7 +1286,7 @@ TEST(projection_python_test_files_follow_name_conventions) { ASSERT_NOT_NULL(api); ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(api, "role")), "non_test"); ASSERT_EQ(yyjson_get_int(yyjson_obj_get(api, "member_count")), 2); - ASSERT_EQ(yyjson_arr_size(projection_field(doc, "components")), 6); + ASSERT_EQ(yyjson_arr_size(projection_field(doc, "components")), 7); yyjson_doc_free(doc); free(json); cbm_store_close(store); From 9d6a5c85a92dd3232c1e53895e253fe86f565fbf Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 20:17:10 +0200 Subject: [PATCH 7/7] style(store): split a declaration and name the entry point passes The diff-scoped clang-tidy run (make lint-tidy-diff) flagged a combined declaration and a bare pass count on lines of this branch. The other findings on changed lines follow the file's existing style (statements without braces, the + 1 allocation idiom) or sit in functions that were already above the complexity threshold. Signed-off-by: Bernhard Jackiewicz --- src/store/architecture_projection.c | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/store/architecture_projection.c b/src/store/architecture_projection.c index fab0137f4..26509c39b 100644 --- a/src/store/architecture_projection.c +++ b/src/store/architecture_projection.c @@ -199,7 +199,8 @@ static bool ap_named(const char *text, size_t length, const char *name) { } static bool ap_suffix(const char *text, const char *suffix) { - size_t length = strlen(text), tail = strlen(suffix); + size_t length = strlen(text); + size_t tail = strlen(suffix); return length > tail && !strcmp(text + length - tail, suffix); } @@ -1934,10 +1935,12 @@ static int ap_render(ap_context *c, const char *project, char **out_json) { /* Indexed entry points first, then route handlers, each in id order: * paths take their slots in this order, so many handlers cannot crowd * main out of the default paths. */ - for (int pass = 0; pass < 2; pass++) + enum { AP_ENTRY_INDEXED, AP_ENTRY_HANDLER, AP_ENTRY_PASSES }; + for (int pass = AP_ENTRY_INDEXED; pass < AP_ENTRY_PASSES; pass++) for (int i = 0; i < c->n; i++) { ap_node *node = &c->nodes[i]; - if (node->component < 0 || !(pass ? node->handler : node->entry)) + if (node->component < 0 || + !(pass == AP_ENTRY_HANDLER ? node->handler : node->entry)) continue; entry_total++; if (entry_count < AP_ENTRYPOINTS) {