Skip to content
77 changes: 74 additions & 3 deletions crates/loomweave-cli/src/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,27 @@ pub(crate) async fn run_with_options(project_path: PathBuf, options: AnalyzeOpti
HashMap::new(),
)
};
// clarion-e12d424f1d: the per-plugin tag-schema markers from the last
// successful run, keyed by plugin_id. Each plugin's live manifest
// (version, ontology_version) is compared against its stored marker below;
// a mismatch (or absence) forces a full re-dispatch of that plugin's files,
// so a plugin upgrade that changed the emitted vocabulary (e.g. ADR-053/054
// root tags) never leaves unchanged files carrying stale entity_tags. Empty
// under `--no-incremental` (everything re-dispatches regardless), but the
// markers are still re-stamped after the run so the next incremental run
// sees current values.
let prior_plugin_markers: HashMap<String, loomweave_storage::PluginIndexMarker> = if incremental
{
Connection::open(&db_path)
.ok()
.and_then(|conn| loomweave_storage::load_plugin_index_markers(&conn).ok())
.unwrap_or_default()
} else {
HashMap::new()
};
// The markers to persist after a fully-successful run — one per dispatched
// plugin, recording the (version, ontology_version) the index now reflects.
let mut current_plugin_markers: Vec<loomweave_storage::PluginIndexMarker> = Vec::new();
// Locators of skipped-unchanged entities — fed into the SEI matcher's
// current-locator union AND re-appended to the prior-index rebuild below.
let mut retained_locators: HashSet<String> = HashSet::new();
Expand Down Expand Up @@ -785,6 +806,43 @@ pub(crate) async fn run_with_options(project_path: PathBuf, options: AnalyzeOpti
continue;
}

// clarion-e12d424f1d: record the marker this plugin is analysing the
// index under (persisted only on a fully-successful run, alongside the
// prior-index snapshot), and decide whether its tag schema moved since
// the last run. The comparison keys on BOTH `version` (bumps on any
// release) and `ontology_version` (the declared vocabulary version): a
// mismatch on EITHER — or no stored marker at all — forces a full
// re-dispatch of this plugin's files, even on an incremental run. This
// is the only signal that distinguishes "plugin emits no roots" from
// "plugin emits roots but this index's unchanged files predate them",
// which the MCP dead-code survey's all-or-nothing guard cannot.
let plugin_version = plugin.manifest.plugin.version.clone();
let ontology_version = plugin.manifest.ontology.ontology_version.clone();
let plugin_tag_schema_changed = match prior_plugin_markers.get(&plugin_id) {
Some(prior) => {
prior.plugin_version != plugin_version || prior.ontology_version != ontology_version
}
// No recorded marker: a fresh plugin, or the first run after this
// fix shipped against an index built by a (possibly already
// upgraded) plugin. Re-dispatch to heal any pre-existing skew —
// the safe, fail-toward-work direction.
None => true,
};
if incremental && plugin_tag_schema_changed && !prior_plugin_markers.is_empty() {
tracing::info!(
plugin_id = %plugin_id,
plugin_version = %plugin_version,
ontology_version = %ontology_version,
"plugin tag-schema marker changed since last run; forcing full re-dispatch \
of this plugin's files (clarion-e12d424f1d)"
);
}
current_plugin_markers.push(loomweave_storage::PluginIndexMarker {
plugin_id: plugin_id.clone(),
plugin_version,
ontology_version,
});

