diff --git a/src/store/architecture_projection.c b/src/store/architecture_projection.c index 6c8751749..26509c39b 100644 --- a/src/store/architecture_projection.c +++ b/src/store/architecture_projection.c @@ -19,7 +19,14 @@ 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) 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 }; @@ -68,13 +75,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 }; +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; @@ -89,6 +96,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; @@ -186,22 +194,47 @@ 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); + size_t 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. */ -static bool ap_test_path(const char *path) { + * flag. Non-test classification means no test evidence, not production use. + * 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 nested = 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)) || - (len == 9 && !strncmp(base, "__tests__", len))) + 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; } } + if (ap_suffix(base, ".py")) { + if (!strcmp(base, "tests.py") || !strcmp(base, "conftest.py")) + return true; + *package = nested; + } else if (nested) + return true; return !strncmp(base, "test_", 5) || strstr(base, "_test.") || strstr(base, ".test.") || strstr(base, ".spec."); } @@ -233,6 +266,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)); @@ -263,11 +325,14 @@ 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") || !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; } @@ -299,7 +364,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 @@ -1259,6 +1324,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" @@ -1279,6 +1367,7 @@ static int ap_overview(ap_context *c, yyjson_mut_doc *doc, yyjson_mut_val *root) 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 omitted_entries = 0; if (!c->limited) { for (int g = 0; g < c->group_count; g++) { ap_overview_group *group = &c->groups[g]; @@ -1309,8 +1398,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 (ap_list_overview). */ for (int i = 0; i < c->component_count; i++) { ap_component *part = &c->components[i]; + if (!part->listed) { + omitted_entries++; + 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 +1464,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_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_component_entries", omitted_entries); return CBM_STORE_OK; } @@ -1732,13 +1829,10 @@ 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 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; @@ -1751,11 +1845,18 @@ static int ap_render(ap_context *c, const char *project, char **out_json) { 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; 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) @@ -1831,11 +1932,16 @@ 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. */ + 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 == AP_ENTRY_HANDLER ? node->handler : node->entry)) + continue; entry_total++; if (entry_count < AP_ENTRYPOINTS) { entry_indexes[entry_count++] = i; @@ -1845,7 +1951,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 4e93ed30a..088990a9f 100644 --- a/tests/test_architecture_projection.c +++ b/tests/test_architecture_projection.c @@ -1140,6 +1140,225 @@ 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. 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 { 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 < 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); + 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_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_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. */ + 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); + 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); + yyjson_doc_free(doc); + free(json); + cbm_store_close(store); + 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.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. */ + 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.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); + 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")), 7); + yyjson_doc_free(doc); + free(json); + cbm_store_close(store); + PASS(); +} + +/* 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); + 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); + 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"); + 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), 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(second, "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); @@ -1165,4 +1384,7 @@ 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); + RUN_TEST(projection_python_test_files_follow_name_conventions); + RUN_TEST(projection_route_handlers_are_entry_points); }