feat(pipeline): alias-config governance for closure repair

Path-alias configs (tsconfig/jsconfig class) stop declining the closure
route. A config delta re-routes RESOLUTION for the files it governs while
touching none of their bytes, so those files join the closure directly:
they re-extract and re-resolve under the freshly loaded alias collection,
their surfaces come out unchanged, and propagation stops -- the depth-1
argument holds exactly as it does for source edits. Governed means every
discovered file under the config's directory; over-inclusion from nested
scopes is deliberate (safe direction), and the existing budget still
bounds the total, so a root config on a large repo correctly concedes to
a full rebuild.

Classification is now explicit rather than incidental: synthetic
manifest digests (git context, extension configs) decline as
semantic_input_changed; package-control files decline as
control_file_changed (pkgmap is global -- governed repair is unsound
there); alias configs -- recognized by exact match against the loaded
collection plus a basename fallback so a REMOVED config still
classifies -- seed the governed closure, whether changed, added, or
removed.

The existing tsconfig-alias convergence test becomes the proof: no
source file changes, the route asserts CLOSURE_REPAIR, and the caller's
CALL_REFERENCE must move from target_a.ts to target_b.ts to match the
fresh-full reference -- the exact case the legacy partial route silently
corrupted and binary routing paid a full rebuild for.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-08-03 19:10:58 +02:00
parent bc3cc3f49f
commit 45c40ed3a6
2 changed files with 85 additions and 9 deletions
+78 -8
View File
@@ -1442,6 +1442,49 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project,
return rc;
}
/* Is this manifest row a path-alias config (tsconfig/jsconfig class)? Exact
* match against the loaded alias collection's config paths, plus a basename
* fallback so a REMOVED config (absent from the fresh collection) still
* classifies. Alias configs are the one config class whose effect is
* per-scope rather than global, which is what makes governed-closure repair
* sound for them and for nothing else. */
static bool closure_is_alias_config(const cbm_path_alias_collection_t *aliases,
const char *rel_path) {
if (!rel_path) {
return false;
}
if (aliases) {
for (int i = 0; i < aliases->count; i++) {
if (aliases->scopes[i].source_rel_path &&
strcmp(aliases->scopes[i].source_rel_path, rel_path) == 0) {
return true;
}
}
}
const char *slash = strrchr(rel_path, '/');
const char *base = slash ? slash + 1 : rel_path;
return strcmp(base, "tsconfig.json") == 0 || strcmp(base, "jsconfig.json") == 0;
}
/* Every discovered file under the config's directory joins the closure:
* their RESOLUTION consumed the config even though their bytes (and thus
* surfaces) are unchanged, so they re-extract and re-resolve but never
* propagate further. Over-inclusion (nested scopes) is safe; the budget
* still bounds the total. */
static void closure_seed_governed(CBMHashTable *closure_set, const cbm_file_info_t *files,
int file_count, const char *config_rel_path) {
const char *slash = strrchr(config_rel_path, '/');
size_t dir_len = slash ? (size_t)(slash - config_rel_path + 1) : 0;
for (int i = 0; i < file_count; i++) {
if (dir_len > 0 && strncmp(files[i].rel_path, config_rel_path, dir_len) != 0) {
continue;
}
if (!cbm_ht_get(closure_set, files[i].rel_path)) {
cbm_ht_set(closure_set, files[i].rel_path, (void *)files[i].rel_path);
}
}
}
/* Decide whether this manifest delta is closure-repairable, and if so build
* the plan. Returns 1 with *plan filled, or 0 (decline → FULL) with the
* reason logged. Every early exit declines; nothing here mutates state. */
@@ -1465,6 +1508,7 @@ static int closure_try_plan(cbm_pipeline_t *p, cbm_store_t *store, const char *p
cbm_lsp_surface_row_t *probe_rows = NULL;
int probe_count = 0;
cbm_file_info_t *probe_files = NULL;
cbm_path_alias_collection_t *plan_aliases = NULL;
int n_changed = 0;
int n_deleted = 0;
if (!fresh_by_path || !files_by_path || !stored_by_path || !closure_set) {
@@ -1491,31 +1535,56 @@ static int closure_try_plan(cbm_pipeline_t *p, cbm_store_t *store, const char *p
decline = "alloc";
goto done;
}
plan_aliases = cbm_load_path_aliases_excluded(cbm_pipeline_repo_path(p), NULL, 0);
bool governed_seeded = false;
for (int i = 0; i < fresh_count; i++) {
const cbm_file_hash_t *old_row = cbm_ht_get(stored_by_path, fresh[i].rel_path);
bool is_virtual =
fresh[i].rel_path[0] == '\x1f' || !cbm_ht_get(files_by_path, fresh[i].rel_path);
if (!old_row) {
decline = is_virtual ? "virtual_entry_added" : "new_file";
bool synthetic = semantic_manifest_is_virtual_path(fresh[i].rel_path);
bool discovered = cbm_ht_get(files_by_path, fresh[i].rel_path) != NULL;
bool delta = !old_row || strcmp(old_row->sha256, fresh[i].sha256) != 0;
if (!delta) {
continue;
}
if (synthetic) {
decline = "semantic_input_changed";
goto done;
}
if (strcmp(old_row->sha256, fresh[i].sha256) != 0) {
if (is_virtual) {
decline = "virtual_or_config_changed";
if (discovered) {
if (!old_row) {
decline = "new_file";
goto done;
}
changed_paths[n_changed++] = (char *)fresh[i].rel_path;
continue;
}
if (closure_is_alias_config(plan_aliases, fresh[i].rel_path)) {
/* Config content (or a new config) re-routes resolution for the
* files it governs; their surfaces are untouched, so they join
* the closure directly and propagate nowhere. */
closure_seed_governed(closure_set, files, file_count, fresh[i].rel_path);
governed_seeded = true;
continue;
}
decline = "control_file_changed";
goto done;
}
for (int i = 0; i < stored_count; i++) {
if (!cbm_ht_get(fresh_by_path, stored[i].rel_path)) {
if (!cbm_ht_get(files_by_path, stored[i].rel_path) &&
closure_is_alias_config(plan_aliases, stored[i].rel_path)) {
/* Removed alias config: its governed files resolve without
* it now; the config's own manifest row simply disappears. */
closure_seed_governed(closure_set, files, file_count, stored[i].rel_path);
governed_seeded = true;
continue;
}
/* Gone from the manifest: deleted or newly ignored. Its nodes
* will be purged; files with edges into it must re-resolve. */
changed_paths[n_changed + n_deleted] = (char *)stored[i].rel_path;
n_deleted++;
}
}
if (n_changed == 0 && n_deleted == 0) {
if (n_changed == 0 && n_deleted == 0 && !governed_seeded) {
decline = "no_file_delta";
goto done;
}
@@ -1660,6 +1729,7 @@ static int closure_try_plan(cbm_pipeline_t *p, cbm_store_t *store, const char *p
itoa_buf((int)elapsed_ms(t)));
done:
cbm_path_alias_collection_free(plan_aliases);
free(probe_files);
cbm_store_free_lsp_surfaces(probe_rows, probe_count);
cbm_store_free_dependent_files(dependents, dependent_count);
+7 -1
View File
@@ -2699,7 +2699,13 @@ TEST(pipeline_incremental_tsconfig_alias_change_matches_fresh_full) {
cbm_pipeline_t *incremental = cbm_pipeline_new(tmp, incremental_db, CBM_MODE_FULL);
ASSERT_NOT_NULL(incremental);
ASSERT_EQ(cbm_pipeline_run(incremental), 0);
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_FORCED_FULL);
/* Config-mediated retargeting is the closure route's hardest case: no
* source file changed, yet the caller's CALL_REFERENCE must move from
* target_a.ts to target_b.ts. Since alias-config governance landed this
* runs as a closure repair, and the convergence assertions below now
* prove that route rather than being satisfied by a full rebuild. */
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(),
CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
const char *incremental_project = cbm_pipeline_project_name(incremental);
cbm_store_t *incremental_store = cbm_store_open_path(incremental_db);
ASSERT_NOT_NULL(incremental_store);