mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge branch 'main' into docs/windows-troubleshooting
This commit is contained in:
41 files changed
+10108
-266
No files matched your search
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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"}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user