Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ Conventional Commits format. All notable changes documented here.

- **The SPA dashboard rendered only its header on browsers without `scheduler.yield` — every widget below the fold stayed blank on all Safari and iOS Safari, Chrome and Edge before 129, and Firefox before 142.** The cooperative widget-boot loop yields between widgets through a `yieldToMain` helper that prefers `scheduler.yield()` and otherwise falls back to a `MessageChannel`. The fallback's lazy state lived in two module-scope `let` bindings in a later-concatenated script, but the boot loop — defined in an earlier script of the same shared IIFE — calls `yieldToMain` during its first synchronous pass, before those bindings' declarations have run. On engines that expose `scheduler.yield()` the helper returned before touching them, which hid the defect on the common desktop-Chrome path; everywhere else the first yield read a binding inside its temporal dead zone, the un-awaited boot loop rejected, and only the first widget ever rendered — the header and quality-dimension tiles sat above a blank page. The fallback state now lives on the `yieldToMain` function object itself, a hoisted declaration reachable from the IIFE's first statement, so no dead-zone read can occur under any concatenation order. A headless regression test boots the dashboard with `scheduler` removed and asserts the full widget set renders.

- **`codelore check` and the `check_gates` MCP tool no longer pass green over an empty fact store.** A shallow `fetch-depth` checkout whose tip is a merge commit ingests zero history under the default merge filter (`include_merges = false`), leaving every gate with nothing to violate — the run printed `PASS (0 files evaluated)`, wrote `result=pass`, and exited 0, and under `--format sarif` or a `steps.*.outputs.result` read the "0 files" tell was invisible. Both gate paths now **witness the ingest**: a real HEAD over a zero-commit store is a hard error (exit 3 — the shallow/corrupted-repo bucket) that names `fetch-depth` as the likely cause, never a pass. As defence in depth, a shallow checkout that *did* ingest partial history is warned about loudly (naming `fetch-depth`) on the check output, and the `code_health_min` degraded sentinel now witnesses "the repository has analyzable source" by reading the HEAD tree directly rather than counting `complexity_metrics` — which derives from the same `changes ⋈ commits` join as the health rows and so could not fire for the very ingest-blindness it guards. A source-less tree (docs or config only) stays the honest vacuous pass it always was.

- **The `[new_code]` gate's shallow-history skip is now disclosed as structured data on the `check_gates` MCP path and names the true cause on both paths.** When the repository's history does not reach past the `[new_code]` window there is no legacy baseline, so the gate skips — but a truncated `fetch-depth` checkout is indistinguishable from a genuinely young repository at that query, and the truncated case is the CI default. The MCP tool previously disclosed nothing, so an agent could not tell "the new-code gate passed" from "it did not run"; it now reports the skip in the `skipped_gates` array as a `{gate, reason}` entry alongside the structural check-only skips (which gain reasons too — one unified shape). Both the CLI disclosure and the MCP reason now **discriminate**: when the checkout is shallow (`.git/shallow` present) the reason names `fetch-depth` and advises re-fetching full history; otherwise it names a genuinely young repository. Exit codes and verdicts are unchanged.

- **`.codelore-ratchet.toml` is written atomically, and a destroyed (empty) ratchet file is rejected loudly instead of silently rebaselining the regression gate.** The committed ratchet snapshot was written with a plain truncate-then-write, so a crash, OOM kill, or ENOSPC mid-write could leave a 0-byte file; that empty file parses cleanly to an all-`None` snapshot, which the ratchet reads as "everything improved" and then rewrites the floor from the current — possibly already regressed — run, disabling the regression gate for the whole team once the reset is committed. The write now routes through the same write-to-temp + fsync + atomic-rename helper every `analyze` output uses, so an interrupted write leaves the previous good file untouched. And `read_snapshot` now treats a present-but-empty (0-byte or whitespace-only) file as **corrupt** with a loud restore-or-delete error, keeping three states distinct that were previously conflated: no file (initialize), a well-formed empty `[ratchet]` table (no floors configured), and a destroyed file (the loud error).

## [0.23.0] - 2026-07-28

