Merge branch 'main' into fix/606-php-unindexed-receiver

This commit is contained in:
Martin Vogel authored and GitHub committed 2026-10-20 07:04:42 +02:00
commit f460813b94
41 files changed
+10108 -266

No files matched your search

+1
View File
@@ -537,6 +537,7 @@ PIPELINE_SRCS = \
src/pipeline/pass_compile_commands.c \
src/pipeline/pass_infrascan.c \
src/pipeline/pass_k8s.c \
src/pipeline/helm_template.c \
src/pipeline/pass_operator.c \
src/pipeline/pass_shell.c \
src/pipeline/pass_terraform.c \
+1
View File
@@ -333,6 +333,7 @@ The install script placed beside the binary is **reported, not deleted**: uninst
- `INFRA_MAPS`: a Kubernetes Service to each workload whose pod labels carry every `key: value` of the Service's selector, within one namespace
- `CONNECTS_TO`: a Kubernetes workload or compose service to the Service its containers' environment addresses (`PRODUCT_CATALOG_SERVICE_ADDR: productcatalogservice:3550`, a URL's host, `<svc>.<ns>.svc...`, a bare name on an `*_ADDR` / `*_HOST` / `*_URL` / ... key; compose: the project's `.env` interpolated): a Kubernetes Service of the same bundle and namespace, or a compose service of the same file. The edge keeps `via: env`, `key` and `container`, never the value
- `USAGE` with `ref: image`: a Kubernetes workload (any pod template: containers, initContainers, CronJobs, custom resources) or a docker compose service to the `Image` it runs
- Helm templates are rendered for their images: with the chart's values (the `Chart` `DEPLOYS` them, `default_of`: the values keys read) and with each release's (a Flux HelmRelease, a kustomize `helmCharts` entry, a Terraform `helm_release`: `override_of`). An image the render cannot know is left out, never guessed: after a change of values it does not run exactly, a `tpl` or include it cannot look into, a release value it cannot read, or a work budget it runs past. Known limit: each template file renders on its own, while Helm renders a chart's files with one shared `.Values` (a subchart's is its parent's `.Values.<alias>`), so a chart that changes `.Values` in one file and reads it in another can still show a wrong image
- `DEPLOYS`: what puts manifests, kustomizations or charts on a cluster, to what it puts there: a Flux Kustomization or Argo Application to the kustomizations / manifests / chart of its path, a Flux HelmRelease to its chart (`spec.chart` or `chartRef` → OCIRepository), a shell command or Make recipe (`kubectl apply -f/-k`, `kustomize build | kubectl apply`, `helm install/upgrade/template`), a Go installer loading a chart with the Helm SDK, a Terraform `helm_release`, a Terraform `kubernetes_*` resource the object it declares (a `Resource` like a manifest's, `ecosystem: terraform`, when its name is a literal), a kustomization inflating a chart (`helmCharts`; its values file an `IMPORTS`). A chart outside the repository is an external `Chart`, keyed by its publisher when its repository URL is stated (`__chart__<repository>/<name>`, the same node in every project, the URL in `repository`), else one per project and name; the edge says `via` (`flux`, `argo`, `helmrelease`, `kubectl`, `kustomize`, `helm`, `helm-sdk`, `terraform`, `helmcharts`, ...)
- `DEPENDS_ON`: a Helm chart to the charts it declares (the local chart for a resolvable `file://` repository, otherwise an external `Chart`; the edge keeps `repository`), and a `go.mod` / `requirements.txt` file to an external `Package`; a Dockerfile to the `Image`s it builds on (`via: from`, `copy-from`); an `Image` to the Dockerfile it is built from (`via: build`; `tool`: `skaffold`, `compose`, `docker-build` for `docker` / `podman` / `nerdctl` / `buildah` build commands in scripts, Make and CI); a compose service to the services its `depends_on` / `links` name
- `CONFIGURES`: code reading an environment variable to the shared `EnvVar` of that name (`strategy: env_access`); a Kubernetes container, compose service or Dockerfile (`ENV` of the image) setting one to that container's own `EnvVar` (`strategy: env_set`, with `source`: `value`, `configMapKeyRef`, `secretKeyRef`, `fieldRef`, ..., `compose`, `dockerfile`; values are never stored); and that container's `EnvVar` to the code reading the name inside the files its image is built from (`strategy: env_read`, with `dockerfile`): the image's files are the sources its Dockerfile's `COPY` / `ADD` put into it, less the context's `.dockerignore`
+5 -1
View File
@@ -734,7 +734,9 @@ typedef struct {
* from the URL `name`; a HelmRelease whose chartRef names it deploys the
* chart there.
* CBM_K8S_FACT_IMAGE: object def_index runs its container `container`
* (NULL: unnamed) from image `name` (a literal reference); via "container".
* (NULL: unnamed) from image `name` (a literal reference, or a Helm
* template's placeholder `<...>`, which names none); via "container";
* `line` the image's line, start_line / end_line the container's.
* CBM_K8S_FACT_ENV: object def_index sets environment variable `name` in
* its container `container` (NULL: unnamed) at `line`; via = where the
* value comes from ("value", or the valueFrom kind) -- never the value.
@@ -773,6 +775,8 @@ typedef struct {
const char *url; /* DEPLOY (kustomize): the chart repository */
const char *release; /* DEPLOY (kustomize): the release name */
int line;
int start_line; /* IMAGE: the container mapping's first line */
int end_line; /* IMAGE: its last line */
} CBMK8sFact;
typedef struct {
+9 -7
View File
@@ -43,10 +43,10 @@
* drops these lines and so carries no line number
* at all.
* G path go.mod: the module path
* w says who keeps the node. A Go method node has a FLAT qualified name
* (<dir>.<Method>), so a function, the methods of every receiver, a type and
* a value of one name all claim the node <dir>.<name>, and within a file the
* declaration on the LAST line keeps it (cbm_gbuf_upsert_node). w is 1 for
* w says who keeps the node <dir>.<name>. A function, a type and a value of
* one name all claim it, and within a file the declaration on the LAST line
* keeps it (cbm_gbuf_upsert_node). A method claims nothing: its node is
* receiver-qualified (<dir>.<Recv>.<Method>, #1913), and its w is `-`. w is 1 for
* that declaration, 0 for the others of that name, x when two of them start
* on that last line, and - for a value the extractor gives no node at all
* (it gives one to the first name of a constant spec and of a parenthesis-
@@ -1511,9 +1511,11 @@ static void go_scan_method(go_scan_t *s, TSNode m) {
recv = "?";
recv_len = SKIP_ONE;
}
go_push_claim(s,
cbm_arena_sprintf(s->tmp, "M\t%.*s\t%.*s", (int)recv_len, recv, (int)len, name),
name, len, m);
/* #1913: a Go method node is receiver-qualified (<dir>.<Recv>.<Method>),
* so a method claims no flat name: w is `-`, and the method is found
* through its receiver (the member table), never through <dir>.<name>. */
(void)go_push_rec(
s, cbm_arena_sprintf(s->tmp, "M\t%.*s\t%.*s\t-", (int)recv_len, recv, (int)len, name));
}
/* The implied name of an embedded field: the type's own name, without
+18
View File
@@ -4493,6 +4493,24 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
}
}
}
// Go receiver-aware guard (same direction as the TS/JS flag above).
// Flag a selector call x.foo(). The Go AST cannot separate a method
// call on a value from a package-qualified call — but every selector
// call the Go LSP or the import/qualified registry strategies CAN
// place never reaches the weak short-name guards, so the flag only
// bites on unresolvable receivers (`f.Close()` on an os.File,
// `sha256.New()` behind an unindexed import), where a project-wide
// short-name match fabricates an edge to an unrelated project
// symbol sharing the name. Bare calls (helper()) keep
// is_method=false and resolve same-module/import paths as before.
if (ctx->language == CBM_LANG_GO &&
strcmp(ts_node_type(node), "call_expression") == 0) {
TSNode gofn = ts_node_child_by_field_name(node, TS_FIELD("function"));
if (!ts_node_is_null(gofn) &&
strcmp(ts_node_type(gofn), "selector_expression") == 0) {
call.is_method = true;
}
}
TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments"));
/* tree-sitter-elixir attaches NO field name to a call's arguments
+17 -2
View File
@@ -5772,7 +5772,7 @@ static void set_def_complexity(CBMDefinition *def, TSNode body, const CBMLangSpe
* Walks to the parameter_declaration's `type` field, unwrapping pointer_type
* and generic_type, and returns the type_identifier text (e.g. "OrderService").
* Returns NULL if no type_identifier is found. */
static char *go_receiver_type_name(CBMArena *a, TSNode recv, const char *source) {
char *cbm_go_receiver_type_name(CBMArena *a, TSNode recv, const char *source) {
uint32_t nc = ts_node_child_count(recv);
for (uint32_t i = 0; i < nc; i++) {
TSNode child = ts_node_child(recv, i);
@@ -7006,12 +7006,27 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec
* (and downstream Go IMPLEMENTS/OVERRIDE) link the method to its owning
* struct/type node. The parent QN must match the type's node QN, which
* is computed the same way (cbm_fqn_compute on the type name). */
char *recv_type = go_receiver_type_name(a, recv, ctx->source);
char *recv_type = cbm_go_receiver_type_name(a, recv, ctx->source);
if (recv_type && recv_type[0]) {
/* Must match the Go type node QN (directory-based module) so the
* DEFINES_METHOD edge links the method to its owning type. */
def.parent_class = cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path,
recv_type, ctx->language);
/* Receiver-qualify the method QN (proj.pkg.Recv.method) — same
* shape as the C++ out-of-line path below and Go interface
* members. With the flat proj.pkg.method QN every same-name
* method in a package collided in the graph upsert: one body
* survived and the twins' call edges accreted onto it. The
* call-scope side (compute_func_qn in extract_unified.c) mirrors
* this formula, and go_lsp consumers read the def QN and
* parent_class (receiver_type) verbatim, so resolution joins
* stay exact. Go only: templ's Go-derived grammar reaches this
* branch too, but its call-scope QN (compute_func_qn) stays flat,
* and a def QN the calls do not share would orphan every call
* inside a templ method. */
if (ctx->language == CBM_LANG_GO && def.parent_class) {
def.qualified_name = cbm_arena_sprintf(a, "%s.%s", def.parent_class, name);
}
}
}
+520 -11
View File
@@ -484,12 +484,32 @@ static void parse_python_imports(CBMExtractCtx *ctx) {
ts_tree_cursor_delete(&cursor);
}
/* A proven same-module function (its OWN_DEF fact and definition), and
* whether a top-level call of it leaves the namespace intact (see
* py_ns_allowed_call). */
typedef enum {
PY_NS_CALL_UNCHECKED = 0,
PY_NS_CALL_ALLOWED = 1,
PY_NS_CALL_EFFECT = 2,
} PyNsCallVerdict;
typedef struct {
int fact;
TSNode node;
PyNsCallVerdict verdict;
const char **shadows; /* submodules the body imports into the package */
int shadow_count;
} PyNsFunction;
/* Ordered Python namespace facts are a separate content-only pass. The older
* import records keep their existing graph/heuristic meaning. */
typedef struct {
CBMExtractCtx *ctx;
CBMPyNamespaceFacts facts;
CBMPyNamespaceFailure failure;
PyNsFunction *functions; /* in fact order; scratch, never part of the result */
int function_count;
int function_cap;
} PyNamespaceBuilder;
#ifdef CBM_ENABLE_TEST_SEAMS
@@ -516,6 +536,18 @@ static void *py_ns_alloc(PyNamespaceBuilder *b, size_t size) {
return p;
}
/* Builder bookkeeping nothing in the result points at: the extraction scratch
* when the context has one. */
static void *py_ns_scratch_alloc(PyNamespaceBuilder *b, size_t size) {
if (b->failure)
return NULL;
CBMArena *arena = b->ctx->scratch ? b->ctx->scratch : b->ctx->arena;
void *p = cbm_arena_alloc(arena, size);
if (!p)
b->failure = CBM_PY_NS_FAILURE_ALLOCATION;
return p;
}
static const char *py_ns_copy(PyNamespaceBuilder *b, const char *text, size_t len) {
if (len == SIZE_MAX) {
b->failure = CBM_PY_NS_FAILURE_LIMIT;
@@ -1024,6 +1056,477 @@ static bool py_ns_future_import(PyNamespaceBuilder *b, TSNode node) {
return ok && emitted > 0;
}
/* ── A top-level call the namespace survives ──────────────────────────────
* Any top-level call may rebind any name of the module, so it withdraws the
* whole namespace (UNKNOWN, reason EFFECT). sqlalchemy's sql/__init__.py ends
* with `__go(locals())`, which withdrew all of its 108 re-exports. A call is
* allowed when its callee provably cannot rebind the caller's names:
* - the callee is the latest binding of its name, a proven (undecorated)
* same-module function, with no star import or unknown effect since;
* - every argument is a safe expression or a bare `locals()` / `globals()`
* the module does not rebind;
* - the body, nested scopes included, has no `global` / `nonlocal` and never
* names a parameter, a function or class this module defined earlier (it
* could rebind through `global`), the module's own name, or a name that
* reaches a namespace (py_ns_namespace_names).
* The body may import: each submodule of this package it imports becomes a
* package attribute, so in a package `__init__` those names are shadowed at
* the call. Every other call stays a whole-namespace effect. */
static const char *const py_ns_namespace_names[] = {
"globals", "vars", "locals", "exec", "eval", "setattr", "delattr",
"__import__", "sys", "importlib", "__name__", "__dict__", "__globals__", "__builtins__",
"builtins", "__self__", "inspect", "f_globals", "f_locals", "f_back", "gc",
};
typedef struct {
const char *text;
size_t len;
} PyNsSlice;
typedef struct {
PyNamespaceBuilder *b;
PyNsSlice own;
bool package;
PyNsSlice *params;
int param_count;
const char **defs; /* earlier same-module functions and classes, sorted */
int def_count;
const char **shadows;
int shadow_count;
int shadow_cap;
} PyNsCallCheck;
static bool py_ns_text_is(const char *text, size_t len, const char *s) {
return strlen(s) == len && memcmp(text, s, len) == 0;
}
/* strcmp of a NUL-terminated string against a slice. */
static int py_ns_slice_cmp(const char *s, const char *text, size_t len) {
int r = strncmp(s, text, len);
if (r != 0)
return r;
return s[len] ? 1 : 0;
}
static int py_ns_cmp_cstr(const void *a, const void *b) {
return strcmp(*(const char *const *)a, *(const char *const *)b);
}
/* `pkg/sub/__init__.py` -> "sub", a package; `pkg/mod.py` -> "mod". */
static bool py_ns_own_name(const char *rel_path, PyNsSlice *own, bool *package) {
if (!rel_path)
return false;
const char *slash = strrchr(rel_path, '/');
const char *file = slash ? slash + 1 : rel_path;
const char *ext = strrchr(file, '.');
if (!ext || ext == file)
return false;
size_t stem = (size_t)(ext - file);
*package = py_ns_text_is(file, stem, "__init__");
if (!*package) {
*own = (PyNsSlice){file, stem};
} else {
if (!slash)
return false;
const char *dir = slash;
while (dir > rel_path && dir[-1] != '/')
dir--;
*own = (PyNsSlice){dir, (size_t)(slash - dir)};
}
return py_ns_identifier_bytes(own->text, own->len, false);
}
static bool py_ns_reaches_namespace(const char *text, size_t len) {
for (size_t i = 0; i < sizeof(py_ns_namespace_names) / sizeof(py_ns_namespace_names[0]); i++) {
if (py_ns_text_is(text, len, py_ns_namespace_names[i]))
return true;
}
return false;
}
/* A name of the body (not a module path) the call cannot allow. */
static bool py_ns_forbidden_name(const PyNsCallCheck *c, const char *text, size_t len) {
if (py_ns_reaches_namespace(text, len) ||
(len == c->own.len && memcmp(text, c->own.text, len) == 0))
return true;
for (int i = 0; i < c->param_count; i++) {
if (len == c->params[i].len && memcmp(text, c->params[i].text, len) == 0)
return true;
}
int lo = 0, hi = c->def_count - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
int r = py_ns_slice_cmp(c->defs[mid], text, len);
if (r == 0)
return true;
if (r < 0)
lo = mid + 1;
else
hi = mid - 1;
}
return false;
}
static bool py_ns_node_forbidden(const PyNsCallCheck *c, TSNode node) {
const char *text = NULL;
size_t len = 0;
return ts_node_is_null(node) || !py_ns_slice(c->b, node, &text, &len) ||
py_ns_forbidden_name(c, text, len);
}
static bool py_ns_add_shadow(PyNsCallCheck *c, const char *text, size_t len) {
if (!py_ns_identifier_bytes(text, len, false))
return false;
if (c->shadow_count == c->shadow_cap) {
int cap = c->shadow_cap ? c->shadow_cap * 2 : 8;
const char **items = py_ns_scratch_alloc(c->b, (size_t)cap * sizeof(*items));
if (!items)
return false;
if (c->shadow_count)
memcpy(items, c->shadows, (size_t)c->shadow_count * sizeof(*items));
c->shadows = items;
c->shadow_cap = cap;
}
const char *name = py_ns_copy(c->b, text, len);
if (!name)
return false;
c->shadows[c->shadow_count++] = name;
return true;
}
/* The segments of a dotted module path: none may reach a namespace. Sets
* *own_at to the index of the segment naming this module (-1: none) and
* *next to the slice after it (empty when it is the last segment). */
static bool py_ns_check_path(const PyNsCallCheck *c, const char *text, size_t len, int *own_at,
PyNsSlice *next) {
*own_at = -1;
*next = (PyNsSlice){NULL, 0};
int index = 0;
size_t start = 0;
while (start <= len && len > 0) {
size_t end = start;
while (end < len && text[end] != '.')
end++;
const char *seg = text + start;
size_t seg_len = end - start;
if (py_ns_reaches_namespace(seg, seg_len))
return false;
if (*own_at >= 0 && *own_at == index - 1)
*next = (PyNsSlice){seg, seg_len};
if (*own_at < 0 && seg_len == c->own.len && memcmp(seg, c->own.text, seg_len) == 0)
*own_at = index;
index++;
start = end + 1;
}
return true;
}
/* The relative level of a `from` module path: its leading dots. */
static size_t py_ns_leading_dots(const char *text, size_t len) {
const char *end = text + len;
const char *p = text;
while (p != end && *p == '.')
p++;
return (size_t)(p - text);
}
/* An import in the callee's body. When its module is this package, the
* submodule it installs as a package attribute is shadowed: the first
* segment after the package, or every imported member when the import names
* the package itself. A relative import of a package `__init__` is relative
* to the package (level 1) or to its parent (level 2, whose first segment
* then names this package or a sibling). Deeper relative imports that name
* this module cannot be placed and are refused. The names an import binds are
* checked like every name of the body; this module's own name is allowed in a
* module path only. */
static bool py_ns_check_import(PyNsCallCheck *c, TSNode node, bool from) {
TSNode module = from ? ts_node_child_by_field_name(node, TS_FIELD("module_name")) : (TSNode){0};
bool into_package = false; /* the import's module is this package */
PyNsSlice sub = {NULL, 0}; /* the submodule after the package, if any */
if (from) {
const char *text = NULL;
size_t len = 0;
if (!py_ns_slice(c->b, module, &text, &len))
return false;
size_t level = py_ns_leading_dots(text, len);
PyNsSlice rest = {text + level, len - level};
if (rest.len && !py_ns_identifier_bytes(rest.text, rest.len, true))
return false;
int own_at = -1;
PyNsSlice next;
if (!py_ns_check_path(c, rest.text, rest.len, &own_at, &next))
return false;
if (c->package && level == 1) {
/* `from . import x` names the package; `from .x.y import z` installs x */
const char *dot = rest.len ? memchr(rest.text, '.', rest.len) : NULL;
into_package = rest.len == 0;
sub = (PyNsSlice){rest.text, dot ? (size_t)(dot - rest.text) : rest.len};
} else if (c->package && (level == 0 || (level == 2 && own_at == 0)) && own_at >= 0) {
/* level 2: the parent's child of this name is this package; a
* later segment of the same name is a package further down */
if (next.len)
sub = next;
else
into_package = true;
} else if (c->package && level > 2 && own_at >= 0) {
return false;
}
if (sub.len && !py_ns_add_shadow(c, sub.text, sub.len))
return false;
}
TSTreeCursor cursor = ts_tree_cursor_new(node);
bool ok = true;
if (ts_tree_cursor_goto_first_child(&cursor)) {
do {
TSNode child = ts_tree_cursor_current_node(&cursor);
if (!ts_node_is_named(child) || ts_node_eq(child, module) ||
strcmp(ts_node_type(child), "comment") == 0)
continue;
const char *kind = ts_node_type(child);
bool alias = strcmp(kind, "aliased_import") == 0;
if (!alias && strcmp(kind, "dotted_name") != 0) {
ok = false; /* a star import, or a form this check does not read */
break;
}
TSNode name = alias ? ts_node_child_by_field_name(child, TS_FIELD("name")) : child;
const char *text = NULL;
size_t len = 0;
if (!py_ns_slice(c->b, name, &text, &len)) {
ok = false;
break;
}
if (alias &&
py_ns_node_forbidden(c, ts_node_child_by_field_name(child, TS_FIELD("alias")))) {
ok = false;
break;
}
if (from) {
/* a member is a name the import binds (or a submodule) */
if (py_ns_forbidden_name(c, text, len) ||
(into_package && !py_ns_add_shadow(c, text, len))) {
ok = false;
break;
}
continue;
}
int own_at = -1;
PyNsSlice next;
if (!py_ns_identifier_bytes(text, len, true) ||
!py_ns_check_path(c, text, len, &own_at, &next) ||
(alias && own_at >= 0 && !next.len)) {
ok = false; /* `import pkg.own as alias` hands out this module */
break;
}
if (c->package && own_at >= 0 && next.len &&
!py_ns_add_shadow(c, next.text, next.len)) {
ok = false;
break;
}
} while (ts_tree_cursor_goto_next_sibling(&cursor));
}
ts_tree_cursor_delete(&cursor);
return ok;
}
static bool py_ns_collect_params(PyNsCallCheck *c, TSNode def) {
TSNode params = ts_node_child_by_field_name(def, TS_FIELD("parameters"));
if (ts_node_is_null(params))
return false;
uint32_t count = ts_node_named_child_count(params);
c->params = count ? py_ns_scratch_alloc(c->b, (size_t)count * sizeof(*c->params)) : NULL;
if (count && !c->params)
return false;
for (uint32_t i = 0; i < count; i++) {
TSNode p = ts_node_named_child(params, i);
const char *kind = ts_node_type(p);
if (strcmp(kind, "comment") == 0 || strcmp(kind, "keyword_separator") == 0 ||
strcmp(kind, "positional_separator") == 0)
continue;
TSNode name = p;
if (strcmp(kind, "default_parameter") == 0 ||
strcmp(kind, "typed_default_parameter") == 0) {
name = ts_node_child_by_field_name(p, TS_FIELD("name"));
} else if (strcmp(kind, "typed_parameter") == 0 ||
strcmp(kind, "list_splat_pattern") == 0 ||
strcmp(kind, "dictionary_splat_pattern") == 0) {
name = ts_node_named_child(p, 0);
const char *inner = ts_node_is_null(name) ? "" : ts_node_type(name);
if (strcmp(inner, "list_splat_pattern") == 0 ||
strcmp(inner, "dictionary_splat_pattern") == 0)
name = ts_node_named_child(name, 0);
} else if (strcmp(kind, "identifier") != 0) {
return false;
}
PyNsSlice s;
if (ts_node_is_null(name) || strcmp(ts_node_type(name), "identifier") != 0 ||
!py_ns_slice(c->b, name, &s.text, &s.len))
return false;
c->params[c->param_count++] = s;
}
return true;
}
static bool py_ns_collect_defs(PyNsCallCheck *c, int before) {
c->defs = before ? py_ns_scratch_alloc(c->b, (size_t)before * sizeof(*c->defs)) : NULL;
if (before && !c->defs)
return false;
for (int i = 0; i < before; i++) {
const CBMPyNamespaceFact *f = &c->b->facts.items[i];
bool def = f->kind == CBM_PY_NS_OWN_DEF ||
(f->kind == CBM_PY_NS_SHADOW && (f->reason == CBM_PY_NS_REASON_DECORATED ||
f->reason == CBM_PY_NS_REASON_UNPROVEN_CLASS));
if (def && f->local_name)
c->defs[c->def_count++] = f->local_name;
}
if (c->def_count > 1)
qsort(c->defs, (size_t)c->def_count, sizeof(*c->defs), py_ns_cmp_cstr);
return true;
}
/* Walk the callee's body (iteratively: nesting must not exhaust the stack). */
static bool py_ns_check_body(PyNsCallCheck *c, TSNode body) {
TSTreeCursor cursor = ts_tree_cursor_new(body);
bool ok = true;
bool done = false;
while (ok && !done && !c->b->failure) {
TSNode node = ts_tree_cursor_current_node(&cursor);
const char *kind = ts_node_type(node);
bool from = strcmp(kind, "import_from_statement") == 0;
bool is_import = from || strcmp(kind, "import_statement") == 0;
if (strcmp(kind, "global_statement") == 0 || strcmp(kind, "nonlocal_statement") == 0 ||
strcmp(kind, "future_import_statement") == 0) {
ok = false;
} else if (is_import) {
ok = py_ns_check_import(c, node, from);
} else if (strcmp(kind, "identifier") == 0) {
ok = !py_ns_node_forbidden(c, node);
}
if (!ok)
break;
if (!is_import && ts_tree_cursor_goto_first_child(&cursor))
continue;
while (!ts_tree_cursor_goto_next_sibling(&cursor)) {
if (!ts_tree_cursor_goto_parent(&cursor)) {
done = true;
break;
}
}
}
ts_tree_cursor_delete(&cursor);
return ok && !c->b->failure;
}
static PyNsCallVerdict py_ns_check_function(PyNamespaceBuilder *b, PyNsFunction *fn) {
PyNsCallCheck c = {.b = b};
TSNode body = ts_node_child_by_field_name(fn->node, TS_FIELD("body"));
if (ts_node_is_null(body) || !py_ns_own_name(b->ctx->rel_path, &c.own, &c.package) ||
!py_ns_collect_params(&c, fn->node) || !py_ns_collect_defs(&c, fn->fact) ||
!py_ns_check_body(&c, body))
return PY_NS_CALL_EFFECT;
if (!c.package)
c.shadow_count = 0; /* a plain module's imports install nothing in it */
fn->shadows = c.shadows;
fn->shadow_count = c.shadow_count;
return PY_NS_CALL_ALLOWED;
}
static void py_ns_note_function(PyNamespaceBuilder *b, int fact, TSNode node) {
if (b->function_count == b->function_cap) {
int cap = b->function_cap ? b->function_cap * 2 : 16;
PyNsFunction *items = py_ns_scratch_alloc(b, (size_t)cap * sizeof(*items));
if (!items)
return;
if (b->function_count)
memcpy(items, b->functions, (size_t)b->function_count * sizeof(*items));
b->functions = items;
b->function_cap = cap;
}
b->functions[b->function_count++] = (PyNsFunction){.fact = fact, .node = node};
}
enum { PY_NS_UNBOUND = -1, PY_NS_BARRIER = -2 };
/* The index of the latest fact binding `name`: PY_NS_UNBOUND when none did,
* PY_NS_BARRIER when a star import or an unknown effect may have since. */
static int py_ns_latest_binding(const PyNamespaceBuilder *b, const char *name, size_t len) {
for (int i = b->facts.count - 1; i >= 0; i--) {
const CBMPyNamespaceFact *f = &b->facts.items[i];
if (f->kind == CBM_PY_NS_UNKNOWN || f->kind == CBM_PY_NS_IMPORT_STAR)
return PY_NS_BARRIER;
if (f->local_name && py_ns_slice_cmp(f->local_name, name, len) == 0)
return i;
}
return PY_NS_UNBOUND;
}
/* Safe expressions, or a bare `locals()` / `globals()` of the builtins (the
* body never names the parameter that receives it). */
static bool py_ns_call_arguments_safe(PyNamespaceBuilder *b, TSNode args) {
if (ts_node_is_null(args) || strcmp(ts_node_type(args), "argument_list") != 0)
return false;
uint32_t count = ts_node_named_child_count(args);
for (uint32_t i = 0; i < count; i++) {
TSNode arg = ts_node_named_child(args, i);
if (py_ns_safe_expr(b, arg, 0))
continue;
if (strcmp(ts_node_type(arg), "call") != 0)
return false;
TSNode fn = ts_node_child_by_field_name(arg, TS_FIELD("function"));
TSNode inner = ts_node_child_by_field_name(arg, TS_FIELD("arguments"));
const char *text = NULL;
size_t len = 0;
if (ts_node_is_null(fn) || strcmp(ts_node_type(fn), "identifier") != 0 ||
!py_ns_slice(b, fn, &text, &len) ||
!(py_ns_text_is(text, len, "locals") || py_ns_text_is(text, len, "globals")) ||
ts_node_is_null(inner) || ts_node_named_child_count(inner) != 0 ||
py_ns_latest_binding(b, text, len) != PY_NS_UNBOUND)
return false;
}
return true;
}
/* True when the top-level call `call` cannot rebind a name of this module's
* namespace; the submodules its callee imports into the package are then
* shadowed here. False when it stays a whole-namespace effect. */
static bool py_ns_allowed_call(PyNamespaceBuilder *b, TSNode call) {
TSNode callee = ts_node_child_by_field_name(call, TS_FIELD("function"));
const char *name = NULL;
size_t len = 0;
if (ts_node_is_null(callee) || strcmp(ts_node_type(callee), "identifier") != 0 ||
!py_ns_slice(b, callee, &name, &len) ||
!py_ns_call_arguments_safe(b, ts_node_child_by_field_name(call, TS_FIELD("arguments"))))
return false;
int at = py_ns_latest_binding(b, name, len);
if (at < 0 || b->facts.items[at].kind != CBM_PY_NS_OWN_DEF ||
b->facts.items[at].def_kind != CBM_PY_NS_DEF_FUNCTION)
return false;
int lo = 0, hi = b->function_count - 1;
PyNsFunction *fn = NULL;
while (lo <= hi && !fn) {
int mid = lo + (hi - lo) / 2;
if (b->functions[mid].fact == at)
fn = &b->functions[mid];
else if (b->functions[mid].fact < at)
lo = mid + 1;
else
hi = mid - 1;
}
if (!fn)
return false;
if (fn->verdict == PY_NS_CALL_UNCHECKED)
fn->verdict = py_ns_check_function(b, fn);
if (fn->verdict != PY_NS_CALL_ALLOWED || b->failure)
return false;
for (int i = 0; i < fn->shadow_count; i++) {
if (!py_ns_push(b, (CBMPyNamespaceFact){.kind = CBM_PY_NS_SHADOW,
.local_name = fn->shadows[i],
.reason = CBM_PY_NS_REASON_VALUE}))
return false;
}
return true;
}
static bool py_ns_statement(PyNamespaceBuilder *b, TSNode node) {
const char *kind = ts_node_type(node);
if (strcmp(kind, "comment") == 0 || strcmp(kind, "pass_statement") == 0)
@@ -1050,16 +1553,20 @@ static bool py_ns_statement(PyNamespaceBuilder *b, TSNode node) {
if (!py_ns_unknown(b, CBM_PY_NS_REASON_EFFECT))
return false;
}
return py_ns_push(
b, proven ? (CBMPyNamespaceFact){.kind = CBM_PY_NS_OWN_DEF,
.local_name = name,
.def_kind = function ? CBM_PY_NS_DEF_FUNCTION
: CBM_PY_NS_DEF_CLASS}
: (CBMPyNamespaceFact){.kind = CBM_PY_NS_SHADOW,
.local_name = name,
.reason = decorated
? CBM_PY_NS_REASON_DECORATED
: CBM_PY_NS_REASON_UNPROVEN_CLASS});
if (!py_ns_push(
b, proven ? (CBMPyNamespaceFact){.kind = CBM_PY_NS_OWN_DEF,
.local_name = name,
.def_kind = function ? CBM_PY_NS_DEF_FUNCTION
: CBM_PY_NS_DEF_CLASS}
: (CBMPyNamespaceFact){
.kind = CBM_PY_NS_SHADOW,
.local_name = name,
.reason = decorated ? CBM_PY_NS_REASON_DECORATED
: CBM_PY_NS_REASON_UNPROVEN_CLASS}))
return false;
if (proven && function)
py_ns_note_function(b, b->facts.count - 1, def);
return !b->failure;
}
}
if (strcmp(kind, "delete_statement") == 0)
@@ -1069,7 +1576,9 @@ static bool py_ns_statement(PyNamespaceBuilder *b, TSNode node) {
const char *ek = ts_node_type(expr);
if (strcmp(ek, "assignment") == 0 || strcmp(ek, "augmented_assignment") == 0)
return py_ns_assignment(b, expr);
return py_ns_safe_expr(b, expr, 0) || py_ns_unknown(b, CBM_PY_NS_REASON_EFFECT);
if (py_ns_safe_expr(b, expr, 0) || (strcmp(ek, "call") == 0 && py_ns_allowed_call(b, expr)))
return true;
return py_ns_unknown(b, CBM_PY_NS_REASON_EFFECT);
}
if (strcmp(kind, "if_statement") == 0 && py_ns_is_type_checking_if(b, node))
return py_ns_type_checking_block(b, node);
+8 -2
View File
@@ -1487,12 +1487,18 @@ static void k8s_container(k8s_walk_t *w, const char *path, TSNode container) {
const char *cname = k8s_text(w, container, "name");
cname = cname && cname[0] && !strstr(cname, "{{") ? cname : NULL;
const char *image = k8s_text(w, container, "image");
if (k8s_image_literal(image)) {
/* a Helm template's image is its skeleton placeholder: pass_k8s.c renders it */
if (k8s_image_literal(image) || (image && strchr(image, '<'))) {
TSNode iv = k8s_child(w, container, "image");
CBMK8sFact f = {.fact = CBM_K8S_FACT_IMAGE,
.def_index = w->def_index,
.name = image,
.container = cname,
.via = "container"};
.via = "container",
.line =
ts_node_is_null(iv) ? 0 : (int)ts_node_start_point(iv).row + SKIP_ONE,
.start_line = (int)ts_node_start_point(container).row + SKIP_ONE,
.end_line = (int)ts_node_end_point(container).row + SKIP_ONE};
cbm_k8sfact_push(&w->ctx->result->k8s_facts, w->ctx->arena, f);
}
k8s_iter_t env = k8s_iter(k8s_child(w, container, "env"));
+23
View File
@@ -1110,6 +1110,29 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan
}
}
/* Go method `func (s *Storage) Close() {...}`: the def extractor records
* this as Method "proj.pkg.Storage.Close" (receiver-qualified, mirroring
* the C++ out-of-line rule above). The call-scope QN must match — a bare
* "proj.pkg.Close" names a node that no longer exists, so every call
* inside the method body would fall back to File-node attribution
* (calls_find_source). Same ONE-formula contract as the def side:
* cbm_go_receiver_type_name + cbm_fqn_compute_source_lang. */
if (ctx->language == CBM_LANG_GO && strcmp(ts_node_type(node), "method_declaration") == 0) {
TSNode recv = ts_node_child_by_field_name(node, TS_FIELD("receiver"));
if (!ts_node_is_null(recv)) {
char *recv_type = cbm_go_receiver_type_name(ctx->arena, recv, ctx->source);
if (recv_type && recv_type[0]) {
const char *type_qn = cbm_fqn_compute_source_lang(
ctx->arena, ctx->project, ctx->rel_path, recv_type, ctx->language);
/* NULL only on allocation failure: fall through to the flat
* QN, as the def side does (extract_defs.c). */
if (type_qn) {
return cbm_arena_sprintf(ctx->arena, "%s.%s", type_qn, name);
}
}
}
}
/* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def
* extractor bakes it into the def QN. Compose it identically here — otherwise
* an in-body call sources to a QN one or more segments short of the def, and
+6
View File
@@ -173,6 +173,12 @@ TSNode cbm_resolve_func_name(TSNode node, CBMLanguage lang);
// def extractor — drift dropped the class qualifier from in-body calls (#554/#621).
char *cbm_cpp_out_of_line_parent_class(CBMArena *a, TSNode node, const char *source);
/* Go: resolve a method_declaration's receiver parameter_list down to the bare
* receiver type_identifier (unwrapping pointer_type / generic_type). Shared by
* def extraction (extract_defs.c) and call-scope attribution
* (extract_unified.c) so the receiver-qualified method QN has ONE formula. */
char *cbm_go_receiver_type_name(CBMArena *a, TSNode recv, const char *source);
// Find a child node by kind string.
TSNode cbm_find_child_by_kind(TSNode parent, const char *kind);
+17 -8
View File
@@ -1987,15 +1987,24 @@ static void process_function(GoLSPContext *ctx, TSNode func_node) {
if (!func_name || !func_name[0])
return;
// Enclosing-function QN must be the BARE package.Func form (no receiver
// type segment). The textual call events (extract_unified.c) source calls
// as package_qn.func_name — methods included — and the defs pass creates
// the graph Method node under the same QN, so any other form breaks the
// caller-QN join in cbm_pipeline_find_lsp_resolution and the LSP-resolved
// call silently falls back to the registry short-name resolver. The
// receiver type still reaches the registry via the def's parent_class /
// method->receiver_type; it just does not appear in the caller QN.
// Enclosing-function QN must be EXACTLY the QN the textual call events
// (extract_unified.c compute_func_qn) and the defs pass produce, or the
// caller-QN join in cbm_pipeline_find_lsp_resolution breaks and the
// LSP-resolved call silently falls back to the registry short-name
// resolver. Since #1909 receiver-qualified Go method QNs, that shared
// form is package_qn.ReceiverType.method for methods (the ONE-formula
// contract: cbm_go_receiver_type_name) and package_qn.func for functions.
ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->package_qn, func_name);
if (strcmp(ts_node_type(func_node), "method_declaration") == 0) {
TSNode recv = ts_node_child_by_field_name(func_node, "receiver", 8);
if (!ts_node_is_null(recv)) {
char *recv_type = cbm_go_receiver_type_name(ctx->arena, recv, ctx->source);
if (recv_type && recv_type[0]) {
ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s.%s", ctx->package_qn,
recv_type, func_name);
}
}
}
// Push function scope
CBMScope *saved_scope = ctx->current_scope;
+12 -1
View File
@@ -377,6 +377,13 @@ static char *ha_build_args(const char *project, const char *token) {
return out; /* caller frees */
}
/* Closes every search augmentation that found symbols: cbm's own statement,
* outside the untrusted data block, naming the graph tool for the likely next
* question at the moment the agent greps. Facts, not commands. */
static const char HA_SEARCH_FOLLOW_UP[] =
"\n[codebase-memory] For these symbols, trace_path lists callers and callees, "
"get_code_snippet returns the exact source, and search_graph finds related symbols by name.";
/* Parse the MCP envelope returned by cbm_mcp_handle_tool and, if it is a
* successful search_graph result with >=1 hit, format a compact
* additionalContext string. Returns malloc'd text or NULL.
@@ -432,7 +439,7 @@ static char *ha_format_context(const char *envelope, const char *token, bool *is
return NULL; /* valid project, just no matching symbols */
}
char *text = malloc(4096);
char *text = malloc(4096 + sizeof(HA_SEARCH_FOLLOW_UP));
if (!text) {
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
@@ -476,6 +483,10 @@ static char *ha_format_context(const char *envelope, const char *token, bool *is
safe_path, safe_label[0] ? " " : "", safe_label);
}
}
/* off can run past the list cap when a row is truncated, so append at the
* real end; the allocation reserves room for the whole follow-up line. */
size_t used = strlen(text);
snprintf(text + used, 4096 + sizeof(HA_SEARCH_FOLLOW_UP) - used, "%s", HA_SEARCH_FOLLOW_UP);
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
+13 -10
View File
@@ -32,10 +32,11 @@
* fields of its own struct declaration and the methods of its own interface
* declaration: nothing promoted through embedding.
*
* Binding. The graph keeps one node per qualified name, Go method nodes have
* a FLAT name (<dir>.<Method>), and <dir> is the directory, not the package
* clause: functions, methods of every receiver, types and values of one name,
* in every file of the directory, share one node, and the node is ONE of those
* Binding. The graph keeps one node per qualified name, and <dir> is the
* directory, not the package clause. Go method nodes are receiver-qualified
* (<dir>.<Recv>.<Method>, #1913) and are found through their receiver only.
* Functions, types and values of one name, in every file of the directory,
* share one node <dir>.<name>, and the node is ONE of those
* declarations (cbm_gbuf_upsert_node keeps the smallest file path, then the
* last line). So a reference binds only when the node IS the entity it names:
* the node's file is a file of the named package, the declaration that keeps
@@ -59,7 +60,7 @@
* not), missing (a repository package that does not declare the name; an
* import path inside a module without a package there), graph_gap (declared,
* but the node is another declaration's or absent: block variables, later
* names of a declaration, embedded fields, methods that lost the flat name;
* names of a declaration, embedded fields;
* also an undeclared name in a package with a parse error), test_only_target
* (product code naming a declaration that only test files have), ambiguous
* (several modules or packages fit; a name declared as two kinds; a name two
@@ -1063,7 +1064,7 @@ static void go_record_decl(go_index_t *ix, go_file_t *file, char **f, int nf) {
FIELD_FIELDS = 4,
IM_FIELDS = 3
};
enum { F_OWNER = 2, T_ALIAS = 3, T_OWNER = 4, V_OWNER = 2, M_OWNER = 3, FIELD_NODE = 3 };
enum { F_OWNER = 2, T_ALIAS = 3, T_OWNER = 4, V_OWNER = 2, FIELD_NODE = 3 };
char kind = f[0][0];
if (nf < IM_FIELDS || !go_name_ok(f[SKIP_ONE])) {
return;
@@ -1078,10 +1079,12 @@ static void go_record_decl(go_index_t *ix, go_file_t *file, char **f, int nf) {
go_add_sym(ix, file, GO_KIND_VALUE, f[SKIP_ONE], NULL);
go_add_claim(ix, file, GO_KIND_VALUE, f[SKIP_ONE], NULL, f[V_OWNER]);
} else if (kind == 'M' && nf == M_FIELDS && go_name_ok(f[PAIR_LEN])) {
/* #1913: a method is found through its receiver only; it claims no
* flat <dir>.<name> (its record says w `-`), so a type, function or
* value of the same name keeps that node. */
if (strcmp(f[SKIP_ONE], "?") != 0) {
go_add_member(ix, file, GO_MEM_METHOD, f[SKIP_ONE], f[PAIR_LEN], true);
}
go_add_claim(ix, file, GO_KIND_METHOD, f[PAIR_LEN], f[SKIP_ONE], f[M_OWNER]);
} else if (kind == 'f' && nf == FIELD_FIELDS && go_name_ok(f[PAIR_LEN])) {
go_add_member(ix, file, GO_MEM_FIELD, f[SKIP_ONE], f[PAIR_LEN], f[FIELD_NODE][0] == '1');
} else if (kind == 'm' && nf == IM_FIELDS && go_name_ok(f[PAIR_LEN])) {
@@ -1342,9 +1345,9 @@ static const cbm_gbuf_node_t *go_member_node(const go_ctx_t *c, const go_pkg_t *
return (fm && (fm->node_kinds & kind)) ? n : NULL;
}
/* The node of the method `recv.name`: its receiver-qualified node, else the
* flat node <dir>.<name> when the method is the one that kept it (the last
* declaration of that name in the node's file). */
/* The node of the method `recv.name`: its receiver-qualified node. The flat
* fallback below (the method that kept <dir>.<name>) is unreachable since
* #1913: no method record claims a flat name any more. */
static const cbm_gbuf_node_t *go_method_node(const go_ctx_t *c, const go_pkg_t *pkg, go_str_t recv,
go_str_t name) {
const cbm_gbuf_node_t *qualified = go_member_node(c, pkg, GO_MEM_METHOD, recv, name);
+4 -3
View File
@@ -328,9 +328,10 @@ static void qn_candidate(const pdfr_index_t *x, const cbm_gbuf_node_t *n, const
const char *rel = rel_qn(x, n->qualified_name);
char alt[PDFR_KEY_CAP];
bool owned = owner_name(x, n, alt, sizeof(alt));
/* A member whose QN leaves its owner out (a Go method's has no receiver)
* is named through the owner: `time.Now` is package time's function,
* never the method Now of a type that package time declares. */
/* A member whose QN leaves its owner out (a templ method's keeps the flat
* package QN; a Go method's did before index format 4) is named through
* the owner: `time.Now` is package time's function, never the method Now
* of a type that package time declares. */
bool ownerless_qn = owned && !comp_suffix(rel, alt);
bool via_rel = !ownerless_qn && comp_suffix(rel, s);
bool via_alt = !via_rel && owned && strcmp(alt, rel) != 0 && comp_suffix(alt, s);
File diff suppressed because it is too large. Load diff
+93
View File
@@ -0,0 +1,93 @@
/*
* helm_template.h -- Helm values and the rendering of a chart template's
* image references (pass_k8s.c). Internal to the pipeline.
*
* Values are JSON (yyjson mutable values), as Helm holds them after its
* YAML -> JSON step: go-yaml v2 scalars (YAML 1.1 booleans), every number a
* float64. A template renders with Go text/template semantics and the part
* of Sprig image expressions use; anything else -- a function not modelled,
* .Release / .Capabilities / lookup, a missing helper, a parse error on the
* way -- is UNKNOWN, and an image that reads an UNKNOWN is no image. Nothing
* is guessed: a value is what Helm would render, or none.
*/
#ifndef CBM_HELM_TEMPLATE_H
#define CBM_HELM_TEMPLATE_H
#include "foundation/arena.h"
#include "tree_sitter/api.h"
#include <yyjson/yyjson.h>
#include <stdbool.h>
#include <stddef.h>
/* The first document of YAML text src as a value of doc (Helm's scalar
* types); NULL when it does not parse. An empty document is an empty map. */
yyjson_mut_val *cbm_helm_values_parse(yyjson_mut_doc *doc, const char *src, size_t len);
/* The YAML node `node` of text `src` (already parsed, e.g. a HelmRelease's
* spec.values) as a value of doc; NULL when it holds an unknown part. */
yyjson_mut_val *cbm_helm_values_node(yyjson_mut_doc *doc, const char *src, TSNode node);
/* Helm's coalesce: a deep copy of `base` with `over` merged in -- maps merge
* key by key, any other value replaces, a null deletes. Either may be NULL. */
yyjson_mut_val *cbm_helm_values_coalesce(yyjson_mut_doc *doc, yyjson_mut_val *base,
yyjson_mut_val *over);
/* Helm --set NAME=VALUE into map `root`: NAME's `.` separates keys (`\.`
* escapes one), `[n]` indexes a list; VALUE is typed like --set (int, bool,
* null, else string) unless `as_string`. False when NAME is malformed. */
bool cbm_helm_values_set(yyjson_mut_doc *doc, yyjson_mut_val *root, const char *name,
const char *value, bool as_string);
/* A values scalar as a template prints it (a number like Go's %v: 1.10 ->
* 1.1) into out; false for a map, a list or null. */
bool cbm_helm_scalar_text(yyjson_mut_val *v, char *out, size_t sz);
/* A chart's metadata, as its templates see it under .Chart -- and where its
* templates are: .Template.BasePath as Helm sets it (`<chart path>/templates`,
* the chart path its root's name and `/charts/<name or alias>` per subchart
* below it; NULL: not known) and the chart's directory as its template files
* are added to the defines ("" at the repository root). */
typedef struct {
const char *name;
const char *version;
const char *app_version;
const char *base_path;
const char *dir;
} cbm_helm_chart_meta_t;
/* The named templates (`define` / `block`) of one chart family -- a chart and
* the charts below it share one namespace; for one name the define of the
* file nearest the family root wins (Helm loads deeper files first), then
* the alphabetically first path. */
typedef struct cbm_helm_defines cbm_helm_defines_t;
cbm_helm_defines_t *cbm_helm_defines_new(void);
/* Register the defines of template file `rel` (text src, len). The text is
* copied; its parse lives as long as the set. */
void cbm_helm_defines_add(cbm_helm_defines_t *d, const char *rel, const char *src, size_t len);
void cbm_helm_defines_free(cbm_helm_defines_t *d);
/* A probe: the `image:` key on a template line. Each time rendering passes
* that line it captures the scalar written after the key. */
typedef struct {
int line; /* 1-based line of the key in the template */
int n; /* captures */
int cap; /* room in image / keys (the render's own) */
const char **image; /* the rendered scalar, YAML quoting removed; NULL: UNKNOWN */
const char **keys; /* JSON array of the values paths it read; NULL: none */
} cbm_helm_probe_t;
/* Render template `rel` (text src, len) with `values` (its .Values; NULL:
* an empty map), `chart` and the family's defines, filling `probes` (strings
* in arena a, which gets only these: the render works in an arena of its own).
* Values paths are dot-joined keys, a key's own `.` escaped (`\.`), as --set
* writes them. False when the render ran past its step, output or memory
* budget -- a template Helm would not finish either (logged): then every
* capture is UNKNOWN. */
bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len,
yyjson_mut_val *values, const cbm_helm_chart_meta_t *chart,
const cbm_helm_defines_t *defs, cbm_helm_probe_t *probes, int nprobes);
#endif /* CBM_HELM_TEMPLATE_H */
+9 -1
View File
@@ -1116,7 +1116,13 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
* language gated on only one resolver produces an edge on the sequential
* path and not the parallel one (or vice versa), breaking MT determinism.
* ArkTS belongs to the JS/TS family here (#1842); dropping it would
* reintroduce the #592/#606 false-edge class for .ets files. */
* reintroduce the #592/#606 false-edge class for .ets files.
*
* Go (#1906) rides the same deferred-drop plumbing through its OWN
* predicate: its drop-list differs (field_type_hint is receiver-aware for
* Go, and unique_name drops only when import-unreachability-penalized), so
* it composes via cbm_go_suppress_weak_method_match instead of widening
* the shared gate. Same lockstep rule: mirror pass_parallel.c. */
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
lang == CBM_LANG_ARKTS ||
@@ -1144,6 +1150,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
res.strategy)) ||
cbm_suppress_weak_local_binding_call(suppress_weak_local_binding,
call->callee_is_locally_bound, res.strategy) ||
cbm_go_suppress_weak_method_match(lang == CBM_LANG_GO, call->is_method, res.strategy,
res.confidence) ||
/* Import-binding suppression (#2127): an external `from m import f`
* binds `f` for the whole module, so a weak short-name match onto a
* project `X.f` outside m is fabricated. Python-only (the import
+91 -4
View File
@@ -25,6 +25,7 @@
*/
#include "foundation/constants.h"
#include "foundation/compat.h"
#include "foundation/str_util.h" /* cbm_json_escape */
#include "pipeline/pipeline_internal.h"
#include "cbm.h"
#include "helpers.h" /* cbm_env_address */
@@ -783,9 +784,33 @@ static const char *ci_env_lookup(const ci_env_t *env, const char *name, size_t n
return NULL;
}
/* JSON array `list` (NULL: empty) with identifier `name` appended once. */
static const char *ci_names_add(CBMArena *a, const char *list, const char *name, size_t nl) {
if (!list) {
return cbm_arena_sprintf(a, "[\"%.*s\"]", (int)nl, name);
}
char quoted[CBM_SZ_256];
snprintf(quoted, sizeof(quoted), "\"%.*s\"", (int)nl, name);
if (strstr(list, quoted)) {
return list;
}
return cbm_arena_sprintf(a, "%.*s,%s]", (int)(strlen(list) - SKIP_ONE), list, quoted);
}
static const char *ci_interp_marks(CBMArena *a, const char *s, const ci_env_t *env,
const char **dflt_of, const char **ovr_of);
/* `s` with ${X} / ${X:-d} / ${X-d} / $X expanded from env; NULL when a
* variable has no value (and no default). */
static const char *ci_interp(CBMArena *a, const char *s, const ci_env_t *env) {
return ci_interp_marks(a, s, env, NULL, NULL);
}
/* ci_interp; `dflt_of` (may be NULL) gains, as a JSON array, the variables
* whose inline default was used, `ovr_of` those with an inline default that
* env set instead. */
static const char *ci_interp_marks(CBMArena *a, const char *s, const ci_env_t *env,
const char **dflt_of, const char **ovr_of) {
if (!s || !strchr(s, '$')) {
return s;
}
@@ -839,6 +864,11 @@ static const char *ci_interp(CBMArena *a, const char *s, const ci_env_t *env) {
}
use = dflt;
ul = dl;
if (dflt_of) {
*dflt_of = ci_names_add(a, *dflt_of, name, nl);
}
} else if (dflt && ovr_of) {
*ovr_of = ci_names_add(a, *ovr_of, name, nl);
}
if (len + ul + SKIP_ONE > cap) {
return NULL;
@@ -1054,7 +1084,53 @@ static int ci_compose_env(const ci_t *c, TSNode svc, const ci_env_t *dotenv, con
return k;
}
/* A compose build's `args` (a mapping, or a list of "K=V" items), their
* values interpolated, as a JSON object; NULL: none. An argument without a
* value (the environment's), with an unknown one or with a secret (by name or
* value -- never kept in the graph, never an image) is left out. */
static const char *ci_compose_build_args(const ci_t *c, TSNode build, const ci_env_t *env) {
TSNode args = ts_node_is_null(build) ? build : ci_get(c, build, "args");
if (ts_node_is_null(args)) {
return NULL;
}
int n = 0;
TSNode *items = ci_items(c, args, &n);
ci_pair_t *pairs = items ? NULL : ci_pairs(c, args, &n);
const char *json = NULL;
for (int i = 0; i < n; i++) {
const char *key = NULL;
const char *raw = NULL;
if (items) {
const char *s = ci_scalar(c, items[i], NULL);
const char *eq = s ? strchr(s, '=') : NULL;
key = eq ? cbm_arena_strndup(c->a, s, (size_t)(eq - s)) : NULL;
raw = eq ? eq + SKIP_ONE : NULL;
} else {
key = pairs[i].key;
raw = ts_node_is_null(pairs[i].value) ? NULL : ci_scalar(c, pairs[i].value, NULL);
}
const char *value = raw ? ci_interp(c->a, raw, env) : NULL;
if (!key || !key[0] || !value || cbm_is_secret_binding(key, value)) {
continue;
}
/* JSON escaping writes at most 6 bytes a byte: nothing is cut */
int kn = (int)(strlen(key) * 6 + SKIP_ONE);
int vn = (int)(strlen(value) * 6 + SKIP_ONE);
char *ek = cbm_arena_alloc(c->a, (size_t)kn);
char *ev = cbm_arena_alloc(c->a, (size_t)vn);
if (!ek || !ev) {
return NULL;
}
cbm_json_escape(ek, kn, key);
cbm_json_escape(ev, vn, value);
json = cbm_arena_sprintf(c->a, "%s%s\"%s\":\"%s\"", json ? json : "{", json ? "," : "", ek,
ev);
}
return json ? cbm_arena_sprintf(c->a, "%s}", json) : NULL;
}
static void ci_compose(const ci_t *c, TSNode root, const char *dir, const ci_env_t *env) {
static const ci_env_t no_env = {0};
int ns = 0;
ci_pair_t *svcs = ci_pairs(c, ci_get(c, root, "services"), &ns);
for (int i = 0; i < ns; i++) {
@@ -1064,16 +1140,27 @@ static void ci_compose(const ci_t *c, TSNode root, const char *dir, const ci_env
}
int row = 0;
const char *raw = ci_scalar(c, ci_get(c, svc, "image"), &row);
const char *image = raw ? ci_interp(c->a, raw, env) : NULL;
const char *df = ci_compose_dockerfile(c, ci_get(c, svc, "build"), dir, env);
const char *dflt_of = NULL;
const char *ovr_of = NULL;
const char *image = raw ? ci_interp_marks(c->a, raw, env, &dflt_of, &ovr_of) : NULL;
/* With .env replacing a default: what the defaults alone would name. */
const char *default_image = image && ovr_of ? ci_interp(c->a, raw, &no_env) : NULL;
TSNode build = ci_get(c, svc, "build");
const char *df = ci_compose_dockerfile(c, build, dir, env);
if (c->site_fn && image && df) {
cbm_build_site_t site = {
.via = "compose", .image = image, .dockerfile = df, .line = row + SKIP_ONE};
cbm_build_site_t site = {.via = "compose",
.image = image,
.dockerfile = df,
.build_args = ci_compose_build_args(c, build, env),
.line = row + SKIP_ONE};
c->site_fn(&site, c->ud);
}
if (c->service_fn) {
cbm_compose_service_t s = {.name = svcs[i].key,
.image = image,
.image_default_of = image ? dflt_of : NULL,
.image_override_of = image ? ovr_of : NULL,
.default_image = default_image,
.dockerfile = df,
.line = (int)ts_node_start_point(svc).row,
.end_line = (int)ts_node_end_point(svc).row + SKIP_ONE};
+610 -49
View File
@@ -108,12 +108,31 @@ typedef struct {
CBMHashTable *dirs; /* every directory holding a file (not the root) */
} dk_index_t;
/* Where a value came from: the ARGs whose default (`dflt`) or build argument
* (`ovr`) supplied part of it, as '\x1f'-separated names; NULL: none. */
typedef struct {
const char *dflt;
const char *ovr;
} dk_prov_t;
typedef struct dk_var {
const char *name;
const char *value; /* NULL: declared without a value */
bool arg; /* an ARG: a build argument can set it */
dk_prov_t prov;
struct dk_var *next;
} dk_var_t;
/* An image reference of FROM / COPY --from, linked once the file is read. */
typedef struct {
int64_t img;
const char *ref;
const char *via;
int stage;
int line;
dk_prov_t prov;
} dk_ref_t;
typedef struct {
cbm_pipeline_ctx_t *ctx;
CBMArena *a;
@@ -131,6 +150,14 @@ typedef struct {
dk_entry_t entry; /* the current stage's ENTRYPOINT */
dk_entry_t cmd; /* the current stage's CMD */
const dk_index_t *ix;
dk_ref_t *refs; /* FROM / COPY --from references, in file order */
int nrefs;
int refs_cap;
/* A build's view (dk_build_args): its build arguments set the ARGs they
* name, and the references they change link from the Image it builds. */
const dk_var_t *build_args;
int64_t built; /* 0: the Dockerfile's own view */
const char *build_props; /* the build's tool / file / build_line members */
} dk_t;
static bool dk_type(TSNode n, const char *t) {
@@ -172,14 +199,72 @@ static const dk_var_t *dk_find(const dk_var_t *v, const char *name, size_t nl) {
return NULL;
}
static void dk_set(dk_t *d, dk_var_t **list, const char *name, const char *value) {
static void dk_set(dk_t *d, dk_var_t **list, const char *name, const char *value, bool arg,
dk_prov_t prov) {
dk_var_t *v = cbm_arena_alloc(d->a, sizeof(*v));
if (v) {
*v = (dk_var_t){.name = name, .value = value, .next = *list};
*v = (dk_var_t){.name = name, .value = value, .arg = arg, .prov = prov, .next = *list};
*list = v;
}
}
enum { DK_NAME_SEP = '\x1f' };
static bool dk_names_has(const char *list, const char *name, size_t nl) {
for (const char *p = list; p && *p;) {
size_t l = strcspn(p, "\x1f");
if (l == nl && strncmp(p, name, nl) == 0) {
return true;
}
p += l;
p += *p ? SKIP_ONE : 0;
}
return false;
}
/* `list` with `name` appended unless it holds it already. */
static const char *dk_names_add(dk_t *d, const char *list, const char *name, size_t nl) {
if (dk_names_has(list, name, nl)) {
return list;
}
return list && list[0] ? cbm_arena_sprintf(d->a, "%s%c%.*s", list, DK_NAME_SEP, (int)nl, name)
: cbm_arena_strndup(d->a, name, nl);
}
static const char *dk_names_union(dk_t *d, const char *a, const char *b) {
for (const char *p = b; p && *p;) {
size_t l = strcspn(p, "\x1f");
a = dk_names_add(d, a, p, l);
p += l;
p += *p ? SKIP_ONE : 0;
}
return a;
}
static void dk_prov_merge(dk_t *d, dk_prov_t *into, dk_prov_t from) {
into->dflt = dk_names_union(d, into->dflt, from.dflt);
into->ovr = dk_names_union(d, into->ovr, from.ovr);
}
/* `,"key":["A","B"]` for a non-empty name list, else "". */
static const char *dk_names_member(dk_t *d, const char *key, const char *list) {
if (!list || !list[0]) {
return "";
}
const char *out = cbm_arena_sprintf(d->a, ",\"%s\":[", key);
for (const char *p = list; out && *p;) {
size_t l = strcspn(p, "\x1f");
char esc[CBM_SZ_256];
char name[CBM_SZ_256];
snprintf(name, sizeof(name), "%.*s", (int)l, p);
cbm_json_escape(esc, (int)sizeof(esc), name);
out = cbm_arena_sprintf(d->a, "%s%s\"%s\"", out, p == list ? "" : ",", esc);
p += l;
p += *p ? SKIP_ONE : 0;
}
return out ? cbm_arena_sprintf(d->a, "%s]", out) : "";
}
/* ASCII case-insensitive equality (stage names, `scratch`). */
static bool dk_ieq(const char *a, const char *b) {
for (; *a && *b; a++, b++) {
@@ -195,8 +280,10 @@ static bool dk_name_char(char c) {
}
/* `text` with $X / ${X} / ${X:-d} / ${X-d} expanded from `vars`; NULL when a
* reference has no value. */
static const char *dk_expand(dk_t *d, const dk_var_t *vars, const char *text) {
* reference has no value. `prov` (may be NULL) gains the ARGs whose default
* or build argument supplied a value -- an inline `${X:-d}` default counts
* only for a declared ARG X, the one thing a build argument can set. */
static const char *dk_expand(dk_t *d, const dk_var_t *vars, const char *text, dk_prov_t *prov) {
size_t cap = strlen(text) + SKIP_ONE;
size_t len = 0;
char *out = cbm_arena_alloc(d->a, cap);
@@ -249,6 +336,11 @@ static const char *dk_expand(dk_t *d, const dk_var_t *vars, const char *text) {
return NULL;
}
const char *from = val ? val : dflt;
if (prov && v && val) {
dk_prov_merge(d, prov, v->prov);
} else if (prov && v && v->arg) {
prov->dflt = dk_names_add(d, prov->dflt, name, nl);
}
if (len + vl + SKIP_ONE > cap) {
size_t ncap = (len + vl + SKIP_ONE) * PAIR_LEN;
char *grown = cbm_arena_alloc(d->a, ncap);
@@ -370,6 +462,139 @@ static int dk_stage_of(const dk_t *d, const char *ref) {
return found;
}
/* A path component: [a-z0-9]+ joined by `.`, `_`, `__` or `-`+. */
static bool dk_ref_component(const char *s, size_t n) {
if (n == 0 || !isalnum((unsigned char)s[0]) || !isalnum((unsigned char)s[n - SKIP_ONE])) {
return false;
}
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)s[i];
if (isupper(c)) {
return false;
}
if (isalnum(c)) {
continue;
}
size_t run = SKIP_ONE;
while (i + run < n && s[i + run] == c) {
run++;
}
if (!((c == '.' && run == SKIP_ONE) || (c == '_' && run <= PAIR_LEN) || c == '-')) {
return false;
}
i += run - SKIP_ONE;
}
return true;
}
/* A registry host: DNS labels (`.`-joined), or an IPv6 literal, with an
* optional :port. */
static bool dk_ref_domain(const char *s, size_t n) {
const char *colon = memchr(s, ':', n);
if (n && s[0] == '[') {
const char *close = memchr(s, ']', n);
colon = close && (size_t)(close - s) + SKIP_ONE < n ? close + SKIP_ONE : NULL;
if (!close) {
return false;
}
}
size_t hl = colon ? (size_t)(colon - s) : n;
if (hl == 0) {
return false;
}
if (s[0] != '[') {
for (size_t i = 0; i < hl; i++) {
unsigned char c = (unsigned char)s[i];
if (!isalnum(c) && c != '.' && c != '-') {
return false;
}
}
}
for (size_t i = hl + SKIP_ONE; colon && i < n; i++) {
if (!isdigit((unsigned char)s[i])) {
return false;
}
}
return !colon || hl + SKIP_ONE < n;
}
bool cbm_image_ref_valid(const char *ref) {
if (!ref || !ref[0]) {
return false;
}
size_t n = strlen(ref);
const char *at = strchr(ref, '@');
if (at) {
const char *colon = strchr(at, ':');
if (!colon || colon == at + SKIP_ONE || strlen(colon + SKIP_ONE) < CBM_SZ_32) {
return false;
}
for (const char *p = at + SKIP_ONE; p < colon; p++) {
if (!isalnum((unsigned char)*p) && !strchr("+.-_", *p)) {
return false;
}
}
for (const char *p = colon + SKIP_ONE; *p; p++) {
if (!isxdigit((unsigned char)*p)) {
return false;
}
}
n = (size_t)(at - ref);
}
/* the tag: after the last ':' that follows the last '/' */
size_t name_end = n;
for (size_t i = n; i > 0; i--) {
if (ref[i - SKIP_ONE] == '/') {
break;
}
if (ref[i - SKIP_ONE] == ':') {
name_end = i - SKIP_ONE;
break;
}
}
if (name_end < n) {
size_t tl = n - name_end - SKIP_ONE;
const char *tag = ref + name_end + SKIP_ONE;
if (tl == 0 || tl > CBM_SZ_128 || !(isalnum((unsigned char)tag[0]) || tag[0] == '_')) {
return false;
}
for (size_t i = 0; i < tl; i++) {
if (!isalnum((unsigned char)tag[i]) && !strchr("_.-", tag[i])) {
return false;
}
}
}
const char *p = ref;
const char *end = ref + name_end;
const char *slash = memchr(p, '/', (size_t)(end - p));
if (slash) {
size_t fl = (size_t)(slash - p);
bool host = memchr(p, '.', fl) || memchr(p, ':', fl) ||
(fl == strlen("localhost") && memcmp(p, "localhost", fl) == 0);
if (host) {
if (!dk_ref_domain(p, fl)) {
return false;
}
p = slash + SKIP_ONE;
}
}
if (p >= end) {
return false;
}
while (p < end) {
const char *s = memchr(p, '/', (size_t)(end - p));
size_t cl = s ? (size_t)(s - p) : (size_t)(end - p);
if (!dk_ref_component(p, cl)) {
return false;
}
p += cl + (s ? SKIP_ONE : 0);
if (s && p >= end) {
return false;
}
}
return true;
}
int64_t cbm_pipeline_image_node(cbm_pipeline_ctx_t *ctx, const char *ref) {
char repo[CBM_SZ_512];
char reg[CBM_SZ_512];
@@ -438,19 +663,101 @@ int64_t cbm_pipeline_env_setter(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *
return id;
}
/* DEPENDS_ON from the Dockerfile to the Image of resolved reference `ref`. */
static void dk_link(dk_t *d, const char *ref, const char *via, int line) {
/* Record the Image of resolved reference `ref`, linked by dk_refs_link. */
static void dk_link(dk_t *d, const char *ref, const char *via, int line, dk_prov_t prov) {
int64_t img = cbm_pipeline_image_node(d->ctx, ref);
if (img <= 0) {
return;
}
char esc_ref[CBM_SZ_1K];
cbm_json_escape(esc_ref, (int)sizeof(esc_ref), ref);
char props[CBM_SZ_2K];
snprintf(props, sizeof(props), "{\"via\":\"%s\",\"image\":\"%s\",\"stage\":%d,\"line\":%d}",
via, esc_ref, d->stage, line);
cbm_gbuf_insert_edge(d->ctx->gbuf, d->module, img, "DEPENDS_ON", props);
d->edges++;
if (d->nrefs == d->refs_cap) {
int cap = d->refs_cap ? d->refs_cap * PAIR_LEN : DK_MAX_STAGES;
dk_ref_t *grown = cbm_arena_alloc(d->a, (size_t)cap * sizeof(*grown));
if (!grown) {
return;
}
if (d->nrefs) {
memcpy(grown, d->refs, (size_t)d->nrefs * sizeof(*grown));
}
d->refs = grown;
d->refs_cap = cap;
}
d->refs[d->nrefs++] = (dk_ref_t){img, ref, via, d->stage, line, prov};
}
/* The names a default supplies to the references of Image `img`: their union,
* or NULL when any of them names it without a default (a literal reference
* wins -- the edges to one Image are one edge, so every one of them carries
* the same mark). */
static const char *dk_refs_defaults(dk_t *d, int64_t img) {
const char *names = NULL;
for (int i = 0; i < d->nrefs; i++) {
if (d->refs[i].img != img) {
continue;
}
if (!d->refs[i].prov.dflt) {
return NULL;
}
names = dk_names_union(d, names, d->refs[i].prov.dflt);
}
return names;
}
/* The Dockerfile's own view: DEPENDS_ON from its Module to each Image FROM /
* COPY --from names {via, image, stage, line[, default_of: the ARGs whose
* defaults named it]}. */
static void dk_refs_link(dk_t *d) {
for (int i = 0; i < d->nrefs; i++) {
const dk_ref_t *r = &d->refs[i];
char esc_ref[CBM_SZ_1K];
cbm_json_escape(esc_ref, (int)sizeof(esc_ref), r->ref);
const char *dflt = dk_names_member(d, "default_of", dk_refs_defaults(d, r->img));
const char *props =
cbm_arena_sprintf(d->a, "{\"via\":\"%s\",\"image\":\"%s\",\"stage\":%d,\"line\":%d%s}",
r->via, esc_ref, r->stage, r->line, dflt);
if (props) {
cbm_gbuf_insert_edge(d->ctx->gbuf, d->module, r->img, "DEPENDS_ON", props);
d->edges++;
}
}
}
/* A build's view: each Image a reference the build's arguments changed names
* -- the first reference to it, its build arguments unioned --
* DEPENDS_ON from the Image the build makes {via, image, override_of[,
* default_of], dockerfile, stage, line, tool, file, build_line}. A reference
* no build argument reached is the Dockerfile's own edge already. */
static void dk_refs_link_build(dk_t *d) {
for (int i = 0; i < d->nrefs; i++) {
const dk_ref_t *r = &d->refs[i];
bool first = r->prov.ovr != NULL && r->img != d->built;
for (int j = 0; first && j < i; j++) {
first = !(d->refs[j].img == r->img && d->refs[j].prov.ovr);
}
if (!first) {
continue;
}
dk_prov_t all = {0};
for (int j = i; j < d->nrefs; j++) {
if (d->refs[j].img == r->img && d->refs[j].prov.ovr) {
dk_prov_merge(d, &all, d->refs[j].prov);
}
}
char esc_ref[CBM_SZ_1K];
char esc_file[CBM_SZ_1K];
cbm_json_escape(esc_ref, (int)sizeof(esc_ref), r->ref);
cbm_json_escape(esc_file, (int)sizeof(esc_file), d->file);
const char *props = cbm_arena_sprintf(
d->a,
"{\"via\":\"%s\",\"image\":\"%s\"%s%s,\"dockerfile\":\"%s\",\"stage\":%d,"
"\"line\":%d,%s}",
r->via, esc_ref, dk_names_member(d, "override_of", all.ovr),
dk_names_member(d, "default_of", all.dflt), esc_file, r->stage, r->line,
d->build_props);
if (props) {
cbm_gbuf_insert_edge(d->ctx->gbuf, d->built, r->img, "DEPENDS_ON", props);
d->edges++;
}
}
}
static int dk_line(TSNode n) {
@@ -467,19 +774,33 @@ static void dk_arg(dk_t *d, TSNode ins) {
if (!name) {
return;
}
if (d->stage < 0) {
dk_set(d, &d->global, name, ts_node_is_null(df) ? NULL : dk_value(d, df));
size_t nl = strlen(name);
dk_var_t **list = d->stage < 0 ? &d->global : &d->local;
/* A build argument sets the ARG it names, in every stage declaring it,
* whatever its default. */
const dk_var_t *set = dk_find(d->build_args, name, nl);
if (set) {
dk_set(d, list, name, set->value, true,
(dk_prov_t){.ovr = dk_names_add(d, NULL, name, nl)});
return;
}
const char *value = NULL;
dk_prov_t prov = {0};
if (!ts_node_is_null(df)) {
/* A default may name ARGs declared before it (ARG BASE=golang:${GO}). */
const char *raw = dk_value(d, df);
value = raw ? dk_expand(d, d->local, raw) : NULL;
} else {
const dk_var_t *g = dk_find(d->global, name, strlen(name));
value = g ? g->value : NULL;
value = raw ? dk_expand(d, *list, raw, &prov) : NULL;
if (value) {
prov.dflt = dk_names_add(d, prov.dflt, name, nl);
}
} else if (d->stage >= 0) {
const dk_var_t *g = dk_find(d->global, name, nl);
if (g) {
value = g->value;
prov = g->prov;
}
}
dk_set(d, &d->local, name, value);
dk_set(d, list, name, value, true, prov);
}
static void dk_env(dk_t *d, TSNode ins) {
@@ -498,7 +819,9 @@ static void dk_env(dk_t *d, TSNode ins) {
}
const char *raw = ts_node_is_null(vl) ? NULL : dk_value(d, vl);
const char *name = dk_text(d, nm);
dk_set(d, &d->local, name, raw ? dk_expand(d, d->local, raw) : NULL);
dk_prov_t prov = {0};
const char *value = raw ? dk_expand(d, d->local, raw, &prov) : NULL;
dk_set(d, &d->local, name, value, false, prov);
dk_envkey_t *k = d->stage < DK_MAX_STAGES ? cbm_arena_alloc(d->a, sizeof(*k)) : NULL;
if (k && name) {
*k = (dk_envkey_t){name, dk_line(pair), d->st[d->stage].envs};
@@ -515,7 +838,9 @@ static void dk_from(dk_t *d, TSNode ins) {
spec = ts_node_named_child(ins, i);
}
}
const char *ref = ts_node_is_null(spec) ? NULL : dk_expand(d, d->global, dk_text(d, spec));
dk_prov_t prov = {0};
const char *ref =
ts_node_is_null(spec) ? NULL : dk_expand(d, d->global, dk_text(d, spec), &prov);
d->stage++;
d->local = NULL;
d->workdir = "/";
@@ -523,7 +848,7 @@ static void dk_from(dk_t *d, TSNode ins) {
d->cmd.set = false;
bool stage = ref && dk_is_stage(d, ref);
if (ref && !stage) {
dk_link(d, ref, "from", dk_line(ins));
dk_link(d, ref, "from", dk_line(ins), prov);
}
if (d->stage < DK_MAX_STAGES) {
d->st[d->stage].parent = stage ? dk_stage_of(d, ref) : -1;
@@ -605,7 +930,7 @@ static int dk_copy_json(dk_t *d, const char *p, const char **out, int max) {
if (yyjson_is_arr(arr)) {
for (yyjson_arr_iter_init(arr, &it); n < max && (v = yyjson_arr_iter_next(&it));) {
const char *s = yyjson_get_str(v);
out[n++] = s ? dk_expand(d, d->local, cbm_arena_strdup(d->a, s)) : NULL;
out[n++] = s ? dk_expand(d, d->local, cbm_arena_strdup(d->a, s), NULL) : NULL;
}
}
if (doc) {
@@ -639,11 +964,12 @@ static void dk_copy(dk_t *d, TSNode ins) {
if (l > sizeof(FROM) - SKIP_ONE && strncmp(p, FROM, sizeof(FROM) - SKIP_ONE) == 0) {
const char *v =
cbm_arena_strndup(d->a, p + sizeof(FROM) - SKIP_ONE, l - (sizeof(FROM) - SKIP_ONE));
const char *ref = v ? dk_expand(d, d->local, v) : NULL;
dk_prov_t prov = {0};
const char *ref = v ? dk_expand(d, d->local, v, &prov) : NULL;
c->has_from = true;
c->from = ref;
if (ref && ref[0] && !dk_is_stage(d, ref)) {
dk_link(d, ref, "copy-from", dk_line(ins));
dk_link(d, ref, "copy-from", dk_line(ins), prov);
}
}
p += l;
@@ -652,7 +978,7 @@ static void dk_copy(dk_t *d, TSNode ins) {
while (!json && *p && nw < DK_MAX_WORDS) {
size_t l = strcspn(p, " \t");
const char *w = cbm_arena_strndup(d->a, p, l);
words[nw++] = w && strncmp(w, "<<", PAIR_LEN) != 0 ? dk_expand(d, d->local, w) : NULL;
words[nw++] = w && strncmp(w, "<<", PAIR_LEN) != 0 ? dk_expand(d, d->local, w, NULL) : NULL;
p += l;
p += strspn(p, " \t");
}
@@ -1023,8 +1349,8 @@ static void dk_go_builds(dk_t *d, const char **w, int n) {
if (!b) {
return;
}
*b = (dk_build_t){.out = out ? dk_expand(d, d->local, out) : NULL,
.pkg = dk_expand(d, d->local, pkg ? pkg : "."),
*b = (dk_build_t){.out = out ? dk_expand(d, d->local, out, NULL) : NULL,
.pkg = dk_expand(d, d->local, pkg ? pkg : ".", NULL),
.workdir = d->workdir,
.next = d->st[d->stage].builds};
d->st[d->stage].builds = b;
@@ -1066,7 +1392,7 @@ static void dk_workdir(dk_t *d, TSNode ins) {
continue;
}
const char *raw = dk_text(d, pn);
const char *v = raw ? dk_expand(d, d->local, raw) : NULL;
const char *v = raw ? dk_expand(d, d->local, raw, NULL) : NULL;
const char *w = v ? v : raw;
if (w) {
d->workdir = dk_absolute(d, w, d->workdir);
@@ -1431,11 +1757,31 @@ static void dk_file(dk_t *d, TSNode root) {
dk_entry(d, ins, &d->cmd);
}
}
dk_refs_link(d);
dk_spawn(d);
dk_envs(d);
dk_image_set(d);
}
/* A build's view of the Dockerfile: only what decides its image references
* (ARG, ENV, FROM, COPY --from) is read, under the build's arguments. */
static void dk_file_build(dk_t *d, TSNode root) {
for (uint32_t i = 0; i < ts_node_named_child_count(root); i++) {
TSNode ins = ts_node_named_child(root, i);
if (dk_type(ins, "arg_instruction")) {
dk_arg(d, ins);
} else if (dk_type(ins, "env_instruction")) {
dk_env(d, ins);
} else if (dk_type(ins, "from_instruction")) {
dk_from(d, ins);
} else if ((dk_type(ins, "copy_instruction") || dk_type(ins, "add_instruction")) &&
d->stage >= 0) {
dk_copy(d, ins);
}
}
dk_refs_link_build(d);
}
static char *dk_read(const char *path, size_t *len) {
FILE *f = cbm_fopen(path, "rb");
if (!f) {
@@ -1545,12 +1891,15 @@ static void dk_build_site(const cbm_build_site_t *s, void *ud) {
char esc_img[CBM_SZ_1K];
cbm_json_escape(esc_file, (int)sizeof(esc_file), b->file);
cbm_json_escape(esc_img, (int)sizeof(esc_img), s->image);
char props[CBM_SZ_4K];
snprintf(props, sizeof(props),
"{\"via\":\"build\",\"tool\":\"%s\",\"file\":\"%s\",\"image\":\"%s\",\"line\":%d}",
s->via, esc_file, esc_img, s->line);
cbm_gbuf_insert_edge(b->ctx->gbuf, img, mod->id, "DEPENDS_ON", props);
b->edges++;
const char *props = cbm_arena_sprintf(
b->a,
"{\"via\":\"build\",\"tool\":\"%s\",\"file\":\"%s\",\"image\":\"%s\",\"line\":%d%s%s}",
s->via, esc_file, esc_img, s->line, s->build_args ? ",\"build_args\":" : "",
s->build_args ? s->build_args : "");
if (props) {
cbm_gbuf_insert_edge(b->ctx->gbuf, img, mod->id, "DEPENDS_ON", props);
b->edges++;
}
}
typedef struct {
@@ -1599,6 +1948,41 @@ static void dk_compose_connects(dk_compose_t *c, const cbm_compose_service_t *s,
* DEFINES; it DEPENDS_ON the services it depends on / links, USAGE
* {ref:image} its image, DEPENDS_ON {via:build} the Dockerfile it builds and
* CONNECTS_TO the services its environment addresses. */
/* The image a service runs: USAGE {ref: image, image[, default_of]} -- the
* variables whose inline default named it. When the project's .env replaced
* a default, the edge carries {override_of, override_source: ".env"}, and
* the image the defaults alone name is kept: on the same Image as
* `default_image`, else its own USAGE {ref: image_default, default_of}, a
* reference the service does not run. */
static void dk_compose_image(dk_compose_t *c, const cbm_compose_service_t *s, int64_t self) {
int64_t img = cbm_pipeline_image_node(c->ctx, s->image);
int64_t dimg = s->default_image ? cbm_pipeline_image_node(c->ctx, s->default_image) : 0;
char esc[CBM_SZ_1K];
char desc[CBM_SZ_1K];
cbm_json_escape(esc, (int)sizeof(esc), s->image);
cbm_json_escape(desc, (int)sizeof(desc), s->default_image ? s->default_image : "");
const char *props =
cbm_arena_sprintf(c->a, "{\"ref\":\"image\",\"image\":\"%s\"%s%s%s%s%s%s%s}", esc,
s->image_default_of ? ",\"default_of\":" : "",
s->image_default_of ? s->image_default_of : "",
s->image_override_of ? ",\"override_of\":" : "",
s->image_override_of ? s->image_override_of : "",
s->image_override_of ? ",\"override_source\":\".env\"" : "",
dimg > 0 && dimg == img ? ",\"default_image\":\"" : "",
dimg > 0 && dimg == img ? cbm_arena_sprintf(c->a, "%s\"", desc) : "");
if (props) {
dk_compose_edge(c, self, img, "USAGE", props);
}
if (dimg > 0 && dimg != img && s->image_override_of) {
const char *dprops = cbm_arena_sprintf(
c->a, "{\"ref\":\"image_default\",\"image\":\"%s\",\"default_of\":%s}", desc,
s->image_override_of);
if (dprops) {
dk_compose_edge(c, self, dimg, "USAGE", dprops);
}
}
}
static void dk_compose_service(const cbm_compose_service_t *s, void *ud) {
dk_compose_t *c = ud;
const char *qn = dk_compose_qn(c, s->name);
@@ -1635,11 +2019,7 @@ static void dk_compose_service(const cbm_compose_service_t *s, void *ud) {
}
dk_compose_connects(c, s, self->id);
if (s->image) {
char esc[CBM_SZ_1K];
cbm_json_escape(esc, (int)sizeof(esc), s->image);
char props[CBM_SZ_2K];
snprintf(props, sizeof(props), "{\"ref\":\"image\",\"image\":\"%s\"}", esc);
dk_compose_edge(c, self->id, cbm_pipeline_image_node(c->ctx, s->image), "USAGE", props);
dk_compose_image(c, s, self->id);
}
if (s->dockerfile) {
const char *mqn = cbm_fqn_module(c->a, c->ctx->project_name, s->dockerfile);
@@ -1706,6 +2086,152 @@ static int dk_link_builds(cbm_pipeline_ctx_t *ctx, CBMArena *a) {
return b.edges;
}
static bool dk_prop(const char *props, const char *key, char *out, size_t out_sz);
static void *dk_grow(CBMArena *a, void *v, int n, int *cap, size_t size);
/* A build that passes build arguments (compose build.args, docker build
* --build-arg; on its build edge as `build_args`). */
typedef struct {
int64_t img;
int64_t module;
const char *dockerfile;
const dk_var_t *args;
const char *props; /* "tool":..,"file":..,"build_line":N */
} dk_argbuild_t;
/* The build arguments of build edge properties `props` (NULL: none). */
static const dk_var_t *dk_build_args_of(CBMArena *a, const char *props) {
if (!props || !strstr(props, "\"build_args\"")) {
return NULL;
}
yyjson_doc *doc = yyjson_read(props, strlen(props), 0);
yyjson_val *args = doc ? yyjson_obj_get(yyjson_doc_get_root(doc), "build_args") : NULL;
dk_var_t *list = NULL;
yyjson_obj_iter it;
yyjson_val *k;
if (yyjson_is_obj(args)) {
yyjson_obj_iter_init(args, &it);
while ((k = yyjson_obj_iter_next(&it))) {
const char *v = yyjson_get_str(yyjson_obj_iter_get_val(k));
dk_var_t *var = v ? cbm_arena_alloc(a, sizeof(*var)) : NULL;
if (var) {
*var = (dk_var_t){.name = cbm_arena_strdup(a, yyjson_get_str(k)),
.value = cbm_arena_strdup(a, v),
.arg = true,
.next = list};
list = var;
}
}
}
if (doc) {
yyjson_doc_free(doc);
}
return list;
}
/* Every build edge (Image -DEPENDS_ON {via: build}-> Dockerfile) that passes
* build arguments, into out (arena); returns how many. */
static int dk_argbuilds(cbm_pipeline_ctx_t *ctx, CBMArena *a, dk_argbuild_t **out) {
const cbm_gbuf_node_t **imgs = NULL;
int ni = 0;
*out = NULL;
if (cbm_gbuf_find_by_label(ctx->gbuf, "Image", &imgs, &ni) != 0) {
return 0;
}
int n = 0;
int cap = 0;
for (int i = 0; i < ni; i++) {
const cbm_gbuf_edge_t **es = NULL;
int ne = 0;
if (cbm_gbuf_find_edges_by_source_type(ctx->gbuf, imgs[i]->id, "DEPENDS_ON", &es, &ne) !=
0) {
continue;
}
for (int j = 0; j < ne; j++) {
const char *p = es[j]->properties_json;
char via[CBM_SZ_32];
const dk_var_t *args = NULL;
if (!dk_prop(p, "via", via, sizeof(via)) || strcmp(via, "build") != 0 ||
!(args = dk_build_args_of(a, p))) {
continue;
}
const cbm_gbuf_node_t *mod = cbm_gbuf_find_by_id(ctx->gbuf, es[j]->target_id);
char tool[CBM_SZ_64];
char file[CBM_SZ_1K];
char esc[CBM_SZ_2K];
if (!mod || !mod->file_path || !dk_prop(p, "tool", tool, sizeof(tool)) ||
!dk_prop(p, "file", file, sizeof(file))) {
continue;
}
const char *line = strstr(p, "\"line\":");
if (n == cap) {
*out = dk_grow(a, *out, n, &cap, sizeof(**out));
if (!*out) {
return 0;
}
}
cbm_json_escape(esc, (int)sizeof(esc), file);
(*out)[n++] = (dk_argbuild_t){
.img = imgs[i]->id,
.module = mod->id,
.dockerfile = cbm_arena_strdup(a, mod->file_path),
.args = args,
.props = cbm_arena_sprintf(a, "\"tool\":\"%s\",\"file\":\"%s\",\"build_line\":%d",
tool, esc, line ? atoi(line + strlen("\"line\":")) : 0)};
}
}
return n;
}
/* Each build passing build arguments reads its Dockerfile in its own view
* (dk_file_build): what those arguments change, it builds FROM. Image-sourced
* DEPENDS_ON edges, recomputed whole every run (CBM_BUILD_EDGE_SOURCE). */
static int dk_link_build_args(cbm_pipeline_ctx_t *ctx, CBMArena *a) {
dk_argbuild_t *bs = NULL;
int n = ctx->repo_path ? dk_argbuilds(ctx, a, &bs) : 0;
if (n == 0) {
return 0;
}
TSParser *parser = ts_parser_new();
if (!parser || !ts_parser_set_language(parser, cbm_ts_language(CBM_LANG_DOCKERFILE))) {
if (parser) {
ts_parser_delete(parser);
}
return 0;
}
int edges = 0;
for (int i = 0; i < n; i++) {
char path[CBM_SZ_4K];
snprintf(path, sizeof(path), "%s/%s", ctx->repo_path, bs[i].dockerfile);
size_t len = 0;
char *src = dk_read(path, &len);
if (!src) {
continue;
}
dk_uncomment(src, len);
TSTree *tree = ts_parser_parse_string(parser, NULL, src, (uint32_t)len);
if (tree) {
dk_t d = {.ctx = ctx,
.a = a,
.src = src,
.file = bs[i].dockerfile,
.module = bs[i].module,
.stage = -1,
.dir = "",
.workdir = "/",
.build_args = bs[i].args,
.built = bs[i].img,
.build_props = bs[i].props};
dk_file_build(&d, ts_tree_root_node(tree));
edges += d.edges;
ts_tree_delete(tree);
}
cbm_free(CBM_MEM_CLASS_OTHER, src);
}
ts_parser_delete(parser);
return edges;
}
static bool dk_node_before(const cbm_gbuf_node_t *a, const cbm_gbuf_node_t *b) {
int c = strcmp(a->file_path, b->file_path);
return c < 0 || (c == 0 && a->start_line < b->start_line);
@@ -1891,17 +2417,19 @@ static void dk_hub_push(CBMArena *a, dk_hub_edges_t *l, const cbm_gbuf_edge_t *e
* USAGE {ref:image}, or DEPENDS_ON {via:from|copy-from}? */
static bool dk_hub_ref(const char *type, const char *p) {
char v[CBM_SZ_32];
if (strcmp(type, "USAGE") == 0) {
if (strcmp(type, "USAGE") == 0 || strcmp(type, "DEPLOYS") == 0) {
return dk_prop(p, "ref", v, sizeof(v)) && strcmp(v, "image") == 0;
}
return dk_prop(p, "via", v, sizeof(v)) &&
(strcmp(v, "from") == 0 || strcmp(v, "copy-from") == 0);
}
/* The edges of Image `img`: build edges out (DEPENDS_ON {via:build}) and
* references in, each with the tag of its `image` reference. */
/* The edges of Image `img`: build edges out (DEPENDS_ON {via:build}), the
* base images a build's arguments chose out (DEPENDS_ON {via: from |
* copy-from, build_line}: they follow their build) and references in, each
* with the tag of its `image` reference. */
static void dk_hub_collect(cbm_pipeline_ctx_t *ctx, CBMArena *a, int64_t img,
dk_hub_edges_t *builds, dk_hub_edges_t *refs) {
dk_hub_edges_t *builds, dk_hub_edges_t *refs, dk_hub_edges_t *follows) {
const cbm_gbuf_edge_t **es = NULL;
int n = 0;
char ref[CBM_SZ_1K];
@@ -1909,8 +2437,13 @@ static void dk_hub_collect(cbm_pipeline_ctx_t *ctx, CBMArena *a, int64_t img,
if (cbm_gbuf_find_edges_by_source_type(ctx->gbuf, img, "DEPENDS_ON", &es, &n) == 0) {
for (int i = 0; i < n; i++) {
char via[CBM_SZ_32];
if (!dk_prop(es[i]->properties_json, "via", via, sizeof(via)) ||
strcmp(via, "build") != 0) {
if (!dk_prop(es[i]->properties_json, "via", via, sizeof(via))) {
continue;
}
if (strcmp(via, "build") != 0) {
if (strstr(es[i]->properties_json, "\"build_line\":")) {
dk_hub_push(a, follows, es[i], "");
}
continue;
}
tag[0] = '\0';
@@ -1921,7 +2454,7 @@ static void dk_hub_collect(cbm_pipeline_ctx_t *ctx, CBMArena *a, int64_t img,
}
atomic_fetch_add_explicit(&g_dk_hub_edges, (uint64_t)n, memory_order_relaxed);
}
static const char *const REF_TYPES[] = {"USAGE", "DEPENDS_ON"};
static const char *const REF_TYPES[] = {"USAGE", "DEPENDS_ON", "DEPLOYS"};
for (size_t t = 0; t < sizeof(REF_TYPES) / sizeof(REF_TYPES[0]); t++) {
if (cbm_gbuf_find_edges_by_target_type(ctx->gbuf, img, REF_TYPES[t], &es, &n) != 0) {
continue;
@@ -2122,6 +2655,27 @@ static int dk_hub_move(cbm_pipeline_ctx_t *ctx, CBMArena *a, const dk_hub_edge_t
return SKIP_ONE;
}
static int dk_prop_int(const char *props, const char *key) {
char pat[CBM_SZ_64];
snprintf(pat, sizeof(pat), "\"%s\":", key);
const char *p = props ? strstr(props, pat) : NULL;
return p ? atoi(p + strlen(pat)) : -SKIP_ONE;
}
/* Does base-image edge `f` come from build edge `b`: one source, and its
* tool / file / build_line those of the build? */
static bool dk_hub_follows(const dk_hub_edge_t *f, const dk_hub_edge_t *b) {
char ft[CBM_SZ_64];
char bt[CBM_SZ_64];
char ff[CBM_SZ_1K];
char bf[CBM_SZ_1K];
return f->src == b->src && dk_prop(f->props, "tool", ft, sizeof(ft)) &&
dk_prop(b->props, "tool", bt, sizeof(bt)) && strcmp(ft, bt) == 0 &&
dk_prop(f->props, "file", ff, sizeof(ff)) && dk_prop(b->props, "file", bf, sizeof(bf)) &&
strcmp(ff, bf) == 0 &&
dk_prop_int(f->props, "build_line") == dk_prop_int(b->props, "line");
}
/* One repository's Images (imgs[0..n)): split it by component when it is
* built from several Dockerfiles, else keep (or bring back) everything on
* the repository Image. Returns the edges moved. */
@@ -2129,10 +2683,11 @@ static int dk_hub_repo(cbm_pipeline_ctx_t *ctx, CBMArena *a, const dk_img_t *img
dk_drop_t *drop) {
dk_hub_edges_t builds = {0};
dk_hub_edges_t refs = {0};
dk_hub_edges_t follows = {0};
bool has_sub = false;
for (int i = 0; i < n; i++) {
has_sub = has_sub || strcmp(imgs[i].name, imgs[i].repo) != 0;
dk_hub_collect(ctx, a, imgs[i].id, &builds, &refs);
dk_hub_collect(ctx, a, imgs[i].id, &builds, &refs, &follows);
}
const char **comps = NULL;
size_t pre = 0;
@@ -2158,6 +2713,11 @@ static int dk_hub_repo(cbm_pipeline_ctx_t *ctx, CBMArena *a, const dk_img_t *img
int k = builds.v[i].comp;
int64_t src = sub && k >= 0 && sub[k] > 0 ? sub[k] : repo_img;
moved += dk_hub_move(ctx, a, &builds.v[i], src, builds.v[i].tgt, drop);
for (int f = 0; f < follows.n; f++) {
if (dk_hub_follows(&follows.v[f], &builds.v[i])) {
moved += dk_hub_move(ctx, a, &follows.v[f], src, follows.v[f].tgt, drop);
}
}
}
for (int i = 0; i < refs.n; i++) {
int k = sub ? dk_hub_match(refs.v[i].tag, comps, nc) : -1;
@@ -2274,6 +2834,7 @@ void cbm_pipeline_pass_docker(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *fi
cbm_ht_free(ix.dirs);
}
edges += dk_link_builds(ctx, &arena);
edges += dk_link_build_args(ctx, &arena);
edges += dk_image_hubs(ctx, &arena);
cbm_arena_destroy(&arena);
if (edges > 0) {
+1420 -50
View File
File diff suppressed because it is too large. Load diff
+14 -2
View File
@@ -3348,7 +3348,15 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
}
_rc_t0 = extract_now_ns();
try_field_type_hint(rc, &res, call->callee_name, source_node->id);
/* Not for Go (F-G9): the hint matches the capitalized receiver
* VARIABLE name as a substring of a candidate QN (`t.Run` -> any
* `...T...Run`), with no type evidence. The sequential resolver has no
* such step, so for Go it produced parallel-only edges (par/seq CALLS
* diverged), and the Go selector guard could not see them as the weak
* pick they are. SCIP precision of Go field_type_hint: 0.016. */
if (lang != CBM_LANG_GO) {
try_field_type_hint(rc, &res, call->callee_name, source_node->id);
}
/* Cross-language veto — the same predicate as pass_calls.c, applied
* AFTER the field-type hint so the hint may still re-pick a compatible
* candidate first, and BEFORE the empty-resolution fallbacks below so a
@@ -3385,7 +3393,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
* #606 direction.
*
* This language set MUST match the one in pass_calls.c exactly — see the
* note there. ArkTS belongs to the JS/TS family (#1842). */
* note there. ArkTS belongs to the JS/TS family (#1842). Go (#1906)
* composes via its own predicate (different drop-list — see
* cbm_go_suppress_weak_method_match), mirrored in pass_calls.c. */
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
lang == CBM_LANG_ARKTS ||
@@ -3403,6 +3413,8 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
call->callee_name, res.strategy)) ||
cbm_suppress_weak_local_binding_call(suppress_weak_local_binding,
call->callee_is_locally_bound, res.strategy) ||
cbm_go_suppress_weak_method_match(lang == CBM_LANG_GO, call->is_method, res.strategy,
res.confidence) ||
/* Import-binding suppression (#2127) — see pass_calls.c; this gate
* MUST stay identical to the one there. */
(lang == CBM_LANG_PYTHON &&
+3 -3
View File
@@ -266,9 +266,9 @@ static int check_go_class_implements(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_nod
}
/* Resolve the struct's methods two ways and use whichever finds them:
* (a) the struct's DEFINES_METHOD edges, matched by method NAME — the real
* pipeline path, where Go receiver methods carry a flat QN (e.g.
* "pkg.Area" rather than "pkg.Circle.Area"), so QN-string
* reconstruction would miss; and
* pipeline path. Go method QNs are receiver-qualified since #1913
* ("pkg.Circle.Area"), so (b) matches them as well, but the edges
* stay the primary join; and
* (b) the reconstructed QN "<ClassQN>.<methodName>" — used by tests and any
* extractor that does emit class-qualified method QNs without
* DEFINES_METHOD edges from the class. */
+154 -18
View File
@@ -376,10 +376,12 @@ typedef struct {
const char *ci; /* static: the CI running the command; NULL: none */
const char *job; /* pass-arena: its job; NULL: none */
bool chart;
bool spawn; /* SPAWNS the script `tgt` / the Process `ext`, not DEPLOYS */
bool make; /* SPAWNS the rule of target `ext` that makefile `path` reads */
bool build; /* the image `ext` is built from the Dockerfile `path` */
bool http; /* HTTP_CALLS the URL `ext` (scheme://host/path) with method `path` */
bool spawn; /* SPAWNS the script `tgt` / the Process `ext`, not DEPLOYS */
bool make; /* SPAWNS the rule of target `ext` that makefile `path` reads */
bool build; /* the image `ext` is built from the Dockerfile `path` */
bool http; /* HTTP_CALLS the URL `ext` (scheme://host/path) with method `path` */
const char *build_args; /* pass-arena, build: --build-arg as a JSON object; NULL: none */
const char *default_of; /* pass-arena, build: JSON array of the ${X:-d} defaults naming `ext` */
} sh_hit_t;
typedef struct {
@@ -413,9 +415,10 @@ typedef struct {
const char *s; /* scalar value; NULL: unknown */
const char **items; /* array items (an item NULL: unknown); NULL: no array */
int n;
const char *dflt_of; /* JSON array: the variables whose ${X:-d} default made it */
} sh_val_t;
static const sh_val_t SH_UNKNOWN_VAL = {NULL, NULL, 0};
static const sh_val_t SH_UNKNOWN_VAL = {NULL, NULL, 0, NULL};
typedef enum { SH_FREE, SH_AT, SH_LOST } sh_cwd_mode_t;
@@ -464,8 +467,38 @@ typedef struct {
sh_cwd_t *dirs; /* pushd stack */
int ndirs;
int cap_dirs;
/* While set, an evaluation adds to dflt_acc (a JSON array) the variables
* whose ${X:-d} default supplied its value. */
bool dflt_track;
const char *dflt_acc;
} sh_t;
/* JSON array `list` (NULL: empty) with identifier `name` appended once. */
static const char *sh_names_add(CBMArena *a, const char *list, const char *name, size_t nl) {
if (!list) {
return cbm_arena_sprintf(a, "[\"%.*s\"]", (int)nl, name);
}
char quoted[CBM_SZ_256];
snprintf(quoted, sizeof(quoted), "\"%.*s\"", (int)nl, name);
if (strstr(list, quoted)) {
return list;
}
return cbm_arena_sprintf(a, "%.*s,%s]", (int)(strlen(list) - SKIP_ONE), list, quoted);
}
/* JSON array `a` with the names of JSON array `b` appended once. */
static const char *sh_names_union(CBMArena *ar, const char *a, const char *b) {
for (const char *p = b ? strchr(b, '"') : NULL; p;) {
const char *e = strchr(p + SKIP_ONE, '"');
if (!e) {
break;
}
a = sh_names_add(ar, a, p + SKIP_ONE, (size_t)(e - p - SKIP_ONE));
p = strchr(e + SKIP_ONE, '"');
}
return a;
}
static void sh_set(sh_t *s, const char *name, const sh_val_t *v, bool local) {
if (!sh_grow((void **)&s->undo, &s->cap_undo, s->nundo, sizeof(*s->undo))) {
return;
@@ -622,14 +655,14 @@ static const sh_val_t *sh_lookup(sh_t *s, const char *name, bool *found) {
}
sh_val_t *v = cbm_arena_alloc(s->a, sizeof(*v));
if (v) {
*v = (sh_val_t){s->script, NULL, 0};
*v = (sh_val_t){s->script, NULL, 0, NULL};
}
return v ? v : &SH_UNKNOWN_VAL;
}
if (strcmp(name, "PWD") == 0) {
sh_val_t *v = cbm_arena_alloc(s->a, sizeof(*v));
if (v) {
*v = (sh_val_t){sh_cwd_value(s), NULL, 0};
*v = (sh_val_t){sh_cwd_value(s), NULL, 0, NULL};
}
return v ? v : &SH_UNKNOWN_VAL;
}
@@ -745,20 +778,29 @@ static const char *sh_eval_expansion(sh_t *s, TSNode n) {
val = v->n > 0 ? v->items[0] : "";
}
if (!op) {
if (found && s->dflt_track) {
s->dflt_acc = sh_names_union(s->a, s->dflt_acc, v->dflt_of);
}
return found ? val : NULL;
}
bool colon = op[0] == ':';
bool use_default = !found || (colon && val && !val[0]);
if (!use_default) {
if (s->dflt_track) {
s->dflt_acc = sh_names_union(s->a, s->dflt_acc, v->dflt_of);
}
return val;
}
sh_str_t b = {0};
sh_concat(s, &b, n, op_end, close, true);
const char *d = sh_str_done(&b);
if (s->dflt_track) {
s->dflt_acc = sh_names_add(s->a, s->dflt_acc, nm, strlen(nm));
}
if (d && strchr(op, '=')) {
sh_val_t *nv = cbm_arena_alloc(s->a, sizeof(*nv));
if (nv) {
*nv = (sh_val_t){d, NULL, 0};
*nv = (sh_val_t){d, NULL, 0, sh_names_add(s->a, NULL, nm, strlen(nm))};
sh_set(s, nm, nv, false);
}
}
@@ -943,6 +985,9 @@ static const char *sh_eval(sh_t *s, TSNode n) {
}
bool found = false;
const sh_val_t *val = sh_lookup(s, nm, &found);
if (found && s->dflt_track) {
s->dflt_acc = sh_names_union(s->a, s->dflt_acc, val->dflt_of);
}
return found ? val->s : NULL;
}
if (strcmp(t, "expansion") == 0) {
@@ -1319,6 +1364,8 @@ static void sh_hit_push(sh_t *s, TSNode cmd, sh_hit_t x) {
x.line = (int)ts_node_start_point(cmd).row + SKIP_ONE + s->line_base;
x.ext = x.ext ? cbm_arena_strdup(&h->arena, x.ext) : NULL;
x.path = x.path ? cbm_arena_strdup(&h->arena, x.path) : NULL;
x.build_args = x.build_args ? cbm_arena_strdup(&h->arena, x.build_args) : NULL;
x.default_of = x.default_of ? cbm_arena_strdup(&h->arena, x.default_of) : NULL;
x.ci = s->ci;
x.job = s->job ? cbm_arena_strdup(&h->arena, s->job) : NULL;
h->items[h->count++] = x;
@@ -2268,7 +2315,11 @@ static void sh_assign(sh_t *s, TSNode n, bool local) {
if (!nm || !v) {
return;
}
*v = (sh_val_t){"", NULL, 0};
*v = (sh_val_t){"", NULL, 0, NULL};
bool track = s->dflt_track;
const char *acc = s->dflt_acc;
s->dflt_track = true;
s->dflt_acc = NULL;
if (sh_type(value, "array")) {
uint32_t cnt = ts_node_named_child_count(value);
const char **items = cbm_arena_alloc(s->a, (cnt ? cnt : SKIP_ONE) * sizeof(*items));
@@ -2282,10 +2333,13 @@ static void sh_assign(sh_t *s, TSNode n, bool local) {
items[k++] = sh_eval_word(s, c);
}
}
*v = (sh_val_t){k > 0 ? items[0] : "", items, k};
*v = (sh_val_t){k > 0 ? items[0] : "", items, k, NULL};
} else if (!ts_node_is_null(value)) {
v->s = sh_eval(s, value);
}
v->dflt_of = s->dflt_acc;
s->dflt_track = track;
s->dflt_acc = acc;
bool append = false;
for (uint32_t i = 0; i < ts_node_child_count(n); i++) {
TSNode c = ts_node_child(n, i);
@@ -2434,6 +2488,64 @@ static const char *sh_image_repo(sh_t *s, TSNode cmd, const sh_argv_t *a, int i)
return sh_str_done(&out);
}
/* JSON object `json` (NULL: none) with build argument `kv` ("K=V") added; a
* `K` alone (the environment's value), an unknown word or a secret (by name
* or value -- never kept in the graph, never an image) adds nothing. */
static const char *sh_build_arg(sh_t *s, const char *json, const char *kv) {
const char *eq = kv ? strchr(kv, '=') : NULL;
if (!eq || eq == kv) {
return json;
}
for (const char *p = kv; p < eq; p++) {
if (!isalnum((unsigned char)*p) && *p != '_') {
return json;
}
}
const char *key = cbm_arena_strndup(s->a, kv, (size_t)(eq - kv));
if (!key || cbm_is_secret_binding(key, eq + SKIP_ONE)) {
return json;
}
/* JSON escaping writes at most 6 bytes a byte: nothing is cut */
int vn = (int)(strlen(eq + SKIP_ONE) * 6 + SKIP_ONE);
char *ev = cbm_arena_alloc(s->a, (size_t)vn);
if (!ev) {
return json;
}
cbm_json_escape(ev, vn, eq + SKIP_ONE);
const char *pair = cbm_arena_sprintf(s->a, "\"%.*s\":\"%s\"", (int)(eq - kv), kv, ev);
if (!pair) {
return json;
}
return json ? cbm_arena_sprintf(s->a, "%.*s,%s}", (int)(strlen(json) - SKIP_ONE), json, pair)
: cbm_arena_sprintf(s->a, "{%s}", pair);
}
/* The variables whose ${X:-d} default supplied argv word `i` (a JSON array,
* NULL: none): the word evaluated once more with the defaults recorded. */
static const char *sh_word_defaults(sh_t *s, TSNode cmd, const sh_argv_t *a, int i) {
uint32_t b = a->span[(size_t)i * PAIR_LEN];
uint32_t e = a->span[(size_t)i * PAIR_LEN + SKIP_ONE];
if (!memchr(s->src + b, '$', e - b)) {
return NULL;
}
TSNode w = ts_node_descendant_for_byte_range(cmd, b, e);
while (!ts_node_is_null(w) && (ts_node_start_byte(w) != b || ts_node_end_byte(w) != e)) {
w = ts_node_parent(w);
}
if (ts_node_is_null(w)) {
return NULL;
}
bool track = s->dflt_track;
const char *acc = s->dflt_acc;
s->dflt_track = true;
s->dflt_acc = NULL;
(void)sh_eval_word(s, w);
const char *out = s->dflt_acc;
s->dflt_track = track;
s->dflt_acc = acc;
return out;
}
/* docker / podman / nerdctl [buildx | image | builder] build, buildah bud:
* each tagged image is built from the Dockerfile -f names (against the cwd),
* else <context>/Dockerfile -- one file of the repository. */
@@ -2467,17 +2579,20 @@ static void sh_build(sh_t *s, TSNode cmd, const sh_argv_t *a) {
cbm_ht_set(s->hits->declarers, s->file, (void *)s->file);
}
int tags[SH_BUILD_TAGS];
int tag_words[SH_BUILD_TAGS];
const char *tag_vals[SH_BUILD_TAGS];
int nt = 0;
const char *dfile = NULL;
bool dfile_known = true;
const char *ctx = NULL;
bool ctx_seen = false;
const char *args = NULL;
for (; i < a->n; i++) {
const char *w = a->v[i];
if (w && (sh_eq(w, "-t") || sh_eq(w, "--tag")) && i + SKIP_ONE < a->n) {
if (nt < SH_BUILD_TAGS) {
tags[nt] = i + SKIP_ONE;
tag_words[nt] = i + SKIP_ONE;
tag_vals[nt++] = a->v[i + SKIP_ONE];
}
i++;
@@ -2485,9 +2600,15 @@ static void sh_build(sh_t *s, TSNode cmd, const sh_argv_t *a) {
dfile = a->v[i + SKIP_ONE];
dfile_known = dfile != NULL;
i++;
} else if (w && sh_eq(w, "--build-arg") && i + SKIP_ONE < a->n) {
args = sh_build_arg(s, args, a->v[i + SKIP_ONE]);
i++;
} else if (w && strncmp(w, "--build-arg=", strlen("--build-arg=")) == 0) {
args = sh_build_arg(s, args, w + strlen("--build-arg="));
} else if (w && strncmp(w, "--tag=", strlen("--tag=")) == 0) {
if (nt < SH_BUILD_TAGS && w[strlen("--tag=")]) {
tags[nt] = -SKIP_ONE;
tag_words[nt] = i;
tag_vals[nt++] = w + strlen("--tag=");
}
} else if (w && strncmp(w, "--file=", strlen("--file=")) == 0) {
@@ -2513,7 +2634,12 @@ static void sh_build(sh_t *s, TSNode cmd, const sh_argv_t *a) {
}
if (img && img[0]) {
sh_hit_push(s, cmd,
(sh_hit_t){.via = "docker-build", .ext = img, .path = hit, .build = true});
(sh_hit_t){.via = "docker-build",
.ext = img,
.path = hit,
.build = true,
.build_args = args,
.default_of = sh_word_defaults(s, cmd, a, tag_words[k])});
}
}
}
@@ -3305,8 +3431,8 @@ static void sh_makefile(sh_pass_t *p, const char *rel, const char *src, size_t l
sh_val_t *root = cbm_arena_alloc(&a, sizeof(*root));
sh_val_t *cur = cbm_arena_alloc(&a, sizeof(*cur));
if (root && cur) {
*root = (sh_val_t){SH_ROOT, NULL, 0};
*cur = (sh_val_t){sh_anchor(&a, s.sdir), NULL, 0};
*root = (sh_val_t){SH_ROOT, NULL, 0, NULL};
*cur = (sh_val_t){sh_anchor(&a, s.sdir), NULL, 0, NULL};
cbm_ht_set(s.env, "__cbm_root", root);
if (m.makefile) {
cbm_ht_set(s.env, "__cbm_curdir", cur);
@@ -3647,15 +3773,25 @@ static int sh_link_build(cbm_pipeline_ctx_t *ctx, const sh_hit_t *x, CBMHashTabl
char esc_file[CBM_SZ_1K];
char esc_img[CBM_SZ_1K];
char ci[CBM_SZ_1K];
char props[CBM_SZ_4K];
cbm_json_escape(esc_file, (int)sizeof(esc_file), x->file);
cbm_json_escape(esc_img, (int)sizeof(esc_img), x->ext);
sh_ci_props(x, ci, sizeof(ci));
snprintf(props, sizeof(props),
/* sized to what it holds: default_of and build_args are never cut */
size_t pn = strlen(esc_file) + strlen(esc_img) + strlen(ci) +
(x->default_of ? strlen(x->default_of) : 0) +
(x->build_args ? strlen(x->build_args) : 0) + CBM_SZ_256;
char *props = cbm_alloc(CBM_MEM_CLASS_OTHER, pn);
if (!props) {
return 0;
}
snprintf(props, pn,
"{\"via\":\"build\",\"tool\":\"docker-build\",\"file\":\"%s\",\"image\":\"%s\"%s,"
"\"line\":%d}",
esc_file, esc_img, ci, x->line);
"\"line\":%d%s%s%s%s}",
esc_file, esc_img, ci, x->line, x->default_of ? ",\"default_of\":" : "",
x->default_of ? x->default_of : "", x->build_args ? ",\"build_args\":" : "",
x->build_args ? x->build_args : "");
cbm_gbuf_insert_edge(ctx->gbuf, img, mod, "DEPENDS_ON", props);
cbm_free(CBM_MEM_CLASS_OTHER, props);
return SKIP_ONE;
}
@@ -3852,7 +3988,7 @@ typedef struct {
static void sh_ci_var(sh_t *s, const char *name, const char *value) {
sh_val_t *v = value ? cbm_arena_alloc(s->a, sizeof(*v)) : NULL;
if (v) {
*v = (sh_val_t){value, NULL, 0};
*v = (sh_val_t){value, NULL, 0, NULL};
}
sh_set(s, name, v ? v : &SH_UNKNOWN_VAL, false);
}
+520
View File
@@ -22,13 +22,16 @@
#include "foundation/log.h"
#include "foundation/mem_core.h"
#include "foundation/str_util.h"
#include "foundation/json_writer.h" /* cbm_json_writer_allocator */
#include "graph_buffer/graph_buffer.h"
#include "pipeline/helm_template.h"
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include "cbm.h"
#include "helpers.h"
#include "lang_specs.h"
#include <ctype.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
@@ -249,6 +252,522 @@ static bool tf_plain_name(const char *s) {
}
/* One `resource "helm_release" "<name>" { ... }` block. */
/* ── helm_release values (for pass_k8s.c's release images) ────────── */
/* The expression inside wrapper nodes. */
static TSNode tf_unwrap(TSNode n) {
while (tf_type(n, "expression") || tf_type(n, "literal_value") ||
tf_type(n, "collection_value") || tf_type(n, "template_expr")) {
if (ts_node_named_child_count(n) != SKIP_ONE) {
break;
}
n = ts_node_named_child(n, 0);
}
return n;
}
/* A heredoc's text (a `<<-` one dedented); NULL when it interpolates. */
static char *tf_heredoc(const tf_t *t, TSNode n) {
TSNode open = {0};
TSNode close = {0};
bool dedent = false;
for (uint32_t i = 0; i < ts_node_named_child_count(n); i++) {
TSNode c = ts_node_named_child(n, i);
if (tf_type(c, "heredoc_start")) {
uint32_t b = ts_node_start_byte(c);
dedent = ts_node_end_byte(c) - b >= 3 && t->src[b + PAIR_LEN] == '-';
} else if (tf_type(c, "heredoc_identifier")) {
if (ts_node_is_null(open)) {
open = c;
} else {
close = c;
}
} else if (!tf_type(c, "template_literal")) {
return NULL; /* an interpolation or a directive: no literal */
}
}
if (ts_node_is_null(open) || ts_node_is_null(close)) {
return NULL;
}
const char *b = t->src + ts_node_end_byte(open);
const char *e = t->src + ts_node_start_byte(close);
const char *nl = memchr(b, '\n', (size_t)(e - b));
b = nl ? nl + SKIP_ONE : e;
while (e > b && e[-1] != '\n') {
e--; /* the closing line's indentation */
}
size_t indent = (size_t)-1;
for (const char *p = b; dedent && p < e;) {
const char *le = memchr(p, '\n', (size_t)(e - p));
le = le ? le : e;
size_t sp = 0;
while (p + sp < le && (p[sp] == ' ' || p[sp] == '\t')) {
sp++;
}
if (p + sp < le && sp < indent) {
indent = sp;
}
p = le + (le < e ? SKIP_ONE : 0);
}
if (!dedent || indent == (size_t)-1) {
indent = 0;
}
char *out = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)(e - b) + SKIP_ONE);
size_t k = 0;
for (const char *p = b; out && p < e;) {
const char *le = memchr(p, '\n', (size_t)(e - p));
le = le ? le + SKIP_ONE : e;
size_t skip = 0;
while (skip < indent && p + skip < le && (p[skip] == ' ' || p[skip] == '\t')) {
skip++;
}
memcpy(out + k, p + skip, (size_t)(le - p) - skip);
k += (size_t)(le - p) - skip;
p = le;
}
if (out) {
out[k] = '\0';
}
return out;
}
/* HCL number literal s (digits, a fraction, an exponent) as Terraform turns a
* number into a string: exact decimal, no exponent, no trailing zeros (cty's
* big.Float Text('f', -1); 1.10 -> "1.1", 2e3 -> "2000"). False when it is
* no such literal or does not fit out. */
static bool tf_number_text(const char *s, size_t n, char *out, size_t sz) {
enum { TF_MAX_EXP = 4096 };
char dig[CBM_SZ_256];
size_t nd = 0;
long exp = 0;
bool frac = false;
size_t i = 0;
for (; i < n && (isdigit((unsigned char)s[i]) || s[i] == '.'); i++) {
if (s[i] == '.') {
if (frac) {
return false;
}
frac = true;
continue;
}
if (nd + SKIP_ONE >= sizeof(dig)) {
return false;
}
dig[nd++] = s[i];
exp -= frac ? SKIP_ONE : 0;
}
if (i < n) {
if (s[i] != 'e' && s[i] != 'E') {
return false;
}
i++;
bool neg = i < n && s[i] == '-';
i += i < n && (s[i] == '-' || s[i] == '+');
long e = 0;
if (i == n) {
return false;
}
for (; i < n; i++) {
if (!isdigit((unsigned char)s[i]) || e > TF_MAX_EXP) {
return false;
}
e = e * 10 + (s[i] - '0');
}
exp += neg ? -e : e;
}
size_t b = 0;
while (b < nd && dig[b] == '0') {
b++;
}
if (b == nd) {
return snprintf(out, sz, "0") == SKIP_ONE; /* every digit 0 (or none) */
}
size_t e = nd;
while (dig[e - SKIP_ONE] == '0') { /* stops at dig[b], which is no 0 */
e--;
exp++;
}
size_t m = e - b; /* significant digits: dig[b, e) x 10^exp */
size_t o = 0;
long point = (long)m + exp; /* digits before the decimal point */
size_t need = exp >= 0 ? m + (size_t)exp
: point > 0 ? m + SKIP_ONE
: m + PAIR_LEN + (size_t)-point;
if (need + SKIP_ONE > sz) {
return false;
}
if (point <= 0) {
out[o++] = '0';
out[o++] = '.';
for (long z = 0; z < -point; z++) {
out[o++] = '0';
}
}
for (size_t k = 0; k < m; k++) {
if (point > 0 && (long)k == point) {
out[o++] = '.';
}
out[o++] = dig[b + k];
}
for (long z = 0; z < exp; z++) {
out[o++] = '0';
}
out[o] = '\0';
return true;
}
/* An HCL literal as JSON: a string without interpolation, a number, a bool,
* null, a tuple or object of those; NULL: anything else. `num_text`: a number
* as the string Terraform makes of it (a helm_release set value is a string),
* else a float64. */
static yyjson_mut_val *tf_json_v(const tf_t *t, yyjson_mut_doc *doc, TSNode n, bool num_text) {
n = tf_unwrap(n);
if (tf_type(n, "string_lit") || tf_type(n, "quoted_template")) {
char buf[CBM_SZ_4K];
size_t len = 0;
buf[0] = '\0';
for (uint32_t i = 0; i < ts_node_named_child_count(n); i++) {
TSNode c = ts_node_named_child(n, i);
if (tf_type(c, "quoted_template_start") || tf_type(c, "quoted_template_end")) {
continue;
}
if (!tf_type(c, "template_literal")) {
return NULL;
}
uint32_t b = ts_node_start_byte(c);
if (!tf_add(buf, sizeof(buf), &len, t->src + b, ts_node_end_byte(c) - b)) {
return NULL;
}
}
size_t k = 0; /* \" \\ \n \t */
for (size_t i = 0; i < len; i++) {
if (buf[i] == '\\' && i + SKIP_ONE < len) {
char c = buf[++i];
buf[k++] = c == 'n' ? '\n' : c == 't' ? '\t' : c;
} else {
buf[k++] = buf[i];
}
}
return yyjson_mut_strncpy(doc, buf, k);
}
if (tf_type(n, "heredoc_template")) {
char *s = tf_heredoc(t, n);
yyjson_mut_val *v = s ? yyjson_mut_strcpy(doc, s) : NULL;
cbm_free(CBM_MEM_CLASS_OTHER, s);
return v;
}
uint32_t b = ts_node_start_byte(n);
uint32_t e = ts_node_end_byte(n);
if (tf_type(n, "numeric_lit")) {
char buf[CBM_SZ_256];
if (num_text) {
return tf_number_text(t->src + b, e - b, buf, sizeof(buf)) ? yyjson_mut_strcpy(doc, buf)
: NULL;
}
snprintf(buf, sizeof(buf), "%.*s", (int)(e - b), t->src + b);
return yyjson_mut_real(doc, strtod(buf, NULL));
}
if (tf_type(n, "bool_lit")) {
return yyjson_mut_bool(doc, e - b == strlen("true") && strncmp(t->src + b, "true", 4) == 0);
}
if (tf_type(n, "null_lit")) {
return yyjson_mut_null(doc);
}
if (tf_type(n, "tuple")) {
yyjson_mut_val *arr = yyjson_mut_arr(doc);
for (uint32_t i = 0; arr && i < ts_node_named_child_count(n); i++) {
TSNode c = ts_node_named_child(n, i);
if (tf_type(c, "tuple_start") || tf_type(c, "tuple_end")) {
continue;
}
yyjson_mut_val *v = tf_json_v(t, doc, c, num_text);
if (!v) {
return NULL;
}
yyjson_mut_arr_append(arr, v);
}
return arr;
}
if (tf_type(n, "object")) {
yyjson_mut_val *obj = yyjson_mut_obj(doc);
for (uint32_t i = 0; obj && i < ts_node_named_child_count(n); i++) {
TSNode c = ts_node_named_child(n, i);
if (!tf_type(c, "object_elem")) {
continue;
}
TSNode kn = tf_unwrap(ts_node_child_by_field_name(c, "key", (uint32_t)strlen("key")));
TSNode vn = ts_node_child_by_field_name(c, "val", (uint32_t)strlen("val"));
yyjson_mut_val *key = NULL;
if (tf_type(kn, "variable_expr") && ts_node_named_child_count(kn) == SKIP_ONE) {
TSNode id = ts_node_named_child(kn, 0);
uint32_t kb = ts_node_start_byte(id);
key = yyjson_mut_strncpy(doc, t->src + kb, ts_node_end_byte(id) - kb);
} else {
key = tf_json_v(t, doc, kn, false);
}
yyjson_mut_val *val = tf_json_v(t, doc, vn, num_text);
if (!key || !yyjson_mut_is_str(key) || !val) {
return NULL;
}
(void)yyjson_mut_obj_put(obj, key, val);
}
return obj;
}
return NULL;
}
static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) {
return tf_json_v(t, doc, n, false);
}
static char *tf_read(const char *path, size_t *len);
/* A repository file's text (cbm_alloc; capped and padded like every file
* read; an empty file is empty text), NULL when unreadable -- or when it is
* no file discovery took in: a symlink, or a path through one, is never
* followed out of the repository. */
static char *tf_read_repo(const tf_t *t, const char *path, size_t *len) {
if (!cbm_pipeline_file_node(t->ctx->gbuf, t->ctx->project_name, path)) {
return NULL;
}
char full[CBM_SZ_4K];
int n = snprintf(full, sizeof(full), "%s/%s", t->ctx->repo_path, path);
if (n <= 0 || (size_t)n >= sizeof(full)) {
return NULL;
}
char *buf = tf_read(full, len);
cbm_path_info_t info;
if (!buf && cbm_path_info_utf8(full, &info) == CBM_PATH_INFO_OK && info.is_regular &&
!info.is_symlink && info.size == 0) {
enum { TF_EMPTY_PAD = 16 };
buf = cbm_calloc(CBM_MEM_CLASS_OTHER, TF_EMPTY_PAD);
*len = 0;
}
return buf;
}
/* A `values = [...]` item as values: a YAML string / heredoc, file(<path>)
* of a repository file, yamlencode / jsonencode of a literal; NULL: none of
* those. */
static yyjson_mut_val *tf_values_item(const tf_t *t, yyjson_mut_doc *doc, TSNode item, CBMArena *a,
const char ***files, int *nfiles) {
TSNode n = tf_unwrap(item);
if (tf_type(n, "function_call")) {
TSNode fn = ts_node_named_child(n, 0);
TSNode args = {0};
for (uint32_t i = 0; i < ts_node_named_child_count(n); i++) {
if (tf_type(ts_node_named_child(n, i), "function_arguments")) {
args = ts_node_named_child(n, i);
}
}
if (ts_node_is_null(fn) || ts_node_is_null(args) ||
ts_node_named_child_count(args) != SKIP_ONE) {
return NULL;
}
uint32_t fb = ts_node_start_byte(fn);
size_t fl = ts_node_end_byte(fn) - fb;
TSNode arg = ts_node_named_child(args, 0);
if ((fl == strlen("yamlencode") && strncmp(t->src + fb, "yamlencode", fl) == 0) ||
(fl == strlen("jsonencode") && strncmp(t->src + fb, "jsonencode", fl) == 0)) {
return tf_json(t, doc, arg);
}
if (fl == strlen("file") && strncmp(t->src + fb, "file", fl) == 0) {
char v[CBM_SZ_1K];
char path[CBM_SZ_1K];
if (!t->ctx->repo_path || !tf_string(t->src, t->mdir, arg, v, sizeof(v)) ||
!tf_repo_path(t->mdir, v, path, sizeof(path))) {
return NULL;
}
const char **grown = cbm_arena_alloc(a, (size_t)(*nfiles + SKIP_ONE) * sizeof(*grown));
if (grown) {
if (*nfiles) {
memcpy(grown, *files, (size_t)*nfiles * sizeof(*grown));
}
grown[(*nfiles)++] = cbm_arena_strdup(a, path);
*files = grown;
}
size_t len = 0;
char *buf = tf_read_repo(t, path, &len);
yyjson_mut_val *out = buf ? cbm_helm_values_parse(doc, buf, len) : NULL;
cbm_free(CBM_MEM_CLASS_OTHER, buf);
return out;
}
return NULL;
}
yyjson_mut_val *s = tf_json(t, doc, n);
return yyjson_mut_is_str(s)
? cbm_helm_values_parse(doc, yyjson_mut_get_str(s), yyjson_mut_get_len(s))
: NULL;
}
/* A `set` entry (name / value / type; read with numbers as text) applied to
* root; false when it is no literal. */
static bool tf_set_entry(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_val *entry) {
yyjson_mut_val *nm = yyjson_mut_obj_get(entry, "name");
yyjson_mut_val *val = yyjson_mut_obj_get(entry, "value");
yyjson_mut_val *ty = yyjson_mut_obj_get(entry, "type");
if (!yyjson_mut_is_str(nm) || !val) {
return false;
}
const char *v = NULL;
if (yyjson_mut_is_str(val)) {
v = yyjson_mut_get_str(val);
} else if (yyjson_mut_is_bool(val)) {
v = yyjson_mut_get_bool(val) ? "true" : "false";
}
bool as_string = yyjson_mut_is_str(ty) && strcmp(yyjson_mut_get_str(ty), "string") == 0;
return v && cbm_helm_values_set(doc, root, yyjson_mut_get_str(nm), v, as_string);
}
/* A `set` block's attributes as an object; NULL when one is no literal. */
static yyjson_mut_val *tf_set_block(const tf_t *t, yyjson_mut_doc *doc, TSNode block) {
yyjson_mut_val *entry = yyjson_mut_obj(doc);
for (uint32_t j = 0; entry && j < ts_node_named_child_count(block); j++) {
TSNode sb = ts_node_named_child(block, j);
if (!tf_type(sb, "body")) {
continue;
}
for (uint32_t k = 0; k < ts_node_named_child_count(sb); k++) {
TSNode at = ts_node_named_child(sb, k);
if (!tf_type(at, "attribute") || ts_node_named_child_count(at) < PAIR_LEN) {
continue;
}
TSNode key = ts_node_named_child(at, 0);
uint32_t b = ts_node_start_byte(key);
yyjson_mut_val *v = tf_json_v(t, doc, ts_node_named_child(at, SKIP_ONE), true);
if (!v) {
return NULL;
}
yyjson_mut_obj_add(entry,
yyjson_mut_strncpy(doc, t->src + b, ts_node_end_byte(key) - b), v);
}
}
return entry;
}
/* Is block `a` a `dynamic "set"` / `"set_list"` / `"set_sensitive"` /
* `"postrender"` -- made by a for_each, never literal? */
static bool tf_dynamic_set(const tf_t *t, TSNode a) {
TSNode kw = ts_node_named_child(a, 0);
TSNode label = ts_node_named_child(a, SKIP_ONE);
if (!tf_type(a, "block") || ts_node_is_null(kw) || ts_node_is_null(label)) {
return false;
}
uint32_t kb = ts_node_start_byte(kw);
if (ts_node_end_byte(kw) - kb != strlen("dynamic") ||
strncmp(t->src + kb, "dynamic", strlen("dynamic")) != 0) {
return false;
}
uint32_t lb = ts_node_start_byte(label);
size_t ll = ts_node_end_byte(label) - lb;
return (ll == strlen("\"set\"") && strncmp(t->src + lb, "\"set\"", ll) == 0) ||
(ll == strlen("\"set_list\"") && strncmp(t->src + lb, "\"set_list\"", ll) == 0) ||
(ll == strlen("\"set_sensitive\"") &&
strncmp(t->src + lb, "\"set_sensitive\"", ll) == 0) ||
(ll == strlen("\"postrender\"") && strncmp(t->src + lb, "\"postrender\"", ll) == 0);
}
/* Is node n an identifier spelling `word`? */
static bool tf_word(const tf_t *t, TSNode n, const char *word) {
if (ts_node_is_null(n)) {
return false;
}
uint32_t b = ts_node_start_byte(n);
size_t l = ts_node_end_byte(n) - b;
return l == strlen(word) && strncmp(t->src + b, word, l) == 0;
}
/* The values a helm_release body gives its chart -- `values` items
* coalesced in order, then each `set` -- as JSON (cbm_alloc); NULL when a
* part is no literal, is made by a `dynamic` block, is sensitive (its value
* is never read), or a postrender -- a block, an attribute or a dynamic one
* -- may rewrite what it deploys. */
static char *tf_release_values(const tf_t *t, TSNode body, const char ***files, int *nfiles) {
yyjson_mut_doc *doc = yyjson_mut_doc_new(cbm_json_writer_allocator());
yyjson_mut_val *root = doc ? yyjson_mut_obj(doc) : NULL;
bool ok = root != NULL;
for (int pass = 0; ok && pass < PAIR_LEN; pass++) {
for (uint32_t i = 0; ok && i < ts_node_named_child_count(body); i++) {
TSNode a = ts_node_named_child(body, i);
TSNode key = ts_node_named_child(a, 0);
uint32_t kb = ts_node_is_null(key) ? 0 : ts_node_start_byte(key);
size_t kl = ts_node_is_null(key) ? 0 : ts_node_end_byte(key) - kb;
bool is_set = kl == strlen("set") && strncmp(t->src + kb, "set", kl) == 0;
bool is_values = kl == strlen("values") && strncmp(t->src + kb, "values", kl) == 0;
/* set_list, set_sensitive and dynamic set values are not read:
* the release's values would be partial; a postrender may
* rewrite its images */
if (tf_word(t, key, "set_list") || tf_word(t, key, "set_sensitive") ||
tf_word(t, key, "postrender") || tf_dynamic_set(t, a)) {
ok = false;
break;
}
if (pass == 0 && is_values && tf_type(a, "attribute")) {
TSNode list = tf_unwrap(ts_node_named_child(a, SKIP_ONE));
ok = tf_type(list, "tuple");
for (uint32_t j = 0; ok && j < ts_node_named_child_count(list); j++) {
TSNode it = ts_node_named_child(list, j);
if (tf_type(it, "tuple_start") || tf_type(it, "tuple_end")) {
continue;
}
yyjson_mut_val *v = tf_values_item(t, doc, it, t->arena, files, nfiles);
ok = v && yyjson_mut_is_obj(v);
root = ok ? cbm_helm_values_coalesce(doc, root, v) : root;
}
} else if (pass == SKIP_ONE && is_set && tf_type(a, "block")) {
yyjson_mut_val *entry = tf_set_block(t, doc, a);
ok = entry && tf_set_entry(doc, root, entry);
} else if (pass == SKIP_ONE && is_set && tf_type(a, "attribute")) {
/* provider v3: set = [{ name, value, type }, ...] */
yyjson_mut_val *list = tf_json_v(t, doc, ts_node_named_child(a, SKIP_ONE), true);
ok = yyjson_mut_is_arr(list);
size_t idx, max;
yyjson_mut_val *entry;
yyjson_mut_arr_foreach(list, idx, max, entry) {
ok = ok && tf_set_entry(doc, root, entry);
}
}
}
}
char *out = NULL;
if (ok) {
size_t len = 0;
out = yyjson_mut_val_write_opts(root, 0, cbm_json_writer_allocator(), &len, NULL);
}
if (doc) {
yyjson_mut_doc_free(doc);
}
return out;
}
static void tf_release_push(tf_t *t, int64_t src, int64_t chart, TSNode body) {
cbm_pipeline_ctx_t *ctx = t->ctx;
if (ctx->tf_release_n == ctx->tf_release_cap) {
int cap = ctx->tf_release_cap ? ctx->tf_release_cap * PAIR_LEN : CBM_SZ_16;
cbm_tf_release_t *g =
cbm_realloc(CBM_MEM_CLASS_OTHER, ctx->tf_releases, (size_t)cap * sizeof(*g));
if (!g) {
return;
}
ctx->tf_releases = g;
ctx->tf_release_cap = cap;
}
const char **files = NULL;
int nfiles = 0;
char *values = tf_release_values(t, body, &files, &nfiles);
ctx->tf_releases[ctx->tf_release_n++] = (cbm_tf_release_t){src, chart, values};
/* the values files it reads, like a kustomization's valuesFile: an edit of
* one alone renders the release again (pipeline_incremental.c) */
for (int i = 0; i < nfiles; i++) {
const char *mqn = cbm_fqn_module(t->arena, ctx->project_name, files[i]);
const cbm_gbuf_node_t *mod = mqn ? cbm_gbuf_find_by_qn(ctx->gbuf, mqn) : NULL;
if (mod && mod->id != src) {
cbm_gbuf_insert_edge(ctx->gbuf, src, mod->id, "IMPORTS", "{\"local_name\":\"values\"}");
t->edges++;
}
}
}
static void tf_helm_release(tf_t *t, TSNode block, const char *name) {
TSNode body = {0};
for (uint32_t i = 0; i < ts_node_named_child_count(block); i++) {
@@ -320,6 +839,7 @@ static void tf_helm_release(tf_t *t, TSNode block, const char *name) {
external ? ",\"external\":true" : "", (int)ts_node_start_point(block).row + SKIP_ONE);
cbm_gbuf_insert_edge(t->ctx->gbuf, src, tgt, "DEPLOYS", props);
t->edges++;
tf_release_push(t, src, tgt, body);
}
/* The text of a string_lit label: "helm_release" -> helm_release. */
+9
View File
@@ -412,6 +412,15 @@ bool cbm_weak_member_unique_name_exempt(bool is_python, bool receiver_is_self_at
bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_bound,
const char *strategy);
/* Go analog of the TS/JS guard, same failure class: a selector call whose
* receiver the Go LSP could not type must not be bound by a receiver-blind
* short-name strategy. Drops suffix_match / fuzzy always, and unique_name only
* when its confidence is import-unreachability-penalized (the stdlib/vendor
* hijack shape). field_type_hint is deliberately NOT dropped for Go — struct
* fields carry declared types, so the hint is receiver-aware there. */
bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy,
double confidence);
/* Import-binding counterpart (#2127). True when the callee's root identifier
* is bound by an import of this file whose module chain contradicts the
* resolved target (`from unittest.mock import patch; patch()` must not bind a
+42 -3
View File
@@ -2466,8 +2466,9 @@ static bool closure_dir_has_compose(const char *repo_path, const char *rel, cons
return found;
}
/* The nearest directory at or above `rel`'s that holds a Chart.yaml, into out
* ("" for the repository root); false when none does. */
/* The nearest directory at or above `rel`'s that holds a Chart.yaml, or is a
* werf `.helm` chart, into out ("" for the repository root); false when none
* is. */
static bool closure_chart_dir_of(const char *repo_path, const char *rel, char *out, size_t sz) {
snprintf(out, sz, "%s", rel);
char *cut = strrchr(out, '/');
@@ -2485,6 +2486,9 @@ static bool closure_chart_dir_of(const char *repo_path, const char *rel, char *o
return true;
}
}
if (out[0] && cbm_werf_helm_dir(repo_path, out)) {
return true;
}
if (!out[0]) {
return false;
}
@@ -2492,6 +2496,33 @@ static bool closure_chart_dir_of(const char *repo_path, const char *rel, char *o
}
}
/* Is `rel` a file a release reads as values (an IMPORTS {local_name:
* valuesFile | values} edge reaches its node)? */
static bool closure_release_values(cbm_store_t *store, const char *project, const char *rel) {
cbm_node_t *nodes = NULL;
int count = 0;
if (cbm_store_find_nodes_by_file(store, project, rel, &nodes, &count) != CBM_STORE_OK) {
return false;
}
bool hit = false;
for (int i = 0; i < count && !hit; i++) {
cbm_edge_t *edges = NULL;
int ne = 0;
if (cbm_store_find_edges_by_target_type(store, nodes[i].id, "IMPORTS", &edges, &ne) !=
CBM_STORE_OK) {
continue;
}
for (int j = 0; j < ne && !hit; j++) {
const char *p = edges[j].properties_json;
hit = p && (strstr(p, "\"local_name\":\"valuesFile\"") ||
strstr(p, "\"local_name\":\"values\""));
}
cbm_store_free_edges(edges, ne);
}
cbm_store_free_nodes(nodes, count);
return hit;
}
/* Kubernetes inputs reach past their own file (pass_k8s.c): a kustomization
* decides which manifests deploy together and in which namespace, a
* Chart.yaml links other charts, and a manifest's Services select workloads
@@ -2508,7 +2539,9 @@ static const char *closure_k8s_decline(cbm_store_t *store, const char *project,
if (cbm_is_kustomize_file(base) || strcmp(base, "Kustomization") == 0) {
return "k8s_bundle_input_changed";
}
if (strcmp(base, "Chart.yaml") == 0 || strcmp(base, "Chart.yml") == 0) {
/* A werf.yaml names the chart of the `.helm` dir beside it. */
if (strcmp(base, "Chart.yaml") == 0 || strcmp(base, "Chart.yml") == 0 ||
strcmp(base, "werf.yaml") == 0 || strcmp(base, "werf.yml") == 0) {
return "helm_chart_changed";
}
/* A chart's values file CONFIGURES it and its templates/ are what it
@@ -2528,6 +2561,12 @@ static const char *closure_k8s_decline(cbm_store_t *store, const char *project,
if (strcmp(base, ".env") == 0 && closure_dir_has_compose(repo_path, rel, base)) {
return "compose_env_changed";
}
/* A release's values file -- a kustomization's valuesFile, a Terraform
* helm_release's file(...) -- feeds the images the release renders,
* which a repair of the values file alone does not redraw. */
if (closure_release_values(store, project, rel)) {
return "helm_release_values_changed";
}
cbm_node_t *nodes = NULL;
int count = 0;
if (cbm_store_find_nodes_by_file(store, project, rel, &nodes, &count) != CBM_STORE_OK) {
+36 -2
View File
@@ -407,6 +407,16 @@ typedef struct {
int reader_count;
} cbm_envjoin_base_t;
/* A Terraform helm_release (pass_terraform.c): the chart it DEPLOYS and its
* values -- `values = [...]` coalesced in order, then `set` -- as JSON; NULL
* when a part is not a literal (an interpolation, a variable). pass_k8s.c
* renders the chart's images with them. */
typedef struct cbm_tf_release {
int64_t src; /* the resource.helm_release node */
int64_t chart; /* the Chart node */
char *values; /* owned (cbm_free); NULL: unknown */
} cbm_tf_release_t;
/* Shared context passed to each pass function.
* Derived from cbm_pipeline_t fields during run. */
typedef struct {
@@ -513,6 +523,12 @@ typedef struct {
const struct cbm_doclink_scope *doc_link_base;
int doc_link_base_count;
bool doc_links_failed;
/* The run's Terraform helm_releases (pass_terraform.c fills them, the K8s
* pass reads and frees them right after). */
cbm_tf_release_t *tf_releases;
int tf_release_n;
int tf_release_cap;
} cbm_pipeline_ctx_t;
/* Origin-aware LSP returns only success/failure. Never retain partial output
@@ -1087,6 +1103,9 @@ bool cbm_is_k8s_manifest(const char *name, const char *content);
/* A Helm values file name: values.yaml / .yml, values-<x>.yaml / .yml
* (pass_k8s.c; beside Chart.yaml it CONFIGURES the chart). */
bool cbm_helm_values_name(const char *base);
/* Is `dir` (repository relative) a werf chart: a `.helm` directory without a
* Chart.yaml whose parent holds werf.yaml (pass_k8s.c)? */
bool cbm_werf_helm_dir(const char *repo_path, const char *dir);
/* Secret detection */
bool cbm_is_secret_binding(const char *key, const char *value);
@@ -1286,6 +1305,7 @@ typedef struct {
const char *via; /* "skaffold" / "compose" */
const char *image; /* the reference, variables expanded */
const char *dockerfile; /* repository path */
const char *build_args; /* compose build.args as a JSON object; NULL: none */
int line;
} cbm_build_site_t;
typedef void (*cbm_build_site_fn)(const cbm_build_site_t *site, void *ud);
@@ -1296,10 +1316,17 @@ typedef void (*cbm_build_site_fn)(const cbm_build_site_t *site, void *ud);
* depends_on and links list, and the names of the environment variables its
* environment sets (map keys / list items' names -- never a value), with
* their 1-based lines and the service name each value addresses (a bare
* name, `name:port`, a URL's host; NULL: none -- cbm_env_address). */
* name, `name:port`, a URL's host; NULL: none -- cbm_env_address).
* image_default_of: the variables whose inline default (${X:-d}) named the
* image; image_override_of: those whose default the project's .env replaced
* (JSON arrays, NULL: none), and default_image the image those defaults
* name. */
typedef struct {
const char *name;
const char *image;
const char *image_default_of;
const char *image_override_of;
const char *default_image;
const char *dockerfile;
const char **deps;
int ndeps;
@@ -1367,6 +1394,12 @@ bool cbm_repo_rel_join(const char *dir, const char *v, char *out, size_t sz);
* for an official image; registry "unknown" when the reference names no
* host -- none is invented), made when new; 0 for scratch or no image. */
int64_t cbm_pipeline_image_node(cbm_pipeline_ctx_t *ctx, const char *ref);
/* Does `ref` follow the image reference grammar (distribution/reference):
* [host[:port]/]path[:tag][@algo:hex], path components lowercase alphanumerics
* joined by `.`, `_`, `__` or dashes, a tag of word characters, `.` and `-`
* (128 at most)? A value rendered from a template is checked before it names
* an Image (pass_docker.c). */
bool cbm_image_ref_valid(const char *ref);
/* A chart repository URL as an identity (pass_k8s.c): `url` trimmed, its
* scheme and host lower-cased, trailing `/` and `.git` dropped. False for no
@@ -1757,7 +1790,8 @@ int cbm_delta_drop_edges_from_label(cbm_store_t *store, const char *project, con
const char *label);
/* The edges that keep a file-less shared node alive. */
#define SH_PROCESS_EDGES ((const char *const[]){"SPAWNS", NULL})
#define DK_IMAGE_EDGES ((const char *const[]){"DEPENDS_ON", "USAGE", NULL})
/* An Image a release DEPLOYS (pass_k8s.c release images) may have no other edge. */
#define DK_IMAGE_EDGES ((const char *const[]){"DEPENDS_ON", "USAGE", "DEPLOYS", NULL})
/* Pre-seed proxy nodes with their REAL database ids and move the gbuf id
* watermark above MAX(id); returns that max id, or -1 on failure. */
int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *gbuf);
+29
View File
@@ -750,6 +750,35 @@ bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_b
return weak_short_name_strategy(strategy);
}
bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy,
double confidence) {
/* Go analog of the TS/JS guard above, same failure class: a selector call
* whose receiver the Go LSP could not type reaches the registry and a bare
* short-name strategy binds it to an arbitrary same-named project symbol
* (`f.Close()` on an os.File -> a project `Close`, suffix_match over 15
* candidates). Built on the shared drop-list so "weak" means one thing
* for every guard; Go narrows it in one place only (unique_name below).
* field_type_hint stays weak: it is a receiver-NAME substring match, not
* a declared-type lookup (the parallel resolver no longer produces it for
* Go; a typed field call such as lrp_go_s8's `s.repo.Find()` resolves
* through the Go LSP). */
if (!is_go || !is_method || !weak_short_name_strategy(strategy)) {
return false;
}
if (strcmp(strategy, "unique_name") != 0) {
return true; /* suffix_match, field_type_hint, fuzzy */
}
/* unique_name is dropped only when PENALIZED: resolve_name_lookup scales
* CONF_UNIQUE_NAME by DEFAULT_CONFIDENCE exactly when the lone candidate
* is not reachable through the caller's imports — the stdlib/vendor
* hijack shape (`io.Copy` -> a project `Copy`). An unpenalized
* unique_name target sits inside the caller's import closure (or the
* file has no imports, e.g. a same-package call) and must be kept —
* dropping it kills genuinely-typed lone-candidate calls that never
* enter the field-type-hint upgrade (candidate_count == 1). */
return confidence < CONF_UNIQUE_NAME;
}
/* Import-binding counterpart of the parameter guard above (#2127). An import
* binds the identifier for the whole module; when that import is external it
* never materializes, and the chain falls through to project-wide short-name
+4 -2
View File
@@ -32,8 +32,10 @@ typedef struct cbm_store_read_scope cbm_store_read_scope_t;
* Dockerfile.<variant> keeps its suffix in its QNs.
* 3 Image identity invents no registry: a reference without a host is
* __image__<repository as written>, registry "unknown" -- no longer
* __image__docker.io/[library/]<repository>. */
#define CBM_INDEX_FORMAT_VERSION 3
* __image__docker.io/[library/]<repository>.
* 4 #1913: Go method QNs are receiver-qualified (pkg.Recv.method, generic
* receivers unwrapped); free functions stay pkg.func. */
#define CBM_INDEX_FORMAT_VERSION 4
#define CBM_STORE_CANCELLED (-3)
#define CBM_STORE_SCAN_LIMIT (-4)
#define CBM_STORE_CALLBACK_ERR (-5)
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v2
name: cx
version: 0.1.0
appVersion: "1.0"
@@ -0,0 +1,4 @@
{{- define "cx.image" -}}
{{- $reg := .registry -}}
{{- printf "%s/%s:%s" $reg .repository .tag -}}
{{- end -}}
@@ -0,0 +1,14 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: cx
spec:
template:
spec:
containers:
- name: main
image: "{{ include "cx.image" .Values.image }}"
{{- range .Values.sidecars }}
- name: {{ .name }}
image: "{{ .image.repository }}:{{ .image.tag }}"
{{- end }}
+12
View File
@@ -0,0 +1,12 @@
common: &common
pullPolicy: IfNotPresent
image:
<<: *common
registry: ghcr.io
repository: acme/cx
tag: "1.0"
sidecars:
- name: a
image: {repository: ghcr.io/acme/cx-side, tag: "2.0"}
- name: b
image: {repository: ghcr.io/acme/cx-side, tag: "2.1"}
+45
View File
@@ -13498,6 +13498,50 @@ TEST(cli_hook_augment_context_tracks_search_json_shape) {
PASS();
}
/* A search that found symbols closes with cbm's own follow-up line, after the
* untrusted symbol list and on its own line, naming the graph tools for the
* next question; it carries no repository-derived value. */
TEST(cli_hook_augment_context_names_graph_follow_ups) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "hookproj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/hookproj");
cbm_node_t n = {.project = proj,
.label = "Function",
.name = "someIndexedSymbol",
.qualified_name = "hookproj.mod.someIndexedSymbol",
.file_path = "mod.py",
.start_line = 1,
.end_line = 4};
ASSERT_GT(cbm_store_upsert_node(st, &n), 0);
char *envelope =
cbm_mcp_handle_tool(srv, "search_graph",
"{\"project\":\"hookproj\",\"name_pattern\":\".*someIndexedSymbol.*\","
"\"limit\":5,\"format\":\"json\"}");
ASSERT_NOT_NULL(envelope);
bool is_error = true;
char *ctx =
cbm_hook_augment_format_context_for_testing(envelope, "someIndexedSymbol", &is_error);
ASSERT_FALSE(is_error);
ASSERT_NOT_NULL(ctx);
const char *list = strstr(ctx, "\n- hookproj.mod.someIndexedSymbol");
const char *follow = strstr(ctx, "\n[codebase-memory] For these symbols, trace_path lists "
"callers and callees, get_code_snippet returns the exact "
"source, and search_graph finds related symbols by name.");
ASSERT_NOT_NULL(list);
ASSERT_NOT_NULL(follow);
ASSERT_TRUE(follow > list);
ASSERT_NULL(strstr(follow, "someIndexedSymbol"));
ASSERT_NULL(strstr(follow, "mod.py"));
free(ctx);
free(envelope);
cbm_mcp_server_free(srv);
PASS();
}
/* Exercise the real PreToolUse + Bash route instead of calling only the
* tokenizer seam. This pins the event guard and the graph lookup path together.
*/
@@ -18479,6 +18523,7 @@ SUITE(cli) {
RUN_TEST(cli_codex_preflight_reports_heading_and_reason);
#endif
RUN_TEST(cli_hook_augment_context_tracks_search_json_shape);
RUN_TEST(cli_hook_augment_context_names_graph_follow_ups);
RUN_TEST(cli_hook_augment_bash_pretooluse_reaches_augmenter);
RUN_TEST(cli_hook_augment_bash_pattern_extractor);
RUN_TEST(cli_hook_augment_coverage_requests_machine_json);
+9 -6
View File
@@ -1087,8 +1087,9 @@ TEST(pdf_links_pipeline) {
ASSERT_NOT_NULL(strstr(props, "\"line\":1"));
/* qualified names (pdf_qn) passed a second held-out audit (97 of 99
* correct, Wilson 95 % low 0.929) once a member whose QN leaves its owner
* out binds only through the owner. A Go method through its receiver: */
dm_edge(db, "docs.design.page_1", "pkg.server.Serve", props, sizeof(props), &n);
* out binds only through the owner. A Go method through its receiver
* (receiver-qualified QN since index format 4): */
dm_edge(db, "docs.design.page_1", "pkg.server.Handler.Serve", props, sizeof(props), &n);
ASSERT_EQ(n, 1);
ASSERT_NOT_NULL(strstr(props, "\"syntax\":\"pdf_qn\""));
ASSERT_NOT_NULL(strstr(props, "\"tier\":\"unique\""));
@@ -1142,10 +1143,12 @@ TEST(pdf_links_pipeline) {
PASS();
}
/* A held-out finding of the PDF audit: a Go method's QN has no receiver, so
/* A held-out finding of the PDF audit: a Go method's QN had no receiver, so
* `time.Now` (the standard library's function, in a spec) matched the method
* Now of a type that the repository's own package time declares. A method is
* named through its owner: `time.Provider.Now` still binds it. */
* named through its owner: `time.Provider.Now` still binds it. Since index
* format 4 the QN carries the receiver (pkg.time.Provider.Now), and both
* directions are checked against that node. */
TEST(pdf_qn_method_through_owner) {
cbm_doclink_test_set_ships(CBM_DOCLINK_PDF_QN, true);
char tmp[256];
@@ -1169,9 +1172,9 @@ TEST(pdf_qn_method_through_owner) {
ASSERT_EQ(dm_index(repo, db, NULL), 0);
char props[512];
int n;
dm_edge(db, "docs.spec.page_1", "pkg.time.Now", props, sizeof(props), &n);
dm_edge(db, "docs.spec.page_1", "pkg.time.Provider.Now", props, sizeof(props), &n);
ASSERT_EQ(n, 0);
dm_edge(db, "docs.spec.page_2", "pkg.time.Now", props, sizeof(props), &n);
dm_edge(db, "docs.spec.page_2", "pkg.time.Provider.Now", props, sizeof(props), &n);
ASSERT_EQ(n, 1);
ASSERT_NOT_NULL(strstr(props, "\"syntax\":\"pdf_qn\""));
th_cleanup(tmp);
+122 -68
View File
@@ -228,10 +228,10 @@ TEST(doc_mentions_go_sources) {
const char *raw;
const char *source;
} want[] = {
{"RefF", "p.pkg.F"}, {"RefS", "p.pkg.S"}, {"RefField", "p.pkg.S.Field"},
{"RefM", "p.pkg.M"}, /* Go method nodes have a flat name */
{"RefI", "p.pkg.I"}, {"RefDo", "p.pkg.I.Do"}, {"RefC", "p.pkg.C"},
{"RefK", "p.pkg.K"}, {"RefV", "p.pkg.V"}, {"RefA", "p.pkg.A"},
{"RefF", "p.pkg.F"}, {"RefS", "p.pkg.S"}, {"RefField", "p.pkg.S.Field"},
{"RefM", "p.pkg.S.M"}, /* #1913: Go method nodes are receiver-qualified */
{"RefI", "p.pkg.I"}, {"RefDo", "p.pkg.I.Do"}, {"RefC", "p.pkg.C"},
{"RefK", "p.pkg.K"}, {"RefV", "p.pkg.V"}, {"RefA", "p.pkg.A"},
};
for (size_t i = 0; i < GO_COUNT(want); i++) {
const CBMDocLink *t = dm_find_token(r, want[i].raw);
@@ -772,14 +772,15 @@ TEST(doc_mentions_go_scope_blob) {
"T\tAlias\ta\tal.Widget\t1",
"T\tSame\ta\tKind\t1",
"T\tList\ts\t-\t1",
/* functions and methods; two methods named Run compete for one node
* and the later one keeps it */
/* functions and methods; a method node is receiver-qualified
* (#1913), so a method claims no flat name and its w is `-`: the two
* methods named Run no longer compete */
"F\tNew\t1",
"F\tMap\t1",
"M\tWidget\tRun\t0",
"M\tKind\tRun\t1",
"M\tList\tPush\t1",
"M\tlower\tExported\t1",
"M\tWidget\tRun\t-",
"M\tKind\tRun\t-",
"M\tList\tPush\t-",
"M\tlower\tExported\t-",
/* own type parameters, by the declaration's first and last line */
"g\t36\t36\tT,U",
"g\t42\t42\tA,B",
@@ -814,15 +815,20 @@ TEST(doc_mentions_go_scope_blob) {
char *portable = cbm_doclink_portable_scope(s);
ASSERT_NOT_NULL(portable);
ASSERT_NULL(strstr(portable, "\ng\t"));
ASSERT_TRUE(go_has_rec(portable, "M\tKind\tRun\t1"));
ASSERT_TRUE(go_has_rec(portable, "M\tKind\tRun\t-"));
ASSERT(strncmp(portable, "go1\n", 4) == 0);
cbm_free(CBM_MEM_CLASS_OTHER, portable);
cbm_free_result(r);
/* moving declarations changes nothing unless a node changes its owner */
/* moving declarations changes nothing unless a node changes its owner.
* Methods own no flat name (#1913), so swapping two methods of one name
* changes nothing either; two package-level declarations of one name
* still hand <dir>.<name> over when swapped */
const char *a = "package p\n\nfunc (a A) Run() {}\nfunc (b B) Run() {}\nfunc F() {}\n";
const char *moved = "package p\n\nfunc F() {}\n\n\nfunc (a A) Run() {}\nfunc (b B) Run() {}\n";
const char *swapped = "package p\n\nfunc (b B) Run() {}\nfunc (a A) Run() {}\nfunc F() {}\n";
const char *pair = "package p\n\nfunc Open() {}\ntype Open struct{}\n";
const char *pair_swapped = "package p\n\ntype Open struct{}\nfunc Open() {}\n";
CBMFileResult *ra = go_extract(a, "p/x.go");
CBMFileResult *rm = go_extract(moved, "p/x.go");
CBMFileResult *rs = go_extract(swapped, "p/x.go");
@@ -830,16 +836,25 @@ TEST(doc_mentions_go_scope_blob) {
ASSERT_NOT_NULL(rm);
ASSERT_NOT_NULL(rs);
ASSERT_STR_EQ(ra->doc_scope, rm->doc_scope);
ASSERT(strcmp(ra->doc_scope, rs->doc_scope) != 0);
ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tA\tRun\t1"));
ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tB\tRun\t0"));
ASSERT_STR_EQ(ra->doc_scope, rs->doc_scope);
ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tA\tRun\t-"));
ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tB\tRun\t-"));
cbm_free_result(ra);
cbm_free_result(rm);
cbm_free_result(rs);
CBMFileResult *rp = go_extract(pair, "p/x.go");
CBMFileResult *rq = go_extract(pair_swapped, "p/x.go");
ASSERT_NOT_NULL(rp);
ASSERT_NOT_NULL(rq);
ASSERT(strcmp(rp->doc_scope, rq->doc_scope) != 0);
ASSERT_TRUE(go_has_rec(rp->doc_scope, "T\tOpen\ts\t-\t1"));
ASSERT_TRUE(go_has_rec(rq->doc_scope, "F\tOpen\t1"));
cbm_free_result(rp);
cbm_free_result(rq);
/* the node <dir>.<name> is one for a function, a type, a value and the
* methods of that name: the last declaration keeps it, whatever its kind,
* and two on one line contest it */
/* the node <dir>.<name> is one for a function, a type and a value of
* that name: the last declaration keeps it, whatever its kind, and two on
* one line contest it. A method of that name claims nothing (#1913) */
CBMFileResult *k1 = go_extract("package p\n\nfunc Open() {}\nfunc (t T) Open() {}\n", "p/k.go");
CBMFileResult *k2 = go_extract("package p\n\nfunc (t T) Open() {}\nfunc Open() {}\n", "p/k.go");
CBMFileResult *k3 = go_extract("package p\n\ntype Open int\nvar Open = 1\n"
@@ -848,14 +863,21 @@ TEST(doc_mentions_go_scope_blob) {
ASSERT_NOT_NULL(k1);
ASSERT_NOT_NULL(k2);
ASSERT_NOT_NULL(k3);
ASSERT_TRUE(go_has_rec(k1->doc_scope, "F\tOpen\t0"));
ASSERT_TRUE(go_has_rec(k1->doc_scope, "M\tT\tOpen\t1"));
ASSERT_TRUE(go_has_rec(k1->doc_scope, "F\tOpen\t1"));
ASSERT_TRUE(go_has_rec(k1->doc_scope, "M\tT\tOpen\t-"));
ASSERT_TRUE(go_has_rec(k2->doc_scope, "F\tOpen\t1"));
ASSERT_TRUE(go_has_rec(k2->doc_scope, "M\tT\tOpen\t0"));
ASSERT_TRUE(go_has_rec(k2->doc_scope, "M\tT\tOpen\t-"));
ASSERT_TRUE(go_has_rec(k3->doc_scope, "T\tOpen\to\t-\t0"));
ASSERT_TRUE(go_has_rec(k3->doc_scope, "V\tOpen\t0"));
ASSERT_TRUE(go_has_rec(k3->doc_scope, "V\tOpen\tx"));
ASSERT_TRUE(go_has_rec(k3->doc_scope, "M\tT\tOpen\tx"));
ASSERT_TRUE(go_has_rec(k3->doc_scope, "V\tOpen\t1"));
ASSERT_TRUE(go_has_rec(k3->doc_scope, "M\tT\tOpen\t-"));
/* two package-level declarations on one line contest the name */
CBMFileResult *k4 =
go_extract("package p\n\ntype Open int\nvar Open = 1; const Open = 2\n", "p/k.go");
ASSERT_NOT_NULL(k4);
ASSERT_TRUE(go_has_rec(k4->doc_scope, "T\tOpen\to\t-\t0"));
ASSERT_TRUE(go_has_rec(k4->doc_scope, "V\tOpen\tx"));
cbm_free_result(k4);
cbm_free_result(k1);
cbm_free_result(k2);
cbm_free_result(k3);
@@ -1030,7 +1052,7 @@ static const char GO_ALPHA_C[] = "package alpha\n"
"// Target is a target; see [A.Close] and [B.Close].\n"
"func Target() {}\n"
"\n"
"// Open is a type that loses its node name to the method below.\n"
"// Open is a type; a method of B below has the same name.\n"
"type Open struct{}\n"
"\n"
"// Open opens: a method named like the type.\n"
@@ -1295,7 +1317,7 @@ TEST(doc_mentions_go_forms) {
static const go_want_edge_t edges[] = {
/* [Name], [Recv.Method], an interface's method, a constant, a variable */
{"alpha.Widget", "alpha.New", "unique"},
{"alpha.Widget", "alpha.Run", "unique"},
{"alpha.Widget", "alpha.Widget.Run", "unique"},
{"alpha.Widget", "alpha.Runner.Run", "unique"},
{"alpha.Widget", "alpha.Max", "unique"},
{"alpha.Widget", "alpha.Default", "unique"},
@@ -1311,7 +1333,7 @@ TEST(doc_mentions_go_forms) {
{"alpha.New", "sub.deep.Deep", "exact"},
{"alpha.New", "nested.pkg.Far", "exact"},
/* [pkg.Recv.Name]: a method, a field, an interface method */
{"alpha.New", "beta.Do", "exact"},
{"alpha.New", "beta.Thing.Do", "exact"},
{"alpha.New", "beta.Thing.Field", "exact"},
{"alpha.New", "beta.Doer.Act", "exact"},
/* an identifier with letters beyond ASCII */
@@ -1395,7 +1417,7 @@ TEST(doc_mentions_go_plain_text) {
ASSERT_EQ(go_bad_rows(db, rows, GO_COUNT(rows)), 0);
/* and none of them is an edge: Run's doc holds rows, plain text and one
* link, to the field Widget.Size */
ASSERT_EQ(dm_mentions_from(db, "alpha.Run"), 1);
ASSERT_EQ(dm_mentions_from(db, "alpha.Widget.Run"), 1);
ASSERT_EQ(dm_mentions_from(db, "alpha.Bare"), 0);
static const go_want_edge_t edges[] = {
{"alpha.Sibling", "alpha.Widget", "unique"},
@@ -1424,14 +1446,14 @@ TEST(doc_mentions_go_struct_fields) {
static const go_want_edge_t edges[] = {
/* the same package: a field named from a method's doc and from a
* sibling field's doc */
{"alpha.Run", "alpha.Widget.Size", "exact"},
{"alpha.Widget.Run", "alpha.Widget.Size", "exact"},
{"alpha.Widget.Name", "alpha.Widget.Size", "exact"},
/* another package, by its import */
{"alpha.New", "beta.Thing.Field", "exact"},
{"gamma.ViaAlias", "alpha.Widget.Name", "exact"},
/* Depth is a field of beta.Embedded, which Widget embeds: it is no
* field of Widget */
{"alpha.Run", "beta.Embedded.Depth", ""},
{"alpha.Widget.Run", "beta.Embedded.Depth", ""},
};
ASSERT_EQ(go_bad_edges(db, edges, GO_COUNT(edges)), 0);
/* the promoted field exists as a node: the test above is not vacuous */
@@ -1545,7 +1567,7 @@ TEST(doc_mentions_go_lookup_order) {
* file that imports "fmt", the repository's own in a file that imports
* example.com/demo/fmt */
ASSERT_STR_EQ(go_reason(db, "alpha/a.go", "fmt.Println"), "external");
ASSERT_STR_EQ(go_tier(db, "alpha.Run", "fmt.Println"), "");
ASSERT_STR_EQ(go_tier(db, "alpha.Widget.Run", "fmt.Println"), "");
ASSERT_STR_EQ(go_tier(db, "gamma.Local", "fmt.Println"), "exact");
/* 5. the longest module path decides: a directory inside the root module
* that belongs to a nested module of another path is not reachable through
@@ -1555,17 +1577,17 @@ TEST(doc_mentions_go_lookup_order) {
ASSERT_STR_EQ(go_tier(db, "alpha.New", "sub.deep.Deep"), "exact");
/* 6. the bare name stops at the package: beta.Make is imported and still
* no [Make] */
ASSERT_STR_EQ(go_tier(db, "alpha.Run", "beta.Make"), "");
ASSERT_STR_EQ(go_tier(db, "alpha.Widget.Run", "beta.Make"), "");
/* 7. members stop at the type: nothing promoted through embedding */
ASSERT_STR_EQ(go_reason(db, "gamma/k.go", "alpha.Widget.Promoted"), "missing");
ASSERT_STR_EQ(go_tier(db, "gamma.ViaAlias", "beta.Promoted"), "");
ASSERT_STR_EQ(go_tier(db, "gamma.ViaAlias", "beta.Embedded.Promoted"), "");
/* 8. an alias is followed for a member written with its package, to the
* type that declares the member */
static const go_want_edge_t alias[] = {
{"gamma.ViaAlias", "beta.Do", "exact"},
{"gamma.ViaAlias", "beta.Thing.Do", "exact"},
{"gamma.ViaAlias", "beta.Thing.Field", "exact"},
{"gamma.ViaAlias", "alpha.Alias", "exact"},
{"gamma.ViaAlias", "alpha.End", "exact"}, /* seven aliases away */
{"gamma.ViaAlias", "alpha.Deep10.End", "exact"}, /* seven aliases away */
{"gamma.ViaAlias", "alpha.Widget.Name", "exact"},
};
ASSERT_EQ(go_bad_edges(db, alias, GO_COUNT(alias)), 0);
@@ -1638,28 +1660,42 @@ TEST(doc_mentions_go_node_owner) {
char tmp[256];
char db[512];
ASSERT_EQ(go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)), 0);
/* Go method nodes have a flat name: A.Close and B.Close share
* alpha.Close, and the later declaration keeps it */
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE qualified_name LIKE '%.alpha.Close'"),
/* #1913: Go method nodes are receiver-qualified. A.Close and B.Close are
* two nodes (no shared alpha.Close), each a target of its own */
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Method' AND "
"qualified_name LIKE '%.alpha.A.Close'"),
1);
ASSERT_STR_EQ(go_tier(db, "alpha.Target", "alpha.Close"), "unique");
ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "A.Close"), "graph_gap");
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Method' AND "
"qualified_name LIKE '%.alpha.B.Close'"),
1);
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE qualified_name LIKE '%.alpha.Close'"),
0);
ASSERT_STR_EQ(go_tier(db, "alpha.Target", "alpha.A.Close"), "unique");
ASSERT_STR_EQ(go_tier(db, "alpha.Target", "alpha.B.Close"), "unique");
ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "A.Close"), "");
ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "B.Close"), "");
/* the doc of the method that lost the name is not attributed to the one
* that kept it: B.Close's [Target] is the edge, A.Close's a row */
/* each method's doc is attributed to its own node: both [Target]s are
* edges, from the line of that method's doc, and neither is a row */
char props[512];
int n = 0;
dm_edge(db, "alpha.Close", "alpha.Target", props, sizeof(props), &n);
dm_edge(db, "alpha.A.Close", "alpha.Target", props, sizeof(props), &n);
ASSERT_EQ(n, 1);
ASSERT_NOT_NULL(strstr(props, "\"line\":9,\"count\":1"));
dm_edge(db, "alpha.B.Close", "alpha.Target", props, sizeof(props), &n);
ASSERT_EQ(n, 1);
ASSERT_NOT_NULL(strstr(props, "\"line\":12,\"count\":1"));
ASSERT_EQ(dm_count(db, "SELECT line FROM doc_link_unresolved WHERE rel_path = 'alpha/c.go' "
"AND raw = 'Target'"),
9);
/* a type that lost its name to a method of that name is no target, the
* method is */
ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "Open"), "graph_gap");
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM doc_link_unresolved WHERE rel_path = "
"'alpha/c.go' AND raw = 'Target'"),
0);
/* a type and a method of the same name no longer share a node: [Open]
* is the type, [B.Open] the method */
ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "Open"), "");
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label <> 'Method' AND "
"qualified_name LIKE '%.alpha.Open'"),
1);
ASSERT_STR_EQ(go_tier(db, "alpha.UsesOpen", "alpha.Open"), "unique");
ASSERT_EQ(dm_mentions_from(db, "alpha.UsesOpen"), 1);
ASSERT_STR_EQ(go_tier(db, "alpha.UsesOpen", "alpha.B.Open"), "unique");
ASSERT_EQ(dm_mentions_from(db, "alpha.UsesOpen"), 2);
/* build variants are one entity: whichever file holds the node */
static const go_want_edge_t variants[] = {
@@ -1836,15 +1872,16 @@ TEST(doc_mentions_go_incremental_equals_full) {
ASSERT_EQ(dm_index(repo, inc_db, NULL), 0);
/* the preconditions the steps below move away from */
static const go_want_edge_t before[] = {
{"a.Use", "b.Target", "exact"}, {"a.Use", "b.Gone", "exact"},
{"a.Use", "b.M", "exact"}, /* T.M, in the first file of the directory, keeps b.M */
{"a.Use", "b.Name", "exact"}, /* W.Name, the later declaration, keeps b.Name */
{"a.Use", "a.Local", "unique"}, {"a.doc.go.__file__", "a.Local", "unique"},
{"a.Use", "b.Target", "exact"}, {"a.Use", "b.Gone", "exact"},
{"a.Use", "b.T.M", "exact"}, /* #1913: every method has its own node */
{"a.Use", "b.W.Name", "exact"}, {"a.Use", "b.V.Name", "exact"},
{"b.U.M", "b.Target", "unique"}, /* U.M is a source of its own */
{"a.Use", "a.Local", "unique"}, {"a.doc.go.__file__", "a.Local", "unique"},
{"a.Other", "b.Target", ""}, /* other.go does not import b */
};
ASSERT_EQ(go_bad_edges(inc_db, before, GO_COUNT(before)), 0);
ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.V.Name"), "graph_gap");
ASSERT_STR_EQ(go_reason(inc_db, "b/c.go", "Target"), "graph_gap"); /* U.M has no node */
ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.V.Name"), "");
ASSERT_STR_EQ(go_reason(inc_db, "b/c.go", "Target"), "");
/* a doc-comment edit re-extracts the file alone */
go_w(repo, "a/a.go", GO_INC_A_DOC_EDIT);
@@ -1880,23 +1917,27 @@ TEST(doc_mentions_go_incremental_equals_full) {
0);
ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.Gone"), "missing");
/* the method that kept a shared node name goes: the other file's method of
* that name takes the node over, and its doc becomes a source */
/* a method goes: links to it break, and another receiver's method of
* the same name, in another file, is untouched */
go_w(repo, "b/b.go", GO_INC_B_NO_M);
ASSERT_EQ(
dm_step(repo, inc_db, full_db, "name keeper removed", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR),
0);
ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.T.M"), "missing");
ASSERT_STR_EQ(go_tier(inc_db, "b.M", "b.Target"), "unique");
ASSERT_STR_EQ(go_tier(inc_db, "b.U.M", "b.Target"), "unique");
ASSERT_STR_EQ(go_reason(inc_db, "b/c.go", "Target"), "");
/* declaration order decides which declaration keeps a shared name:
* swapping two methods moves the node, for files with no edge into it too */
/* declaration order no longer decides anything: swapping two methods of
* the same name keeps both nodes and both links */
go_w(repo, "b/d.go", GO_INC_D_SWAPPED);
ASSERT_EQ(dm_step(repo, inc_db, full_db, "methods swapped", CBM_INCREMENTAL_ROUTE_FORCED_FULL),
0);
ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.W.Name"), "graph_gap");
/* and the scope blob does not change (no method w moves), so the step
* is an ordinary closure repair, no longer a forced full rebuild */
ASSERT_EQ(
dm_step(repo, inc_db, full_db, "methods swapped", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0);
ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.W.Name"), "");
ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.V.Name"), "");
ASSERT_STR_EQ(go_tier(inc_db, "a.Use", "b.W.Name"), "exact");
ASSERT_STR_EQ(go_tier(inc_db, "a.Use", "b.V.Name"), "exact");
/* renaming a target */
go_w(repo, "b/b.go", GO_INC_B_RENAME);
@@ -2006,10 +2047,20 @@ TEST(doc_mentions_go_scope_delta_rules) {
GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_T GO_DELTA_M GO_DELTA_I GO_DELTA_C
"var (\n\tV = 2\n)\n",
CBM_DOCLINK_DELTA_GLOBAL, NULL},
/* the declaration that keeps a shared node name changes */
{"same-named declarations swapped", twins,
"package p\n\nfunc (T) Open() {}\nfunc Open() {}\n", CBM_DOCLINK_DELTA_GLOBAL, NULL},
{"one of two same-named declarations removed", twins, "package p\n\nfunc Open() {}\n",
/* a method and a function of one name no longer share a node
* (#1913): swapping them changes no keeper */
{"method and function swapped", twins, "package p\n\nfunc (T) Open() {}\nfunc Open() {}\n",
CBM_DOCLINK_DELTA_LOCAL, ""},
/* the declaration that keeps a shared node name changes: two
* package-level declarations of one name still share <dir>.<name> */
{"same-named declarations swapped", "package p\n\nfunc Open() {}\ntype Open struct{}\n",
"package p\n\ntype Open struct{}\nfunc Open() {}\n", CBM_DOCLINK_DELTA_GLOBAL, NULL},
/* the method of a method/function pair removed: a member left, the
* function keeps its node either way */
{"method of a same-named pair removed", twins, "package p\n\nfunc Open() {}\n",
CBM_DOCLINK_DELTA_LOCAL, "Open"},
{"one of two same-named declarations removed",
"package p\n\nfunc Open() {}\ntype Open struct{}\n", "package p\n\nfunc Open() {}\n",
CBM_DOCLINK_DELTA_GLOBAL, NULL},
/* other files follow `type X = q.Y` through this file's imports */
{"import changed under a qualified alias", alias,
@@ -2091,7 +2142,10 @@ TEST(doc_mentions_go_workers_agree) {
ASSERT_STR_EQ(go_tier(par_db, "p00.T0", "p01.T0"), "exact");
ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "p01.Gone"), "missing");
ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "fmt.Println"), "external");
ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "p01.T3.Close"), "graph_gap");
/* #1913: [p01.T3.Close] lands on its own method node now; the graph_gap
* reason stays covered by Shared, one name declared by every file */
ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "p01.T3.Close"), "");
ASSERT_STR_EQ(go_tier(par_db, "p00.T0", "p01.T3.Close"), "exact");
ASSERT_STR_EQ(go_reason(par_db, "p00/f3.go", "Shared"), "graph_gap");
ASSERT_TRUE(dm_count(par_db, "SELECT COUNT(*) FROM edges WHERE type = 'MENTIONS'") > 100);
dm_unlink_db(par_db);
+239 -8
View File
@@ -6683,12 +6683,12 @@ TEST(extract_go_no_filename_in_module_qn) {
ASSERT_NOT_NULL(conn);
ASSERT_STR_EQ(conn->qualified_name, "proj.myapp.db.Conn");
/* Go method nodes keep a FLAT QN (module + name) with a separate
* parent_class link to the receiver type — the QN must carry the
/* Go method nodes carry a receiver-qualified QN (module + receiver type +
* name) plus the parent_class link — the QN must carry the
* directory-based module and NOT the `.conn.` filename segment. */
const CBMDefinition *query = find_def_by_name(r, "Query");
ASSERT_NOT_NULL(query);
ASSERT_STR_EQ(query->qualified_name, "proj.myapp.db.Query");
ASSERT_STR_EQ(query->qualified_name, "proj.myapp.db.Conn.Query");
ASSERT_EQ(strstr(query->qualified_name, ".conn."), NULL);
/* The method's parent_class must match the type node QN (for DEFINES_METHOD). */
ASSERT_NOT_NULL(query->parent_class);
@@ -7353,12 +7353,17 @@ TEST(extract_rust_generic_impl_call_scope_matches_def) {
PASS();
}
/* Languages OUTSIDE the is_method flag set (only Perl and TS/JS/TSX set it) must
* be unaffected: a Go method call never sets is_method. */
/* Languages OUTSIDE the is_method flag set (Perl, Python, TS/JS/TSX and Go set
* it) must be unaffected: a Rust method call never sets is_method. */
TEST(extract_flag_exempt_method_call_not_flagged_is_method) {
CBMFileResult *r = extract("package m\n"
"func run(o Obj) { o.Commit(); helper() }\n",
CBM_LANG_GO, "t", "x.go");
/* Rust is flag-exempt: only Perl, Python, TS/JS and Go set is_method.
* Guards the blast radius of the receiver-aware flags for every other
* language. */
CBMFileResult *r = extract("fn run(o: Obj) {\n"
" o.commit();\n"
" helper();\n"
"}\n",
CBM_LANG_RUST, "t", "x.rs");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
for (int i = 0; i < r->calls.count; i++) {
@@ -7516,6 +7521,33 @@ TEST(extract_python_bare_call_flags_locally_bound_callee) {
PASS();
}
TEST(extract_go_selector_call_flags_is_method) {
/* Go selector calls are flagged so the weak-match guard can fire when the
* Go LSP cannot type the receiver; bare calls stay unflagged. */
CBMFileResult *r = extract("package m\n"
"func run(o Obj) { o.Commit(); helper() }\n",
CBM_LANG_GO, "t", "x.go");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
bool saw_selector = false;
bool saw_bare = false;
for (int i = 0; i < r->calls.count; i++) {
const CBMCall *c = &r->calls.items[i];
if (c->callee_name && strstr(c->callee_name, "Commit") != NULL) {
ASSERT_TRUE(c->is_method);
saw_selector = true;
}
if (c->callee_name && strcmp(c->callee_name, "helper") == 0) {
ASSERT_FALSE(c->is_method);
saw_bare = true;
}
}
ASSERT_TRUE(saw_selector);
ASSERT_TRUE(saw_bare);
cbm_free_result(r);
PASS();
}
/* The bare-call flag is DEPTH-INDEPENDENT, and the binding is UNWOUND when its
* scope closes.
*
@@ -7621,6 +7653,91 @@ TEST(extract_python_bare_call_flag_is_depth_independent) {
PASS();
}
TEST(extract_go_method_receiver_qualified_qn) {
/* A Go method QN carries the receiver type (proj.pkg.Recv.method), same
* shape as C++ out-of-line methods and Go interface members — so two
* same-name methods on different receivers no longer collide in the
* graph upsert. Free functions keep the flat package QN. */
CBMFileResult *r = extract("package m\n"
"type Storage struct{}\n"
"type Cache struct{}\n"
"func (s *Storage) Close() {}\n"
"func (c Cache) Close() {}\n"
"func Shutdown() {}\n",
CBM_LANG_GO, "t", "x.go");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
bool saw_storage = false;
bool saw_cache = false;
bool saw_free = false;
for (int i = 0; i < r->defs.count; i++) {
const CBMDefinition *d = &r->defs.items[i];
if (!d->name || !d->qualified_name) {
continue;
}
if (strcmp(d->name, "Close") == 0 && strcmp(d->qualified_name, "t.Storage.Close") == 0) {
ASSERT_STR_EQ(d->label, "Method");
ASSERT_STR_EQ(d->parent_class, "t.Storage");
saw_storage = true;
}
if (strcmp(d->name, "Close") == 0 && strcmp(d->qualified_name, "t.Cache.Close") == 0) {
ASSERT_STR_EQ(d->parent_class, "t.Cache");
saw_cache = true;
}
if (strcmp(d->name, "Shutdown") == 0) {
ASSERT_STR_EQ(d->qualified_name, "t.Shutdown");
saw_free = true;
}
}
ASSERT_TRUE(saw_storage);
ASSERT_TRUE(saw_cache);
ASSERT_TRUE(saw_free);
cbm_free_result(r);
PASS();
}
/* Templ shares Go's method grammar (method_declaration with a receiver), but
* only Go method QNs are receiver-qualified (#1913); templ's call-scope QN
* stays flat. The method's def QN and the enclosing-function QN of a call
* inside it must agree, or the call has no source node and degrades to the
* File node. */
TEST(extract_templ_method_qn_matches_call_scope) {
CBMFileResult *r = extract("package views\n"
"\n"
"type Page struct{}\n"
"\n"
"func (p Page) Title() string {\n"
"\treturn helper()\n"
"}\n"
"\n"
"func helper() string { return \"x\" }\n"
"\n"
"templ hello(name string) {\n"
"\t<div>{ name }</div>\n"
"}\n",
CBM_LANG_TEMPL, "t", "x.templ");
ASSERT_NOT_NULL(r);
const char *title_qn = NULL;
for (int i = 0; i < r->defs.count; i++) {
const CBMDefinition *d = &r->defs.items[i];
if (d->name && d->qualified_name && strcmp(d->name, "Title") == 0) {
title_qn = d->qualified_name;
}
}
const char *scope = NULL;
for (int i = 0; i < r->calls.count; i++) {
const CBMCall *c = &r->calls.items[i];
if (c->callee_name && strcmp(c->callee_name, "helper") == 0) {
scope = c->enclosing_func_qn;
}
}
ASSERT_NOT_NULL(title_qn);
ASSERT_NOT_NULL(scope);
ASSERT_STR_EQ(scope, title_qn);
cbm_free_result(r);
PASS();
}
/* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above).
* A member call x.foo() with a non-this/super receiver is flagged is_method so
* the resolver can suppress a weak short-name match (`re.test()` must not bind a
@@ -12408,6 +12525,116 @@ TEST(python_namespace_facts_string_statements_and_future_imports) {
PASS();
}
/* A top-level call of a same-module function is a namespace effect, unless the
* callee provably cannot rebind the caller's names: sqlalchemy's
* `sql/__init__.py` ends with `__go(locals())`. Every submodule its body
* imports becomes a package attribute, so those names are shadowed at the
* call; everything else of the namespace survives. */
static bool py_facts_has_effect(const CBMFileResult *r) {
for (int i = 0; r && i < r->py_namespace.count; i++) {
if (r->py_namespace.items[i].kind == CBM_PY_NS_UNKNOWN &&
r->py_namespace.items[i].reason == CBM_PY_NS_REASON_EFFECT)
return true;
}
return false;
}
static int py_facts_count_kind(const CBMFileResult *r, CBMPyNamespaceFactKind kind) {
int n = 0;
for (int i = 0; r && i < r->py_namespace.count; i++)
n += r->py_namespace.items[i].kind == kind;
return n;
}
static bool py_facts_valid_complete(const CBMFileResult *r) {
return r && !r->has_error && r->py_namespace.status == CBM_PY_NS_COMPLETE &&
cbm_py_namespace_facts_valid(&r->py_namespace);
}
TEST(python_namespace_facts_init_call_allowlist) {
static const char go[] = "from typing import TYPE_CHECKING, Any\n"
"from .impl import base\n"
"from .elements import cast\n"
"def __go(lcls: Any) -> None:\n"
" from .. import util as _sa_util\n"
" from . import base\n"
" from . import coercions, elements as _el\n"
" if not TYPE_CHECKING:\n"
" base.coercions = _el.coercions = coercions\n"
" from .annotation import _prepare_annotations\n"
" _prepare_annotations(cast, cast)\n"
" _sa_util.preloaded.import_prefix(\"sqlalchemy.sql\")\n"
"\n"
"__go(locals())\n";
CBMFileResult *r = extract(go, CBM_LANG_PYTHON, "t", "sql/__init__.py");
bool ok = py_facts_valid_complete(r) && !py_facts_has_effect(r) && r->py_namespace.count == 9;
if (ok) {
const CBMPyNamespaceFact *f = r->py_namespace.items;
static const char *const shadowed[] = {"base", "coercions", "elements", "annotation"};
ok = f[2].kind == CBM_PY_NS_IMPORT_NAME && strcmp(f[2].local_name, "base") == 0 &&
f[4].kind == CBM_PY_NS_OWN_DEF && strcmp(f[4].local_name, "__go") == 0;
for (int i = 0; ok && i < 4; i++) {
ok = f[5 + i].kind == CBM_PY_NS_SHADOW && f[5 + i].local_name &&
strcmp(f[5 + i].local_name, shadowed[i]) == 0;
}
}
if (!ok && r) {
for (int i = 0; i < r->py_namespace.count; i++) {
const CBMPyNamespaceFact *f = &r->py_namespace.items[i];
printf(" __go fact %d: kind %d reason %d name %s\n", i, (int)f->kind, (int)f->reason,
f->local_name ? f->local_name : "-");
}
}
if (r)
cbm_free_result(r);
ASSERT_TRUE(ok); /* RED while every top-level call withdraws the namespace */
/* A plain module is no package: the body's imports set attributes of its
* parent package, never of the module itself. */
r = extract("def __go(lcls):\n from . import base\n\n__go(locals())\n", CBM_LANG_PYTHON, "t",
"sql/mod.py");
ok = py_facts_valid_complete(r) && !py_facts_has_effect(r) &&
py_facts_count_kind(r, CBM_PY_NS_SHADOW) == 0;
if (r)
cbm_free_result(r);
ASSERT_TRUE(ok);
/* Each of these can rebind a name of the caller's namespace (or is not
* provably a same-module function): the call stays a whole-namespace
* effect. */
static const char *const effects[][2] = {
{"param", "def __go(lcls):\n lcls['base'] = 1\n\n__go(locals())\n"},
{"global", "def __go(lcls):\n global base\n base = 1\n\n__go(locals())\n"},
{"nested global", "def __go(lcls):\n def inner():\n global base\n"
" base = 1\n inner()\n\n__go(locals())\n"},
{"globals()", "def __go(lcls):\n globals()['base'] = 1\n\n__go(locals())\n"},
{"sys.modules", "def __go(lcls):\n import sys\n sys.modules[__name__].base = 1\n"
"\n__go(locals())\n"},
{"imported callee", "from .setup import __go\n__go(locals())\n"},
{"own package name", "def __go(lcls):\n from .. import sql\n sql.base = 1\n"
"\n__go(locals())\n"},
{"same-module helper", "def helper():\n pass\n\ndef __go(lcls):\n helper()\n"
"\n__go(locals())\n"},
{"rebound callee", "def __go(lcls):\n pass\n\n__go = other\n__go(locals())\n"},
{"call before def", "__go(locals())\n\ndef __go(lcls):\n pass\n"},
{"argument effect", "def __go(lcls):\n pass\n\n__go(make())\n"},
{"rebound locals", "def locals():\n return {}\n\ndef __go(lcls):\n pass\n"
"\n__go(locals())\n"},
};
for (size_t i = 0; i < sizeof(effects) / sizeof(effects[0]); i++) {
char src[512];
snprintf(src, sizeof(src), "from .impl import base\n%s", effects[i][1]);
r = extract(src, CBM_LANG_PYTHON, "t", "sql/__init__.py");
ok = py_facts_valid_complete(r) && py_facts_has_effect(r);
if (!ok)
printf(" control '%s' is no namespace effect\n", effects[i][0]);
if (r)
cbm_free_result(r);
ASSERT_TRUE(ok);
}
PASS();
}
TEST(python_namespace_facts_distinguish_all_operations_and_unknowns) {
static const char source[] = "__all__ = ['old']; __all__ = []\n"
"__all__ = dynamic\n"
@@ -12798,6 +13025,7 @@ SUITE(extraction) {
RUN_TEST(python_namespace_facts_parse_errors_are_complete_unknown);
RUN_TEST(python_namespace_facts_type_checking_alternatives);
RUN_TEST(python_namespace_facts_string_statements_and_future_imports);
RUN_TEST(python_namespace_facts_init_call_allowlist);
RUN_TEST(python_namespace_facts_capture_and_copy_fail_atomically);
RUN_TEST(python_namespace_facts_validation_rejects_partial_and_unknown_schema);
RUN_TEST(python_namespace_facts_compact_spill_and_copy_keep_owned_payload);
@@ -12821,6 +13049,9 @@ SUITE(extraction) {
RUN_TEST(extract_python_member_call_flags_is_method);
RUN_TEST(extract_python_bare_call_flags_locally_bound_callee);
RUN_TEST(extract_python_bare_call_flag_is_depth_independent);
RUN_TEST(extract_go_selector_call_flags_is_method);
RUN_TEST(extract_go_method_receiver_qualified_qn);
RUN_TEST(extract_templ_method_qn_matches_call_scope);
RUN_TEST(extract_ts_member_call_flags_is_method);
RUN_TEST(extract_ts_this_super_receiver_not_flagged);
RUN_TEST(extract_js_member_call_flags_is_method);
+811 -2
View File
@@ -4445,6 +4445,7 @@ static char *gpf_chart_edges(const GpfFile *files, int nfiles) {
"SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT CASE WHEN "
"s.label='File' THEN s.file_path ELSE s.name END || ' -> ' || t.label || ':' || "
"t.name || ' (' || e.type || coalesce(' ' || json_extract(e.properties,'$.ref'), '') "
"|| coalesce(' ' || json_extract(e.properties,'$.phase'), '') "
"|| ')' AS x 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 ((s.label='Chart' AND e.type='DEPLOYS') OR "
"(t.label='Chart' AND e.type='CONFIGURES')) ORDER BY x)";
@@ -4462,8 +4463,8 @@ static char *gpf_chart_edges(const GpfFile *files, int nfiles) {
/* A chart deploys what its templates render: Chart -DEPLOYS {via: chart}->
* each object of its templates/ (a subchart's objects are the subchart's),
* and each Image those objects name literally ({ref: image}) -- a templated
* image names none, and no values default is read. Its values files
* and each Image those objects use ({ref: image}) -- a templated one rendered
* from values.yaml (`nginx`: host-less, kept as written). Its values files
* (values.yaml, values-*.yaml beside Chart.yaml) CONFIGURE it; a values file
* elsewhere (ci/) does not. */
TEST(probe_helm_chart_deploys_its_objects) {
@@ -4492,6 +4493,7 @@ TEST(probe_helm_chart_deploys_its_objects) {
"db -> Image:ghcr.io/acme/db (DEPLOYS image)\n"
"db -> Resource:StatefulSet/db (DEPLOYS)\n"
"web -> Image:ghcr.io/acme/sidecar (DEPLOYS image)\n"
"web -> Image:nginx (DEPLOYS image)\n"
"web -> Resource:Deployment/web (DEPLOYS)\n"
"web -> Resource:Service/web (DEPLOYS)";
if (!got || strcmp(got, want) != 0) {
@@ -4503,6 +4505,793 @@ TEST(probe_helm_chart_deploys_its_objects) {
PASS();
}
/* A chart also deploys the CRDs of its crds/ ({phase: crds}), which Helm
* installs before its templates. werf deploys the `.helm` dir beside its
* werf.yaml as a chart without Chart.yaml, named after the werf project
* (templated werf.yaml lines before it are skipped); a `.helm` with its own
* Chart.yaml stays that chart, and a werf.yaml without `.helm` makes none. */
TEST(probe_helm_chart_crds_and_werf_chart) {
static const GpfFile files[] = {
{"charts/op/Chart.yaml", "apiVersion: v2\nname: op\nversion: 1.0.0\n"},
{"charts/op/crds/widget.yaml",
"apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n"
" name: widgets.acme.io\nspec:\n group: acme.io\n names:\n kind: Widget\n"
" plural: widgets\n scope: Namespaced\n"},
{"charts/op/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: op\n"},
{"site/werf.yaml", "{{- $_ := set . \"Env\" (.Env | default \"prod\") }}\n\n"
"project: site-web\nconfigVersion: 1\n---\nimage: web\nfrom: nginx\n"},
{"site/.helm/values.yaml", "replicas: 1\n"},
{"site/.helm/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: site\nspec:\n"
" replicas: {{ .Values.replicas }}\n"},
{"plain/werf.yaml", "project: plain\nconfigVersion: 1\n"},
{"own/werf.yaml", "project: own-werf\nconfigVersion: 1\n"},
{"own/.helm/Chart.yaml", "apiVersion: v2\nname: own\nversion: 0.1.0\n"},
{"own/.helm/templates/cm.yaml",
"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: own\n"}};
char *got = gpf_chart_edges(files, 10);
static const char want[] =
"op -> Resource:CustomResourceDefinition/widgets.acme.io (DEPLOYS crds)\n"
"op -> Resource:Deployment/op (DEPLOYS)\n"
"own -> Resource:ConfigMap/own (DEPLOYS)\n"
"site-web -> Resource:Deployment/site (DEPLOYS)\n"
"site/.helm/values.yaml -> Chart:site-web (CONFIGURES)";
if (!got || strcmp(got, want) != 0) {
fprintf(stderr, "chart edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
free(got);
FAIL("crds/ and werf chart edge set");
}
free(got);
PASS();
}
/* Every edge into an Image (or out of one into an Image), one line each:
* `source -> Image:name TYPE image=.. [via=..] [ref=..] [default_of=..]
* [override_of=..] [inferred=..] [values_key=..]`, sorted. */
static char *gpf_image_edges(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 s.label || ':' || s.name "
"|| ' -> Image:' || t.name || ' ' || e.type || ' image=' || "
"coalesce(json_extract(e.properties,'$.image'),'-') || "
"coalesce(' via=' || json_extract(e.properties,'$.via'), '') || "
"coalesce(' ref=' || json_extract(e.properties,'$.ref'), '') || "
"coalesce(' default_of=' || json_extract(e.properties,'$.default_of'), '') || "
"coalesce(' override_of=' || json_extract(e.properties,'$.override_of'), '') || "
"coalesce(' inferred=' || json_extract(e.properties,'$.inferred'), '') || "
"coalesce(' values_key=' || json_extract(e.properties,'$.values_key'), '') AS x "
"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 t.label='Image' 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;
}
static bool gpf_expect_image_edges(const GpfFile *files, int n, const char *want) {
char *got = gpf_image_edges(files, n);
bool ok = got && strcmp(got, want) == 0;
if (!ok) {
fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
}
free(got);
return ok;
}
/* An image a default value names keeps its Image, its edge marked
* default_of with the ARGs / variables whose default supplied it (an ARG
* chain names each); a literal FROM is unmarked. A build's arguments (compose
* build.args, docker build --build-arg) make the Image it builds DEPEND_ON
* the base they choose {override_of}; compose's .env replacing an inline
* default is the effective edge's override_of, the default's image kept as
* {ref: image_default} -- one the service does not run. */
TEST(probe_docker_default_images_marked_and_overrides) {
static const GpfFile files[] = {
{"src/web/Dockerfile", "ARG GO=1.22\nARG BASE=golang:${GO}\nFROM ${BASE} AS build\n"
"RUN go build -o /app .\nFROM alpine:3.20\n"
"COPY --from=build /app /app\n"},
{"src/api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"},
{".env", "REG=ghcr.io/acme\nAPI_IMG=ghcr.io/acme/api-custom\n"},
{"compose.yaml", "services:\n web:\n image: ${REG}/web:${TAG:-dev}\n build:\n"
" context: ./src/web\n args:\n GO: \"1.23\"\n"
" api:\n image: ${API_IMG:-ghcr.io/acme/api}\n build: ./src/api\n"},
{"scripts/build.sh", "#!/bin/sh\nIMAGE=${IMAGE:-ghcr.io/acme/api-ci}\n"
"docker build --build-arg PY=3.13 -t \"$IMAGE:1\" src/api\n"}};
static const char want[] =
"Image:ghcr.io/acme/api-ci -> Image:python DEPENDS_ON image=python:3.13-slim via=from "
"override_of=[\"PY\"]\n"
"Image:ghcr.io/acme/web -> Image:golang DEPENDS_ON image=golang:1.23 via=from "
"default_of=[\"BASE\"] override_of=[\"GO\"]\n"
"Module:src/api/Dockerfile -> Image:python DEPENDS_ON image=python:3.12-slim via=from "
"default_of=[\"PY\"]\n"
"Module:src/web/Dockerfile -> Image:alpine DEPENDS_ON image=alpine:3.20 via=from\n"
"Module:src/web/Dockerfile -> Image:golang DEPENDS_ON image=golang:1.22 via=from "
"default_of=[\"GO\",\"BASE\"]\n"
"Resource:Service/api -> Image:ghcr.io/acme/api USAGE image=ghcr.io/acme/api "
"ref=image_default default_of=[\"API_IMG\"]\n"
"Resource:Service/api -> Image:ghcr.io/acme/api-custom USAGE image=ghcr.io/acme/api-custom "
"ref=image override_of=[\"API_IMG\"]\n"
"Resource:Service/web -> Image:ghcr.io/acme/web USAGE image=ghcr.io/acme/web:dev "
"ref=image default_of=[\"TAG\"]";
if (!gpf_expect_image_edges(files, 5, want)) {
FAIL("default-valued images and their in-repo overrides");
}
PASS();
}
/* The chart a Helm template renders (helm_template.c): the container image
* from the chart family's defaults -- an include helper (bitnami
* common.images.image, a dict argument, .Chart.AppVersion), a `with`
* binding, the taken branch of an if / else, a subchart under its alias with
* the parent's values and global. The object USES it {default_of: the values
* keys read}; the chart DEPLOYS it. A value that does not render -- a
* .Release field, lookup, a missing define, a field of a missing map -- names
* no Image. */
TEST(probe_helm_template_images_render_with_defaults) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml",
"apiVersion: v2\nname: app\nversion: 1.2.3\nappVersion: \"2.0.1\"\ndependencies:\n"
" - name: db\n version: 0.1.0\n alias: database\n"},
{"charts/app/values.yaml",
"image:\n registry: ghcr.io\n repository: acme/app\n tag: \"\"\n digest: \"\"\n"
"sidecar:\n image: { repository: docker.io/library/busybox, tag: 1.36 }\nglobal: {}\n"
"database:\n image:\n tag: \"17\"\n"},
{"charts/app/templates/_helpers.tpl",
"{{- define \"common.images.image\" -}}\n"
"{{- $registryName := default .imageRoot.registry ((.global).imageRegistry) -}}\n"
"{{- $termination := .imageRoot.tag | toString -}}\n"
"{{- if not .imageRoot.tag }}\n {{- if .chart }}\n"
" {{- $termination = .chart.AppVersion | toString -}}\n {{- end -}}\n{{- end -}}\n"
"{{- if $registryName }}\n"
"{{- printf \"%s/%s:%s\" $registryName .imageRoot.repository $termination -}}\n"
"{{- else -}}\n{{- printf \"%s:%s\" .imageRoot.repository $termination -}}\n"
"{{- end -}}\n{{- end -}}\n"},
{"charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n image: {{ include "
"\"common.images.image\" (dict \"imageRoot\" .Values.image \"global\" .Values.global "
"\"chart\" .Chart) }}\n - name: side\n {{- with .Values.sidecar.image }}\n"
" image: \"{{ .repository }}:{{ .tag }}\"\n {{- end }}\n"
" - name: rel\n image: \"ghcr.io/acme/{{ .Release.Name }}:1\"\n"
" - name: miss\n image: {{ include \"nope\" . }}\n"
" - name: deep\n image: \"{{ .Values.absent.repository }}\"\n"},
{"charts/app/charts/db/Chart.yaml",
"apiVersion: v2\nname: db\nversion: 0.1.0\nappVersion: \"16.4\"\n"},
{"charts/app/charts/db/values.yaml",
"image:\n repository: bitnami/postgresql\n tag: \"\"\n digest: \"\"\n"},
{"charts/app/charts/db/templates/sts.yaml",
"apiVersion: apps/v1\nkind: StatefulSet\nmetadata:\n name: db\nspec:\n template:\n"
" spec:\n containers:\n - name: db\n {{- if .Values.image.digest }}\n"
" image: \"{{ .Values.global.imageRegistry | default \"docker.io\" }}/"
"{{ .Values.image.repository }}@{{ .Values.image.digest }}\"\n {{- else }}\n"
" image: \"{{ .Values.global.imageRegistry | default \"docker.io\" }}/"
"{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}\"\n"
" {{- end }}\n"}};
static const char want[] =
"Chart:app -> Image:docker.io/library/busybox DEPLOYS image=- via=chart ref=image "
"default_of=[\"sidecar.image.repository\",\"sidecar.image.tag\"]\n"
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.registry\",\"global.imageRegistry\",\"image.tag\","
"\"image.repository\"]\n"
"Chart:db -> Image:docker.io/bitnami/postgresql DEPLOYS image=- via=chart ref=image "
"default_of=[\"global.imageRegistry\",\"image.repository\",\"image.tag\"]\n"
"Resource:Deployment/app -> Image:docker.io/library/busybox USAGE "
"image=docker.io/library/busybox:1.36 ref=image "
"default_of=[\"sidecar.image.repository\",\"sidecar.image.tag\"]\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:2.0.1 "
"ref=image default_of=[\"image.registry\",\"global.imageRegistry\",\"image.tag\","
"\"image.repository\"]\n"
"Resource:StatefulSet/db -> Image:docker.io/bitnami/postgresql USAGE "
"image=docker.io/bitnami/postgresql:17 ref=image "
"default_of=[\"global.imageRegistry\",\"image.repository\",\"image.tag\"]";
if (!gpf_expect_image_edges(files, 7, want)) {
FAIL("Helm template images rendered with the chart family's defaults");
}
PASS();
}
/* A release renders its chart with its values over the defaults: of a chart
* in the repository each image that differs is the release's own {via,
* override_of} -- a Flux HelmRelease's spec.values (a Git source), a
* kustomize helmCharts entry's valuesFile + valuesInline (chartHome charts/),
* a Terraform helm_release's values + set. Of an external chart the values
* name images by convention -- `repository` (+ tag / registry) naming a
* registry host -- {inferred: values}; a host-less repository names none. */
TEST(probe_helm_release_images) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"},
{"flux/app.yaml",
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: app\nspec:\n"
" chart:\n spec:\n chart: ./charts/app\n sourceRef:\n"
" kind: GitRepository\n name: repo\n values:\n image:\n"
" tag: \"9.9\"\n---\n"
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: sonarr\n"
"spec:\n chart:\n spec:\n chart: app-template\n sourceRef:\n"
" kind: HelmRepository\n name: bjw-s\n values:\n controllers:\n"
" main:\n containers:\n main:\n image:\n"
" repository: ghcr.io/home-operations/sonarr\n tag: 4.0.15\n"
" side:\n image:\n repository: busybox\n"
" tag: \"1.36\"\n"},
{"kz/kustomization.yaml",
"apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n"
" - name: app\n releaseName: kapp\n valuesFile: values-prod.yaml\n"
" valuesInline:\n image:\n tag: \"5.5\"\n"},
{"kz/values-prod.yaml", "image:\n repository: quay.io/acme/app\n tag: \"4.4\"\n"},
{"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"kz/charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: kapp\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"},
{"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n"
" value = \"7.7\"\n }\n}\n"}};
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\",\"image.tag\"]\n"
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\",\"image.tag\"]\n"
"Class:resource.helm_release.app -> Image:ghcr.io/acme/app DEPLOYS "
"image=ghcr.io/acme/app:7.7 via=terraform ref=image override_of=[\"image.tag\"]\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image default_of=[\"image.repository\",\"image.tag\"]\n"
"Resource:Deployment/kapp -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image default_of=[\"image.repository\",\"image.tag\"]\n"
"Resource:HelmRelease/app -> Image:ghcr.io/acme/app DEPLOYS image=ghcr.io/acme/app:9.9 "
"via=helmrelease ref=image override_of=[\"image.tag\"]\n"
"Resource:HelmRelease/sonarr -> Image:ghcr.io/home-operations/sonarr DEPLOYS "
"image=ghcr.io/home-operations/sonarr:4.0.15 via=helmrelease ref=image inferred=values "
"values_key=controllers.main.containers.main.image\n"
"Resource:Kustomization -> Image:quay.io/acme/app DEPLOYS image=quay.io/acme/app:5.5 "
"via=helmcharts ref=image override_of=[\"image.repository\",\"image.tag\"]";
if (!gpf_expect_image_edges(files, 10, want)) {
FAIL("release images: local renders and the external values convention");
}
PASS();
}
/* The chart every release test below deploys: an image from two values. */
#define GPF_APP_CHART \
{"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, \
{"charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, \
{ \
"charts/app/templates/deploy.yaml", \
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" \
" spec:\n containers:\n - name: app\n" \
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n" \
}
/* The edges of the chart's defaults (every test below has them). */
#define GPF_APP_DEFAULTS \
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image " \
"default_of=[\"image.repository\",\"image.tag\"]\n" \
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 " \
"ref=image default_of=[\"image.repository\",\"image.tag\"]"
/* A Terraform number set on a helm_release reaches the chart as the string
* Terraform makes of it -- its exact decimal (1.1, 1.10 -> "1.1", 2e3 ->
* "2000"), never a float's %.17g ("1.1000000000000001"). */
TEST(probe_helm_release_terraform_number_set) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n"
" value = 1.10\n }\n}\n"}};
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\",\"image.tag\"]\n"
"Class:resource.helm_release.app -> Image:ghcr.io/acme/app DEPLOYS "
"image=ghcr.io/acme/app:1.1 via=terraform ref=image override_of=[\"image.tag\"]\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image default_of=[\"image.repository\",\"image.tag\"]";
if (!gpf_expect_image_edges(files, 4, want)) {
FAIL("a Terraform number set is its exact decimal");
}
PASS();
}
/* A Flux HelmRelease whose values also come from a source not read --
* valuesFrom, the chart's valuesFiles, a postRenderer that may patch the
* images -- renders no image of its own: its values are not known whole. */
TEST(probe_helm_release_flux_unread_sources) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"flux/app.yaml",
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: files\n"
"spec:\n chart:\n spec:\n chart: ./charts/app\n valuesFiles:\n"
" - charts/app/values-prod.yaml\n sourceRef:\n kind: GitRepository\n"
" name: repo\n values:\n image:\n tag: \"9.9\"\n---\n"
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: from\n"
"spec:\n chart:\n spec:\n chart: ./charts/app\n sourceRef:\n"
" kind: GitRepository\n name: repo\n valuesFrom:\n"
" - kind: ConfigMap\n name: app-values\n values:\n image:\n"
" tag: \"8.8\"\n---\n"
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: post\n"
"spec:\n chart:\n spec:\n chart: ./charts/app\n sourceRef:\n"
" kind: GitRepository\n name: repo\n postRenderers:\n"
" - kustomize:\n images:\n - name: ghcr.io/acme/app\n"
" newName: quay.io/acme/app\n values:\n image:\n tag: \"7.7\"\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a HelmRelease with a source not read has no image of its own");
}
PASS();
}
/* A kustomize helmCharts entry with additionalValuesFiles, or a valuesMerge
* other than the default override, renders no image of its own. */
TEST(probe_helm_release_kustomize_unread_sources) {
static const GpfFile files[] = {
{"kz/kustomization.yaml",
"apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n"
" - name: app\n releaseName: more\n additionalValuesFiles:\n"
" - extra.yaml\n valuesInline:\n image:\n tag: \"5.5\"\n"
" - name: app\n releaseName: merged\n valuesMerge: merge\n"
" valuesInline:\n image:\n tag: \"6.6\"\n"},
{"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"kz/charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a helmCharts entry with a part not read has no image of its own");
}
PASS();
}
/* A Terraform helm_release with a `dynamic "set"` block (entries made by a
* for_each) renders no image of its own, whatever its literal sets say. */
TEST(probe_helm_release_terraform_dynamic_set) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n"
" chart = \"../charts/app\"\n dynamic \"set\" {\n"
" for_each = var.sets\n content {\n name = set.key\n"
" value = set.value\n }\n }\n set {\n name = \"image.tag\"\n"
" value = \"3.3\"\n }\n}\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a helm_release with a dynamic set has no image of its own");
}
PASS();
}
/* A set_sensitive's value is never read: any set_sensitive leaves the
* release without an image of its own, whatever its name -- image.tag or
* db.password (which kept its edge in round 3). A postrender may rewrite
* what a release deploys: no image of its own either. */
TEST(probe_helm_release_terraform_sensitive_and_postrender) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf",
"resource \"helm_release\" \"tagged\" {\n name = \"tagged\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.repository\"\n"
" value = \"quay.io/acme/app\"\n }\n set_sensitive {\n name = \"image.tag\"\n"
" value = var.tag\n }\n}\n"
"resource \"helm_release\" \"pw\" {\n name = \"pw\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.repository\"\n"
" value = \"quay.io/acme/pw\"\n }\n set_sensitive {\n name = \"db.password\"\n"
" value = var.pw\n }\n}\n"
"resource \"helm_release\" \"post\" {\n name = \"post\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n"
" value = \"3.3\"\n }\n postrender {\n binary_path = \"./kustomize.sh\"\n }\n}\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("any set_sensitive and a postrender make release images unknown");
}
PASS();
}
/* A set_sensitive whose name an image reads on another line ($tag, before
* the image line): the release still has no image of its own -- never one
* with the chart's default tag. */
TEST(probe_helm_release_terraform_sensitive_read_earlier) {
static const GpfFile files[] = {
{"charts/web/Chart.yaml", "apiVersion: v2\nname: web\nversion: 1.0.0\nappVersion: \"1.0\"\n"},
{"charts/web/values.yaml", "image:\n repository: ghcr.io/acme/web\n"},
{"charts/web/templates/deploy.yaml",
"{{- $tag := .Values.image.tag | default .Chart.AppVersion }}\n"
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: web\nspec:\n template:\n"
" spec:\n containers:\n - name: web\n"
" image: \"{{ .Values.image.repository }}:{{ $tag }}\"\n"},
{"tf/main.tf",
"resource \"helm_release\" \"web\" {\n name = \"web\"\n"
" chart = \"../charts/web\"\n set {\n name = \"image.repository\"\n"
" value = \"quay.io/acme/web\"\n }\n set_sensitive {\n name = \"image.tag\"\n"
" value = var.tag\n }\n}\n"}};
static const char want[] =
"Chart:web -> Image:ghcr.io/acme/web DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\"]\n"
"Resource:Deployment/web -> Image:ghcr.io/acme/web USAGE image=ghcr.io/acme/web:1.0 "
"ref=image default_of=[\"image.repository\"]";
if (!gpf_expect_image_edges(files, 4, want)) {
FAIL("a set_sensitive read on another line makes the release images unknown");
}
PASS();
}
/* A postrender as a dynamic block or as an attribute (provider v3) may
* rewrite what a release deploys, as a block does: no image of its own. */
TEST(probe_helm_release_terraform_postrender_forms) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf",
"resource \"helm_release\" \"dyn\" {\n name = \"dyn\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n"
" value = \"3.3\"\n }\n dynamic \"postrender\" {\n for_each = var.post\n"
" content {\n binary_path = postrender.value\n }\n }\n}\n"
"resource \"helm_release\" \"attr\" {\n name = \"attr\"\n"
" chart = \"../charts/app\"\n set = [{ name = \"image.tag\", value = \"4.4\" }]\n"
" postrender = { binary_path = \"./kustomize.sh\" }\n}\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a dynamic or attribute postrender makes release images unknown");
}
PASS();
}
/* A subchart included under an alias (redis as `cache`) has .Template.BasePath
* app/charts/cache/templates, as Helm sets it (an image here shows it). Its
* checksum of cm.yaml names its own file, which changes no map -- not app's
* cm.yaml, which sets a value: its image stays known, as does app's (which
* checksums its Secret).
*
* NOT a cross-file guarantee: app's cm.yaml sets .Values.image.tag, and Helm
* renders a chart's files with one shared .Values (a subchart's is the
* parent's .Values.<alias>). Each file renders alone here, so app's image is
* known only because nothing models that set reaching deploy.yaml -- the
* known limit (review round 4, R4-5), whose exact fix is to run the family's
* files in Helm's order with one .Values (the run-included-files follow-up). */
TEST(probe_helm_checksum_include_by_base_path) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\ndependencies:\n"
" - name: redis\n version: 1.0.0\n alias: cache\n"},
{"charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"charts/app/templates/cm.yaml", "{{- $_ := set .Values.image \"tag\" \"2.0\" }}\n"
"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n"},
{"charts/app/templates/secret.yaml", "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n"},
{"charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" metadata:\n annotations:\n checksum/secret: "
"{{ include (print $.Template.BasePath \"/secret.yaml\") . | sha256sum }}\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"},
{"charts/app/charts/redis/Chart.yaml", "apiVersion: v2\nname: redis\nversion: 1.0.0\n"},
{"charts/app/charts/redis/values.yaml",
"image:\n repository: ghcr.io/acme/redis\n tag: \"7.0\"\n"},
{"charts/app/charts/redis/templates/cm.yaml",
"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: redis\n"},
{"charts/app/charts/redis/templates/sts.yaml",
"apiVersion: apps/v1\nkind: StatefulSet\nmetadata:\n name: redis\nspec:\n template:\n"
" metadata:\n annotations:\n checksum/config: "
"{{ include (print $.Template.BasePath \"/cm.yaml\") . | sha256sum }}\n"
" spec:\n containers:\n - name: redis\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"
" - name: path\n image: \"ghcr.io/{{ .Template.BasePath }}:1\"\n"}};
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\",\"image.tag\"]\n"
"Chart:redis -> Image:ghcr.io/acme/redis DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\",\"image.tag\"]\n"
"Chart:redis -> Image:ghcr.io/app/charts/cache/templates DEPLOYS image=- via=chart "
"ref=image\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image default_of=[\"image.repository\",\"image.tag\"]\n"
"Resource:StatefulSet/redis -> Image:ghcr.io/acme/redis USAGE "
"image=ghcr.io/acme/redis:7.0 ref=image default_of=[\"image.repository\",\"image.tag\"]\n"
"Resource:StatefulSet/redis -> Image:ghcr.io/app/charts/cache/templates USAGE "
"image=ghcr.io/app/charts/cache/templates:1 ref=image";
if (!gpf_expect_image_edges(files, 9, want)) {
FAIL("a checksum include by .Template.BasePath names the chart's own file");
}
PASS();
}
/* A kustomization that also transforms what it inflates (images:, patches)
* has no release image of its own: the transformer may change it. */
TEST(probe_helm_release_kustomize_transformers) {
static const GpfFile files[] = {
{"kz/kustomization.yaml",
"apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n"
" - name: app\n releaseName: kapp\n valuesInline:\n image:\n"
" tag: \"1.5\"\nimages:\n - name: ghcr.io/acme/app\n newTag: \"2.0\"\n"},
{"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"kz/charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a kustomization's own transformers make its release images unknown");
}
PASS();
}
/* An empty values.yaml is values {} (Helm's), not values not read: the
* subchart under it still renders its image. */
TEST(probe_helm_empty_values_file_renders) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml",
"apiVersion: v2\nname: app\nversion: 1.0.0\nappVersion: \"1.0\"\n"},
{"charts/app/values.yaml", ""},
{"charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"ghcr.io/acme/{{ .Chart.Name }}:{{ .Chart.AppVersion }}\"\n"}};
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image";
if (!gpf_expect_image_edges(files, 3, want)) {
FAIL("an empty values.yaml renders with {}");
}
PASS();
}
/* A subchart a dependency aliases renders with .Chart.Name its alias (Helm's
* getAliasDependency), so an image naming the chart names the alias. */
TEST(probe_helm_aliased_subchart_chart_name) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml",
"apiVersion: v2\nname: app\nversion: 1.0.0\ndependencies:\n - name: worker\n"
" version: 0.1.0\n alias: worker-high\n"},
{"charts/app/values.yaml", "worker-high: {}\n"},
{"charts/app/charts/worker/Chart.yaml",
"apiVersion: v2\nname: worker\nversion: 0.1.0\nappVersion: \"1.0\"\n"},
{"charts/app/charts/worker/values.yaml", "{}\n"},
{"charts/app/charts/worker/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: worker\nspec:\n template:\n"
" spec:\n containers:\n - name: worker\n"
" image: \"ghcr.io/acme/{{ .Chart.Name }}:{{ .Chart.AppVersion }}\"\n"}};
static const char want[] =
"Chart:worker -> Image:ghcr.io/acme/worker-high DEPLOYS image=- via=chart ref=image\n"
"Resource:Deployment/worker -> Image:ghcr.io/acme/worker-high USAGE "
"image=ghcr.io/acme/worker-high:1.0 ref=image";
if (!gpf_expect_image_edges(files, 5, want)) {
FAIL("an aliased subchart's .Chart.Name is the alias");
}
PASS();
}
/* An image read from a values list keeps its mark on the chart's edge:
* default_of names the list paths (images[0].repository) whole. */
TEST(probe_helm_list_path_mark_on_chart_edge) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"charts/app/values.yaml",
"images:\n - repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ (index .Values.images 0).repository }}:"
"{{ (index .Values.images 0).tag }}\"\n"}};
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"images\",\"images[0].repository\",\"images[0].tag\"]\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image default_of=[\"images\",\"images[0].repository\",\"images[0].tag\"]";
if (!gpf_expect_image_edges(files, 3, want)) {
FAIL("a list path's mark is kept whole on the chart edge");
}
PASS();
}
#ifndef _WIN32
/* gpf_image_edges with symlinks laid in after the files: links[i] names a
* repository path and the absolute or relative target it points at (POSIX
* symlinks: the tests using it skip on Windows). */
static char *gpf_image_edges_linked(const GpfFile *files, int nfiles, const GpfFile *links,
int nlinks) {
GpfProj lp;
memset(&lp, 0, sizeof(lp));
snprintf(lp.tmpdir, sizeof(lp.tmpdir), "/tmp/cbm_gpf_XXXXXX");
if (!cbm_mkdtemp(lp.tmpdir)) {
return NULL;
}
gpf_to_fwd_slashes(lp.tmpdir);
for (int i = 0; i < nfiles + nlinks; i++) {
const GpfFile *f = i < nfiles ? &files[i] : &links[i - nfiles];
char path[700];
snprintf(path, sizeof(path), "%s/%s", lp.tmpdir, f->name);
char *slash = strrchr(path, '/');
if (slash && slash > path + strlen(lp.tmpdir)) {
*slash = '\0';
cbm_mkdir_p(path, 0755);
*slash = '/';
}
if (i >= nfiles) {
if (symlink(f->content, path) != 0) {
return NULL;
}
continue;
}
FILE *fp = fopen(path, "wb");
if (!fp) {
return NULL;
}
fputs(f->content, fp);
fclose(fp);
}
cbm_store_t *store = gpf_open_indexed(&lp);
char *out = NULL;
if (store) {
sqlite3_stmt *st = NULL;
const char *sql =
"SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT s.label || ':' || s.name "
"|| ' -> Image:' || t.name || ' ' || e.type || ' image=' || "
"coalesce(json_extract(e.properties,'$.image'),'-') || "
"coalesce(' via=' || json_extract(e.properties,'$.via'), '') AS x "
"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 t.label='Image' 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;
}
#endif
/* A values file a chart, a kustomization or a Terraform file() names by path
* is read only when it is a file of the repository: a symlink out of it --
* to a file holding an image -- is never followed. The chart whose values
* file is such a link renders nothing (its values are unknown, not empty);
* the releases reading one have no image of their own. */
TEST(probe_helm_values_symlink_not_followed) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX symlink contract");
#else
char outside[256];
snprintf(outside, sizeof(outside), "/tmp/cbm_gpf_out_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(outside));
char leak[300];
snprintf(leak, sizeof(leak), "%s/values.yaml", outside);
FILE *lf = fopen(leak, "wb");
ASSERT_NOT_NULL(lf);
fputs("image:\n repository: ghcr.io/evil/leak\n tag: \"6.6\"\n", lf);
fclose(lf);
const GpfFile files[] = {
GPF_APP_CHART,
{"linked/Chart.yaml", "apiVersion: v2\nname: linked\nversion: 1.0.0\n"},
{"linked/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: linked\nspec:\n template:\n"
" spec:\n containers:\n - name: linked\n"
" image: \"ghcr.io/acme/linked:{{ .Values.tag | default \"latest\" }}\"\n"},
{"kz/kustomization.yaml",
"apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n"
" - name: app\n releaseName: kapp\n valuesFile: prod.yaml\n"},
{"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"kz/charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: kapp\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}};
const GpfFile links[] = {{"linked/values.yaml", leak}, {"kz/prod.yaml", leak}};
char *got = gpf_image_edges_linked(files, 9, links, 2);
th_rmtree(outside);
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n"
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0\n"
"Resource:Deployment/kapp -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0";
bool ok = got && strcmp(got, want) == 0;
if (!ok) {
fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
}
free(got);
if (!ok) {
FAIL("no values read through a symlink out of the repository");
}
PASS();
#endif
}
/* A Terraform helm_release reading its values with file() of a symlink out
* of the repository has no image of its own: the link is never followed. */
TEST(probe_helm_terraform_values_symlink_not_followed) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX symlink contract");
#else
char outside[256];
snprintf(outside, sizeof(outside), "/tmp/cbm_gpf_out_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(outside));
char leak[300];
snprintf(leak, sizeof(leak), "%s/vals.yaml", outside);
FILE *lf = fopen(leak, "wb");
ASSERT_NOT_NULL(lf);
fputs("image:\n repository: ghcr.io/evil/leak\n tag: \"6.6\"\n", lf);
fclose(lf);
const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n"
" chart = \"../charts/app\"\n values = [file(\"vals.yaml\")]\n}\n"}};
const GpfFile links[] = {{"tf/vals.yaml", leak}};
char *got = gpf_image_edges_linked(files, 4, links, 1);
th_rmtree(outside);
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0";
bool ok = got && strcmp(got, want) == 0;
if (!ok) {
fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
}
free(got);
if (!ok) {
FAIL("no Terraform values read through a symlink out of the repository");
}
PASS();
#endif
}
/* A build argument holding a secret -- by its name (*_TOKEN) or its value (a
* GitHub token) -- is never kept on a build edge; the others still choose the
* base image they set. */
TEST(probe_docker_build_args_keep_no_secret) {
static const GpfFile files[] = {
{"web/Dockerfile", "ARG GO=1.22\nFROM golang:${GO}\n"},
{"api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"},
{".env", "NPM_TOKEN=npm-secret-value\n"},
{"compose.yaml", "services:\n web:\n image: ghcr.io/acme/web\n build:\n"
" context: ./web\n args:\n NPM_TOKEN: ${NPM_TOKEN}\n"
" GO: \"1.23\"\n"},
{"scripts/build.sh",
"#!/bin/sh\ndocker build --build-arg CI_KEY=ghp_abcdefghijklmnopqrstuvwxyz0123456789AB "
"--build-arg PY=3.13 -t ghcr.io/acme/api:1 api\n"}};
int secrets = gpf_query_int(files, 5,
"SELECT count(*) FROM edges WHERE project=?1 AND "
"(properties LIKE '%npm-secret-value%' OR properties LIKE "
"'%ghp_abcdefghij%' OR properties LIKE '%NPM_TOKEN%')");
int overrides = gpf_query_int(files, 5,
"SELECT count(*) FROM edges WHERE project=?1 AND "
"json_extract(properties,'$.override_of') IS NOT NULL");
ASSERT_EQ(secrets, 0);
ASSERT_EQ(overrides, 2);
PASS();
}
/* Build arguments of any length are kept whole on the build edge (never cut
* to malformed JSON): the one choosing the base still makes its edge. */
TEST(probe_docker_long_build_args_kept_whole) {
enum { ARGS = 40, VAL = 150, CAP = ARGS * (VAL + 40) + 512 };
char *script = malloc(CAP);
ASSERT_NOT_NULL(script);
int o = snprintf(script, CAP, "#!/bin/sh\ndocker build");
char val[VAL + 1];
memset(val, 'v', VAL);
val[VAL] = '\0';
for (int i = 0; i < ARGS; i++) {
o += snprintf(script + o, (size_t)(CAP - o), " --build-arg LABEL%d=%s", i, val);
}
snprintf(script + o, (size_t)(CAP - o), " --build-arg PY=3.13 -t ghcr.io/acme/api:1 api\n");
const GpfFile files[] = {{"api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"},
{"scripts/build.sh", script}};
int overrides =
gpf_query_int(files, 2,
"SELECT count(*) FROM edges e JOIN nodes t ON t.id=e.target_id WHERE "
"e.project=?1 AND t.name='python' AND "
"json_extract(e.properties,'$.override_of') IS NOT NULL");
free(script);
ASSERT_EQ(overrides, 1);
PASS();
}
/* A template's `include "name"` calls the define of that name in its chart
* family -- the chart and its subcharts share one template namespace -- never
* a same-named define of an unrelated chart; a define may also be written in
@@ -6313,6 +7102,26 @@ SUITE(grammar_probe_f) {
RUN_TEST(probe_terraform_helm_release_deploys_its_chart);
RUN_TEST(probe_helm_templated_objects_are_resources);
RUN_TEST(probe_helm_chart_deploys_its_objects);
RUN_TEST(probe_helm_chart_crds_and_werf_chart);
RUN_TEST(probe_docker_default_images_marked_and_overrides);
RUN_TEST(probe_helm_template_images_render_with_defaults);
RUN_TEST(probe_helm_release_images);
RUN_TEST(probe_helm_release_terraform_number_set);
RUN_TEST(probe_helm_release_flux_unread_sources);
RUN_TEST(probe_helm_release_kustomize_unread_sources);
RUN_TEST(probe_helm_release_terraform_dynamic_set);
RUN_TEST(probe_helm_release_terraform_sensitive_and_postrender);
RUN_TEST(probe_helm_release_terraform_sensitive_read_earlier);
RUN_TEST(probe_helm_release_terraform_postrender_forms);
RUN_TEST(probe_helm_checksum_include_by_base_path);
RUN_TEST(probe_helm_release_kustomize_transformers);
RUN_TEST(probe_helm_empty_values_file_renders);
RUN_TEST(probe_helm_aliased_subchart_chart_name);
RUN_TEST(probe_helm_list_path_mark_on_chart_edge);
RUN_TEST(probe_helm_values_symlink_not_followed);
RUN_TEST(probe_helm_terraform_values_symlink_not_followed);
RUN_TEST(probe_docker_build_args_keep_no_secret);
RUN_TEST(probe_docker_long_build_args_kept_whole);
RUN_TEST(probe_helm_include_resolves_in_the_chart_family);
RUN_TEST(probe_k8s_selector_stays_in_its_directory);
RUN_TEST(probe_k8s_selector_within_kustomize_build);
+3 -2
View File
@@ -4080,8 +4080,9 @@ TEST(parallel_go_cross_package_field_chain_resolves) {
cbm_gbuf_t *gbuf = run_go_field_chain_sequential("go_field_fold", tmpdir, files, 2);
ASSERT_NOT_NULL(gbuf);
const cbm_gbuf_edge_t *edge =
find_call_edge_to_target_fragment(gbuf, "handler.PlaceOrder", ".service.PlaceOrder");
/* Since #1909, Go method QNs are receiver-qualified on both endpoints. */
const cbm_gbuf_edge_t *edge = find_call_edge_to_target_fragment(
gbuf, "OrderHandler.PlaceOrder", ".service.OrderService.PlaceOrder");
const bool found = edge != NULL;
const bool dispatch = edge && edge->properties_json &&
strstr(edge->properties_json, "\"strategy\":\"lsp_type_dispatch\"");
+1367 -1
View File
File diff suppressed because it is too large. Load diff
+53
View File
@@ -1673,10 +1673,61 @@ TEST(weak_call_guards_share_one_drop_list) {
strategies[i], member, binding);
}
ASSERT_EQ(member, binding);
/* The Go selector guard builds on the same list. At a penalized
* confidence (0.75 * 0.5) it agrees with it exactly; its one narrowing
* (an unpenalized unique_name) does not apply here. Any drift from
* the shared list fails here. */
bool go = cbm_go_suppress_weak_method_match(true, true, strategies[i], 0.375);
bool go_expected = member;
if (go != go_expected) {
printf(" Go drop-list divergence on strategy \"%s\": go=%d expected=%d\n",
strategies[i], go, go_expected);
}
ASSERT_EQ(go, go_expected);
}
PASS();
}
TEST(go_suppress_drops_weak_selector_matches) {
/* Go selector call with an untyped receiver, landed via a receiver-blind
* short-name strategy → drop (same failure class as #592/#606).
* suffix_match/fuzzy drop at any confidence; unique_name drops only when
* import-unreachability-penalized (CONF_UNIQUE_NAME 0.75 * 0.5 = 0.375 —
* the `io.Copy` -> project `Copy` stdlib-hijack shape). */
ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "suffix_match", 0.9));
ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "suffix_match", 0.11));
ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "fuzzy", 0.9));
ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "unique_name", 0.375));
/* field_type_hint is a receiver-NAME substring match, not type evidence
* (F-G9): weak for Go like the shared list. */
ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "field_type_hint", 0.85));
PASS();
}
TEST(go_suppress_keeps_typed_and_import_aware_matches) {
/* Unpenalized unique_name = lone candidate inside the caller's import
* closure (or an import-free file, e.g. same-package) — a genuinely-typed
* lone-candidate call never enters the field-type-hint upgrade, so it must
* survive (lrp_go_s8_field_type_hint). */
ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "unique_name", 0.75));
ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "same_module", 0.9));
ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "import_map", 0.95));
ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "import_map_suffix", 0.9));
ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "qualified_suffix", 0.9));
ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "callee_suffix", 0.5));
ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "service_pattern", 0.5));
ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "lsp_strategy_cross_file", 0.92));
ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "lsp_direct", 0.95));
/* A bare call (is_method=false) is a free-function call → never suppressed. */
ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, false, "suffix_match", 0.11));
/* Non-Go languages are never affected by this gate. */
ASSERT_FALSE(cbm_go_suppress_weak_method_match(false, true, "suffix_match", 0.11));
/* No match (NULL/empty strategy) → nothing to suppress. */
ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, NULL, 0.5));
ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "", 0.5));
PASS();
}
/* ── Suite ─────────────────────────────────────────────────────── */
/* Method call THROUGH an imported symbol that is itself an indexed node
@@ -2235,6 +2286,8 @@ SUITE(registry) {
RUN_TEST(python_import_binding_contradicts_only_foreign_chains);
RUN_TEST(python_external_base_contradicts_django_shapes);
RUN_TEST(weak_call_guards_share_one_drop_list);
RUN_TEST(go_suppress_drops_weak_selector_matches);
RUN_TEST(go_suppress_keeps_typed_and_import_aware_matches);
RUN_TEST(registry_python_function_local_is_exact_only_issue1548);
RUN_TEST(registry_resolve_lexical_walks_enclosing_defs_issue1548);
RUN_TEST(registry_python_local_bound_at_own_file_issue1548);