diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index fd80382dd..b6241d545 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -613,6 +613,47 @@ static bool is_infra_file(const char *fp) { strstr(fp, ".tf") != NULL || strstr(fp, ".hcl") != NULL || strstr(fp, ".toml") != NULL); } +/* CI/tooling configs describe the development TOOLCHAIN — their URLs are + * repository/action/registry references, never endpoints this service + * exposes. Minting infra Route nodes from them lets the route matcher's + * root-service heuristic attach every handler of an ambiguous "/" route to + * each tooling URL (junk HANDLES churn on plain pallets/flask, #999). + * Deny by file identity, not URL shape: deployment configs (Cloud + * Scheduler, compose) keep minting their genuine endpoints. */ +static bool is_ci_tooling_config(const char *fp) { + if (!fp) { + return false; + } + if (strstr(fp, ".github/") != NULL || strstr(fp, ".gitlab/") != NULL || + strstr(fp, ".circleci/") != NULL) { + return true; + } + const char *slash = strrchr(fp, '/'); + const char *base = slash ? slash + 1 : fp; + static const char *const tooling[] = {".pre-commit-config.yaml", + ".pre-commit-hooks.yaml", + ".gitlab-ci.yml", + ".travis.yml", + "azure-pipelines.yml", + "appveyor.yml", + "bitbucket-pipelines.yml", + ".readthedocs.yaml", + ".readthedocs.yml", + "codecov.yml", + ".codecov.yml", + ".goreleaser.yaml", + ".goreleaser.yml", + ".golangci.yml", + ".golangci.yaml", + NULL}; + for (int i = 0; tooling[i]; i++) { + if (strcmp(base, tooling[i]) == 0) { + return true; + } + } + return false; +} + /* True when a YAML key path denotes an UPSTREAM dependency, CONFIG value, or * HEALTHCHECK target rather than an endpoint this service exposes. Such URLs * (auth JWKS, downstream service base URLs, package-registry URLs, healthcheck @@ -681,7 +722,8 @@ static void cbm_pipeline_extract_infra_routes(cbm_gbuf_t *gbuf, const cbm_file_i CBMHashTable *denied = cbm_ht_create(16); for (int pass = 0; pass < 2; pass++) { for (int i = 0; i < file_count; i++) { - if (!result_cache[i] || !is_infra_file(files[i].rel_path)) { + if (!result_cache[i] || !is_infra_file(files[i].rel_path) || + is_ci_tooling_config(files[i].rel_path)) { continue; } for (int si = 0; si < result_cache[i]->string_refs.count; si++) { diff --git a/tests/test_lang_contract.c b/tests/test_lang_contract.c index f9f91066f..54d396aaa 100644 --- a/tests/test_lang_contract.c +++ b/tests/test_lang_contract.c @@ -1202,6 +1202,100 @@ TEST(contract_edge_imports_alias_resolves_real_file_issue767) { PASS(); } +/* #999: URLs in CI/tooling configs (.pre-commit-config.yaml, .github/ + * workflows) are repository references for TOOLING, not endpoints this + * service exposes. They must not mint __route__infra__ Route nodes — the + * route matcher's root-service heuristic otherwise attaches every handler + * of an ambiguous "/" route to each junk URL (HANDLES churn on plain + * pallets/flask). */ +static int count_infra_routes_matching(cbm_store_t *store, const char *project, + const char *substr) { + cbm_node_t *nodes = NULL; + int count = 0; + if (cbm_store_find_nodes_by_label(store, project, "Route", &nodes, &count) != CBM_STORE_OK) + return -1; + int hits = 0; + for (int i = 0; i < count; i++) { + const char *qn = nodes[i].qualified_name; + if (qn && strncmp(qn, "__route__infra__", (sizeof("__route__infra__") - 1)) == 0 && + strstr(qn, substr)) + hits++; + } + cbm_store_free_nodes(nodes, count); + return hits; +} + +TEST(contract_edge_no_infra_routes_from_ci_configs_issue999) { + LangProj lp; + static const LangFile f[] = { + {"app.py", "from flask import Flask\n" + "app = Flask(__name__)\n" + "\n" + "@app.route(\"/\")\n" + "def index():\n" + " return \"hi\"\n" + "\n" + "@app.route(\"/auth/login\")\n" + "def login():\n" + " return \"login\"\n"}, + {".pre-commit-config.yaml", "repos:\n" + " - repo: https://github.com/astral-sh/ruff-pre-commit\n" + " rev: v0.1.0\n" + " hooks:\n" + " - id: ruff\n" + " - repo: https://github.com/pre-commit/pre-commit-hooks\n" + " rev: v4.5.0\n" + " hooks:\n" + " - id: trailing-whitespace\n"}, + {".github/workflows/ci.yml", "name: CI\n" + "on: [push]\n" + "jobs:\n" + " build:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + " - run: curl https://coverage.example.io/upload\n"}}; + cbm_store_t *store = lang_index_files(&lp, f, 3); + ASSERT_NOT_NULL(store); + /* No tooling URL may materialize as an infra Route node. */ + int gh_routes = count_infra_routes_matching(store, lp.project, "github.com"); + int wf_routes = count_infra_routes_matching(store, lp.project, "coverage.example.io"); + if (gh_routes != 0 || wf_routes != 0) { + fprintf(stderr, + " [999] FAIL infra routes from CI configs: github.com=%d workflows=%d " + "(expected 0/0)\n", + gh_routes, wf_routes); + } + ASSERT_EQ(gh_routes, 0); + ASSERT_EQ(wf_routes, 0); + lang_cleanup(&lp, store); + PASS(); +} + +/* #999 inverse guard: genuine infra endpoint URLs (Cloud Scheduler push + * targets and similar deployment configs) must STILL mint infra Route + * nodes — the CI/tooling deny must be file-scoped, not a blanket URL kill. */ +TEST(contract_edge_infra_routes_from_deploy_configs_still_minted) { + LangProj lp; + static const LangFile f[] = { + {"scheduler.yaml", + "jobs:\n" + " - name: nightly-sync\n" + " schedule: \"0 3 * * *\"\n" + " push_endpoint: https://sync.internal.example/api/v1/sync\n"}}; + cbm_store_t *store = lang_index_files(&lp, f, 1); + ASSERT_NOT_NULL(store); + int routes = count_infra_routes_matching(store, lp.project, "sync.internal.example"); + if (routes < 1) { + fprintf(stderr, " [999] FAIL deploy-config endpoint minted %d infra routes " + "(expected >=1)\n", + routes); + } + ASSERT_TRUE(routes >= 1); + lang_cleanup(&lp, store); + PASS(); +} + /* DEPENDS_ON — Helm Chart.yaml `dependencies:` -> per-dependency Chart node. * Basename must be exactly "Chart.yaml"; pass_k8s runs in both pipeline paths. */ TEST(contract_edge_depends_on) { @@ -1411,6 +1505,8 @@ SUITE(lang_contract) { RUN_TEST(contract_edge_workspaces_imports_issue408); RUN_TEST(contract_edge_imports_alias_no_phantom_folder_edge_issue767); RUN_TEST(contract_edge_imports_alias_resolves_real_file_issue767); + RUN_TEST(contract_edge_no_infra_routes_from_ci_configs_issue999); + RUN_TEST(contract_edge_infra_routes_from_deploy_configs_still_minted); RUN_TEST(contract_edge_depends_on); RUN_TEST(contract_edge_parallel_service_edges); RUN_TEST(contract_edge_file_changes_with);