### Added
Expand Down
52 changes: 41 additions & 11 deletions crates/codelore-cli/src/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,28 @@ pub(crate) fn run_check_cmd(args: &args::CheckArgs) -> Result<()> {
let head_sha = repo.head_sha().context("get HEAD sha")?;
let db =
FactsDb::open_or_ingest_with_cache_root(&opts, &repo, &cache_root).context("ingest")?;
// Witness the ingest before any gate runs: a real HEAD over an empty commit
// store is the truncated-checkout signature (a shallow merge-tip fetch under
// the default merge filter ingests zero history), on which every gate would
// pass over no data. Turn that silent green into a hard, distinct error.
db.ensure_ingest_witnessed(&head_sha)?;
// Defence in depth: a shallow checkout that DID ingest a commit or two still
// carries only partial history, which quietly weakens every behavioral gate.
// Warn loudly and name the cause; SARIF mode keeps stdout a clean document.
// The same signal discriminates the `new_code` skip disclosure below (a
// truncated checkout reads identically to a young repository at that query).
let shallow_checkout = repo.is_shallow();
if shallow_checkout {
let warning = "⚠ codelore check: shallow checkout detected (.git/shallow present) — \
history is truncated by fetch-depth, so the behavioral gates (hotspots, \
effort-exposure, new-code) evaluate only partial history. Re-run against full \
history (fetch-depth: 0) for an authoritative verdict.";
if matches!(args.format, CheckFormat::Sarif) {
eprintln!("{warning}");
} else {
println!("{warning}");
}
}
let ts = now_utc_ts();

// Open the external-findings sidecar without creating it, only when it
Expand Down Expand Up @@ -131,7 +153,7 @@ pub(crate) fn run_check_cmd(args: &args::CheckArgs) -> Result<()> {
// branches so both paths surface them; stderr keeps stdout a clean SARIF
// document in --format sarif; suppressed under --quiet.
if !args.quiet {
emit_gate_notices(&ledger_records);
emit_gate_notices(&ledger_records, shallow_checkout);
}
// One-per-run hint when the corpus lens is inactive — the check path always
// computes code-health rows, which carry no corpus_percentile without an
Expand Down Expand Up @@ -318,6 +340,7 @@ pub(crate) fn run_check_cmd(args: &args::CheckArgs) -> Result<()> {
/// beside the rest of `run_check_cmd`'s reporting.
fn emit_gate_notices(
ledger_records: &[codelore_lib::cli_api::quality_gates::ledger::GateRunRecord],
shallow_checkout: bool,
) {
for r in ledger_records {
match (r.gate.as_str(), r.verdict.as_str()) {
Expand All @@ -333,8 +356,12 @@ fn emit_gate_notices(
("code_health_min", "degraded") => eprintln!(
" ⚠ code_health_min: degraded — health scan returned no rows on a non-empty repo"
),
("new_code", "skipped") if shallow_checkout => eprintln!(
" ⚠ new_code: skipped — the checkout is shallow (fetch-depth): its history is truncated to within the {:.0}-day window, so there is no pre-window baseline to contrast the working set against. This is the checkout, not the repository — re-run against full history (fetch-depth: 0).",
r.threshold
),
("new_code", "skipped") => eprintln!(
" ⚠ new_code: skipped — repository history is shallower than the {:.0}-day window (no pre-window baseline to contrast the working set against)",
" ⚠ new_code: skipped — the repository's history is shallower than the {:.0}-day window (a genuinely young repository), so there is no pre-window baseline to contrast the working set against.",
r.threshold
),
_ => {}
Expand Down Expand Up @@ -435,6 +462,7 @@ fn eval_hotspot_gates(
fn eval_code_health_gate(
thresholds: &codelore_lib::cli_api::quality_gates::Thresholds,
db: &codelore_lib::cli_api::facts::FactsDb,
repo: &impl codelore_lib::cli_api::repo::Repo,
opts: &codelore_lib::cli_api::Options,
ts: &str,
head_sha: &str,
Expand All @@ -457,14 +485,15 @@ fn eval_code_health_gate(
return Ok(((Vec::new(), Vec::new()), code_health));
};
let ch_violations = evaluate_code_health_gate(thresholds, &code_health);
// Degraded: empty result when the repo has scorable files.
let degraded = code_health.is_empty() && {
db.query_row("SELECT COUNT(*) FROM complexity_metrics", [], |r| {
r.get::<_, i64>(0)
})
.unwrap_or(0)
> 0
};
// Degraded: the health scan returned nothing, yet the repository actually
// carries analyzable source. The witness reads the HEAD tree directly rather
// than counting `complexity_metrics`, which derives from the same
// changes⋈commits join as the (empty) health set and so empties in lockstep
// — it cannot witness an ingest that went blind. A source-less tree (docs or
// config only) legitimately yields no rows and stays a vacuous pass. The `&&`
// short-circuit keeps the tree walk off every healthy run.
let degraded = code_health.is_empty()
&& codelore_lib::cli_api::quality_gates::head_has_scorable_source(repo, opts);
let worst = code_health
.iter()
.map(|r| r.score)
Expand Down Expand Up @@ -576,7 +605,8 @@ fn evaluate_all_gates(
violations.extend(hs_v);
recs.extend(hs_r);

let ((ch_v, ch_r), code_health) = eval_code_health_gate(thresholds, db, opts, ts, head_sha)?;
let ((ch_v, ch_r), code_health) =
eval_code_health_gate(thresholds, db, repo, opts, ts, head_sha)?;
violations.extend(ch_v);
recs.extend(ch_r);

Expand Down
Loading
Loading