Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0be6e87
fix(scan): keep JVM packages named build under source roots
Oct 1, 2026
27ad0eb
fix(scan): read the root .gitignore with git's rules in scan and watch
Oct 1, 2026
f9fae7a
fix(resolve): read no file extraction would skip
Oct 1, 2026
fc4ffd0
fix(extract): bound every source read and skip MPEG-TS clips named .ts
Oct 1, 2026
a1b029c
fix(extract): read MyBatis and iBatis mappers as upstream does
Oct 1, 2026
a1b91a9
fix(extract): read Liquid tags inside {% liquid %} blocks
Oct 1, 2026
7514594
fix(sync): report the pending references a healing sync resolved
Oct 1, 2026
e99c949
fix(mcp): a database file without a published index is not a root
Oct 1, 2026
d7d43b8
fix(wsl): keep WSL off Windows' index on /mnt drives and explain the …
Oct 1, 2026
1851a2b
fix(daemon): retry a Windows sharing violation when replacing daemon.pid
Oct 1, 2026
a5c29a6
fix(install): point Windows shell users at the PowerShell installer
Oct 1, 2026
6d38394
fix(installer): keep detection read-only for configs it cannot parse
Oct 1, 2026
baae0cb
fix(watch): keep a failed sync's batch and owed full reconcile
Oct 1, 2026
bce672f
fix(watch): recover auto-sync after lock contention and report its he…
Oct 1, 2026
c0e3921
fix(prompt-hook): require corroboration for everyday como and wie
Oct 1, 2026
cdc699f
fix(explore): flag dynamic imports built from a template or concatena…
Oct 1, 2026
1fe6c5c
perf(resolve): answer each file-existence probe once per pass
Oct 1, 2026
07ecb2c
docs(upstream): record where the PR-5 v1.6.1 rows landed
Oct 1, 2026
fd5fc42
test(resolve): keep the FIFO-only Path import out of non-unix builds
Oct 1, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ sha2 = "0.10"
sha1_smol = "1"
rayon = "1.8"
globset = "0.4"
ignore = "0.4"

# Logging (rust-base-convention)
tracing = "0.1"
Expand Down
87 changes: 74 additions & 13 deletions crates/codegraph-cli/src/installer/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,33 +356,60 @@ pub fn parse_json_object(text: &str) -> Option<Map<String, Value>> {
}
}

/// Read an agent config file, distinguishing missing / parsed / unparseable.
/// Read an agent config file on a WRITE path, distinguishing missing / parsed /
/// unparseable.
///
/// Tolerates JSONC (comments + trailing commas). A present-but-unparseable file
/// is backed up to `<path>.backup` and reported as [`ConfigRead::Unparseable`]
/// WITHOUT being modified, so the caller can skip writing instead of clobbering
/// the user's config.
pub fn read_config_file(path: &Path) -> ConfigRead {
let read = read_config(path);
if matches!(read, ConfigRead::Unparseable) {
let _ = fs::copy(path, path.with_extension("backup"));
}
read
}

/// Read a JSON/JSONC file into a map, `{}` when missing or unparseable.
///
/// Read-only and silent: every target's `detect()` reads its agent's config
/// on every run, including agents codegraph was never installed into, and
/// `install --refresh` skips the unconfigured ones on that answer, so a
/// `.backup` left by a read would touch a config codegraph has no business
/// touching (upstream #1870). Callers on the WRITE path must NOT use this (it
/// cannot signal the unparseable case); use [`read_config_file`] and abort on
/// [`ConfigRead::Unparseable`] there.
pub fn read_json_file(path: &Path) -> Map<String, Value> {
match read_config(path) {
ConfigRead::Parsed(map) => map,
ConfigRead::Missing | ConfigRead::Unparseable => Map::new(),
}
}

fn read_config(path: &Path) -> ConfigRead {
let Ok(text) = fs::read_to_string(path) else {
return ConfigRead::Missing;
};
match parse_json_object(&text) {
Some(map) => ConfigRead::Parsed(map),
None => {
let _ = fs::copy(path, path.with_extension("backup"));
ConfigRead::Unparseable
}
None => ConfigRead::Unparseable,
}
}

