fix: close the remaining review findings (cross-process lock, partials)

Second Codex (gpt-6.1-sol, xhigh) pass over 60634d3, each item verified
before fixing:

- new: the sync recheck hashed an existing empty database before schema
  setup, so the first sync falsely reported an external change. Hash
  after setup; a published snapshot already has the schema and keeps
  its bytes.
- sync, import, and entity edits now hold a cross-process lock
  (<db>.lock, std File::lock), so a sync cannot replace the database
  under an import running in another process. Import validates and
  downloads before taking the lock, keeping its error order. MSRV
  1.85 -> 1.89 for File::lock.
- the row-count rebuild branch also never downgrades history.
- cache reads no longer delete corrupt entries (a concurrent writer may
  have just replaced them); the next put overwrites atomically.
- idx-mcp validates the changes limit before querying and runs each
  requested section or analysis once.
- XLSX parsing bounds total cell text, so one large shared string
  referenced from many cells cannot exhaust memory.
- PDF/archive percentages must be 0-100%; HHI uses saturating math.

CLI goldens unchanged. Live A/B vs 0.2.4: 16 passed, discover hits the
IDX 403 on both builds (exit 4 vs 1, intended).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hermes 2026-10-14 22:20:57 +00:00
commit 1e551385d0
10 changed files with 117 additions and 34 deletions

View file

@ -3,8 +3,7 @@ name = "idx-mcp"
description = "MCP server for Indonesian stock market (IDX) market, financial, and ownership data"
version.workspace = true
edition.workspace = true
# rmcp requires 1.88.
rust-version = "1.88"
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true

View file

@ -171,7 +171,8 @@ earnings, analyst sentiment, insights, and news. Sections that fail are listed u
)]
async fn get_company(&self, params: Parameters<CompanyParams>) -> ToolResult {
let CompanyParams { symbol, sections } = params.0;
let sections = sections.unwrap_or_else(|| CompanySection::ALL.to_vec());
let mut sections = sections.unwrap_or_else(|| CompanySection::ALL.to_vec());
dedup_in_order(&mut sections);
if let Some(err) = empty_input("sections", &sections) {
return err;
}
@ -214,7 +215,8 @@ are listed under `errors`; the call fails only if every analysis fails."
)]
async fn analyze_stock(&self, params: Parameters<AnalyzeParams>) -> ToolResult {
let AnalyzeParams { symbol, kinds } = params.0;
let kinds = kinds.unwrap_or_else(|| AnalysisKind::ALL.to_vec());
let mut kinds = kinds.unwrap_or_else(|| AnalysisKind::ALL.to_vec());
dedup_in_order(&mut kinds);
if let Some(err) = empty_input("kinds", &kinds) {
return err;
}
@ -306,14 +308,10 @@ release dates, optionally for one ticker."
limit,
} = params.0;
self.local(move |investor| {
let limit = bounded_limit("limit", limit, CHANGES_LIMIT, MAX_CHANGES_LIMIT)?;
let mut changes = investor.changes(&from, &to, ticker.as_deref())?;
let total = changes.len();
changes.truncate(bounded_limit(
"limit",
limit,
CHANGES_LIMIT,
MAX_CHANGES_LIMIT,
)?);
changes.truncate(limit);
Ok(json!({ "from": from, "to": to, "total": total, "changes": changes }))
})
.await
@ -449,6 +447,18 @@ fn checked_symbols(symbols: Vec<String>) -> Result<Vec<String>, ToolResult> {
Ok(symbols)
}
/// Each section or analysis runs once, however often it is requested.
fn dedup_in_order<T: PartialEq + Copy>(values: &mut Vec<T>) {
let mut seen = Vec::with_capacity(values.len());
values.retain(|value| {
let new = !seen.contains(value);
if new {
seen.push(*value);
}
new
});
}
fn bounded_limit(
field: &str,
value: Option<usize>,