fix(make): a node per rule target; target-variable lines are no rules

Make rules were read with three gaps:

- A rule naming several targets (`seed-up seed-dev seed-down: ...`,
  `kind-% seed-%:`) became one Function named after its first target:
  `seed-down` had no node, so nothing could link `make seed-down` and a
  search for it found nothing. Every target now gets a Function over the
  rule's span (without fingerprint, structural profile or body tokens, so
  the copies of one recipe do not read as similar code), and the recipe's
  DEPLOYS / SPAWNS edges leave from each of them.
- A target-specific variable line (`kind-%: export X := y`) was taken for
  a rule: a phantom Function for its first target, or a variant span
  merged into the real rule of that name. Such a line is no rule now. Its
  tree-sitter ERROR region is no longer masked, so the file reports it
  as partially parsed, as for any other unparsed region.
- `include hack/tools.mk` imported a Folder `hack/tools` (local name
  "mk\n\n": the generic import reader kept the directive's newlines and
  split at the extension). Make includes are read on their own now: every
  name of include / -include / sinclude, outside recipes, imports that
  file; a name with a variable reference is left out.

Measured on 17 repositories against an independent reading: make runs
linked 298 of 300 (was 282), all 298 correct; the 2 left pick their
target in an if/else, which stays unknown by design. Graph changes are
confined to Makefiles (gardener +26 targets, 13 phantoms removed;
deckhouse +4), their edges, and two side effects of new nodes:
SEMANTICALLY_RELATED edges elsewhere re-rank, and one Go member
reference in deckhouse lost its unique-name binding to a new Make rule
of the same name. Three runs build identical graphs; index time unchanged (deckhouse 9.61 -> 9.60 s,
gardener 5.24 -> 5.27 s, cluster-api 2.68 -> 2.68 s, medians);
renaming a target, editing a Makefile or an included file leaves the
graph a fresh index builds (gardener's Makefile edit differed before,
through the phantom nodes, and no longer does).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-11 08:30:28 +02:00
1 parent b080e982de
commit 7842cd8a69
4 files changed
+294 -20

No files matched your search