/// Read a JSON/JSONC file into a map. Backward-compatible helper that maps
/// [`ConfigRead::Missing`] to `{}`. Callers on the WRITE path must NOT use this
/// (it cannot signal the unparseable case); use [`read_config_file`] and abort
/// on [`ConfigRead::Unparseable`] there.
pub fn read_json_file(path: &Path) -> Map<String, Value> {
match read_config_file(path) {
ConfigRead::Parsed(map) => map,
ConfigRead::Missing | ConfigRead::Unparseable => Map::new(),
/// Copy a config about to be replaced to `<path>.backup` when it does not
/// parse: a read handed the caller `{}` for it, so the write is a replacement,
/// not an edit. No-op when the file is absent or parses.
fn backup_unparseable_config(path: &Path) {
let Ok(bytes) = fs::read(path) else {
return;
};
if std::str::from_utf8(&bytes)
.ok()
.and_then(parse_json_object)
.is_none()
{
let _ = fs::copy(path, path.with_extension("backup"));
}
}

Expand All @@ -406,7 +433,11 @@ pub fn atomic_write_file(path: &Path, content: &str) -> std::io::Result<()> {

/// Atomic JSON write with a trailing newline. Ports `writeJsonFile`
/// (shared.ts:99) — `JSON.stringify(data, null, 2) + '\n'`.
///
/// The one place a target replaces a JSON config, so it is also where an
/// unparseable one is preserved (upstream #1870).
pub fn write_json_file(path: &Path, data: &Map<String, Value>) -> std::io::Result<()> {
backup_unparseable_config(path);
let mut content = to_upstream_json(&Value::Object(data.clone()));
content.push('\n');
atomic_write_file(path, &content)
Expand Down Expand Up @@ -1757,6 +1788,36 @@ name = "important-user-tool"
let _ = fs::remove_dir_all(&dir);
}

/// Reading a config is read-only (upstream #1870): every target's
/// `detect()` reads its agent's config on every run, and `install
/// --refresh` skips unconfigured agents on that answer, so a read must not
/// leave a `.backup` beside a config codegraph never touches. A backup is
/// made where a config is about to be replaced, or before a write path
/// skips one it cannot parse.
#[test]
fn reading_an_unparseable_config_leaves_no_backup() {
let config = tmp_path("mcp_config.json");
let backup = config.with_extension("backup");
fs::write(&config, "{ broken").unwrap();

assert!(read_json_file(&config).is_empty());
assert!(!backup.exists(), "a read is not a write");

assert!(matches!(read_config_file(&config), ConfigRead::Unparseable));
assert_eq!(fs::read_to_string(&backup).unwrap(), "{ broken");
fs::remove_file(&backup).unwrap();

let mut data = Map::new();
data.insert("k".to_string(), Value::from(1));
write_json_file(&config, &data).unwrap();
assert_eq!(fs::read_to_string(&backup).unwrap(), "{ broken");

fs::remove_file(&backup).unwrap();
write_json_file(&config, &data).unwrap();
assert!(!backup.exists(), "a parseable config needs no backup");
let _ = fs::remove_dir_all(config.parent().unwrap());
}

fn tmp_path(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"cg-jsonc-{}-{}-{}",
Expand Down
124 changes: 74 additions & 50 deletions crates/codegraph-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use codegraph_core::generated_header::detect_generated_file;
use codegraph_core::logger::{LoggerConfig, init_logger};
use codegraph_core::node_id::hash_content;
use codegraph_core::types::{Edge, ExtractionResult, FileRecord, Language, Node, NodeKind};
use codegraph_extract::{ExtractOptions, detect_language_with, extract_source_with_observer};
use codegraph_extract::{ExtractOptions, detect_language_with};
use codegraph_graph::graph::{GodotReach, GraphTraverser, group_definitions};
use codegraph_graph::query::{SearchOptions, search_nodes};
use codegraph_graph::{segment_match, segments};
Expand Down Expand Up @@ -209,6 +209,9 @@ fn cli_main() {
if let Some(guidance) = index_removal_holder_guidance(&err) {
eprint!("{guidance}");
}
if let Some(guidance) = codegraph_store::wsl_shared_index_guidance(err.as_ref()) {
eprint!("{guidance}");
}
std::process::exit(1);
}
}
Expand Down Expand Up @@ -1438,6 +1441,22 @@ fn cmd_init(
"init",
)?;
println!("Initialized in {}", project.display());
// A fresh index on a Windows drive under WSL gets its own directory (#995);
// it is not the documented name, so say where it went and why.
if std::env::var_os("CODEGRAPH_DIR").is_none()
&& let Ok(paths) = index_paths(&project)
&& paths
.current_root()
.file_name()
.is_some_and(|name| name != codegraph_core::index_paths::DEFAULT_CURRENT_DIR)
{
println!(
"The index is in {}/: this project is on a Windows drive, so WSL keeps its own index \
rather than share {}/ with CodeGraph on Windows. Set CODEGRAPH_DIR to choose the name yourself.",
codegraph_core::index_paths::WSL_CURRENT_DIR,
codegraph_core::index_paths::DEFAULT_CURRENT_DIR
);
}
print_index_result(&result);
installer::run_install_local_targets(project, target)
}
Expand Down Expand Up @@ -1774,6 +1793,22 @@ fn cmd_sync(path: Option<PathBuf>, quiet: bool, diagnostics: DiagnosticArgs) ->
format_number(outcome.files_removed as i64),
format_duration(outcome.duration_ms as i64)
);
// A sync that healed an interrupted index reports the sweep's work, so
// recovering references on unchanged files is not a no-op (#1360).
if outcome.pending_refs_processed > 0 {
let unresolved = if outcome.pending_refs_unresolved > 0 {
format!(
" ({} unresolved)",
format_number(outcome.pending_refs_unresolved as i64)
)
} else {
String::new()
};
println!(
"Resolved {} pending references{unresolved}",
format_number(outcome.pending_refs_resolved as i64)
);
}
for warning in &sync_warnings {
eprintln!("warning: {warning}");
}
Expand All @@ -1784,6 +1819,9 @@ fn cmd_sync(path: Option<PathBuf>, quiet: bool, diagnostics: DiagnosticArgs) ->
"filesReindexed": outcome.files_reindexed,
"filesSkipped": outcome.files_skipped_unchanged,
"filesRemoved": outcome.files_removed,
"pendingRefsProcessed": outcome.pending_refs_processed,
"pendingRefsResolved": outcome.pending_refs_resolved,
"pendingRefsUnresolved": outcome.pending_refs_unresolved,
"warnings": sync_warnings,
}));
Ok(())
Expand Down Expand Up @@ -5301,6 +5339,7 @@ fn index_project_inner(
let mut files_indexed = 0;
let mut files_skipped = 0;
let mut files_errored = 0;
let mut files_not_source = 0_u64;
let mut warnings = Vec::new();

// Stream the graph to the store in capped batches instead of holding the whole
Expand Down Expand Up @@ -5350,7 +5389,8 @@ fn index_project_inner(
diagnostic_run.phase_start("parse_write");
let parse_started = std::time::Instant::now();

type ParsePayload = (String, FileRecord, ExtractionResult);
// `None` for an MPEG transport stream named `.ts`: video, not source (#1910).
type ParsePayload = Option<(String, FileRecord, ExtractionResult)>;
let schedule_tracker = tracker.clone();
let parse_tracker = tracker.clone();
let buffer_tracker = tracker.clone();
Expand All @@ -5363,55 +5403,34 @@ fn index_project_inner(
let relative = &files[index];
let full = project.join(relative);

parse_tracker.stage(index, "metadata");
let metadata = match fs::metadata(&full)
.with_context(|| format!("reading metadata for {}", full.display()))
{
Ok(metadata) => metadata,
Err(error) => {
parse_tracker.failed(index, &error);
return Err(error);
}
};

parse_tracker.stage(index, "read");
let source = match fs::read_to_string(&full)
.with_context(|| format!("reading source file {}", full.display()))
{
Ok(source) => source,
Err(error) => {
parse_tracker.failed(index, &error);
return Err(error);
}
let (metadata, source) =
match codegraph_extract::read_source_file(&full, relative, options.max_file_size)
.with_context(|| format!("reading source file {}", full.display()))
{
Ok(read) => read,
Err(error) => {
parse_tracker.failed(index, &error);
return Err(error);
}
};
// A file over the limit was never read: its size stamp stands in for
// its content, in the hash and in the generated-file check. A video
// clip named `.ts` is not source at all, so it is not recorded.
let Some(hash_input) = source.hash_input() else {
return Ok(None);
};
parse_tracker.stage(index, "prepare");
let language = detect_language_with(relative, &options.extensions);
parse_tracker.file_info(index, metadata.len(), language);

let result = if metadata.len() > options.max_file_size {
ExtractionResult {
nodes: Vec::new(),
edges: Vec::new(),
unresolved_references: Vec::new(),
errors: vec![format!(
"File exceeds max size ({} > {}): {relative}",
metadata.len(),
options.max_file_size
)],
duration_ms: 0,
}
} else {
extract_source_with_observer(
relative,
&source,
None,
&options.extensions,
|stage| parse_tracker.extraction_stage(index, stage),
)
};
let result =
codegraph_extract::engine::extraction_of(relative, &source, &options, |stage| {
parse_tracker.extraction_stage(index, stage)
});
let file = FileRecord {
path: relative.clone(),
content_hash: hash_content(&source),
content_hash: hash_content(&hash_input),
language,
size: metadata.len() as i64,
modified_at: modified_millis(&metadata),
Expand All @@ -5422,13 +5441,18 @@ fn index_project_inner(
.filter(|node| node.file_path == *relative)
.count() as i64,
errors: result.errors.clone(),
generated: detect_generated_file(relative, &source),
generated: detect_generated_file(relative, &hash_input),
};
parse_tracker.parsed(index, &result);
Ok((relative.clone(), file, result))
Ok(Some((relative.clone(), file, result)))
},
|buffered| buffer_tracker.buffered(buffered),
|index, (_relative, file, mut result)| {
|index, payload| {
let Some((_relative, file, mut result)) = payload else {
files_not_source += 1;
persist_tracker.persisted(index);
return Ok(());
};
let fatal_errors = file
.errors
.iter()
Expand Down Expand Up @@ -5480,7 +5504,7 @@ fn index_project_inner(
}),
);

let scan_files = bar.position();
let scan_files = bar.position() - files_not_source;
finish_phase(
&bar,
&format!("Indexed {} files", format_number(scan_files as i64)),
Expand Down Expand Up @@ -5536,13 +5560,13 @@ fn index_project_inner(
diagnostic_run.phase_start("framework_extract");
let framework_started = std::time::Instant::now();
let pb = phase_spinner("Detecting frameworks", quiet);
let mut resolver = ReferenceResolver::new(project.to_string_lossy());
let mut resolver =
ReferenceResolver::new(project.to_string_lossy()).with_max_file_size(options.max_file_size);
// Detect frameworks then run their per-file extract (route/component/handler
// nodes + refs) BEFORE resolution, mirroring the upstream tree-sitter.ts:4796-4819
// framework-extraction pass feeding the resolution pipeline.
{
let context =
codegraph_resolve::StoreResolutionContext::new(&store, project.to_string_lossy());
let context = resolver.store_context(&store);
resolver.initialize(&context);
}
if resolver.has_framework_resolvers() {
Expand Down
15 changes: 7 additions & 8 deletions crates/codegraph-cli/src/structural_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ use regex::Regex;
/// Structural keywords matched as EXACT words (boundary on both sides): short
/// or ambiguous tokens where prefix matching would false-positive ("flow" in
/// "flower", "path" in "pathological"). Grouped by language; a term appears once
/// even when several languages share it ("como" is Portuguese for how AND
/// unaccented-typed Spanish "cómo"). Verbatim from `directory.ts:255-310`.
/// even when several languages share it. Ambiguous everyday words like PT/ES
/// "como" and DE "wie" are excluded: the hook instead requires another strong
/// keyword, a verified code token, or indexed prose segments (upstream #1654).
/// Verbatim from `directory.ts:255-310`.
const STRUCTURAL_WORDS: &[&str] = &[
// English — the pre-#1126 list minus what moved to STRUCTURAL_STEMS: the
// bare-stem entries never matched their own derived forms (`\barchitect\b`
Expand All @@ -55,7 +57,7 @@ const STRUCTURAL_WORDS: &[&str] = &[
"flux",
"chemins?",
"casse",
// Spanish (cómo/como=how, dónde/donde=where, flujo=flow, ruta/camino=path,
// Spanish (cómo=how, dónde/donde=where, flujo=flow, ruta/camino=path,
// rompe=breaks, llaman / quién llama = call(s) — bare "llama" is excluded:
// it's also the animal/model name in English prompts)
"cómo",
Expand All @@ -68,16 +70,13 @@ const STRUCTURAL_WORDS: &[&str] = &[
"llaman",
"quién llama",
"quien llama",
// Portuguese (como=how — also covers unaccented Spanish; onde=where,
// fluxo=flow, caminho=path)
"como",
// Portuguese (onde=where, fluxo=flow, caminho=path)
"onde",
"fluxos?",
"caminhos?",
// German (wie=how, wo/woher/wohin=where, Pfad=path, Fluss/Ablauf=flow,
// German (wo/woher/wohin=where, Pfad=path, Fluss/Ablauf=flow,
// bricht/kaputt=breaks, ruft=calls, hängt=depends — "hängt … von X ab"
// splits the separable verb "abhängen", so the "abhäng" stem can't catch it)
"wie",
"wo",
"woher",
"wohin",
Expand Down
Loading
Loading