diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4026ff47d..b86300127 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,7 +89,7 @@ Language support is split between two layers: Languages like **Dockerfile**, **docker-compose**, **Kubernetes manifests**, and **Kustomize** do not use tree-sitter grammars. Instead they follow an *infra-pass* pattern: 1. **Detection helpers** in `src/pipeline/pass_infrascan.c` — functions like `cbm_is_dockerfile()`, `cbm_is_k8s_manifest()`, `cbm_is_kustomize_file()` identify files by name and/or content heuristics (e.g., presence of `apiVersion:`). -2. **Custom extractors** in `src/pipeline/extract_k8s.c` (or `extract_infra.c`) — hand-written parsers that walk the raw YAML/text and populate `CBMFileResult` with imports and definitions. +2. **Custom extractors** in `internal/cbm/extract_k8s.c` — tree-sitter-based parsers that walk the YAML AST (using the tree-sitter YAML grammar) and populate `CBMFileResult` with imports and definitions. 3. **Pipeline pass** (`pass_k8s.c`, `pass_infrascan.c`) — calls the extractor and emits graph nodes/edges. K8s manifests emit `Resource` nodes; Kustomize files emit `Module` nodes with `IMPORTS` edges to referenced resource files. **When adding a new infrastructure language:** diff --git a/internal/cbm/extract_k8s.c b/internal/cbm/extract_k8s.c index da54b9efb..26bbb693a 100644 --- a/internal/cbm/extract_k8s.c +++ b/internal/cbm/extract_k8s.c @@ -57,7 +57,7 @@ static const char *get_scalar_text(CBMArena *a, TSNode node, const char *source) static int is_kustomize_list_key(const char *key) { return (strcmp(key, "resources") == 0 || strcmp(key, "bases") == 0 || strcmp(key, "patches") == 0 || strcmp(key, "components") == 0 || - strcmp(key, "patchesStrategicMerge") == 0); + strcmp(key, "patchesStrategicMerge") == 0 || strcmp(key, "crds") == 0); } // --------------------------------------------------------------------------- @@ -196,10 +196,8 @@ static void extract_k8s_scalars(CBMExtractCtx *ctx, TSNode mapping, char *kind_b } } else if (strcmp(key, "metadata") == 0) { // Descend into metadata block_mapping to find "name" + // val_node is already unwrapped from block_node above. TSNode meta_mapping = val_node; - if (strcmp(ts_node_type(meta_mapping), "block_node") == 0) { - meta_mapping = ts_node_named_child(meta_mapping, 0); - } if (ts_node_is_null(meta_mapping) || strcmp(ts_node_type(meta_mapping), "block_mapping") != 0) { continue; @@ -269,7 +267,7 @@ static void extract_k8s_manifest(CBMExtractCtx *ctx) { CBMDefinition def = {0}; def.name = cbm_arena_strdup(a, def_name); def.qualified_name = cbm_arena_sprintf(a, "%s.%s", ctx->module_qn, def_name); - def.label = "Resource"; + def.label = cbm_arena_strdup(a, "Resource"); def.file_path = ctx->rel_path; def.start_line = ts_node_start_point(mapping).row + 1; def.end_line = ts_node_end_point(mapping).row + 1; diff --git a/src/discover/language.c b/src/discover/language.c index 9e883074c..ca9177051 100644 --- a/src/discover/language.c +++ b/src/discover/language.c @@ -279,6 +279,11 @@ static const filename_entry_t FILENAME_TABLE[] = { {"meson_options.txt", CBM_LANG_MESON}, {"kustomization.yaml", CBM_LANG_KUSTOMIZE}, {"kustomization.yml", CBM_LANG_KUSTOMIZE}, + /* Note: FILENAME_TABLE uses case-sensitive strcmp, so mixed-case variants + * (e.g. "Kustomization.yaml") are not matched here. They fall through to + * CBM_LANG_YAML and are re-classified by cbm_is_kustomize_file() in + * pass_k8s.c, which performs a case-insensitive comparison. This is the + * intended behaviour — no additional entries are needed. */ {".vimrc", CBM_LANG_VIMSCRIPT}, }; diff --git a/src/pipeline/pass_infrascan.c b/src/pipeline/pass_infrascan.c index a59cbb8cd..1e30b71f7 100644 --- a/src/pipeline/pass_infrascan.c +++ b/src/pipeline/pass_infrascan.c @@ -207,10 +207,7 @@ bool cbm_is_k8s_manifest(const char *name, const char *content) { return false; } char buf[4097]; - size_t n = strlen(content); - if (n > 4096) { - n = 4096; - } + size_t n = strnlen(content, 4096); memcpy(buf, content, n); buf[n] = '\0'; return ci_strstr(buf, "apiVersion:") != NULL; diff --git a/src/pipeline/pass_k8s.c b/src/pipeline/pass_k8s.c index 3113f6278..2771a4ff8 100644 --- a/src/pipeline/pass_k8s.c +++ b/src/pipeline/pass_k8s.c @@ -142,24 +142,15 @@ static void handle_kustomize(cbm_pipeline_ctx_t *ctx, const char *path, const ch /* ── K8s manifest handler ────────────────────────────────────────── */ +/* source/src_len are the already-read file bytes (caller retains ownership and + * must free after this call returns). */ static void handle_k8s_manifest(cbm_pipeline_ctx_t *ctx, const char *path, const char *rel_path, - CBMFileResult *result) { + const char *source, int src_len) { + (void)path; /* retained for symmetry; source is always provided now */ int resource_count = 0; - CBMFileResult *res = result; - bool allocated = false; - - if (!res) { - /* Fall back to re-extraction */ - int src_len = 0; - char *source = k8s_read_file(path, &src_len); - if (source) { - res = cbm_extract_file(source, src_len, CBM_LANG_K8S, ctx->project_name, rel_path, - CBM_EXTRACT_BUDGET, NULL, NULL); - free(source); - allocated = true; - } - } + CBMFileResult *res = cbm_extract_file(source, src_len, CBM_LANG_K8S, ctx->project_name, + rel_path, CBM_EXTRACT_BUDGET, NULL, NULL); if (!res) { return; } @@ -191,9 +182,7 @@ static void handle_k8s_manifest(cbm_pipeline_ctx_t *ctx, const char *path, const resource_count++; } - if (allocated) { - cbm_free_result(res); - } + cbm_free_result(res); cbm_log_info("pass.k8s.manifest", "file", rel_path, "resources", itoa_k8s(resource_count)); } @@ -226,38 +215,20 @@ int cbm_pipeline_pass_k8s(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, handle_kustomize(ctx, path, rel, cached); kustomize_count++; } else if (lang == CBM_LANG_YAML || lang == CBM_LANG_K8S) { - /* Need source content for cbm_is_k8s_manifest check */ - if (cached) { - /* Use cached result — the file is a k8s manifest if lang is CBM_LANG_K8S, - * or if we check the source. With a cached result available, trust the - * language field set during discovery. */ - if (lang == CBM_LANG_K8S) { - handle_k8s_manifest(ctx, path, rel, cached); + /* Read source once to classify (and reuse for uncached extraction). */ + int src_len = 0; + char *source = k8s_read_file(path, &src_len); + if (source) { + if (cbm_is_k8s_manifest(base, source)) { + /* Always re-extract with CBM_LANG_K8S regardless of any cached + * result: cached results were produced during the parallel YAML + * pass and contain no "Resource" definitions. Pass the already- + * read source buffer so handle_k8s_manifest does not re-read. */ + (void)cached; /* cached YAML result intentionally discarded */ + handle_k8s_manifest(ctx, path, rel, source, src_len); manifest_count++; - } else { - /* CBM_LANG_YAML: need source to confirm apiVersion presence */ - int src_len = 0; - char *source = k8s_read_file(path, &src_len); - if (source) { - if (cbm_is_k8s_manifest(base, source)) { - handle_k8s_manifest(ctx, path, rel, cached); - manifest_count++; - } - free(source); - } - } - } else { - /* No cached result — read source to classify */ - int src_len = 0; - char *source = k8s_read_file(path, &src_len); - if (source) { - if (cbm_is_k8s_manifest(base, source)) { - /* Pass NULL result — handle_k8s_manifest will re-extract */ - handle_k8s_manifest(ctx, path, rel, NULL); - manifest_count++; - } - free(source); } + free(source); } } } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 18a792cc8..87966acaa 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4232,6 +4232,45 @@ TEST(k8s_extract_manifest_no_name) { PASS(); } +TEST(k8s_extract_manifest_multidoc) { + /* Two-document YAML separated by "---". + * extract_k8s_manifest contains a "break" after the first successful push, + * so it processes only the first document that has both kind and + * metadata.name. This test pins that behaviour: the first document's + * resource must be present and no crash must occur. + * + * Note: with some tree-sitter YAML grammar versions the root stream may + * expose both documents as siblings; the break still fires after the first + * successful def push, so defs.count must be exactly 1. */ + const char *src = + "apiVersion: apps/v1\n" + "kind: Deployment\n" + "metadata:\n" + " name: my-app\n" + "---\n" + "apiVersion: v1\n" + "kind: Service\n" + "metadata:\n" + " name: my-svc\n"; + CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S, + "myproj", "k8s/multi.yaml", 0, NULL, NULL); + ASSERT(r != NULL); + ASSERT(!r->has_error); + /* First document's resource must be present */ + int found = 0; + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].label && strcmp(r->defs.items[i].label, "Resource") == 0 && + r->defs.items[i].name && strcmp(r->defs.items[i].name, "Deployment/my-app") == 0) { + found = 1; + } + } + ASSERT(found); + /* At least one def, no more than one (only first document processed) */ + ASSERT(r->defs.count >= 1); + cbm_free_result(r); + PASS(); +} + /* ── Envscan tests (port of envscan_test.go) ───────────────────── */ /* Helper: write a file inside a temp dir */ @@ -5182,6 +5221,7 @@ SUITE(pipeline) { RUN_TEST(k8s_extract_kustomize); RUN_TEST(k8s_extract_manifest); RUN_TEST(k8s_extract_manifest_no_name); + RUN_TEST(k8s_extract_manifest_multidoc); /* Env URL scanning */ RUN_TEST(envscan_dockerfile_env_urls); RUN_TEST(envscan_shell_env_urls);