Merge branch 'main' into fix/pi-extension-tool-schemas
This commit is contained in:
@@ -11,7 +11,10 @@ permissions:
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# 15 min proved too tight on a slow runner day and a timed-out lint reads
|
||||
# as "cancelled", which the release graph must treat as a hard stop; keep
|
||||
# a bound, but one only a genuine hang can hit (normal runtime ~5 min).
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
|
||||
@@ -46,8 +46,29 @@ jobs:
|
||||
uses: ./.github/workflows/_security.yml
|
||||
secrets: inherit
|
||||
|
||||
# ── 0. Preflight: refuse malformed dispatch inputs ──────────────
|
||||
# The tag is inputs.version VERBATIM. A bare "0.10.7" publishes a release the
|
||||
# installers can never resolve (they fetch releases/download/v<version>/...),
|
||||
# and with immutable releases the mis-named tag cannot be retagged, deleted,
|
||||
# or its name reused — the name is burned permanently (2026-08-18 incident).
|
||||
preflight:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Enforce v-prefixed semver version input
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "version OK: $VERSION"
|
||||
else
|
||||
echo "::error::version must be v-prefixed semver (vX.Y.Z), got '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 1. Lint (cppcheck + clang-format) ───────────────────────────
|
||||
lint:
|
||||
needs: [preflight]
|
||||
uses: ./.github/workflows/_lint.yml
|
||||
|
||||
# ── 2. Tests (all platforms, full suite for release) ────────────
|
||||
@@ -65,11 +86,19 @@ jobs:
|
||||
shard_suites: true
|
||||
|
||||
# ── 3. Build all platforms ──────────────────────────────────────
|
||||
# !cancelled() && !failure(): run when `test` is deliberately skipped, but
|
||||
# never when lint or test actually failed.
|
||||
# `test` may be skipped ONLY by the skip_tests input. A skipped `test` is
|
||||
# also what a cancelled/timed-out lint produces (needs-cascade), and the
|
||||
# bare !cancelled() && !failure() form cannot tell those apart: failure()
|
||||
# does not cover a needed job that was CANCELLED, so a lint timeout let the
|
||||
# whole pipeline publish with the test matrix silently skipped
|
||||
# (v0.10.7 incident, 2026-08-18). Require the explicit results.
|
||||
build:
|
||||
if: ${{ !cancelled() && !failure() }}
|
||||
needs: [test]
|
||||
if: >-
|
||||
${{ !cancelled()
|
||||
&& needs.lint.result == 'success'
|
||||
&& (needs.test.result == 'success'
|
||||
|| (inputs.skip_tests && needs.test.result == 'skipped')) }}
|
||||
needs: [lint, test]
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
@@ -92,7 +121,7 @@ jobs:
|
||||
# an optional phase needs this override, or the phase being optional silently
|
||||
# makes the phases after it optional as well.
|
||||
smoke:
|
||||
if: ${{ !cancelled() && !failure() }}
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
needs: [build]
|
||||
uses: ./.github/workflows/_smoke.yml
|
||||
with:
|
||||
@@ -100,7 +129,7 @@ jobs:
|
||||
|
||||
# ── 5. Soak tests ──────────────────────────────────────────────
|
||||
soak:
|
||||
if: ${{ !cancelled() && !failure() }}
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
needs: [build]
|
||||
uses: ./.github/workflows/_soak.yml
|
||||
with:
|
||||
|
||||
@@ -753,4 +753,17 @@ void cbm_extract_k8s(CBMExtractCtx *ctx);
|
||||
// `label` may be NULL (returns false). Defined in helpers.c.
|
||||
bool cbm_label_is_type_like(const char *label);
|
||||
|
||||
// True for data-relation labels (Table, View — SQL DDL). Relations resolve as
|
||||
// lineage targets only: registry members, but never type-like and never valid
|
||||
// CALLS/THROWS/READS/WRITES targets. `label` may be NULL. Defined in helpers.c.
|
||||
bool cbm_label_is_relation(const char *label);
|
||||
|
||||
// True for labels admitted to the cross-file name registry: Function, Method,
|
||||
// every type-like container, Variable, Field, and the relation labels. Single
|
||||
// source of truth for registry seeding — the full (pass_definitions.c),
|
||||
// parallel (pass_parallel.c) and incremental (pipeline_incremental.c) pipelines
|
||||
// all seed through this predicate so their registries never diverge.
|
||||
// `label` may be NULL (returns false). Defined in helpers.c.
|
||||
bool cbm_label_is_registry_symbol(const char *label);
|
||||
|
||||
#endif // CBM_H
|
||||
|
||||
@@ -275,12 +275,31 @@ static TSNode resolve_ocaml_func_name(TSNode node) {
|
||||
return null_node;
|
||||
}
|
||||
|
||||
// SQL: resolve create_function name from object_reference→identifier or direct identifier.
|
||||
// Last identifier (DFS pre-order) under `node`. For a schema-qualified
|
||||
// object_reference (schema.table) this is the table name; the schema prefix is
|
||||
// ignored. Leaves *found false and returns `best` unchanged if none is present.
|
||||
static TSNode sql_last_identifier(TSNode node, TSNode best, bool *found) {
|
||||
if (strcmp(ts_node_type(node), "identifier") == 0) {
|
||||
best = node;
|
||||
*found = true;
|
||||
}
|
||||
uint32_t cc = ts_node_child_count(node);
|
||||
for (uint32_t i = 0; i < cc; i++) {
|
||||
best = sql_last_identifier(ts_node_child(node, i), best, found);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// SQL: resolve create_function / create_table / create_view name. The name sits
|
||||
// on an object_reference; for a schema-qualified name (schema.table) take the
|
||||
// last identifier (the table), not the first (the schema).
|
||||
static TSNode resolve_sql_func_name(TSNode node) {
|
||||
TSNode obj_ref = cbm_find_child_by_kind(node, "object_reference");
|
||||
if (!ts_node_is_null(obj_ref)) {
|
||||
TSNode id = cbm_find_child_by_kind(obj_ref, "identifier");
|
||||
if (!ts_node_is_null(id)) {
|
||||
bool found = false;
|
||||
TSNode empty = {0};
|
||||
TSNode id = sql_last_identifier(obj_ref, empty, &found);
|
||||
if (found) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -4024,6 +4043,65 @@ static bool extract_config_class_def(CBMExtractCtx *ctx, TSNode node, const char
|
||||
return true;
|
||||
}
|
||||
|
||||
// Collect FROM/JOIN table references (tree-sitter-sql `relation` nodes) anywhere
|
||||
// under `node` and emit them as usages scoped to enclosing_qn. pass_usages then
|
||||
// resolves each ref_name to the referenced Table/View def and creates a USAGE
|
||||
// lineage edge (e.g. a view -> the tables it selects from). Emitting them here
|
||||
// (rather than via the generic identifier walker) sets the correct enclosing
|
||||
// scope and bypasses the is_definition_name suppression that drops them.
|
||||
static void collect_sql_relation_usages(CBMExtractCtx *ctx, TSNode node, const char *enclosing_qn) {
|
||||
if (strcmp(ts_node_type(node), "relation") == 0) {
|
||||
TSNode nm = resolve_sql_func_name(node); // object_reference -> identifier
|
||||
if (!ts_node_is_null(nm)) {
|
||||
char *tname = cbm_node_text(ctx->arena, nm, ctx->source);
|
||||
if (tname && tname[0]) {
|
||||
CBMUsage usage = {0};
|
||||
usage.ref_name = tname;
|
||||
usage.enclosing_func_qn = enclosing_qn;
|
||||
cbm_usages_push(&ctx->result->usages, ctx->arena, usage);
|
||||
}
|
||||
}
|
||||
}
|
||||
uint32_t n = ts_node_child_count(node);
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
collect_sql_relation_usages(ctx, ts_node_child(node, i), enclosing_qn);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle SQL DDL relation defs: CREATE TABLE / VIEW / MATERIALIZED VIEW become
|
||||
// first-class Table/View nodes rather than generic Variable nodes. The relation
|
||||
// name sits on an object_reference child (the same shape create_function uses),
|
||||
// so resolve_sql_func_name locates it. Also emits FROM/JOIN dependencies as
|
||||
// usages so lineage edges form. Returns true if handled.
|
||||
static bool extract_sql_ddl_class_def(CBMExtractCtx *ctx, TSNode node, const char *kind) {
|
||||
if (ctx->language != CBM_LANG_SQL) {
|
||||
return false;
|
||||
}
|
||||
const char *label;
|
||||
if (strcmp(kind, "create_table") == 0) {
|
||||
label = "Table";
|
||||
} else if (strcmp(kind, "create_view") == 0 || strcmp(kind, "create_materialized_view") == 0) {
|
||||
label = "View";
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
TSNode name_node = resolve_sql_func_name(node);
|
||||
if (ts_node_is_null(name_node)) {
|
||||
return false;
|
||||
}
|
||||
char *name = cbm_node_text(ctx->arena, name_node, ctx->source);
|
||||
if (!name || !name[0]) {
|
||||
return false;
|
||||
}
|
||||
push_simple_class_def(ctx, node, name, label);
|
||||
// Must match push_simple_class_def's QN exactly (qn_safe_segment included)
|
||||
// or pass_usages cannot find the enclosing def for the lineage source.
|
||||
const char *qn =
|
||||
cbm_fqn_compute(ctx->arena, ctx->project, ctx->rel_path, qn_safe_segment(ctx->arena, name));
|
||||
collect_sql_relation_usages(ctx, node, qn);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) {
|
||||
CBMArena *a = ctx->arena;
|
||||
const char *kind = ts_node_type(node);
|
||||
@@ -4031,6 +4109,9 @@ static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec
|
||||
if (extract_config_class_def(ctx, node, kind)) {
|
||||
return;
|
||||
}
|
||||
if (extract_sql_ddl_class_def(ctx, node, kind)) {
|
||||
return;
|
||||
}
|
||||
|
||||
TSNode name_node = ts_node_child_by_field_name(node, TS_FIELD("name"));
|
||||
// ObjC: class name is first identifier child
|
||||
|
||||
@@ -166,6 +166,31 @@ bool cbm_label_is_type_like(const char *label) {
|
||||
strcmp(label, "Type") == 0 || strcmp(label, "Trait") == 0;
|
||||
}
|
||||
|
||||
// True when `label` names a data relation (SQL CREATE TABLE / CREATE VIEW).
|
||||
// Relations live in the registry so FROM/JOIN lineage can resolve, but they are
|
||||
// deliberately NOT type-like: they must never satisfy inheritance, impl-receiver,
|
||||
// semantic-type, or LSP-registrar lookups, and resolver fallbacks treat them as
|
||||
// lineage-only targets (see cbm_label_is_registry_symbol call sites).
|
||||
bool cbm_label_is_relation(const char *label) {
|
||||
if (!label) {
|
||||
return false;
|
||||
}
|
||||
return strcmp(label, "Table") == 0 || strcmp(label, "View") == 0;
|
||||
}
|
||||
|
||||
// True when `label` belongs in the cross-file name registry (see cbm.h). Single
|
||||
// source of truth for every registry-seeding site — full, parallel and
|
||||
// incremental pipelines MUST admit the same set or an incremental re-resolve
|
||||
// diverges from a clean full reindex.
|
||||
bool cbm_label_is_registry_symbol(const char *label) {
|
||||
if (!label) {
|
||||
return false;
|
||||
}
|
||||
return strcmp(label, "Function") == 0 || strcmp(label, "Method") == 0 ||
|
||||
cbm_label_is_type_like(label) || strcmp(label, "Variable") == 0 ||
|
||||
strcmp(label, "Field") == 0 || cbm_label_is_relation(label);
|
||||
}
|
||||
|
||||
bool cbm_is_keyword(const char *name, CBMLanguage lang) {
|
||||
if (!name || !name[0]) {
|
||||
return true;
|
||||
|
||||
@@ -656,13 +656,17 @@ static const char *hcl_call_types[] = {"function_call", NULL};
|
||||
static const char *hcl_var_types[] = {"attribute", NULL};
|
||||
|
||||
// ==================== SQL ====================
|
||||
static const char *sql_func_types[] = {"create_function", "function_declaration", NULL};
|
||||
static const char *sql_func_types[] = {"create_function", "function_declaration",
|
||||
"create_procedure", NULL};
|
||||
static const char *sql_field_types[] = {"column_definition", NULL};
|
||||
static const char *sql_class_types[] = {"custom_type", NULL};
|
||||
// create_table/create_view route through the class-def path where
|
||||
// extract_sql_ddl_class_def turns them into first-class Table/View nodes
|
||||
// (previously they were generic Variable nodes via sql_var_types).
|
||||
static const char *sql_class_types[] = {"custom_type", "create_table", "create_view",
|
||||
"create_materialized_view", NULL};
|
||||
static const char *sql_module_types[] = {"program", NULL};
|
||||
static const char *sql_call_types[] = {"invocation", NULL};
|
||||
static const char *sql_branch_types[] = {"if_statement", "case_expression", NULL};
|
||||
static const char *sql_var_types[] = {"create_table", "create_view", NULL};
|
||||
|
||||
// ==================== DOCKERFILE ====================
|
||||
static const char *dockerfile_module_types[] = {"source_file", NULL};
|
||||
@@ -1840,7 +1844,7 @@ static const CBMLangSpec lang_specs[CBM_LANG_COUNT] = {
|
||||
// CBM_LANG_SQL
|
||||
[CBM_LANG_SQL] = {CBM_LANG_SQL, sql_func_types, sql_class_types, sql_field_types,
|
||||
sql_module_types, sql_call_types, empty_types, empty_types, sql_branch_types,
|
||||
sql_var_types, empty_types, empty_types, NULL, empty_types, NULL, NULL,
|
||||
empty_types, empty_types, empty_types, NULL, empty_types, NULL, NULL,
|
||||
tree_sitter_sql, NULL},
|
||||
|
||||
// CBM_LANG_DOCKERFILE
|
||||
|
||||
@@ -108,5 +108,8 @@ enum { SKIP_ONE = 1, PAIR_LEN = 2 };
|
||||
#define CBM_SQL_TYPE_LIKE_LABELS "'Class','Struct','Interface','Enum','Type','Trait'"
|
||||
#define CBM_SQL_CALLABLE_LABELS "'Function','Method'"
|
||||
#define CBM_SQL_CALLABLE_OR_TYPE_LABELS CBM_SQL_CALLABLE_LABELS "," CBM_SQL_TYPE_LIKE_LABELS
|
||||
/* SQL mirror of cbm_label_is_relation() (Table/View — data-lineage nodes),
|
||||
* pinned by tests/test_store_nodes.c the same way as the sets above. */
|
||||
#define CBM_SQL_RELATION_LABELS "'Table','View'"
|
||||
|
||||
#endif /* CBM_CONSTANTS_H */
|
||||
|
||||
@@ -3041,6 +3041,9 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
|
||||
" - CASE WHEN n.label IN ('Function','Method') THEN 10.0 "
|
||||
" WHEN n.label = 'Route' THEN 8.0 "
|
||||
" WHEN n.label IN (" CBM_SQL_TYPE_LIKE_LABELS ") THEN 5.0 "
|
||||
/* Relations rank with the type tier: a table IS the schema container
|
||||
* a data question is looking for (findability-first). */
|
||||
" WHEN n.label IN (" CBM_SQL_RELATION_LABELS ") THEN 5.0 "
|
||||
" ELSE 0.0 END) AS rank "
|
||||
"FROM ("
|
||||
" SELECT rowid, bm25(nodes_fts) AS base_rank"
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cbm.h" /* cbm_label_is_relation — reg-only surface membership */
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/sha256.h"
|
||||
#include "yyjson/yyjson.h"
|
||||
@@ -30,12 +31,13 @@ enum { SURFACE_CODEC_VERSION = 1 };
|
||||
/* Labels the incremental name registry serves that pxc_map_label does NOT
|
||||
* carry into the CBMLSPDef set. Their (name, qn, label) triple must still
|
||||
* participate in the surface hash, or renaming one would slip past the
|
||||
* early cutoff while stale references to it survive in dependent files.
|
||||
* KEEP IN SYNC with pxc_map_label (pass_lsp_cross.c) and
|
||||
* incr_label_is_registry_symbol (pipeline_incremental.c); the codec unit
|
||||
* test cross-checks the three. */
|
||||
* early cutoff while stale references to it survive in dependent files —
|
||||
* for Table/View that means a renamed table keeping stale FROM/JOIN lineage
|
||||
* edges from dependent SQL files. KEEP IN SYNC with pxc_map_label
|
||||
* (pass_lsp_cross.c) and incr_label_is_registry_symbol
|
||||
* (pipeline_incremental.c); the codec unit test cross-checks the three. */
|
||||
static bool surface_reg_only_label(const char *label) {
|
||||
return label && strcmp(label, "Field") == 0;
|
||||
return label && (strcmp(label, "Field") == 0 || cbm_label_is_relation(label));
|
||||
}
|
||||
|
||||
static void add_str_or_null(yyjson_mut_doc *doc, yyjson_mut_val *obj, const char *key,
|
||||
|
||||
@@ -327,18 +327,12 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const
|
||||
int64_t node_id = cbm_gbuf_upsert_node(
|
||||
ctx->gbuf, def->label ? def->label : "Function", def->name, def->qualified_name,
|
||||
def->file_path ? def->file_path : rel, (int)def->start_line, (int)def->end_line, props);
|
||||
/* Register callable symbols + every type-like container (Class/Struct/
|
||||
* Interface/Enum/Type/Trait). Type-like defs must be in the registry so
|
||||
* `class Foo : IBar` (INHERITS), `impl Trait for S` (IMPLEMENTS), and method/
|
||||
* field resolution can reach them — Struct included so Rust/Go/Swift/D structs
|
||||
* resolve as type targets just as a Class did. Variable/Field defs are also
|
||||
* registered so pass_usages.c can resolve READS/WRITES accesses (rw->var_name)
|
||||
* to a Variable/Field node QN.
|
||||
* KEEP IN SYNC with pass_parallel.c and pipeline_incremental.c's seed sets. */
|
||||
if (node_id > 0 && def->label &&
|
||||
(strcmp(def->label, "Function") == 0 || strcmp(def->label, "Method") == 0 ||
|
||||
cbm_label_is_type_like(def->label) || strcmp(def->label, "Variable") == 0 ||
|
||||
strcmp(def->label, "Field") == 0)) {
|
||||
/* Registry membership is defined ONCE by cbm_label_is_registry_symbol
|
||||
* (helpers.c): callables + type-like containers (INHERITS/IMPLEMENTS/method/
|
||||
* field resolution), Variable/Field (READS/WRITES resolution), and Table/View
|
||||
* (SQL FROM/JOIN lineage). pass_parallel.c and pipeline_incremental.c seed
|
||||
* through the same predicate, so the three registries cannot diverge. */
|
||||
if (node_id > 0 && cbm_label_is_registry_symbol(def->label)) {
|
||||
cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label);
|
||||
}
|
||||
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__");
|
||||
|
||||
@@ -1200,14 +1200,9 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d
|
||||
if (!def->name || !def->qualified_name || !def->label) {
|
||||
return 0;
|
||||
}
|
||||
/* Register callable symbols + every type-like container (Class/Struct/
|
||||
* Interface/Enum/Type/Trait) — see pass_definitions.c for rationale. Struct
|
||||
* included so Rust/Go/Swift/D structs resolve as type targets. Variable/Field
|
||||
* defs are registered too so READS/WRITES can resolve.
|
||||
* KEEP IN SYNC with pass_definitions.c and pipeline_incremental.c. */
|
||||
if (strcmp(def->label, "Function") == 0 || strcmp(def->label, "Method") == 0 ||
|
||||
cbm_label_is_type_like(def->label) || strcmp(def->label, "Variable") == 0 ||
|
||||
strcmp(def->label, "Field") == 0) {
|
||||
/* Registry membership is defined ONCE by cbm_label_is_registry_symbol
|
||||
* (helpers.c) — see pass_definitions.c for the per-label rationale. */
|
||||
if (cbm_label_is_registry_symbol(def->label)) {
|
||||
cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label);
|
||||
(*reg_entries)++;
|
||||
}
|
||||
@@ -2626,8 +2621,17 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws,
|
||||
if (semantic_reference) {
|
||||
continue;
|
||||
}
|
||||
cbm_resolution_t res = cbm_registry_resolve(rc->registry, usage->ref_name, module_qn,
|
||||
imp_keys, imp_vals, imp_count);
|
||||
/* SQL usages are FROM/JOIN lineage refs and may bind Table/View
|
||||
* targets (cbm_registry_resolve_lineage); every other language
|
||||
* resolves through the default variant, whose central relation
|
||||
* veto keeps same-named code identifiers out of the lineage layer.
|
||||
* Must mirror the sequential twin (pass_usages.c) exactly. */
|
||||
cbm_resolution_t res =
|
||||
(lang == CBM_LANG_SQL)
|
||||
? cbm_registry_resolve_lineage(rc->registry, usage->ref_name, module_qn,
|
||||
imp_keys, imp_vals, imp_count)
|
||||
: cbm_registry_resolve(rc->registry, usage->ref_name, module_qn, imp_keys,
|
||||
imp_vals, imp_count);
|
||||
if (!res.qualified_name || res.qualified_name[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -192,8 +192,16 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res
|
||||
if (semantic_reference) {
|
||||
continue;
|
||||
}
|
||||
cbm_resolution_t res = cbm_registry_resolve(ctx->registry, usage->ref_name, module_qn,
|
||||
imp_keys, imp_vals, imp_count);
|
||||
/* SQL usages are FROM/JOIN lineage refs and may bind Table/View
|
||||
* targets (cbm_registry_resolve_lineage); every other language
|
||||
* resolves through the default variant, whose central relation
|
||||
* veto keeps same-named code identifiers out of the lineage layer. */
|
||||
cbm_resolution_t res =
|
||||
(lang == CBM_LANG_SQL)
|
||||
? cbm_registry_resolve_lineage(ctx->registry, usage->ref_name, module_qn,
|
||||
imp_keys, imp_vals, imp_count)
|
||||
: cbm_registry_resolve(ctx->registry, usage->ref_name, module_qn, imp_keys,
|
||||
imp_vals, imp_count);
|
||||
if (!res.qualified_name || res.qualified_name[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
+12
-1
@@ -211,11 +211,22 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified
|
||||
|
||||
/* Resolve a callee name using prioritized strategies.
|
||||
* import_map: NULL-terminated array of {local_name, resolved_qn} pairs, or NULL.
|
||||
* Returns result with qualified_name="" if unresolved. */
|
||||
* Returns result with qualified_name="" if unresolved.
|
||||
* Never returns a data relation (Table/View): relations are lineage-only
|
||||
* registry members and common table names (users, orders, config) collide with
|
||||
* code identifiers in every language, so the default resolve vetoes them
|
||||
* centrally instead of relying on per-consumer label checks. */
|
||||
cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *callee_name,
|
||||
const char *module_qn, const char **import_map_keys,
|
||||
const char **import_map_vals, int import_map_count);
|
||||
|
||||
/* Relation-permitting resolve for SQL FROM/JOIN lineage usages ONLY — the one
|
||||
* consumer allowed to bind Table/View targets. Uncached (the per-file resolve
|
||||
* cache stores the default variant's relation-vetoed answers). */
|
||||
cbm_resolution_t cbm_registry_resolve_lineage(const cbm_registry_t *r, const char *callee_name,
|
||||
const char *module_qn, const char **import_map_keys,
|
||||
const char **import_map_vals, int import_map_count);
|
||||
|
||||
/* Per-file memoization cache for is_import_reachable. Thread-local —
|
||||
* each resolve worker owns its own cache. Call _begin at the start
|
||||
* of resolve_file_calls (or any per-file resolve loop) and _end at
|
||||
|
||||
@@ -1020,20 +1020,15 @@ static void incr_free_edge_capture(cbm_edge_capture_t *cap) {
|
||||
|
||||
/* ── Registry seed visitor ────────────────────────────────────────── */
|
||||
|
||||
/* Labels the full-index definition pass seeds into the registry
|
||||
* (pass_definitions.c — KEEP IN SYNC). Incremental re-resolution must see the
|
||||
* SAME symbol set, or it diverges from a clean full reindex: seeding extra
|
||||
* container nodes (File / Module / Folder / ...) lets a type usage like `Word`
|
||||
* resolve to the same-named Module node instead of the Class node. Only
|
||||
* callable / declared symbols belong in the registry. */
|
||||
/* Labels the full-index definition pass seeds into the registry. Incremental
|
||||
* re-resolution must see the SAME symbol set, or it diverges from a clean full
|
||||
* reindex: seeding extra container nodes (File / Module / Folder / ...) lets a
|
||||
* type usage like `Word` resolve to the same-named Module node instead of the
|
||||
* Class node. Membership is defined once by cbm_label_is_registry_symbol
|
||||
* (helpers.c) — the same predicate pass_definitions.c / pass_parallel.c seed
|
||||
* through, so divergence is impossible by construction. */
|
||||
static bool incr_label_is_registry_symbol(const char *label) {
|
||||
/* Mirror pass_definitions.c / pass_parallel.c registry seeding EXACTLY:
|
||||
* callables + every type-like container (Class/Struct/Interface/Enum/Type/
|
||||
* Trait) + Variable/Field. Struct included so an incremental re-resolve seeds
|
||||
* the same struct type nodes a full reindex would. */
|
||||
return label && (strcmp(label, "Function") == 0 || strcmp(label, "Method") == 0 ||
|
||||
cbm_label_is_type_like(label) || strcmp(label, "Variable") == 0 ||
|
||||
strcmp(label, "Field") == 0);
|
||||
return cbm_label_is_registry_symbol(label);
|
||||
}
|
||||
|
||||
/* Callback for cbm_gbuf_foreach_node: seed the registry with the existing
|
||||
|
||||
+56
-18
@@ -25,6 +25,7 @@ enum { REG_MAX_CANDIDATES = 256 };
|
||||
|
||||
#define DEFAULT_CONFIDENCE 0.5
|
||||
#include "pipeline/pipeline.h"
|
||||
#include "cbm.h" /* cbm_label_is_relation — the resolve-time relation veto */
|
||||
#include "foundation/compat.h" /* CBM_TLS */
|
||||
#include "foundation/hash_table.h"
|
||||
#include "foundation/dyn_array.h"
|
||||
@@ -868,24 +869,11 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char
|
||||
return empty_result();
|
||||
}
|
||||
|
||||
cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *callee_name,
|
||||
const char *module_qn, const char **import_map_keys,
|
||||
const char **import_map_vals, int import_map_count) {
|
||||
if (!r || !callee_name) {
|
||||
return empty_result();
|
||||
}
|
||||
|
||||
/* Per-file cache: same callee_name in N call sites → 1 chain walk
|
||||
* + N-1 O(1) hash hits. module_qn is constant per file so the
|
||||
* cache key only needs callee_name. */
|
||||
if (_resolve_cache) {
|
||||
resolve_cache_entry_t *cached =
|
||||
(resolve_cache_entry_t *)cbm_ht_get(_resolve_cache, callee_name);
|
||||
if (cached) {
|
||||
return cached->res;
|
||||
}
|
||||
}
|
||||
|
||||
/* The strategy chain shared by both public resolve variants (no caching here —
|
||||
* cbm_registry_resolve owns the per-file cache). */
|
||||
static cbm_resolution_t registry_resolve_chain(const cbm_registry_t *r, const char *callee_name,
|
||||
const char *module_qn, const char **import_map_keys,
|
||||
const char **import_map_vals, int import_map_count) {
|
||||
/* Split callee at the first path separator: "pkg.Func" → prefix="pkg",
|
||||
* suffix="Func". Rust/C++ use "::" rather than ".", so honor whichever
|
||||
* separator appears first ("lib::square" → prefix="lib", suffix="square").
|
||||
@@ -924,6 +912,41 @@ cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *calle
|
||||
/* Strategy 3+4: name lookup */
|
||||
res = resolve_name_lookup(r, callee_name, module_qn, import_map_vals, import_map_count);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *callee_name,
|
||||
const char *module_qn, const char **import_map_keys,
|
||||
const char **import_map_vals, int import_map_count) {
|
||||
if (!r || !callee_name) {
|
||||
return empty_result();
|
||||
}
|
||||
|
||||
/* Per-file cache: same callee_name in N call sites → 1 chain walk
|
||||
* + N-1 O(1) hash hits. module_qn is constant per file so the
|
||||
* cache key only needs callee_name. */
|
||||
if (_resolve_cache) {
|
||||
resolve_cache_entry_t *cached =
|
||||
(resolve_cache_entry_t *)cbm_ht_get(_resolve_cache, callee_name);
|
||||
if (cached) {
|
||||
return cached->res;
|
||||
}
|
||||
}
|
||||
|
||||
cbm_resolution_t res = registry_resolve_chain(r, callee_name, module_qn, import_map_keys,
|
||||
import_map_vals, import_map_count);
|
||||
|
||||
/* Data relations (Table/View) are lineage-only registry members: common
|
||||
* table names (users, orders, config) collide with code identifiers across
|
||||
* every language, so the DEFAULT resolve never returns them — a veto, not a
|
||||
* re-route, so a name-collision does not fall through to a weaker strategy.
|
||||
* Every consumer (CALLS/USAGE/READS/WRITES/THROWS/handlers/decorators,
|
||||
* present and future) is thereby relation-safe by construction. The SQL
|
||||
* lineage path opts in via cbm_registry_resolve_lineage. */
|
||||
if (res.qualified_name && res.qualified_name[0] &&
|
||||
cbm_label_is_relation(cbm_registry_label_of(r, res.qualified_name))) {
|
||||
res = empty_result();
|
||||
}
|
||||
|
||||
/* Cache the result (including empty — caching the negative answer
|
||||
* is just as valuable; same name asks the same question). */
|
||||
@@ -942,6 +965,21 @@ cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *calle
|
||||
return res;
|
||||
}
|
||||
|
||||
cbm_resolution_t cbm_registry_resolve_lineage(const cbm_registry_t *r, const char *callee_name,
|
||||
const char *module_qn, const char **import_map_keys,
|
||||
const char **import_map_vals, int import_map_count) {
|
||||
if (!r || !callee_name) {
|
||||
return empty_result();
|
||||
}
|
||||
/* Relation-permitting variant for SQL FROM/JOIN lineage usages ONLY.
|
||||
* Deliberately uncached: the per-file cache is keyed by bare callee_name
|
||||
* and stores the relation-vetoed answer of the default variant — sharing
|
||||
* it would poison one variant with the other's semantics. SQL files hold
|
||||
* few distinct relation refs, so the chain walk stays cheap. */
|
||||
return registry_resolve_chain(r, callee_name, module_qn, import_map_keys, import_map_vals,
|
||||
import_map_count);
|
||||
}
|
||||
|
||||
/* ── Fuzzy Resolve ──────────────────────────────────────────────── */
|
||||
|
||||
/* Filter candidates by import reachability. Returns count of reachable. */
|
||||
|
||||
+6
-3
@@ -6026,9 +6026,11 @@ static int arch_boundaries(cbm_store_t *s, const char *project, const char *path
|
||||
char like[CBM_SZ_512];
|
||||
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
|
||||
char nsqlbuf[ST_SQL_BUF];
|
||||
/* Relations included: a view->table USAGE across package boundaries is
|
||||
* data-lineage coupling and belongs in the architecture picture. */
|
||||
const char *nbase =
|
||||
"SELECT id, qualified_name, file_path FROM nodes WHERE project=?1 AND label IN "
|
||||
"(" CBM_SQL_CALLABLE_OR_TYPE_LABELS ")";
|
||||
"(" CBM_SQL_CALLABLE_OR_TYPE_LABELS "," CBM_SQL_RELATION_LABELS ")";
|
||||
if (scoped) {
|
||||
snprintf(nsqlbuf, sizeof(nsqlbuf), "%s%s ORDER BY id", nbase, arch_path_scope_sql());
|
||||
} else {
|
||||
@@ -6181,7 +6183,7 @@ static int arch_packages_from_qn(cbm_store_t *s, const char *project, const char
|
||||
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
|
||||
char qsqlbuf[ST_SQL_BUF];
|
||||
const char *qbase = "SELECT qualified_name FROM nodes WHERE project=?1 AND label IN "
|
||||
"(" CBM_SQL_CALLABLE_OR_TYPE_LABELS ")";
|
||||
"(" CBM_SQL_CALLABLE_OR_TYPE_LABELS "," CBM_SQL_RELATION_LABELS ")";
|
||||
if (scoped) {
|
||||
snprintf(qsqlbuf, sizeof(qsqlbuf), "%s%s", qbase, arch_path_scope_sql());
|
||||
} else {
|
||||
@@ -7457,7 +7459,8 @@ static int arch_clusters(cbm_store_t *s, const char *project, const char *path,
|
||||
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
|
||||
char nsqlbuf[ST_SQL_BUF];
|
||||
const char *nbase = "SELECT id, name, qualified_name, file_path FROM nodes "
|
||||
"WHERE project=?1 AND label IN (" CBM_SQL_CALLABLE_OR_TYPE_LABELS ")";
|
||||
"WHERE project=?1 AND label IN (" CBM_SQL_CALLABLE_OR_TYPE_LABELS
|
||||
"," CBM_SQL_RELATION_LABELS ")";
|
||||
if (scoped) {
|
||||
snprintf(nsqlbuf, sizeof(nsqlbuf), "%s%s ORDER BY id LIMIT ?4", nbase,
|
||||
arch_path_scope_sql());
|
||||
|
||||
@@ -1816,6 +1816,58 @@ TEST(sql_function) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(sql_ddl_node_labels) {
|
||||
CBMFileResult *r = extract("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);\n"
|
||||
"CREATE VIEW active_users AS SELECT * FROM users;\n",
|
||||
CBM_LANG_SQL, "t", "schema.sql");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
ASSERT(has_def(r, "Table", "users"));
|
||||
ASSERT(has_def(r, "View", "active_users"));
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(sql_view_lineage_usages) {
|
||||
/* A view's FROM/JOIN relations are emitted as usages (ref_name = table),
|
||||
* which pass_usages later resolves into view -> table USAGE lineage edges. */
|
||||
CBMFileResult *r = extract("CREATE TABLE users (id INTEGER);\n"
|
||||
"CREATE VIEW active_users AS SELECT * FROM users;\n",
|
||||
CBM_LANG_SQL, "t", "schema.sql");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
int found_users = 0;
|
||||
for (int i = 0; i < r->usages.count; i++) {
|
||||
if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, "users") == 0) {
|
||||
found_users = 1;
|
||||
}
|
||||
}
|
||||
ASSERT(found_users);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(sql_schema_qualified_name) {
|
||||
/* schema-qualified DDL (schema.table) is named by the table, not the schema,
|
||||
* and FROM schema.table resolves to that table for lineage. */
|
||||
CBMFileResult *r = extract("CREATE TABLE app.users (id INTEGER);\n"
|
||||
"CREATE VIEW app.active AS SELECT * FROM app.users;\n",
|
||||
CBM_LANG_SQL, "t", "schema.sql");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
ASSERT(has_def(r, "Table", "users"));
|
||||
ASSERT(has_def(r, "View", "active"));
|
||||
int found_users = 0;
|
||||
for (int i = 0; i < r->usages.count; i++) {
|
||||
if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, "users") == 0) {
|
||||
found_users = 1;
|
||||
}
|
||||
}
|
||||
ASSERT(found_users);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* --- Meson project --- */
|
||||
TEST(meson_project) {
|
||||
CBMFileResult *r = extract(
|
||||
@@ -5500,6 +5552,9 @@ SUITE(extraction) {
|
||||
/* Config/Markup */
|
||||
RUN_TEST(html_elements);
|
||||
RUN_TEST(sql_function);
|
||||
RUN_TEST(sql_ddl_node_labels);
|
||||
RUN_TEST(sql_view_lineage_usages);
|
||||
RUN_TEST(sql_schema_qualified_name);
|
||||
RUN_TEST(meson_project);
|
||||
RUN_TEST(css_rules);
|
||||
RUN_TEST(scss_rules);
|
||||
|
||||
@@ -186,7 +186,7 @@ static const LabelGolden LABEL_GOLDENS[] = {
|
||||
{"toml", "Class:1,Module:1,Variable:1"},
|
||||
{"ini", "Class:1,Module:1,Variable:1"},
|
||||
{"csv", "Module:1"},
|
||||
{"sql", "Module:1,Variable:1"},
|
||||
{"sql", "Module:1,Table:1"},
|
||||
{"xml", "Class:2,Module:1"},
|
||||
{"html", "Module:1"},
|
||||
{"css", "Module:1"},
|
||||
|
||||
@@ -157,6 +157,7 @@ typedef struct {
|
||||
int modules;
|
||||
int classes;
|
||||
int variables;
|
||||
int tables;
|
||||
int sections;
|
||||
int imports; /* IMPORTS edges */
|
||||
int depends; /* DEPENDS_ON edges */
|
||||
@@ -172,6 +173,7 @@ static GpgMetrics gpg_metrics_files(const GpgFile *files, int nfiles) {
|
||||
m.modules = gpg_count_label(store, lp.project, "Module");
|
||||
m.classes = gpg_count_label(store, lp.project, "Class");
|
||||
m.variables = gpg_count_label(store, lp.project, "Variable");
|
||||
m.tables = gpg_count_label(store, lp.project, "Table");
|
||||
m.sections = gpg_count_label(store, lp.project, "Section");
|
||||
m.imports = cbm_store_count_edges_by_type(store, lp.project, "IMPORTS");
|
||||
m.depends = cbm_store_count_edges_by_type(store, lp.project, "DEPENDS_ON");
|
||||
@@ -605,12 +607,12 @@ TEST(probe_csv_module_only) {
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
* GROUP 13 — SQL (.sql)
|
||||
*
|
||||
* SQL golden histogram: Module:1, Variable:1
|
||||
* Table references (e.g. CREATE TABLE / SELECT FROM) produce Variable nodes.
|
||||
* SQL golden histogram: Module:1, Table:1
|
||||
* CREATE TABLE / CREATE VIEW produce first-class Table / View nodes.
|
||||
* ══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* SQL: CREATE TABLE + SELECT → at least 1 Variable node. */
|
||||
TEST(probe_sql_variable_node) {
|
||||
/* SQL: CREATE TABLE → a first-class Table node (was Variable). */
|
||||
TEST(probe_sql_table_node) {
|
||||
GpgMetrics m = gpg_metrics("schema.sql", "CREATE TABLE users (\n"
|
||||
" id INTEGER PRIMARY KEY,\n"
|
||||
" name TEXT NOT NULL\n"
|
||||
@@ -618,23 +620,26 @@ TEST(probe_sql_variable_node) {
|
||||
"\n"
|
||||
"SELECT id, name FROM users WHERE id = 1;\n");
|
||||
ASSERT_TRUE(m.ok);
|
||||
/* GREEN: SQL table reference produces at least 1 Variable node. */
|
||||
ASSERT_TRUE(m.variables >= 1);
|
||||
/* GREEN: CREATE TABLE produces a first-class Table node, and no longer
|
||||
* a generic Variable. */
|
||||
ASSERT_TRUE(m.tables >= 1);
|
||||
ASSERT_TRUE(m.variables == 0);
|
||||
ASSERT_TRUE(m.modules >= 1);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* SQL: multiple statements → still at least 1 Variable. */
|
||||
/* SQL: DML-only file → no relation defs at all. */
|
||||
TEST(probe_sql_insert_select) {
|
||||
GpgMetrics m = gpg_metrics(
|
||||
"queries.sql", "INSERT INTO orders (user_id, total) VALUES (1, 99.99);\n"
|
||||
"SELECT o.id, u.name FROM orders o JOIN users u ON o.user_id = u.id;\n");
|
||||
ASSERT_TRUE(m.ok);
|
||||
/* GREEN (fixture fix): SQL Variable nodes come ONLY from DDL
|
||||
* create_table/create_view (lang_specs.c sql_var_types), NOT from
|
||||
* DML table *references* in INSERT/SELECT. A DML-only file correctly
|
||||
* yields 0 Variable nodes; the original `>= 1` asserted a non-feature.
|
||||
* Assert the true contract: no table definitions → 0 Variables. */
|
||||
/* GREEN (fixture fix): SQL Table/View nodes come ONLY from DDL
|
||||
* create_table/create_view (extract_sql_ddl_class_def), NOT from DML
|
||||
* table *references* in INSERT/SELECT. A DML-only file correctly yields
|
||||
* 0 relation defs; the original `>= 1 Variable` asserted a non-feature.
|
||||
* Assert the true contract: no table definitions → 0 Tables, 0 Variables. */
|
||||
ASSERT_TRUE(m.tables == 0);
|
||||
ASSERT_TRUE(m.variables == 0);
|
||||
PASS();
|
||||
}
|
||||
@@ -1084,7 +1089,7 @@ SUITE(grammar_probe_g) {
|
||||
RUN_TEST(probe_csv_module_only);
|
||||
|
||||
/* SQL */
|
||||
RUN_TEST(probe_sql_variable_node);
|
||||
RUN_TEST(probe_sql_table_node);
|
||||
RUN_TEST(probe_sql_insert_select);
|
||||
|
||||
/* SOQL */
|
||||
|
||||
@@ -2204,6 +2204,96 @@ TEST(pipeline_incremental_repoints_call_reference_without_stale_edge) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* SQL DDL becomes first-class Table/View nodes wired into FROM/JOIN lineage,
|
||||
* while the shared name registry must NOT leak those relations into other
|
||||
* languages' textual resolution: a Python call or identifier sharing the
|
||||
* table's name (`users`) would otherwise unique-name-bind a false CALLS/USAGE
|
||||
* edge into the lineage layer. Pins the resolve-time relation veto
|
||||
* (cbm_registry_resolve) together with the lineage opt-in
|
||||
* (cbm_registry_resolve_lineage). */
|
||||
TEST(pipeline_sql_lineage_and_relation_isolation) {
|
||||
char tmp[256];
|
||||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_sql_lineage_XXXXXX");
|
||||
if (!cbm_mkdtemp(tmp)) {
|
||||
FAIL("tmpdir");
|
||||
}
|
||||
write_temp_file(tmp, "schema.sql",
|
||||
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);\n"
|
||||
"CREATE VIEW active_users AS SELECT * FROM users;\n");
|
||||
/* `users` exists project-wide ONLY as the SQL table, so without the
|
||||
* relation veto the cross-file unique-name fallback would bind both the
|
||||
* call and the bare reference below straight to the Table node. */
|
||||
write_temp_file(tmp, "app.py",
|
||||
"def load_users():\n"
|
||||
" return users()\n"
|
||||
"\n"
|
||||
"def show_users():\n"
|
||||
" return users\n");
|
||||
char db_path[512];
|
||||
snprintf(db_path, sizeof(db_path), "%s/sql_lineage.db", tmp);
|
||||
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||||
ASSERT_NOT_NULL(p);
|
||||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||||
const char *project = cbm_pipeline_project_name(p);
|
||||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||||
ASSERT_NOT_NULL(s);
|
||||
/* Positive control: the view's FROM emits real lineage. */
|
||||
ASSERT_EQ(named_edge_count(s, project, "USAGE", "active_users", "users"), 1);
|
||||
/* Isolation: no Python edge of any kind reaches the Table. */
|
||||
ASSERT_EQ(named_edge_count(s, project, "CALLS", "load_users", "users"), 0);
|
||||
ASSERT_EQ(named_edge_count(s, project, "USAGE", "load_users", "users"), 0);
|
||||
ASSERT_EQ(named_edge_count(s, project, "USAGE", "show_users", "users"), 0);
|
||||
ASSERT_EQ(named_edge_count(s, project, "READS", "show_users", "users"), 0);
|
||||
cbm_store_close(s);
|
||||
cbm_pipeline_free(p);
|
||||
th_rmtree(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Renaming a table must drop lineage from DEPENDENT (unchanged) SQL files on
|
||||
* the incremental path. Table/View participate in the per-file LSP surface
|
||||
* hash as registry-only labels (lsp_surface.c), so tables.sql's def change
|
||||
* invalidates views.sql's resolution instead of slipping the early cutoff and
|
||||
* leaving a stale view -> old-table USAGE edge. */
|
||||
TEST(pipeline_incremental_sql_table_rename_drops_stale_lineage) {
|
||||
char tmp[256];
|
||||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_sql_rename_XXXXXX");
|
||||
if (!cbm_mkdtemp(tmp)) {
|
||||
FAIL("tmpdir");
|
||||
}
|
||||
write_temp_file(tmp, "tables.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);\n");
|
||||
write_temp_file(tmp, "views.sql", "CREATE VIEW active AS SELECT * FROM users;\n");
|
||||
char db_path[512];
|
||||
snprintf(db_path, sizeof(db_path), "%s/sql_rename.db", tmp);
|
||||
cbm_pipeline_t *first = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||||
ASSERT_NOT_NULL(first);
|
||||
ASSERT_EQ(cbm_pipeline_run(first), 0);
|
||||
const char *first_project = cbm_pipeline_project_name(first);
|
||||
cbm_store_t *first_store = cbm_store_open_path(db_path);
|
||||
ASSERT_NOT_NULL(first_store);
|
||||
ASSERT_EQ(named_edge_count(first_store, first_project, "USAGE", "active", "users"), 1);
|
||||
cbm_store_close(first_store);
|
||||
cbm_pipeline_free(first);
|
||||
|
||||
write_temp_file(tmp, "tables.sql", "CREATE TABLE people (id INTEGER PRIMARY KEY);\n");
|
||||
cbm_pipeline_incremental_test_reset_faults();
|
||||
cbm_pipeline_t *second = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||||
ASSERT_NOT_NULL(second);
|
||||
ASSERT_EQ(cbm_pipeline_run(second), 0);
|
||||
const char *second_project = cbm_pipeline_project_name(second);
|
||||
cbm_store_t *second_store = cbm_store_open_path(db_path);
|
||||
ASSERT_NOT_NULL(second_store);
|
||||
/* The view's FROM still says `users`, which no longer exists: the old
|
||||
* edge must be gone (no stale lineage), and the unchanged dependent must
|
||||
* not have been rebound to the renamed table either. */
|
||||
ASSERT_EQ(named_edge_count(second_store, second_project, "USAGE", "active", "users"), 0);
|
||||
ASSERT_EQ(named_edge_count(second_store, second_project, "USAGE", "active", "people"), 0);
|
||||
cbm_store_close(second_store);
|
||||
cbm_pipeline_free(second);
|
||||
th_rmtree(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Re-indexing only the caller must retain cross-file semantic proof for a
|
||||
* callable value whose definition lives in an unchanged file. A fresh full
|
||||
* index of the edited sources is the convergence oracle: incremental output
|
||||
@@ -12256,6 +12346,8 @@ SUITE(pipeline) {
|
||||
SUITE(pipeline_semantic_manifest_repro) {
|
||||
RUN_TEST(incremental_downgrade_preserves_scope_and_artifact_across_change_noop_delete);
|
||||
RUN_TEST(pipeline_incremental_repoints_call_reference_without_stale_edge);
|
||||
RUN_TEST(pipeline_sql_lineage_and_relation_isolation);
|
||||
RUN_TEST(pipeline_incremental_sql_table_rename_drops_stale_lineage);
|
||||
RUN_TEST(pipeline_parallel_manifest_is_byte_stable_above_threshold);
|
||||
RUN_TEST(pipeline_closure_repair_body_edit_converges_with_fresh_full);
|
||||
RUN_TEST(pipeline_closure_repair_removed_def_drops_dependent_edge);
|
||||
|
||||
@@ -63,19 +63,46 @@ def cond(job):
|
||||
return ""
|
||||
return " ".join(m.group(2).split())
|
||||
|
||||
# 1. Downstream-of-optional jobs must tolerate a deliberately skipped ancestor.
|
||||
# `test` is the optional phase (if: !inputs.skip_tests); everything after it
|
||||
# in the chain has to survive that.
|
||||
TOLERATE = ["build", "smoke", "soak", "release-draft"]
|
||||
for job in TOLERATE:
|
||||
# 1. Gate conditions: tolerate ONLY the sanctioned skip, fail closed otherwise.
|
||||
# `test` is the optional phase (if: !inputs.skip_tests). The old contract
|
||||
# required the bare `!cancelled() && !failure()` idiom — but failure() does
|
||||
# NOT cover a needed job that was CANCELLED (e.g. a lint timeout), so that
|
||||
# idiom let a cancelled gate cascade test into 'skipped' and publish with
|
||||
# the whole test matrix silently gone (v0.10.7 incident, 2026-08-18).
|
||||
# Each gate must name the results it accepts explicitly.
|
||||
GATE_REQUIREMENTS = {
|
||||
"build": [
|
||||
"!cancelled()",
|
||||
"needs.lint.result == 'success'",
|
||||
"needs.test.result == 'success'",
|
||||
"inputs.skip_tests && needs.test.result == 'skipped'",
|
||||
],
|
||||
"smoke": ["!cancelled()", "needs.build.result == 'success'"],
|
||||
"soak": ["!cancelled()", "needs.build.result == 'success'"],
|
||||
"release-draft": ["!cancelled()", "!failure()"],
|
||||
}
|
||||
for job, required in GATE_REQUIREMENTS.items():
|
||||
if job not in blocks:
|
||||
failures.append(f"{job}: job missing from release.yml — update this contract")
|
||||
continue
|
||||
c = cond(job)
|
||||
if "!cancelled()" not in c or "!failure()" not in c:
|
||||
failures.append(
|
||||
f"{job}: `if:` lacks `!cancelled() && !failure()` (got: {c or '<none>'}).\n"
|
||||
f" With skip_tests=true a skipped ancestor SKIPS this job silently.")
|
||||
for fragment in required:
|
||||
if fragment not in c:
|
||||
failures.append(
|
||||
f"{job}: `if:` must contain `{fragment}` (got: {c or '<none>'}).\n"
|
||||
f" Explicit results only: failure() misses CANCELLED needed\n"
|
||||
f" jobs, and a bare tolerate-skip idiom is fail-open.")
|
||||
|
||||
# 1b. The preflight input guard must exist and gate the whole chain: the tag is
|
||||
# inputs.version verbatim, and a bare (non-v-prefixed) version publishes a
|
||||
# release installers can never resolve — unrecoverable under immutability.
|
||||
if "preflight" not in blocks:
|
||||
failures.append("preflight: job missing — the version-input guard must exist")
|
||||
lint_needs = re.search(r"^ needs:\s*(.*)$", blocks.get("lint", ""), re.M)
|
||||
if not lint_needs or "preflight" not in lint_needs.group(1):
|
||||
failures.append(
|
||||
"lint: must `needs: [preflight]` so a malformed version stops the\n"
|
||||
" chain before any gate runs.")
|
||||
|
||||
# 2. The draft must require both runtime gates to have genuinely succeeded.
|
||||
draft = cond("release-draft")
|
||||
|
||||
@@ -52,6 +52,36 @@ TEST(sql_label_allowlists_match_cbm_label_is_type_like) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Same drift guard for the relation labels (Table/View — SQL data lineage).
|
||||
* Relations are registry symbols but deliberately NOT type-like: the default
|
||||
* cbm_registry_resolve vetoes them, so a code identifier sharing a table's
|
||||
* name never binds into the lineage layer. */
|
||||
TEST(sql_relation_labels_match_cbm_label_is_relation) {
|
||||
static const char *const relations[] = {"Table", "View"};
|
||||
for (size_t i = 0; i < sizeof(relations) / sizeof(relations[0]); i++) {
|
||||
ASSERT_TRUE(cbm_label_is_relation(relations[i]));
|
||||
ASSERT_TRUE(cbm_label_is_registry_symbol(relations[i]));
|
||||
ASSERT_FALSE(cbm_label_is_type_like(relations[i]));
|
||||
char quoted[64];
|
||||
snprintf(quoted, sizeof(quoted), "'%s'", relations[i]);
|
||||
ASSERT_NOT_NULL(strstr(CBM_SQL_RELATION_LABELS, quoted));
|
||||
/* Relations must NOT ride in the callable/type fragments — the arch
|
||||
* queries opt in explicitly by appending CBM_SQL_RELATION_LABELS. */
|
||||
ASSERT_NULL(strstr(CBM_SQL_CALLABLE_OR_TYPE_LABELS, quoted));
|
||||
}
|
||||
/* cbm_label_is_registry_symbol covers exactly the seeded families. */
|
||||
ASSERT_TRUE(cbm_label_is_registry_symbol("Function"));
|
||||
ASSERT_TRUE(cbm_label_is_registry_symbol("Method"));
|
||||
ASSERT_TRUE(cbm_label_is_registry_symbol("Class"));
|
||||
ASSERT_TRUE(cbm_label_is_registry_symbol("Variable"));
|
||||
ASSERT_TRUE(cbm_label_is_registry_symbol("Field"));
|
||||
ASSERT_FALSE(cbm_label_is_registry_symbol("Module"));
|
||||
ASSERT_FALSE(cbm_label_is_registry_symbol("File"));
|
||||
ASSERT_FALSE(cbm_label_is_relation("Class"));
|
||||
ASSERT_FALSE(cbm_label_is_relation(NULL));
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Schema / Open / Close ──────────────────────────────────────── */
|
||||
|
||||
TEST(store_open_memory) {
|
||||
@@ -2177,6 +2207,7 @@ SUITE(store_nodes) {
|
||||
RUN_TEST(store_coverage_replace_rejects_invalid_row_arguments);
|
||||
RUN_TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails);
|
||||
RUN_TEST(sql_label_allowlists_match_cbm_label_is_type_like);
|
||||
RUN_TEST(sql_relation_labels_match_cbm_label_is_relation);
|
||||
RUN_TEST(store_open_memory);
|
||||
RUN_TEST(store_close_null);
|
||||
RUN_TEST(store_open_memory_twice);
|
||||
|
||||
Reference in New Issue
Block a user