diff --git a/crates/codegraph-core/src/domain/graph/builder/entrypoints.rs b/crates/codegraph-core/src/domain/graph/builder/entrypoints.rs index 266dc7680..8a08e59fb 100644 --- a/crates/codegraph-core/src/domain/graph/builder/entrypoints.rs +++ b/crates/codegraph-core/src/domain/graph/builder/entrypoints.rs @@ -43,6 +43,7 @@ use rusqlite::Connection; use std::collections::{BTreeMap, HashMap, HashSet}; +use crate::domain::graph::resolve::resolve_pyproject_script_entrypoints; use crate::types::FileSymbols; /// Replace each reparsed Python file's persisted entrypoint-call evidence @@ -255,6 +256,95 @@ pub fn apply_entrypoint_attribution( project_entrypoint_attribution(conn) } +/// Flag pyproject.toml-declared console/GUI/Poetry script entrypoints +/// (#2408) directly on their target nodes. Mirrors TS +/// `applyPyprojectScriptAttribution` exactly, including the precedence over +/// guard-call attribution and the "clear stale, scoped to rows this function +/// itself set" safety property — see the TS doc comment for the full +/// rationale. `pyproject.toml` is re-parsed fresh on every call (no evidence +/// table needed): it is a single, cheap-to-reread file, so there is no +/// cross-file lifecycle problem to solve the way there is for guard calls. +pub fn apply_pyproject_script_attribution( + conn: &Connection, + root_dir: &str, + known_files: Option<&HashSet>, +) -> HashSet { + let desired = resolve_pyproject_script_entrypoints(root_dir, known_files); + + let mut current: HashMap = HashMap::new(); + if let Ok(mut stmt) = + conn.prepare("SELECT id, file FROM nodes WHERE entrypoint_source_file = 'pyproject.toml'") + { + if let Ok(rows) = stmt.query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) { + current.extend(rows.flatten()); + } + } + + if desired.is_empty() && current.is_empty() { + return HashSet::new(); + } + + let mut touched_files = HashSet::new(); + let mut desired_ids: HashSet = HashSet::new(); + + let Ok(tx) = conn.unchecked_transaction() else { + return touched_files; + }; + { + let Ok(mut find_candidates) = tx.prepare( + "SELECT id, name FROM nodes WHERE file = ?1 AND kind IN ('function', 'method')", + ) else { + return touched_files; + }; + let Ok(mut mark_stmt) = tx.prepare( + "UPDATE nodes SET entrypoint = 1, entrypoint_source_file = 'pyproject.toml' WHERE id = ?1", + ) else { + return touched_files; + }; + + for entry in &desired { + let mut candidates: Vec<(i64, String)> = Vec::new(); + if let Ok(rows) = find_candidates.query_map(rusqlite::params![entry.file], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) { + candidates.extend(rows.flatten()); + } + let target = candidates + .iter() + .find(|(_, name)| *name == entry.attr) + .or_else(|| { + candidates.iter().find(|(_, name)| { + name.len() > entry.attr.len() && name.ends_with(&format!(".{}", entry.attr)) + }) + }); + let Some((id, _)) = target else { continue }; + desired_ids.insert(*id); + let _ = mark_stmt.execute(rusqlite::params![id]); + if !current.contains_key(id) { + touched_files.insert(entry.file.clone()); + } + } + } + { + let Ok(mut clear_stmt) = tx.prepare( + "UPDATE nodes SET entrypoint = 0, entrypoint_source_file = NULL WHERE id = ?1", + ) else { + return touched_files; + }; + for (id, file) in ¤t { + if !desired_ids.contains(id) { + let _ = clear_stmt.execute(rusqlite::params![id]); + touched_files.insert(file.clone()); + } + } + } + let _ = tx.commit(); + + touched_files +} + #[cfg(test)] mod tests { use super::*; @@ -417,4 +507,179 @@ mod tests { assert_eq!(flag_of(&conn, tgt), (1, Some("a_run.py".to_string()))); } + + /// Schema plus fixtures for `apply_pyproject_script_attribution`: a + /// `src/pipeline/cli.py` file with `main` and `helper` functions, matching + /// the shape a `[project.scripts]` entry actually resolves against — the + /// resolver returns paths relative to `root_dir`, so this must match + /// `write_pyproject`'s `src/pipeline/cli.py` layout exactly. + fn script_test_conn() -> Connection { + let conn = test_conn(); + conn.execute( + "INSERT INTO nodes (name, kind, file, line) VALUES ('main', 'function', 'src/pipeline/cli.py', 5)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO nodes (name, kind, file, line) VALUES ('helper', 'function', 'src/pipeline/cli.py', 1)", + [], + ) + .unwrap(); + conn + } + + fn write_pyproject(dir: &std::path::Path, body: &str) { + std::fs::create_dir_all(dir.join("src/pipeline")).unwrap(); + std::fs::write(dir.join("src/pipeline/__init__.py"), "").unwrap(); + std::fs::write( + dir.join("src/pipeline/cli.py"), + "def helper():\n pass\n\n\ndef main():\n pass\n", + ) + .unwrap(); + std::fs::write(dir.join("pyproject.toml"), body).unwrap(); + } + + fn node_id_by_name(conn: &Connection, name: &str) -> i64 { + conn.query_row( + "SELECT id FROM nodes WHERE name = ?1", + rusqlite::params![name], + |row| row.get(0), + ) + .unwrap() + } + + #[test] + fn marks_a_console_script_target_as_an_entrypoint() { + let tmp = std::env::temp_dir().join(format!( + "codegraph-pyproject-script-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&tmp); + write_pyproject( + &tmp, + "[project.scripts]\ningest = \"pipeline.cli:main\"\n\n[tool.setuptools.package-dir]\n\"\" = \"src\"\n", + ); + + let conn = script_test_conn(); + let touched = apply_pyproject_script_attribution(&conn, tmp.to_str().unwrap(), None); + + let main_id = node_id_by_name(&conn, "main"); + assert_eq!( + flag_of(&conn, main_id), + (1, Some("pyproject.toml".to_string())) + ); + assert_eq!(flag_of(&conn, node_id_by_name(&conn, "helper")).0, 0); + assert!(touched.contains("src/pipeline/cli.py")); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn clears_a_stale_script_attribution_when_the_script_is_removed() { + let tmp = std::env::temp_dir().join(format!( + "codegraph-pyproject-script-test-clear-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&tmp); + write_pyproject( + &tmp, + "[project.scripts]\ningest = \"pipeline.cli:main\"\n\n[tool.setuptools.package-dir]\n\"\" = \"src\"\n", + ); + + let conn = script_test_conn(); + apply_pyproject_script_attribution(&conn, tmp.to_str().unwrap(), None); + let main_id = node_id_by_name(&conn, "main"); + assert_eq!(flag_of(&conn, main_id).0, 1); + + // The script entry is removed from pyproject.toml on a later build. + // Every real rebuild clears the pyproject-scripts cache before calling + // this function (see incremental.ts's clearPythonImportRootsCache() + // call ahead of refreshEntrypointAttribution on its common path), so + // this test must too — without it, the cache keyed on `tmp` would + // still hold the first write's parsed scripts. + crate::domain::graph::resolve::clear_python_import_roots_cache(); + write_pyproject(&tmp, "[project]\nname = \"pipeline\"\n"); + let touched = apply_pyproject_script_attribution(&conn, tmp.to_str().unwrap(), None); + + assert_eq!(flag_of(&conn, main_id), (0, None)); + assert!(touched.contains("src/pipeline/cli.py")); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn does_not_clobber_a_guard_attributed_entrypoint() { + // A target already flagged by guard-call evidence (project_entrypoint_attribution) + // must survive a pyproject.toml pass that declares no scripts at all — + // the "clear stale" step is scoped to entrypoint_source_file = 'pyproject.toml'. + let tmp = std::env::temp_dir().join(format!( + "codegraph-pyproject-script-test-noclobber-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&tmp); + write_pyproject(&tmp, "[project]\nname = \"pipeline\"\n"); + + let conn = script_test_conn(); + let main_id = node_id_by_name(&conn, "main"); + conn.execute( + "UPDATE nodes SET entrypoint = 1, entrypoint_source_file = 'guard.py' WHERE id = ?1", + rusqlite::params![main_id], + ) + .unwrap(); + + let touched = apply_pyproject_script_attribution(&conn, tmp.to_str().unwrap(), None); + + assert_eq!(flag_of(&conn, main_id), (1, Some("guard.py".to_string()))); + assert!(touched.is_empty()); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn takes_precedence_over_an_existing_guard_attribution_on_the_same_target() { + let tmp = std::env::temp_dir().join(format!( + "codegraph-pyproject-script-test-precedence-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&tmp); + write_pyproject( + &tmp, + "[project.scripts]\ningest = \"pipeline.cli:main\"\n\n[tool.setuptools.package-dir]\n\"\" = \"src\"\n", + ); + + let conn = script_test_conn(); + let main_id = node_id_by_name(&conn, "main"); + conn.execute( + "UPDATE nodes SET entrypoint = 1, entrypoint_source_file = 'guard.py' WHERE id = ?1", + rusqlite::params![main_id], + ) + .unwrap(); + + apply_pyproject_script_attribution(&conn, tmp.to_str().unwrap(), None); + + assert_eq!( + flag_of(&conn, main_id), + (1, Some("pyproject.toml".to_string())) + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn returns_empty_and_touches_nothing_when_no_scripts_and_no_prior_attribution() { + let tmp = std::env::temp_dir().join(format!( + "codegraph-pyproject-script-test-empty-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&tmp); + write_pyproject(&tmp, "[project]\nname = \"pipeline\"\n"); + + let conn = script_test_conn(); + let touched = apply_pyproject_script_attribution(&conn, tmp.to_str().unwrap(), None); + + assert!(touched.is_empty()); + assert_eq!(flag_of(&conn, node_id_by_name(&conn, "main")).0, 0); + + let _ = std::fs::remove_dir_all(&tmp); + } } diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index 69aa7a076..4ee9e84bb 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -167,10 +167,17 @@ fn pipeline_setup( // calls `resolve::resolve_imports_batch` directly, bypassing lib.rs's // NAPI `resolve_imports` wrapper (and its own cache-clearing) entirely — // so the Cargo target-override cache needs the same explicit reset here - // for repeated native full builds in the same process (issue #2217). + // for repeated native full builds in the same process (issue #2217), and + // likewise the Python import-root caches (pyproject-configured roots and + // layout-derived package roots) for the same reason: a repeated native + // full build in the same process (MCP server, or any programmatic caller + // invoking the native pipeline more than once) could otherwise resolve + // `apply_pyproject_script_attribution`'s script targets against roots + // that predate a `pyproject.toml` root-config edit (issue #2408 review). resolve::reset_workspace_resolved_paths(); resolve::clear_exports_cache(); resolve::clear_cargo_target_overrides_cache(); + resolve::clear_python_import_roots_cache(); Ok(PipelineSetup { config, @@ -463,6 +470,7 @@ fn run_role_classification( file_symbols: &BTreeMap, removal_reverse_deps: Vec, is_full_build: bool, + root_dir: &str, ) { // Program-entrypoint flags must be current before roles are computed, and // the edges they are derived from are complete by this stage — the same @@ -470,7 +478,19 @@ fn run_role_classification( // The returned files are folded in below alongside `removal_reverse_deps` // — same reason: a touched target's role can't be trusted to be // rediscovered by neighbour expansion. - let entrypoint_touched_files = entrypoints::apply_entrypoint_attribution(conn, file_symbols); + let mut entrypoint_touched_files = + entrypoints::apply_entrypoint_attribution(conn, file_symbols); + // #2408: pyproject.toml is re-read fresh every build (no evidence table), + // so this must run unconditionally regardless of which files changed — + // a script target's own file rebuilding, or nothing changing at all + // and only pyproject.toml being edited, are both cases that need it. + // `known_files: None` falls back to real filesystem checks — correct for + // an actual build, where a resolved target genuinely exists on disk, and + // avoids threading the collected-file set through an extra parameter for + // what is a once-per-build (not once-per-file) resolution. + entrypoint_touched_files.extend(entrypoints::apply_pyproject_script_attribution( + conn, root_dir, None, + )); let changed_files: Vec = file_symbols.keys().cloned().collect(); let changed_file_list: Option> = if is_full_build { @@ -843,6 +863,7 @@ pub fn run_pipeline( &file_symbols, removal_reverse_deps, change_result.is_full_build, + root_dir, ); timing.roles_ms = t0.elapsed().as_secs_f64() * 1000.0; diff --git a/crates/codegraph-core/src/domain/graph/resolve.rs b/crates/codegraph-core/src/domain/graph/resolve.rs index e1f9ca9b9..93a6a99fe 100644 --- a/crates/codegraph-core/src/domain/graph/resolve.rs +++ b/crates/codegraph-core/src/domain/graph/resolve.rs @@ -707,6 +707,111 @@ pub fn clear_python_import_roots_cache() { .clear(); } +/// A single `name = "module:attr"` script entry, pre-resolution. +struct PyprojectScriptEntry { + module: String, + attr: String, +} + +/// Console/GUI-script and Poetry script entrypoints declared by +/// `pyproject.toml` (#2408) — `[project.scripts]`, `[project.gui-scripts]`, +/// and `[tool.poetry.scripts]`. Mirrors TS `parsePyprojectScripts` exactly, +/// including only handling the plain string shape (Poetry's rarer +/// extras-table script shape is skipped rather than guessed at) and the +/// same best-effort "malformed TOML yields nothing" precedent as +/// `parse_pyproject_import_roots`. +fn parse_pyproject_scripts(root_dir: &str) -> Vec { + let manifest = Path::new(root_dir).join("pyproject.toml"); + let Ok(content) = std::fs::read_to_string(&manifest) else { + return Vec::new(); + }; + let Ok(parsed) = toml::from_str::(&content) else { + return Vec::new(); + }; + + let mut entries = Vec::new(); + let mut add_from = |table: Option<&toml::Value>| { + let Some(table) = table.and_then(|v| v.as_table()) else { + return; + }; + for value in table.values() { + let Some(s) = value.as_str() else { + continue; // skip Poetry's rarer table-shaped entries + }; + let Some(colon_idx) = s.find(':') else { + continue; + }; + if colon_idx == 0 || colon_idx == s.len() - 1 { + continue; + } + entries.push(PyprojectScriptEntry { + module: s[..colon_idx].to_string(), + attr: s[colon_idx + 1..].to_string(), + }); + } + }; + + let project = parsed.get("project"); + add_from(project.and_then(|p| p.get("scripts"))); + add_from(project.and_then(|p| p.get("gui-scripts"))); + add_from( + parsed + .get("tool") + .and_then(|t| t.get("poetry")) + .and_then(|p| p.get("scripts")), + ); + entries +} + +/// A pyproject-declared script entry, resolved to the file that declares its +/// target. +pub struct ResolvedPyprojectScriptEntrypoint { + /// Root-relative path to the file declaring `attr`. + pub file: String, + /// The target symbol name — a bare function name, or `Class.method`. + pub attr: String, +} + +/// Resolve every pyproject-declared script (#2408) to the file that declares +/// its target. Mirrors TS `resolvePyprojectScriptEntrypoints` exactly, +/// including the synthetic `/pyproject.toml` anchor path used to +/// reuse `resolve_python_import_path`'s ordinary absolute-import resolution +/// (`module:attr` is always an absolute dotted path, never relative) — see +/// the TS doc comment for why the anchor's exact identity beyond "some file +/// at `root_dir`" doesn't otherwise matter. +pub fn resolve_pyproject_script_entrypoints( + root_dir: &str, + known_files: Option<&HashSet>, +) -> Vec { + let scripts = parse_pyproject_scripts(root_dir); + if scripts.is_empty() { + return Vec::new(); + } + + let anchor = Path::new(root_dir) + .join("pyproject.toml") + .display() + .to_string() + .replace('\\', "/"); + let mut seen = HashSet::new(); + let mut resolved = Vec::new(); + for entry in scripts.iter() { + let Some(file) = resolve_python_import_path(&anchor, &entry.module, root_dir, known_files) + else { + continue; + }; + let key = format!("{file} {}", entry.attr); + if !seen.insert(key) { + continue; + } + resolved.push(ResolvedPyprojectScriptEntrypoint { + file, + attr: entry.attr.clone(), + }); + } + resolved +} + fn get_python_configured_roots(root_dir: &str) -> Vec { let mut cache = python_configured_roots_cache() .lock() @@ -2503,6 +2608,147 @@ mod tests { ); } + // ── pyproject.toml script entrypoints (#2408) ────────────────── + // + // Unlike the synthetic-root tests above, `resolve_pyproject_script_entrypoints` + // reads a real `pyproject.toml` off disk (there is no `known_files` bypass + // for that read), so these use a real temp directory for the manifest — + // but the resolved target `.py` files themselves stay synthetic, declared + // only in `known_files`, exactly like the tests above. + + #[test] + fn resolves_a_console_script_target_via_src_layout() { + let tmp = std::env::temp_dir().join("codegraph_pyproject_scripts_basic_test"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + fs::write( + tmp.join("pyproject.toml"), + "[project.scripts]\ningest = \"pipeline.cli:main\"\n", + ) + .unwrap(); + let root = tmp.to_str().unwrap(); + let known: Vec = ["src/pipeline/__init__.py", "src/pipeline/cli.py"] + .iter() + .map(|f| format!("{root}/{f}")) + .collect(); + let known = normalize_known_files(known); + + let resolved = resolve_pyproject_script_entrypoints(root, Some(&known)); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].file, "src/pipeline/cli.py"); + assert_eq!(resolved[0].attr, "main"); + } + + #[test] + fn resolves_gui_scripts_and_poetry_scripts_alongside_console_scripts() { + let tmp = std::env::temp_dir().join("codegraph_pyproject_scripts_all_tables_test"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + fs::write( + tmp.join("pyproject.toml"), + "[project.scripts]\ncli = \"pipeline.cli:main\"\n\ + [project.gui-scripts]\ngui = \"pipeline.gui:launch\"\n\ + [tool.poetry.scripts]\npoetry-cli = \"pipeline.poetry_entry:run\"\n", + ) + .unwrap(); + let root = tmp.to_str().unwrap(); + let known: Vec = [ + "src/pipeline/__init__.py", + "src/pipeline/cli.py", + "src/pipeline/gui.py", + "src/pipeline/poetry_entry.py", + ] + .iter() + .map(|f| format!("{root}/{f}")) + .collect(); + let known = normalize_known_files(known); + + let resolved = resolve_pyproject_script_entrypoints(root, Some(&known)); + let attrs: HashSet<&str> = resolved.iter().map(|e| e.attr.as_str()).collect(); + + assert_eq!(resolved.len(), 3); + assert!(attrs.contains("main")); + assert!(attrs.contains("launch")); + assert!(attrs.contains("run")); + } + + #[test] + fn skips_a_table_shaped_poetry_script_entry() { + // Poetry's extras-conditional shape: `{ callable = "...", extras = [...] }`. + // Skipped rather than guessed at. + let tmp = std::env::temp_dir().join("codegraph_pyproject_scripts_table_shape_test"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + fs::write( + tmp.join("pyproject.toml"), + "[tool.poetry.scripts]\nfoo = { callable = \"pipeline.cli:main\", extras = [\"x\"] }\n", + ) + .unwrap(); + let root = tmp.to_str().unwrap(); + + let resolved = resolve_pyproject_script_entrypoints(root, None); + + assert!(resolved.is_empty()); + } + + #[test] + fn deduplicates_two_script_names_pointing_at_the_same_target() { + let tmp = std::env::temp_dir().join("codegraph_pyproject_scripts_dedup_test"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + fs::write( + tmp.join("pyproject.toml"), + "[project.scripts]\ncli = \"pipeline.cli:main\"\n\ + [project.gui-scripts]\ngui = \"pipeline.cli:main\"\n", + ) + .unwrap(); + let root = tmp.to_str().unwrap(); + let known: Vec = ["src/pipeline/__init__.py", "src/pipeline/cli.py"] + .iter() + .map(|f| format!("{root}/{f}")) + .collect(); + let known = normalize_known_files(known); + + let resolved = resolve_pyproject_script_entrypoints(root, Some(&known)); + + assert_eq!(resolved.len(), 1); + } + + #[test] + fn returns_empty_when_pyproject_toml_is_missing_or_declares_no_scripts() { + let tmp = std::env::temp_dir().join("codegraph_pyproject_scripts_missing_test"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + + assert!(resolve_pyproject_script_entrypoints(tmp.to_str().unwrap(), None).is_empty()); + + fs::write( + tmp.join("pyproject.toml"), + "[project]\nname = \"pipeline\"\n", + ) + .unwrap(); + assert!(resolve_pyproject_script_entrypoints(tmp.to_str().unwrap(), None).is_empty()); + } + + #[test] + fn drops_a_script_entry_whose_module_does_not_resolve_under_any_root() { + let tmp = std::env::temp_dir().join("codegraph_pyproject_scripts_unresolvable_test"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + fs::write( + tmp.join("pyproject.toml"), + "[project.scripts]\ningest = \"third_party_pkg.cli:main\"\n", + ) + .unwrap(); + let root = tmp.to_str().unwrap(); + let known: HashSet = HashSet::new(); // nothing exists + + let resolved = resolve_pyproject_script_entrypoints(root, Some(&known)); + + assert!(resolved.is_empty()); + } + #[test] fn is_python_file_recognizes_source_and_stub_extensions() { assert!(is_python_file("app/main.py")); diff --git a/src/domain/graph/builder/entrypoints.ts b/src/domain/graph/builder/entrypoints.ts index cbc4c8375..7aa9c6b91 100644 --- a/src/domain/graph/builder/entrypoints.ts +++ b/src/domain/graph/builder/entrypoints.ts @@ -44,6 +44,7 @@ */ import type { BetterSqlite3Database } from '../../../types.js'; +import { resolvePyprojectScriptEntrypoints } from '../resolve.js'; /** The subset of an extracted call this module needs. */ interface EntrypointCall { @@ -208,3 +209,78 @@ export function projectEntrypointAttribution(db: BetterSqlite3Database): string[ return [...touchedFiles]; } + +/** + * Flag pyproject.toml-declared console/GUI/Poetry script entrypoints (#2408) + * directly on their target nodes — `nodes.entrypoint = 1`, + * `entrypoint_source_file = 'pyproject.toml'`. + * + * Unlike the guard-call evidence above, this has no cross-file lifecycle + * problem to solve with a persisted evidence table: `pyproject.toml` is a + * single, cheap-to-reread file, so it is re-parsed fresh on every build + * (regardless of incremental scope) via `resolvePyprojectScriptEntrypoints` + * rather than diffed from a prior parse. The target side still needs the + * same "clear stale, then re-mark" treatment as the guard mechanism, since a + * target's node row is deleted and re-inserted with a new id whenever ITS + * file rebuilds. + * + * Must run after `projectEntrypointAttribution` and takes precedence over + * it: an explicit packaging declaration is a stronger signal than an + * inferred guard call, so a node flagged by both ends up attributed to + * `pyproject.toml`. The "clear stale" step is scoped to + * `entrypoint_source_file = 'pyproject.toml'`, so it can only ever touch + * rows this function itself previously set — it never clobbers a + * guard-attributed node that happens not to (or no longer) be a script + * target. + * + * Mirrored in `crates/codegraph-core/src/domain/graph/builder/entrypoints.rs`. + */ +export function applyPyprojectScriptAttribution( + db: BetterSqlite3Database, + rootDir: string, + knownFiles?: readonly string[] | null, +): string[] { + const desired = resolvePyprojectScriptEntrypoints(rootDir, knownFiles); + + const current = db + .prepare(`SELECT id, file FROM nodes WHERE entrypoint_source_file = 'pyproject.toml'`) + .all() as Array<{ id: number; file: string }>; + + if (desired.length === 0 && current.length === 0) return []; + + const clearStmt = db.prepare( + 'UPDATE nodes SET entrypoint = 0, entrypoint_source_file = NULL WHERE id = ?', + ); + const markStmt = db.prepare( + "UPDATE nodes SET entrypoint = 1, entrypoint_source_file = 'pyproject.toml' WHERE id = ?", + ); + const findCandidates = db.prepare( + `SELECT id, name FROM nodes WHERE file = ? AND kind IN ('function', 'method')`, + ); + + const touchedFiles = new Set(); + const currentIds = new Map(current.map((r) => [r.id, r.file])); + const desiredIds = new Set(); + + const tx = db.transaction(() => { + for (const { file, attr } of desired) { + const candidates = findCandidates.all(file) as Array<{ id: number; name: string }>; + const target = + candidates.find((c) => c.name === attr) ?? + candidates.find((c) => c.name.length > attr.length && c.name.endsWith(`.${attr}`)); + if (!target) continue; + desiredIds.add(target.id); + markStmt.run(target.id); + if (!currentIds.has(target.id)) touchedFiles.add(file); + } + for (const [id, file] of currentIds) { + if (!desiredIds.has(id)) { + clearStmt.run(id); + touchedFiles.add(file); + } + } + }); + tx(); + + return [...touchedFiles]; +} diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index e53e36f8a..dca14d81f 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -59,7 +59,11 @@ import { resolveChaTargets, resolveThisDispatch, } from './cha.js'; -import { persistEntrypointCalls, projectEntrypointAttribution } from './entrypoints.js'; +import { + applyPyprojectScriptAttribution, + persistEntrypointCalls, + projectEntrypointAttribution, +} from './entrypoints.js'; import { BUILTIN_RECEIVERS, CHA_DISPATCH_PENALTY, @@ -2277,6 +2281,7 @@ async function applyChaDispatchPostPass( */ function refreshEntrypointAttribution( db: BetterSqlite3Database, + rootDir: string, relPath: string, symbols: ExtractorOutput | null, ): void { @@ -2286,6 +2291,12 @@ function refreshEntrypointAttribution( persistEntrypointCalls(db, [[relPath, symbols.calls]]); } const touchedFiles = projectEntrypointAttribution(db); + // #2408: pyproject.toml is re-read fresh every rebuild (no evidence table), + // so this must run on every call regardless of which file changed — a + // script target's own file rebuilding is exactly the case that needs it. + // `null` knownFiles falls back to real filesystem checks, which is fine at + // single-file-rebuild scale. + touchedFiles.push(...applyPyprojectScriptAttribution(db, rootDir, null)); if (touchedFiles.length === 0) return; // The target's file is frequently not the file being rebuilt, so its cached // `nodes.role` would otherwise stay stale at whatever the last full build @@ -2354,7 +2365,15 @@ export async function rebuildFile( // re-projecting is what clears a target it was attributing — including // one declared in a different file, which nothing else in this rebuild // touches. - refreshEntrypointAttribution(db, relPath, null); + // #2408 review: this bail-out returns before the common path's cache + // clear further down, so a long-lived watcher whose cached Python roots + // predate a pyproject.toml root-config change would resolve this + // build's script declarations against stale roots — wrongly clearing a + // still-valid target's attribution because it no longer appears + // resolvable. Clearing here first keeps every rebuildFile exit path + // resolving against current roots, not just the common one. + clearPythonImportRootsCache(); + refreshEntrypointAttribution(db, rootDir, relPath, null); return buildDeletionResult(relPath, oldNodes, edgesBefore, oldSymbols, diffSymbols); } @@ -2391,7 +2410,9 @@ export async function rebuildFile( if (!fileNodeRow) { // Same invariant, but the parse succeeded — so this file's fresh evidence // is known and worth writing, even though no edges get built below. - refreshEntrypointAttribution(db, relPath, symbols); + // #2408 review: same stale-roots hazard as the deletion path above. + clearPythonImportRootsCache(); + refreshEntrypointAttribution(db, rootDir, relPath, symbols); // Unreachable in practice (`insertFileNodes` just inserted this exact row), // but the purge above has already run, so bailing out here leaves the file // with nodes and no edges. Drop its `file_hashes` row so the next @@ -2498,7 +2519,7 @@ export async function rebuildFile( // #2428: must follow every edge-insert path above — the projection reads // targets back off the committed `calls` edges, including the ones the // reverse-dep cascade just rebuilt. - refreshEntrypointAttribution(db, relPath, symbols); + refreshEntrypointAttribution(db, rootDir, relPath, symbols); // Include pre-deletion edge counts from reverse deps so the net delta // (edgesAdded - edgesBefore) is correct even when the cascade re-inserts diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 887e82dc5..1e55d4f4d 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -59,7 +59,11 @@ import { import type { ChaContext } from '../cha.js'; import { buildChaContext, resolveChaTargets, resolveThisDispatch } from '../cha.js'; import type { PipelineContext } from '../context.js'; -import { persistEntrypointCalls, projectEntrypointAttribution } from '../entrypoints.js'; +import { + applyPyprojectScriptAttribution, + persistEntrypointCalls, + projectEntrypointAttribution, +} from '../entrypoints.js'; import { BUILTIN_RECEIVERS, batchInsertEdges, @@ -1225,7 +1229,10 @@ function buildImportedNamesForNative( /** * Persist this build's Python entrypoint-call evidence (#2392) and re-project - * it onto `nodes.entrypoint` (#2428). + * it onto `nodes.entrypoint` (#2428), then layer pyproject.toml-declared + * script entrypoints on top (#2408) — see `applyPyprojectScriptAttribution` + * for why that second step takes precedence and needs no evidence table of + * its own. * * `persistEntrypointCalls` skips non-Python files itself. The projection then * runs over the whole graph — it is driven by `entrypoint_calls`, which is @@ -1244,6 +1251,9 @@ function applyEntrypointAttribution(ctx: PipelineContext): void { [...fileSymbols].map(([relPath, symbols]) => [relPath, symbols.calls] as const), ); ctx.entrypointTouchedFiles.push(...projectEntrypointAttribution(db)); + ctx.entrypointTouchedFiles.push( + ...applyPyprojectScriptAttribution(db, ctx.rootDir, ctx.allFiles), + ); } /** @@ -3088,10 +3098,11 @@ export async function buildEdges(ctx: PipelineContext): Promise { // tryNativeOrchestrator; this phase covers the WASM and native-fallback paths. runChaPostPass(ctx.db); - // Phase 5: flag program entrypoints (#2392). Must follow every edge-insert - // path above — it identifies its targets from the committed `calls` edges, - // including the reverse-dep edges Phase 3 just reconnected — and must - // precede role classification, which reads the column it sets. + // Phase 5: flag program entrypoints (#2392) and pyproject.toml-declared + // scripts (#2408). The guard-call half must follow every edge-insert path + // above — it identifies its targets from the committed `calls` edges, + // including the reverse-dep edges Phase 3 just reconnected — and both + // halves must precede role classification, which reads the column they set. applyEntrypointAttribution(ctx); ctx.timing.edgesMs = performance.now() - t0; diff --git a/src/domain/graph/resolve.ts b/src/domain/graph/resolve.ts index 7b2d2acea..66d45fa1a 100644 --- a/src/domain/graph/resolve.ts +++ b/src/domain/graph/resolve.ts @@ -934,6 +934,109 @@ export function clearPythonImportRootsCache(): void { loadNative()?.clearPythonImportRootsCache?.(); } +/** A single `name = "module:attr"` script entry, pre-resolution. */ +interface PyprojectScriptEntry { + module: string; + attr: string; +} + +/** + * Console/GUI-script and Poetry script entrypoints declared by + * `pyproject.toml` (#2408) — `[project.scripts]`, `[project.gui-scripts]`, + * and `[tool.poetry.scripts]`. Each maps a command name to a `module:attr` + * target string; the command name itself is discarded here since attribution + * only cares about the target. + * + * Only the plain string shape is handled. Poetry also allows a table shape + * (`{ callable = "module:attr", extras = [...] }`, for extras-conditional + * scripts) — skipped rather than guessed at, matching + * `parsePyprojectImportRoots`'s best-effort precedent of contributing nothing + * for a shape it doesn't understand rather than failing the build. + * + * Best-effort like `parsePyprojectImportRoots`: unreadable or malformed TOML + * yields no scripts rather than failing resolution. + */ +function parsePyprojectScripts(rootDir: string): PyprojectScriptEntry[] { + const manifest = path.join(rootDir, 'pyproject.toml'); + let parsed: unknown; + try { + parsed = parseToml(fs.readFileSync(manifest, 'utf8')); + } catch (e) { + debug(`parsePyprojectScripts: cannot read ${manifest}: ${toErrorMessage(e)}`); + return []; + } + if (typeof parsed !== 'object' || parsed === null) return []; + const root = parsed as Record; + + const entries: PyprojectScriptEntry[] = []; + const addFrom = (table: unknown): void => { + if (typeof table !== 'object' || table === null) return; + for (const value of Object.values(table as Record)) { + if (typeof value !== 'string') continue; // skip Poetry's rarer table-shaped entries + const colonIdx = value.indexOf(':'); + if (colonIdx <= 0 || colonIdx === value.length - 1) continue; + entries.push({ module: value.slice(0, colonIdx), attr: value.slice(colonIdx + 1) }); + } + }; + + addFrom(root.project?.scripts); + addFrom(root.project?.['gui-scripts']); + addFrom(root.tool?.poetry?.scripts); + return entries; +} + +/** A pyproject-declared script entry, resolved to the file that declares its target. */ +export interface ResolvedPyprojectScriptEntrypoint { + /** Root-relative path to the file declaring `attr`. */ + file: string; + /** The target symbol name — a bare function name, or `Class.method`. */ + attr: string; +} + +/** + * Resolve every pyproject-declared script (#2408) to the file that declares + * its target, reusing the same import-root machinery `resolvePythonSubmodule` + * uses for ordinary imports (#2387) — `pythonPackageRoot`, the conventional + * `src/` directory, the repo root, and anything `pyproject.toml` itself + * declares as an extra root. + * + * Anchored at a synthetic `/pyproject.toml` path rather than a real + * source file: `pyproject.toml` lives at the repo root, so + * `pythonPackageRoot`'s `__init__.py` walk starting there immediately bottoms + * out at `rootDir` (its first candidate), leaving `pythonImportRoots`'s other + * three candidates — `rootDir/src`, `rootDir`, and the configured roots — to + * do the actual resolution. `module:attr` is always an absolute dotted path + * (never relative, unlike a source-level `from . import x`), so the anchor's + * exact identity beyond "some file at `rootDir`" doesn't otherwise matter. + * + * Entries whose module doesn't resolve under any known root are silently + * dropped, same as an ordinary unresolvable import — most commonly a script + * pointing at a third-party console-script shim rather than this project's + * own code. Results are deduplicated by (file, attr): several script names + * commonly point at the same target (e.g. a GUI and CLI variant). + */ +export function resolvePyprojectScriptEntrypoints( + rootDir: string, + knownFiles?: readonly string[] | null, +): ResolvedPyprojectScriptEntrypoint[] { + const scripts = parsePyprojectScripts(rootDir); + if (scripts.length === 0) return []; + + const knownFilesSet = toKnownFilesSet(knownFiles); + const anchor = path.join(rootDir, 'pyproject.toml'); + const seen = new Set(); + const resolved: ResolvedPyprojectScriptEntrypoint[] = []; + for (const { module, attr } of scripts) { + const file = resolvePythonImportPath(anchor, module, rootDir, knownFilesSet); + if (!file) continue; + const key = `${file} ${attr}`; + if (seen.has(key)) continue; + seen.add(key); + resolved.push({ file, attr }); + } + return resolved; +} + /** Cache: file directory → its derived package root. */ const _pythonPackageRootCache: Map = new Map(); diff --git a/tests/integration/issue-2408-pyproject-script-entrypoints.test.ts b/tests/integration/issue-2408-pyproject-script-entrypoints.test.ts new file mode 100644 index 000000000..eed39bbc1 --- /dev/null +++ b/tests/integration/issue-2408-pyproject-script-entrypoints.test.ts @@ -0,0 +1,331 @@ +/** + * Regression test for #2408: role classification recognized neither of a + * Python project's packaging-declared entrypoints — a `[project.scripts]` + * (or `[project.gui-scripts]` / `[tool.poetry.scripts]`) console-script + * target — so a repo whose only entrypoints are declared this way (no + * `if __name__ == "__main__":` guard, no `__main__.py`) still reported zero + * `entry` symbols even after #2392 taught role classification the guard + * conventions. + * + * `pyproject.toml` is re-parsed fresh on every build rather than cached as + * per-file evidence like a guard call (#2392's `entrypoint_calls` table) — + * it is a single, cheap-to-reread file, so attribution runs unconditionally + * every build and self-corrects when the declared scripts change. + * + * Attribution is scoped to `entrypoint_source_file = 'pyproject.toml'`, so it + * takes precedence over — but never clobbers — a guard-attributed target + * declared by a different mechanism. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { initSchema, openDb } from '../../src/db/index.js'; +import { rebuildFile } from '../../src/domain/graph/builder/incremental.js'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import type { EngineMode } from '../../src/types.js'; +import { createIncrementalStmts } from '../helpers/incremental-stmts.js'; + +const PYPROJECT = ` +[project.scripts] +ingest = "pipeline.cli:main" + +[project.gui-scripts] +ingest-gui = "pipeline.gui:launch" + +[tool.poetry.scripts] +ingest-poetry = "pipeline.other:run" + +[tool.setuptools.package-dir] +"" = "src" +`; + +const FILES: Record = { + 'pyproject.toml': PYPROJECT, + 'src/pipeline/__init__.py': '', + 'src/pipeline/cli.py': ` +def helper(): + return 1 + +def main(): + return helper() +`, + 'src/pipeline/gui.py': ` +def launch(): + return 2 +`, + 'src/pipeline/other.py': ` +def run(): + return 3 +`, +}; + +function writeFixture(dir: string, files: Record) { + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } +} + +interface NodeRow { + name: string; + file: string; + entrypoint: number; + entrypointSourceFile: string | null; + role: string | null; +} + +function readFunctionNodes(dbPath: string): NodeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT name, file, COALESCE(entrypoint, 0) AS entrypoint, + entrypoint_source_file AS entrypointSourceFile, role + FROM nodes WHERE kind IN ('function', 'method') ORDER BY file, name`, + ) + .all() as NodeRow[]; + } finally { + db.close(); + } +} + +const ENGINES: EngineMode[] = ['wasm', 'native']; + +describe.each(ENGINES)( + 'pyproject.toml script entrypoint classification (#2408) — engine: %s', + (engine) => { + let dir: string; + let nodes: NodeRow[]; + + const byName = (name: string): NodeRow => { + const row = nodes.find((n) => n.name === name); + if (!row) + throw new Error(`no node named ${name} (have: ${nodes.map((n) => n.name).join(', ')})`); + return row; + }; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-2408-${engine}-`)); + writeFixture(dir, FILES); + await buildGraph(dir, { incremental: false, skipRegistry: true, engine }); + nodes = readFunctionNodes(path.join(dir, '.codegraph', 'graph.db')); + }); + + afterAll(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('classifies a [project.scripts] target as entry', () => { + expect(byName('main').entrypoint).toBe(1); + expect(byName('main').role).toBe('entry'); + expect(byName('main').entrypointSourceFile).toBe('pyproject.toml'); + }); + + it('classifies a [project.gui-scripts] target as entry', () => { + expect(byName('launch').entrypoint).toBe(1); + expect(byName('launch').role).toBe('entry'); + }); + + it('classifies a [tool.poetry.scripts] target as entry', () => { + expect(byName('run').entrypoint).toBe(1); + expect(byName('run').role).toBe('entry'); + }); + + it('does not mark a function the script target merely calls', () => { + expect(byName('helper').entrypoint).toBe(0); + expect(byName('helper').role).not.toBe('entry'); + }); + }, +); + +describe.each(ENGINES)( + 'pyproject.toml script entrypoint precedence and staleness (#2408) — engine: %s', + (engine) => { + it('does not clobber a guard-attributed entrypoint when pyproject.toml declares no scripts', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-2408-noclobber-${engine}-`)); + try { + writeFixture(dir, { + 'pyproject.toml': '[project]\nname = "pipeline"\n', + 'run.py': ` +from lib import shared_main + +if __name__ == "__main__": + shared_main() +`, + 'lib.py': 'def shared_main():\n return 1\n', + }); + + await buildGraph(dir, { incremental: false, skipRegistry: true, engine }); + const nodes = readFunctionNodes(path.join(dir, '.codegraph', 'graph.db')); + const row = nodes.find((n) => n.name === 'shared_main'); + + expect(row?.entrypoint).toBe(1); + expect(row?.role).toBe('entry'); + expect(row?.entrypointSourceFile).toBe('run.py'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('takes precedence over an existing guard attribution on the same target', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-2408-precedence-${engine}-`)); + try { + writeFixture(dir, { + 'pyproject.toml': + '[project.scripts]\ningest = "pipeline.cli:main"\n\n[tool.setuptools.package-dir]\n"" = "src"\n', + 'src/pipeline/__init__.py': '', + 'src/pipeline/cli.py': ` +def main(): + return 1 + +if __name__ == "__main__": + main() +`, + }); + + await buildGraph(dir, { incremental: false, skipRegistry: true, engine }); + const row = readFunctionNodes(path.join(dir, '.codegraph', 'graph.db')).find( + (n) => n.name === 'main', + ); + + expect(row?.entrypoint).toBe(1); + expect(row?.role).toBe('entry'); + expect(row?.entrypointSourceFile).toBe('pyproject.toml'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('clears a script attribution on incremental rebuild once the script entry is removed', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-2408-stale-${engine}-`)); + try { + writeFixture(dir, { + 'pyproject.toml': + '[project.scripts]\ningest = "pipeline.cli:main"\n\n[tool.setuptools.package-dir]\n"" = "src"\n', + 'src/pipeline/__init__.py': '', + 'src/pipeline/cli.py': 'def main():\n return 1\n', + }); + + await buildGraph(dir, { incremental: false, skipRegistry: true, engine }); + const before = readFunctionNodes(path.join(dir, '.codegraph', 'graph.db')).find( + (n) => n.name === 'main', + ); + expect(before?.entrypoint).toBe(1); + expect(before?.role).toBe('entry'); + + // Remove the script declaration, and touch the target file itself so + // the incremental build sees a real change and does not fast-skip — + // pyproject.toml re-checks unconditionally on every build that + // actually runs, but a build with zero changed files never runs at + // all (pyproject.toml is not itself a watched/hashed source file). + fs.writeFileSync(path.join(dir, 'pyproject.toml'), '[project]\nname = "pipeline"\n'); + fs.writeFileSync(path.join(dir, 'src/pipeline/cli.py'), 'def main():\n return 2\n'); + await buildGraph(dir, { incremental: true, skipRegistry: true, engine }); + const after = readFunctionNodes(path.join(dir, '.codegraph', 'graph.db')).find( + (n) => n.name === 'main', + ); + + expect(after?.entrypoint).toBe(0); + expect(after?.role).not.toBe('entry'); + expect(after?.entrypointSourceFile).toBeNull(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + }, +); + +/** Run exactly what `codegraph watch` runs for one changed file. */ +async function watchRebuild(dir: string, relFile: string, engine: EngineMode): Promise { + const db = openDb(path.join(dir, '.codegraph', 'graph.db')); + try { + initSchema(db); + await rebuildFile( + db, + dir, + path.join(dir, relFile), + createIncrementalStmts(db), + { engine }, + null, + ); + } finally { + db.close(); + } +} + +describe.each(ENGINES)( + 'codegraph watch (rebuildFile): stale Python roots must not corrupt script attribution (#2408 review) — engine: %s', + (engine) => { + it('resolves a pyproject-configured root freshly on a deletion-triggered rebuild, not from a stale cache', async () => { + // Two pre-existing, already-parsed files declare the SAME dotted module + // ("vendored.helper") under two different roots, so switching which + // root pythonpath names is a pure configured-roots change — neither + // file needs (re)parsing at a new location, isolating the cache itself + // as the only variable. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-2408-staleroots-${engine}-`)); + try { + fs.mkdirSync(path.join(dir, 'lib/vendored'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'lib/vendored/helper.py'), + 'def helper_main():\n return 1\n', + ); + fs.mkdirSync(path.join(dir, 'altlib/vendored'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'altlib/vendored/helper.py'), + 'def helper_main():\n return 2\n', + ); + fs.writeFileSync(path.join(dir, 'other.py'), 'def other():\n return 9\n'); + fs.writeFileSync( + path.join(dir, 'pyproject.toml'), + '[project.scripts]\ningest = "vendored.helper:helper_main"\n\n' + + '[tool.pytest.ini_options]\npythonpath = ["lib"]\n', + ); + + await buildGraph(dir, { incremental: false, skipRegistry: true, engine }); + const nodesBefore = readFunctionNodes(path.join(dir, '.codegraph', 'graph.db')).filter( + (n) => n.name === 'helper_main', + ); + const libBefore = nodesBefore.find((n) => n.file === 'lib/vendored/helper.py'); + const altBefore = nodesBefore.find((n) => n.file === 'altlib/vendored/helper.py'); + expect(libBefore?.entrypoint).toBe(1); + expect(libBefore?.entrypointSourceFile).toBe('pyproject.toml'); + expect(altBefore?.entrypoint).toBe(0); + + // Repoint pythonpath at "altlib" instead — a realistic root-config + // edit. This process has cached "lib" as the resolved root from the + // full build above; nothing has touched an unrelated file yet to + // naturally refresh it. + fs.writeFileSync( + path.join(dir, 'pyproject.toml'), + '[project.scripts]\ningest = "vendored.helper:helper_main"\n\n' + + '[tool.pytest.ini_options]\npythonpath = ["altlib"]\n', + ); + + // Delete an unrelated file — this hits rebuildFile's deletion branch, + // which runs BEFORE the common path's cache clear. Pre-fix, this + // resolves "vendored.helper" against the stale "lib" root: the + // now-superseded lib/ target wrongly keeps its attribution, and the + // now-correct altlib/ target is never marked. + fs.rmSync(path.join(dir, 'other.py')); + await watchRebuild(dir, 'other.py', engine); + + const nodesAfter = readFunctionNodes(path.join(dir, '.codegraph', 'graph.db')).filter( + (n) => n.name === 'helper_main', + ); + const libAfter = nodesAfter.find((n) => n.file === 'lib/vendored/helper.py'); + const altAfter = nodesAfter.find((n) => n.file === 'altlib/vendored/helper.py'); + expect(libAfter?.entrypoint).toBe(0); + expect(libAfter?.entrypointSourceFile).toBeNull(); + expect(altAfter?.entrypoint).toBe(1); + expect(altAfter?.role).toBe('entry'); + expect(altAfter?.entrypointSourceFile).toBe('pyproject.toml'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + }, +); diff --git a/tests/unit/resolve.test.ts b/tests/unit/resolve.test.ts index 86cf83a1a..8935eb68c 100644 --- a/tests/unit/resolve.test.ts +++ b/tests/unit/resolve.test.ts @@ -22,6 +22,7 @@ import { parseBareSpecifier, resolveImportPathJS, resolveImportsBatch, + resolvePyprojectScriptEntrypoints, resolvePythonSubmodule, resolveViaExports, resolveViaWorkspace, @@ -1357,6 +1358,87 @@ describe('resolveImportPathJS - Python module paths (#2387)', () => { }); }); +describe('resolvePyprojectScriptEntrypoints (#2408)', () => { + let scriptDir: string; + + afterEach(() => { + if (scriptDir) fs.rmSync(scriptDir, { recursive: true, force: true }); + clearPythonImportRootsCache(); + }); + + function writeScriptFixture(pyproject: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-resolve-pyscript-')); + fs.mkdirSync(path.join(dir, 'src/pipeline'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src/pipeline/__init__.py'), ''); + fs.writeFileSync(path.join(dir, 'src/pipeline/cli.py'), 'def main():\n pass\n'); + fs.writeFileSync(path.join(dir, 'src/pipeline/gui.py'), 'def launch():\n pass\n'); + fs.writeFileSync(path.join(dir, 'pyproject.toml'), pyproject); + return dir; + } + + it('resolves a console-script target via a src-layout package root', () => { + scriptDir = writeScriptFixture( + '[project.scripts]\ningest = "pipeline.cli:main"\n\n' + + '[tool.setuptools.package-dir]\n"" = "src"\n', + ); + clearPythonImportRootsCache(); + + const resolved = resolvePyprojectScriptEntrypoints(scriptDir); + + expect(resolved).toEqual([{ file: 'src/pipeline/cli.py', attr: 'main' }]); + }); + + it('resolves gui-scripts and poetry scripts alongside console scripts', () => { + scriptDir = writeScriptFixture( + '[project.scripts]\ncli = "pipeline.cli:main"\n' + + '[project.gui-scripts]\ngui = "pipeline.gui:launch"\n' + + '[tool.poetry.scripts]\npoetry-cli = "pipeline.cli:main"\n\n' + + '[tool.setuptools.package-dir]\n"" = "src"\n', + ); + clearPythonImportRootsCache(); + + const resolved = resolvePyprojectScriptEntrypoints(scriptDir); + const files = resolved.map((e) => `${e.file}:${e.attr}`).sort(); + + // The poetry entry duplicates the console-script target, so it dedupes + // away rather than producing a second identical entry. + expect(files).toEqual(['src/pipeline/cli.py:main', 'src/pipeline/gui.py:launch']); + }); + + it('skips a table-shaped poetry script entry', () => { + // Poetry's extras-conditional shape: `{ callable = "...", extras = [...] }`. + scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-resolve-pyscript-table-')); + fs.writeFileSync( + path.join(scriptDir, 'pyproject.toml'), + '[tool.poetry.scripts]\nfoo = { callable = "pipeline.cli:main", extras = ["x"] }\n', + ); + clearPythonImportRootsCache(); + + expect(resolvePyprojectScriptEntrypoints(scriptDir)).toEqual([]); + }); + + it('returns empty when pyproject.toml is missing or declares no scripts', () => { + scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-resolve-pyscript-missing-')); + clearPythonImportRootsCache(); + expect(resolvePyprojectScriptEntrypoints(scriptDir)).toEqual([]); + + fs.writeFileSync(path.join(scriptDir, 'pyproject.toml'), '[project]\nname = "pipeline"\n'); + clearPythonImportRootsCache(); + expect(resolvePyprojectScriptEntrypoints(scriptDir)).toEqual([]); + }); + + it('drops a script entry whose module does not resolve under any root', () => { + scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-resolve-pyscript-unresolved-')); + fs.writeFileSync( + path.join(scriptDir, 'pyproject.toml'), + '[project.scripts]\ningest = "nonexistent.cli:main"\n', + ); + clearPythonImportRootsCache(); + + expect(resolvePyprojectScriptEntrypoints(scriptDir)).toEqual([]); + }); +}); + describe('resolvePythonSubmodule (#2387)', () => { let subDir: string; const knownFiles = [