// Wave 2 / T3.1: partition into files to re-analyse (changed, new,
// unhashable → fail toward work) and files to skip (whole-file hash
// identical to the prior run). Each skipped file's prior entities stay
Expand All @@ -793,9 +851,21 @@ pub(crate) async fn run_with_options(project_path: PathBuf, options: AnalyzeOpti
// A secret-bearing UNCHANGED file skips too (weft-4165f1ed71): its
// finding anchor is seeded from the committed rows below, so the skip
// no longer re-anchors (and thereby duplicates) the finding.
let (plugin_files, skipped_files): (Vec<PathBuf>, Vec<PathBuf>) = plugin_files
.into_iter()
.partition(|path| file_needs_reanalysis(&project_root, path, &prior_file_hashes));
//
// clarion-e12d424f1d: when this plugin's tag schema moved, skip the
// byte-hash consultation entirely and re-dispatch every file (in-memory
// only — the stored per-file hashes are never mutated). The hashes are
// keyed on the core `file` entity, not per language plugin, so there is
// nothing plugin-scoped to clear; overriding the partition is both the
// correct scope and the safe one.
let (plugin_files, skipped_files): (Vec<PathBuf>, Vec<PathBuf>) =
if plugin_tag_schema_changed {
(plugin_files, Vec::new())
} else {
plugin_files.into_iter().partition(|path| {
file_needs_reanalysis(&project_root, path, &prior_file_hashes)
})
};
// Locators of THIS plugin's skipped-unchanged entities. These rows stay in
// the committed DB untouched this run (they are guarded against orphan
// deletion via `retained_locators` below — see the SEI matcher's
Expand Down Expand Up @@ -1479,6 +1549,7 @@ pub(crate) async fn run_with_options(project_path: PathBuf, options: AnalyzeOpti
if let Err(e) = writer
.send_wait(|ack| WriterCmd::UpsertPriorIndex {
entries: prior_index_entries,
plugin_markers: current_plugin_markers,
recorded_at: iso8601_now(),
ack,
})
Expand Down
117 changes: 117 additions & 0 deletions crates/loomweave-cli/tests/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3962,6 +3962,123 @@ fn analyze_no_incremental_forces_full_reanalysis() {
);
}

#[test]
#[cfg_attr(not(unix), ignore = "fixture plugin script is a unix shebang")]
fn analyze_ontology_bump_forces_full_reanalysis() {
// clarion-e12d424f1d: the incremental skip keys ONLY on a file's byte
// content (`file_needs_reanalysis` → whole-file hash), with no plugin
// tag-schema component. After a plugin upgrade that changes the emitted
// vocabulary (e.g. ADR-053/054 reachability-root tags), every UNCHANGED
// file keeps its pre-upgrade `entity_tags` rows — which carry no root tags —
// because the file is byte-identical and so silently skipped. The dead-code
// survey then false-flags the unchanged public surface as dead. The fix
// persists a per-plugin (version, ontology_version) marker and forces a full
// re-dispatch of that plugin's files when the marker moves, even WITHOUT
// --no-incremental.
//
// Here we bump ONLY the manifest's `ontology_version` between two
// byte-identical runs and assert the second (plain incremental) run
// re-analyses everything (skipped_files == 0) rather than skipping on the
// stale hash. Before the fix the second run skips both files.
let (project_dir, plugin_dir, plugin_path) = phase3_env();
std::fs::write(project_dir.path().join("bump_a.p3"), b"module\n").unwrap();
std::fs::write(project_dir.path().join("bump_b.p3"), b"module\n").unwrap();

// Run 1: the PHASE3 manifest ships ontology_version 0.6.0.
loomweave_bin()
.args(["analyze"])
.arg(project_dir.path())
.env("PATH", &plugin_path)
.assert()
.success();
assert_eq!(
latest_run_stats(project_dir.path())["skipped_files"].as_u64(),
Some(0),
"first run has no prior index, so it skips nothing"
);

// Sanity: a plain incremental re-run with the SAME manifest skips both
// unchanged files — proving the skip is live and the next assertion is not
// vacuously true.
loomweave_bin()
.args(["analyze"])
.arg(project_dir.path())
.env("PATH", &plugin_path)
.assert()
.success();
assert_eq!(
latest_run_stats(project_dir.path())["skipped_files"].as_u64(),
Some(2),
"an unchanged incremental re-run with an unchanged manifest skips both files"
);

// Upgrade the plugin in place: bump ONLY ontology_version, leave both source
// files byte-identical.
let bumped = PHASE3_PLUGIN_MANIFEST.replace(
"ontology_version = \"0.6.0\"",
"ontology_version = \"0.7.0\"",
);
assert_ne!(
bumped, PHASE3_PLUGIN_MANIFEST,
"the manifest bump must actually change the ontology_version line"
);
std::fs::write(plugin_dir.path().join("plugin.toml"), &bumped).unwrap();

// Run 3: plain incremental (NO --no-incremental). The tag-schema marker
// moved, so every file must re-dispatch to refresh its entity_tags.
loomweave_bin()
.args(["analyze"])
.arg(project_dir.path())
.env("PATH", &plugin_path)
.assert()
.success();
assert_eq!(
latest_run_stats(project_dir.path())["skipped_files"].as_u64(),
Some(0),
"an ontology_version bump must force a full re-analyse despite byte-identical \
source — otherwise unchanged files keep stale (rootless) entity_tags and the \
dead-code survey false-flags the unchanged public surface (clarion-e12d424f1d)"
);

// Run 4: same (bumped) manifest, byte-identical source. The marker was
// persisted by run 3, so it now MATCHES and the skip re-engages — proving
// the force-full fires once per upgrade, not forever (which would silently
// disable incremental analysis after any upgrade).
loomweave_bin()
.args(["analyze"])
.arg(project_dir.path())
.env("PATH", &plugin_path)
.assert()
.success();
assert_eq!(
latest_run_stats(project_dir.path())["skipped_files"].as_u64(),
Some(2),
"once the marker is persisted, an unchanged re-run skips again — the force-full \
is a one-shot per marker change, not a permanent full-reanalyse"
);

// Run 5: bump the plugin `version` (ontology unchanged). The marker keys on
// BOTH components, so a version-only move must also force a full re-dispatch.
let bumped_version = bumped.replace("version = \"0.1.0\"", "version = \"0.2.0\"");
assert_ne!(
bumped_version, bumped,
"the version bump must actually change the version line"
);
std::fs::write(plugin_dir.path().join("plugin.toml"), &bumped_version).unwrap();
loomweave_bin()
.args(["analyze"])
.arg(project_dir.path())
.env("PATH", &plugin_path)
.assert()
.success();
assert_eq!(
latest_run_stats(project_dir.path())["skipped_files"].as_u64(),
Some(0),
"a plugin version bump must also force a full re-analyse (the marker keys on the \
(version, ontology_version) pair)"
);
}

// ── REQ-ANALYZE-06: parse-failure findings are persisted, not just logged ────

/// Mirrors the real Python plugin: every file yields one `module` entity, and a
Expand Down
Loading