+110
View File
@@ -6275,6 +6275,107 @@ void cbm_test_declarations_finish(CBMExtractCtx *ctx) {
}
}
static bool make_name_char(char c) {
return c == '_' || c == '.' || c == '-' || (c >= '0' && c <= '9') ||
((c | ' ') >= 'a' && (c | ' ') <= 'z');
}
/* A `rule` node that is a target-specific variable: after the targets'
* colon(s) come [export|override|private|unexport]... NAME and an
* assignment operator (= := ::= :::= += ?= !=). */
static bool make_rule_sets_variable(TSNode rule, const char *src) {
static const char *const kws[] = {"export", "override", "private", "unexport", NULL};
TSNode targets = cbm_find_child_by_kind(rule, "targets");
if (ts_node_is_null(targets)) {
return false;
}
uint32_t p = ts_node_end_byte(targets);
uint32_t end = ts_node_end_byte(rule);
while (p < end && (src[p] == ' ' || src[p] == '\t')) {
p++;
}
if (p >= end || src[p] != ':') {
return false;
}
while (p < end && src[p] == ':') {
p++;
}
for (bool kw = true; kw;) {
while (p < end && (src[p] == ' ' || src[p] == '\t')) {
p++;
}
kw = false;
for (int i = 0; kws[i]; i++) {
size_t n = strlen(kws[i]);
if (p + n < end && strncmp(src + p, kws[i], n) == 0 &&
(src[p + n] == ' ' || src[p + n] == '\t')) {
p += (uint32_t)n;
kw = true;
break;
}
}
}
uint32_t name = p;
while (p < end && make_name_char(src[p])) {
p++;
}
if (p == name) {
return false;
}
while (p < end && (src[p] == ' ' || src[p] == '\t')) {
p++;
}
if (p < end && src[p] == ':') {
while (p < end && src[p] == ':') {
p++;
}
return p < end && src[p] == '=';
}
return p < end && (src[p] == '=' ||
(strchr("+?!", src[p]) && p + SKIP_ONE < end && src[p + SKIP_ONE] == '='));
}
/* Make: a rule names several targets (`up down: deps`), each a goal
* `make <target>` runs. The rule's definition carries the first target;
* every further one gets a definition of its own over the same rule span --
* without fingerprint, structural profile or body tokens, so the copies of
* one recipe do not read as similar code. Special (`.X`) targets and
* repeats stay out. */
static void push_make_rule_targets(CBMExtractCtx *ctx, TSNode node, const CBMDefinition *first) {
CBMArena *a = ctx->arena;
TSNode targets = cbm_find_child_by_kind(node, "targets");
uint32_t n = ts_node_is_null(targets) ? 0 : ts_node_named_child_count(targets);
for (uint32_t i = SKIP_ONE; i < n; i++) {
char *name =
cbm_func_name_node_text(a, ts_node_named_child(targets, i), ctx->source, ctx->language);
if (!name || !name[0] || name[0] == '.' || strcmp(name, first->name) == 0) {
continue;
}
bool repeat = false;
for (uint32_t j = SKIP_ONE; j < i && !repeat; j++) {
char *prev = cbm_func_name_node_text(a, ts_node_named_child(targets, j), ctx->source,
ctx->language);
repeat = prev && strcmp(prev, name) == 0;
}
if (repeat) {
continue;
}
CBMDefinition d = *first;
d.name = name;
d.qualified_name =
cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, name, ctx->language);
d.is_exported = cbm_is_exported(name, ctx->language);
d.is_entry_point = strcmp(name, "main") == 0;
d.fingerprint = NULL;
d.fingerprint_k = 0;
d.structural_profile = NULL;
d.body_tokens = NULL;
if (d.qualified_name) {
cbm_defs_push(&ctx->result->defs, a, d);
}
}
}
static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) {
CBMArena *a = ctx->arena;
@@ -6308,6 +6409,12 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec
if (ctx->language == CBM_LANG_MAKEFILE && name[0] == '.') {
return;
}
// `targets: [export] VAR := value` sets a variable for those targets:
// no rule, so no def (else a target only ever configured there is a
// phantom, and a real rule of it gains the line as a variant).
if (ctx->language == CBM_LANG_MAKEFILE && make_rule_sets_variable(node, ctx->source)) {
return;
}
/* C++/CUDA: GoogleTest macros (TEST, TEST_F, TEST_P, ...) parse as
* function definitions whose name resolves to the bare macro identifier.
@@ -6557,6 +6664,9 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec
return;
}
cbm_defs_push(&ctx->result->defs, a, def);
if (ctx->language == CBM_LANG_MAKEFILE && strcmp(ts_node_type(node), "rule") == 0) {
push_make_rule_targets(ctx, node, &def);
}
}
// --- Class definition extraction ---
+50
View File
@@ -48,6 +48,7 @@ static void parse_starlark_imports(CBMExtractCtx *ctx);
static void parse_tcl_imports(CBMExtractCtx *ctx);
static void parse_teal_imports(CBMExtractCtx *ctx);
static void parse_shell_source_imports(CBMExtractCtx *ctx);
static void parse_make_imports(CBMExtractCtx *ctx);
static void parse_css_imports(CBMExtractCtx *ctx);
static void parse_html_imports(CBMExtractCtx *ctx);
static void parse_cmake_imports(CBMExtractCtx *ctx);
@@ -2221,6 +2222,52 @@ static void parse_shell_source_imports(CBMExtractCtx *ctx) {
}
}
// --- Make imports: include / -include / sinclude FILE... ---
// Every name of an include directive -- outside recipes, conditionals
// included -- imports that file; its local name is the file's base name. A
// name holding a variable reference is known only to make and stays out.
// (The generic text fallback kept the directive's trailing newlines in the
// path and split it at the extension's dot.)
static void push_make_include(CBMExtractCtx *ctx, TSNode word) {
CBMArena *a = ctx->arena;
char *path = cbm_node_text(a, word, ctx->source);
if (!path || !path[0] || strchr(path, '$')) {
return;
}
const char *slash = strrchr(path, '/');
CBMImport imp = {.local_name = slash ? slash + SKIP_ONE : path, .module_path = path};
cbm_imports_push(&ctx->result->imports, a, imp);
}
static void parse_make_imports(CBMExtractCtx *ctx) {
TSNodeStack stack;
ts_nstack_init(&stack, ctx, CBM_SZ_512);
ts_nstack_push(&stack, ctx->root);
while (stack.count > 0) {
TSNode node = ts_nstack_pop(&stack);
const char *k = ts_node_type(node);
if (strcmp(k, "recipe") == 0) {
continue;
}
if (strcmp(k, "include_directive") != 0) {
ts_nstack_push_children(&stack, node);
continue;
}
TSNode names = ts_node_child_by_field_name(node, TS_FIELD("filenames"));
if (ts_node_is_null(names)) {
continue;
}
uint32_t n = ts_node_named_child_count(names);
if (strcmp(ts_node_type(names), "list") != 0 || n == 0) {
push_make_include(ctx, names);
continue;
}
for (uint32_t i = 0; i < n; i++) {
push_make_include(ctx, ts_node_named_child(names, i));
}
}
}
// --- CSS / SCSS imports ---
// CSS @import "x.css" and SCSS @use/@forward 'x' are top-level statements that
// carry a `string_value` (whose `string_content` is the unquoted path). The
@@ -3054,6 +3101,9 @@ void cbm_extract_imports(CBMExtractCtx *ctx) {
case CBM_LANG_BASH:
parse_shell_source_imports(ctx);
break;
case CBM_LANG_MAKEFILE:
parse_make_imports(ctx);
break;
case CBM_LANG_ZIG:
parse_zig_imports(ctx);
break;
+55 -20
View File
@@ -3138,10 +3138,25 @@ static int sh_cmp_span(const void *a, const void *b) {
return strcmp(x->file, y->file);
}
typedef struct {
const cbm_gbuf_node_t **items;
int count;
int cap;
} sh_srcs_t;
static int sh_cmp_node_qn(const void *a, const void *b) {
const cbm_gbuf_node_t *x = *(const cbm_gbuf_node_t *const *)a;
const cbm_gbuf_node_t *y = *(const cbm_gbuf_node_t *const *)b;
return strcmp(x->qualified_name ? x->qualified_name : "",
y->qualified_name ? y->qualified_name : "");
}
/* The innermost Function around `line` in `file`, else its Module, else its
* File node. */
* File node. Into `twins` (when given): every Function of exactly that
* span, in QN order -- the targets of one Make rule share it, and the
* rule's recipe runs for each of them. */
static int64_t sh_source(cbm_pipeline_ctx_t *ctx, const sh_span_t *spans, int n,
CBMHashTable *modules, const char *file, int line) {
CBMHashTable *modules, const char *file, int line, sh_srcs_t *twins) {
int lo = 0;
int hi = n;
while (lo < hi) {
@@ -3167,6 +3182,17 @@ static int64_t sh_source(cbm_pipeline_ctx_t *ctx, const sh_span_t *spans, int n,
}
}
if (best) {
for (int i = lo; twins && i < n && strcmp(spans[i].file, file) == 0; i++) {
const cbm_gbuf_node_t *f = spans[i].node;
if (f->start_line == best->start_line && f->end_line == best->end_line &&
sh_grow((void **)&twins->items, &twins->cap, twins->count, sizeof(*twins->items))) {
twins->items[twins->count++] = f;
}
}
if (twins && twins->count > SKIP_ONE) {
qsort((void *)twins->items, (size_t)twins->count, sizeof(*twins->items),
sh_cmp_node_qn);
}
return best->id;
}
int64_t mod = (int64_t)(intptr_t)cbm_ht_get(modules, file);
@@ -3247,6 +3273,27 @@ static int sh_link_spawn(cbm_pipeline_ctx_t *ctx, int64_t src, const sh_hit_t *x
return SKIP_ONE;
}
/* The edge hit `x` draws from source `src`; 1 when drawn. */
static int sh_link_hit(cbm_pipeline_ctx_t *ctx, const sh_hit_t *x, int64_t src,
CBMHashTable *modules) {
if (x->spawn) {
return src > 0 ? sh_link_spawn(ctx, src, x, modules) : 0;
}
int64_t tgt = x->tgt > 0 ? x->tgt : sh_external_chart(ctx, x->ext, x->file);
if (src <= 0 || tgt <= 0 || src == tgt) {
return 0;
}
char esc[CBM_SZ_1K];
char ci[CBM_SZ_1K];
char props[CBM_SZ_4K];
cbm_json_escape(esc, (int)sizeof(esc), x->path ? x->path : "");
sh_ci_props(x, ci, sizeof(ci));
snprintf(props, sizeof(props), "{\"via\":\"%s\",\"%s\":\"%s\"%s%s,\"line\":%d}", x->via,
x->chart ? "chart" : "path", esc, x->tgt > 0 ? "" : ",\"external\":true", ci, x->line);
cbm_gbuf_insert_edge(ctx->gbuf, src, tgt, "DEPLOYS", props);
return SKIP_ONE;
}
static int sh_link(cbm_pipeline_ctx_t *ctx, const sh_hits_t *h) {
CBMHashTable *files = cbm_ht_create(0);
CBMHashTable *modules = cbm_ht_create(0);
@@ -3293,28 +3340,16 @@ static int sh_link(cbm_pipeline_ctx_t *ctx, const sh_hits_t *h) {
}
}
int edges = 0;
sh_srcs_t twins = {0};
for (int i = 0; i < h->count; i++) {
const sh_hit_t *x = &h->items[i];
int64_t src = sh_source(ctx, spans, n, modules, x->file, x->line);
if (x->spawn) {
edges += src > 0 ? sh_link_spawn(ctx, src, x, modules) : 0;
continue;
twins.count = 0;
int64_t one = sh_source(ctx, spans, n, modules, x->file, x->line, &twins);
for (int k = 0; k < (twins.count ? twins.count : SKIP_ONE); k++) {
edges += sh_link_hit(ctx, x, twins.count ? twins.items[k]->id : one, modules);
}
int64_t tgt = x->tgt > 0 ? x->tgt : sh_external_chart(ctx, x->ext, x->file);
if (src <= 0 || tgt <= 0 || src == tgt) {
continue;
}
char esc[CBM_SZ_1K];
char ci[CBM_SZ_1K];
char props[CBM_SZ_4K];
cbm_json_escape(esc, (int)sizeof(esc), x->path ? x->path : "");
sh_ci_props(x, ci, sizeof(ci));
snprintf(props, sizeof(props), "{\"via\":\"%s\",\"%s\":\"%s\"%s%s,\"line\":%d}", x->via,
x->chart ? "chart" : "path", esc, x->tgt > 0 ? "" : ",\"external\":true", ci,
x->line);
cbm_gbuf_insert_edge(ctx->gbuf, src, tgt, "DEPLOYS", props);
edges++;
}
cbm_free(CBM_MEM_CLASS_OTHER, (void *)twins.items);
cbm_free(CBM_MEM_CLASS_OTHER, spans);
cbm_ht_free(files);
cbm_ht_free(modules);
+79
View File
@@ -2119,6 +2119,84 @@ TEST(probe_ci_and_scripts_run_scripts) {
PASS();
}
/* The Make model of a project: every Function of a Makefile / .mk as
* "fn <file>:<name> <start>-<end>[ +variants]" and every IMPORTS edge of one as
* "import <file> -> <label>:<path> (<local_name>)", sorted. */
static char *gpf_make_model(const GpfFile *files, int nfiles) {
GpfProj lp;
cbm_store_t *store = gpf_index_files(&lp, files, nfiles);
char *out = NULL;
if (store) {
sqlite3_stmt *st = NULL;
const char *sql =
"SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT x FROM ("
"SELECT 'fn ' || file_path || ':' || name || ' ' || start_line || '-' || end_line "
"|| CASE WHEN json_extract(properties,'$.variants') IS NULL THEN '' ELSE "
"' +variants' END AS x FROM nodes WHERE project=?1 AND label='Function' AND (file_path LIKE "
"'%Makefile' OR file_path LIKE '%.mk') UNION ALL SELECT 'import ' || s.file_path || "
"' -> ' || t.label || ':' || t.file_path || ' (' || "
"json_extract(e.properties,'$.local_name') || ')' FROM edges e JOIN nodes s ON "
"s.id=e.source_id JOIN nodes t ON t.id=e.target_id WHERE e.project=?1 AND "
"e.type='IMPORTS' AND (s.file_path LIKE '%Makefile' OR s.file_path LIKE '%.mk')) "
"ORDER BY x)";
if (sqlite3_prepare_v2(cbm_store_get_db(store), sql, -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, lp.project, -1, SQLITE_STATIC);
if (sqlite3_step(st) == SQLITE_ROW) {
out = strdup((const char *)sqlite3_column_text(st, 0));
}
}
sqlite3_finalize(st);
}
gpf_cleanup(&lp, store);
return out;
}
/* A rule naming several targets (`up down:`, `kind-% seed-%:`) is a node per
* target over the rule's span -- `make down` reaches its own node, and the
* shared recipe's edges leave from every target. Special targets (.PHONY)
* and target-specific variable lines (`up: export X := 1`) are none.
* include / -include import the named files (a name holding a
* variable stays out), local name the file's base name. */
TEST(probe_make_rules_have_a_node_per_target) {
static const GpfFile files[] = {
{"Makefile", "include mk/tools.mk\n"
"-include mk/opt.mk $(VAR)/x.mk\n\n"
".PHONY: up down\n"
"up down: deps\n"
"\t./hack/deploy.sh\n\n"
"kind-% seed-%:\n"
"\techo pattern\n\n"
"single:\n"
"\techo one\n\n"
"up kind-%: export MODE := fast\n"
"only-var: private Y = 1\n"},
{"mk/tools.mk", "tool:\n\techo tool\n"},
{"mk/opt.mk", "opt:\n\techo opt\n"},
{"hack/deploy.sh", "#!/bin/sh\necho deploy\n"},
{"hack/ci.sh", "#!/bin/sh\nmake down\n"}};
char *got = gpf_make_model(files, 5);
const char *want = "fn Makefile:down 5-8\n"
"fn Makefile:kind-% 8-11\n"
"fn Makefile:seed-% 8-11\n"
"fn Makefile:single 11-14\n"
"fn Makefile:up 5-8\n"
"fn mk/opt.mk:opt 1-3\n"
"fn mk/tools.mk:tool 1-3\n"
"import Makefile -> Module:mk/opt.mk (opt.mk)\n"
"import Makefile -> Module:mk/tools.mk (tools.mk)";
if (!got || strcmp(got, want) != 0) {
fprintf(stderr, "make model got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
free(got);
FAIL("Make model");
}
free(got);
GPF_ASSERT_RUNS(files, 5,
"@hack/ci.sh -> rule:Makefile:down (SPAWNS shell)\n"
"down -> script:hack/deploy.sh (SPAWNS shell)\n"
"up -> script:hack/deploy.sh (SPAWNS shell)");
PASS();
}
/* make TARGET SPAWNS the rule of TARGET the makefile reads: the
* directory's Makefile (-C, --directory=), a -f file, a rule of a file it
* includes; from scripts, CI steps and recipes ($(MAKE)), on the line of
@@ -3984,6 +4062,7 @@ SUITE(grammar_probe_f) {
RUN_TEST(probe_make_programs_by_path_spawn);
RUN_TEST(probe_ci_and_scripts_run_scripts);
RUN_TEST(probe_make_targets_spawn_their_rules);
RUN_TEST(probe_make_rules_have_a_node_per_target);
RUN_TEST(probe_go_installer_deploys_charts);
RUN_TEST(probe_terraform_helm_release_deploys_its_chart);
RUN_TEST(probe_helm_templated_objects_are_resources);