From 71a1542d0ef748a0b95686678aea690fdc13f7ff Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 08:50:47 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(source):=20schema=20=EC=82=AC=EC=8B=A4?= =?UTF-8?q?=EC=97=90=20=EA=B0=90=EC=8B=B8=EB=8A=94=20=EA=B7=B8=EB=9E=98?= =?UTF-8?q?=ED=94=84=20=EC=A0=95=EC=A0=90=20usr=EB=A5=BC=20=EC=8B=A3?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schema의 relation-use 사실은 위치만 있어 isthmus trace가 핸들러 도달을 관계 사용과 잇지 못했다. 사실마다 symbol {qualifiedName, usr}을 싣고 usr는 impact와 같은 정점 ID다. - 정점 ID 규칙(impl self 타입 해석, `Type::::m`)을 schema에서 다시 구현하지 않고 같은 syn 수확을 돌려 정점별 소스 범위(OwnerSpan)를 받는다. 사실의 바이트 오프셋을 감싸는 가장 안쪽 정점을 고르고, 그래프 정점 집합에 없는 ID는 싣지 않는다. - 귀속: fn·impl 메서드·트레이트 기본 메서드·const/static 초기화식은 그 정점, 구조체 어트리뷰트·필드 컬럼은 구조체, 메서드 밖 연관 상수는 impl self 타입. 최상위 table! 호출·orphan 파일처럼 감싸는 정점이 없는 사실은 usr를 지어내지 않고 `missing-relation-usrs:`로 센다. - 그래프 수확이 실패해도 사실은 그대로 낸다 — 파싱 실패 하나로 문서 전체를 잃지 않도록 usr만 빠지고 원인을 limitation에 밝힌다. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/harvest.rs | 44 ++++ src/source.rs | 63 +++++- src/source/schema.rs | 201 ++++++++++++++++++ tests/fixture-schema/schema_app/src/lib.rs | 35 +++ tests/fixture-schema/schema_app/src/orphan.rs | 5 + tests/schema.rs | 124 +++++++++++ 6 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 tests/fixture-schema/schema_app/src/orphan.rs diff --git a/src/harvest.rs b/src/harvest.rs index fa8f637..960472c 100644 --- a/src/harvest.rs +++ b/src/harvest.rs @@ -42,6 +42,24 @@ pub struct ModuleDecls<'a> { pub entry_roots: Vec, /// #[test]/#[bench] 진입점 — --tests일 때만 보존 루트가 된다. pub test_roots: Vec, + /// 본문이 없는 소유 아이템(struct·enum·union·trait·macro_rules)의 소스 + /// 범위 — persistence 사실을 감싸는 그래프 정점으로 귀속할 때 쓴다. + /// 본문 아이템의 범위는 BodyItem이 이미 들고 있다. + pub owner_spans: Vec, +} + +/// 그래프 정점 하나가 소스에서 차지하는 범위다. +/// schema 사실(위치만 있는)을 impact와 같은 정점 ID로 귀속하는 재료다 — +/// 정점 ID 규칙(impl self 타입 해석 등)을 schema 쪽에서 다시 구현하면 +/// 두 규칙이 어긋나 사실의 usr가 그래프에 없는 문자열이 된다. +#[derive(Debug, Clone)] +pub struct OwnerSpan { + /// 정점 ID — 수확이 만든 그대로다. + pub id: String, + /// 선언 파일(수확이 읽은 경로 그대로 — 비교 전에 정규화한다). + pub file: PathBuf, + /// 파일 안 바이트 범위(syn span 기준, 속성 포함). + pub range: std::ops::Range, } /// 아이템 속성이 담은 경로 참조 하나. @@ -77,6 +95,8 @@ pub struct ImplBlock { pub file: PathBuf, /// 선언 파일의 생성 코드 마커 — 메서드 정점의 generated 플래그. pub generated: bool, + /// impl 블록의 바이트 범위 — 메서드 밖 사실(연관 상수 등)을 self 타입에 귀속한다. + pub range: std::ops::Range, } /// 본문을 나중에 방문할 항목. @@ -142,6 +162,7 @@ pub fn decls<'a>( attr_refs: Vec::new(), entry_roots: Vec::new(), test_roots: Vec::new(), + owner_spans: Vec::new(), }; for (file, items) in groups { let generated = file_has_generated_marker(file); @@ -317,10 +338,33 @@ pub fn decls<'a>( unsafe_: i.unsafety.is_some(), file: file.clone(), generated, + range: i.span().byte_range(), }); } _ => {} } + // 본문 없는 소유 아이템의 범위 — 필드 어트리뷰트 같은 선언 수준 + // 사실은 그 타입 정점이 감싼다. + let owner_kind = match item { + syn::Item::Struct(_) + | syn::Item::Enum(_) + | syn::Item::Union(_) + | syn::Item::Trait(_) => true, + syn::Item::Macro(m) => m.ident.is_some() && m.mac.path.is_ident("macro_rules"), + _ => false, + }; + if owner_kind { + if let Some(ident) = item.ident().or(match item { + syn::Item::Macro(m) => m.ident.as_ref(), + _ => None, + }) { + out.owner_spans.push(OwnerSpan { + id: format!("{module_path}::{ident}"), + file: file.clone(), + range: item.span().byte_range(), + }); + } + } // 아이템 속성의 경로 참조 — #[dep::attr]·#[derive(dep::X)]는 // 그 크레이트의 실제 사용 증거다. impl 자체의 속성도 여기서 잡되 // owner는 모듈이다(self 타입 정점은 아직 해석 전). diff --git a/src/source.rs b/src/source.rs index ae89c13..2084257 100644 --- a/src/source.rs +++ b/src/source.rs @@ -64,7 +64,7 @@ pub fn load(dir: &Path, opts: &Options) -> Result { return Ok(doc); } } - let (doc, uses_include) = harvest(dir, &meta, opts)?; + let (doc, uses_include) = harvest(dir, &meta, opts, None)?; if let Some((key, path)) = &cache { // 지문이 못 보는 입력을 소비한 문서는 캐시에 쓰지 않는다 — // include!/OUT_DIR 생성 파일과 루트 밖 #[path] 파일, 지문이 @@ -76,6 +76,23 @@ pub fn load(dir: &Path, opts: &Options) -> Result { Ok(doc) } +/// syn 심볼 그래프와 정점별 소스 범위를 함께 돌려준다 — `schema`가 +/// relation-use 사실을 impact와 같은 정점 ID로 귀속하는 데 쓴다. +/// 기본 옵션(심볼 레벨, 외부 크레이트 정점 없음, semantic 없음)으로 +/// 수확한다 — 정점 집합은 `impact`/`reach` 기본 실행과 같다. +pub fn owner_spans( + dir: &Path, + meta: &Metadata, +) -> Result<(Document, Vec), String> { + let opts = Options { + symbol_level: true, + ..Default::default() + }; + let mut spans = Vec::new(); + let (doc, _) = harvest(dir, meta, &opts, Some(&mut spans))?; + Ok((doc, spans)) +} + /// `--target`의 cfg 팩트 — `rustc --print cfg` 실측이 권위다. /// rustc를 못 쓰거나 트리플을 모르면 트리플 추정으로 폴백한다. /// 어느 쪽이든 프로필·feature·커스텀 --cfg 조건은 미지로 남는다. @@ -264,7 +281,13 @@ fn write_cache(path: &Path, key: u64, doc: &Document) { /// 실제 수확 — 메타데이터 위에서 모듈 트리·간선을 조립한다. /// (문서, include! 계열 매크로 사용 여부)를 돌린다 — include!는 /// 정점 위치에 안 나타나는 입력이라 캐시 판정이 별도로 필요하다. -fn harvest(dir: &Path, meta: &Metadata, opts: &Options) -> Result<(Document, bool), String> { +/// `spans`가 주어지면 정점별 소스 범위(OwnerSpan)도 모은다. +fn harvest( + dir: &Path, + meta: &Metadata, + opts: &Options, + spans: Option<&mut Vec>, +) -> Result<(Document, bool), String> { let mut harvest = Harvest::default(); let mut vertices: Vec = Vec::new(); let mut edges: Vec = Vec::new(); @@ -408,6 +431,7 @@ fn harvest(dir: &Path, meta: &Metadata, opts: &Options) -> Result<(Document, boo let mut impls: Vec = Vec::new(); let mut attr_refs: Vec = Vec::new(); let mut test_roots: Vec = Vec::new(); + let mut owner_spans: Vec = Vec::new(); for mp in tree.modules.keys().cloned().collect::>() { let mh = harvest_module(&mut tree, &arena, &mp, &mut harvest, &dep_vertices); vertices.push(mh.vertex); @@ -418,6 +442,7 @@ fn harvest(dir: &Path, meta: &Metadata, opts: &Options) -> Result<(Document, boo bodies.extend(mh.decls.bodies); attr_refs.extend(mh.decls.attr_refs); impls.extend(mh.decls.impls); + owner_spans.extend(mh.decls.owner_spans); } for block in &impls { let krate = modtree::crate_of(&block.items_module); @@ -428,6 +453,9 @@ fn harvest(dir: &Path, meta: &Metadata, opts: &Options) -> Result<(Document, boo attr_refs.extend(ar); bodies.extend(bs); } + if let Some(out) = spans { + collect_owner_spans(&tree, &impls, &bodies, owner_spans, out); + } // 메서드 이름 → ID 인덱스 — 이름 팬아웃 폴백에 쓴다(semantic에서는 // ra가 해석하지 못한 호출에만 쓰인다). @@ -574,6 +602,37 @@ fn harvest(dir: &Path, meta: &Metadata, opts: &Options) -> Result<(Document, boo )) } +/// 정점별 소스 범위를 모은다 — 본문 아이템(fn·메서드·const·static· +/// 트레이트 기본 메서드), 본문 없는 소유 아이템, 그리고 self 타입이 +/// 해석된 impl 블록(메서드 밖 연관 아이템을 self 타입에 귀속)이다. +fn collect_owner_spans( + tree: &ModTree, + impls: &[harvest::ImplBlock], + bodies: &[BodyItem<'_>], + mut spans: Vec, + out: &mut Vec, +) { + for block in impls { + if let Some(self_id) = tree.resolve( + &block.items_module, + &block.self_ty, + &modtree::DepCrates::new(), + ) { + spans.push(harvest::OwnerSpan { + id: self_id, + file: block.file.clone(), + range: block.range.clone(), + }); + } + } + spans.extend(bodies.iter().map(|b| harvest::OwnerSpan { + id: b.id.clone(), + file: b.file.clone(), + range: b.range.clone(), + })); + *out = spans; +} + /// 크레이트 정점과 `depends` 간선을 만든다. fn emit_crate_level( meta: &Metadata, diff --git a/src/source/schema.rs b/src/source/schema.rs index 9a92933..c77b8b8 100644 --- a/src/source/schema.rs +++ b/src/source/schema.rs @@ -43,6 +43,19 @@ pub struct RelationFact { pub method: Option, pub dynamic: bool, pub location: BridgeLocation, + /// 사실을 감싸는 그래프 정점 — `usr`는 `impact`/`reach`와 같은 정점 ID다. + /// 감싸는 정점이 없으면(최상위 매크로 호출·orphan 파일 등) 키가 빠지고 + /// `missing-relation-usrs:` limitation으로 센다. + #[serde(skip_serializing_if = "Option::is_none")] + pub symbol: Option, +} + +/// relation-use 사실의 `symbol` — 계약상 qualifiedName이 필수다. +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +pub struct FactSymbol { + #[serde(rename = "qualifiedName")] + pub qualified_name: String, + pub usr: String, } /// 계약의 1 기반 소스 위치다 — 열은 UTF-16 코드 단위다. @@ -51,6 +64,9 @@ pub struct BridgeLocation { pub path: String, pub line: u32, pub column: u32, + /// 파일 안 바이트 오프셋 — 직렬화하지 않고 감싸는 정점을 찾는 데만 쓴다. + #[serde(skip)] + pub byte: usize, } /// 문서를 생산한 도구 식별자다. @@ -118,6 +134,18 @@ pub fn facts(dir: &Path, tool_version: &str) -> Result Some(OwnerIndex::new(doc, spans)), + Err(_) => { + scan.graph_failed = true; + None + } + }; + scan.missing_usrs = attach_symbols(&mut facts, &root, index.as_ref()); facts.sort_by(fact_cmp); let limitations = scan.limitations(); Ok(BridgeFactsDocument { @@ -141,6 +169,64 @@ pub fn facts(dir: &Path, tool_version: &str) -> Result, String)>>, +} + +impl OwnerIndex { + /// 범위의 파일 경로를 정규화해 사실 경로(정규화된 루트 기준)와 맞춘다. + /// 정점 집합에 없는 ID는 버린다 — usr는 그래프 정점이어야 한다. + fn new(doc: &crate::graph::Document, spans: &[crate::harvest::OwnerSpan]) -> OwnerIndex { + let ids = doc.vertex_ids(); + let mut canon: BTreeMap<&Path, PathBuf> = BTreeMap::new(); + let mut by_file: BTreeMap, String)>> = BTreeMap::new(); + for sp in spans { + if !ids.contains(sp.id.as_str()) || sp.range.is_empty() { + continue; + } + let file = canon + .entry(sp.file.as_path()) + .or_insert_with(|| sp.file.canonicalize().unwrap_or_else(|_| sp.file.clone())) + .clone(); + by_file + .entry(file) + .or_default() + .push((sp.range.clone(), sp.id.clone())); + } + OwnerIndex { by_file } + } + + /// 바이트 오프셋을 감싸는 가장 안쪽 정점이다. 같은 범위의 후보가 + /// 여럿이면(cfg 변형은 ID가 같다) 사전순 첫 ID로 결정적으로 고른다. + fn owner(&self, file: &Path, byte: usize) -> Option<&str> { + self.by_file + .get(file)? + .iter() + .filter(|(r, _)| r.start <= byte && byte < r.end) + .min_by(|(ra, ia), (rb, ib)| (ra.len(), ia).cmp(&(rb.len(), ib))) + .map(|(_, id)| id.as_str()) + } +} + +/// 사실마다 감싸는 그래프 정점을 symbol로 단다. 못 단 사실 수를 돌려준다. +fn attach_symbols(facts: &mut [RelationFact], root: &Path, index: Option<&OwnerIndex>) -> usize { + let mut missing = 0; + for f in facts.iter_mut() { + let file = root.join(&f.location.path); + match index.and_then(|ix| ix.owner(&file, f.location.byte)) { + Some(id) => { + f.symbol = Some(FactSymbol { + qualified_name: id.to_string(), + usr: id.to_string(), + }) + } + None => missing += 1, + } + } + missing +} + /// 워크스페이스 멤버 각 패키지 루트 아래의 .rs 파일 목록이다 — /// src·tests·examples를 모두 커버하되 `target`과 숨김 디렉터리는 건너뛴다. /// cargo가 아는 패키지만 스캔해 벤더·빌드 산출물을 제외한다. @@ -241,6 +327,8 @@ struct SchemaScan { unresolved_diesel: usize, // 선언 이름과 맞지 않는 DSL 모양 경로 수 unlocated: usize, // span을 위치로 변환하지 못해 버린 사실 수 dynamic: usize, // 리터럴로 읽히지 않아 조인 불가한 SQL 인자 수 + missing_usrs: usize, // 감싸는 그래프 정점이 없어 usr를 못 단 사실 수 + graph_failed: bool, // 심볼 그래프 수확 자체가 실패했는가 diesel_tables: BTreeSet, // 워크스페이스의 diesel 선언 이름 latest: Option, // 읽은 소스의 최신 mtime seen: BTreeSet, @@ -490,6 +578,7 @@ impl<'ast> FileCtx<'ast> { method: None, dynamic: false, location: loc.clone(), + symbol: None, }); for (col, span) in columns { match self.scan_locate(span) { @@ -499,6 +588,7 @@ impl<'ast> FileCtx<'ast> { method: Some(col), dynamic: false, location: cloc, + symbol: None, }), None => self.scan.unlocated += 1, } @@ -674,6 +764,7 @@ impl<'ast> FileCtx<'ast> { method: None, dynamic: false, location: loc.clone(), + symbol: None, }); if let Some(col) = column { self.scan.push(RelationFact { @@ -682,6 +773,7 @@ impl<'ast> FileCtx<'ast> { method: Some(col), dynamic: false, location: loc, + symbol: None, }); } } @@ -735,6 +827,7 @@ impl<'ast> FileCtx<'ast> { method: None, dynamic: false, location: rloc, + symbol: None, }); for f in &st.fields { let mut col: Option = None; @@ -768,6 +861,7 @@ impl<'ast> FileCtx<'ast> { method: Some(column), dynamic: false, location: cloc, + symbol: None, }), None => self.scan.unlocated += 1, } @@ -1056,6 +1150,7 @@ impl SchemaScan { method: None, dynamic: false, location: loc.clone(), + symbol: None, }); } if unresolved { @@ -1103,6 +1198,7 @@ impl SchemaScan { dynamic: true, method: None, location: loc, + symbol: None, }); } @@ -1153,6 +1249,7 @@ impl SchemaScan { path: rel.to_string_lossy().replace('\\', "/"), line: start.line as u32, column, + byte: span.byte_range().start, }) } @@ -1189,6 +1286,19 @@ impl SchemaScan { self.unlocated )); } + if self.missing_usrs > 0 { + // isthmus 체인 전용 접두사다 — trace가 이 사실에서 핸들러 도달로 + // 이어 가지 못한다는 뜻이고 check 심각도에는 영향이 없다. + let why = if self.graph_failed { + "the symbol graph could not be harvested" + } else { + "they sit outside any graph item (top-level macro invocations such as table!, files outside the module tree)" + }; + out.push(format!( + "missing-relation-usrs: {} relation-use fact(s) carry no symbol.usr; {why}", + self.missing_usrs + )); + } if self.dynamic > 0 { // isthmus가 미사용 진단을 unverified로 내리는 근거다 — 접두사를 // 바꾸면 조인기의 severity 계산이 새로 인식하지 못한다. @@ -2132,4 +2242,95 @@ mod tests { let attr: syn::Attribute = syn::parse_quote!(#[diesel(check_for_backend(Pg))]); assert!(meta_name_value(&attr, &["diesel"], "table_name").is_none()); } + + /// 사실 하나 — 귀속 테스트용. + fn fact_at(path: &str, byte: usize) -> RelationFact { + RelationFact { + kind: "relation-use", + channel: "t".into(), + method: None, + dynamic: false, + location: BridgeLocation { + path: path.into(), + line: 1, + column: 1, + byte, + }, + symbol: None, + } + } + + #[test] + fn owner_index_picks_innermost_vertex_and_skips_non_vertices() { + let doc = crate::graph::document( + crate::graph::Level::Symbol, + ".".into(), + None, + vec![], + ["c::T", "c::T::m"] + .iter() + .map(|id| crate::graph::Vertex { + id: id.to_string(), + kind: crate::graph::Kind::Fn, + krate: "c".into(), + module: "c".into(), + position: None, + exported: false, + generated: false, + cfg: None, + unsafe_: false, + }) + .collect(), + vec![], + vec![], + ); + let file = PathBuf::from("/nonexistent-root/src/lib.rs"); + let span = |id: &str, r: std::ops::Range| crate::harvest::OwnerSpan { + id: id.into(), + file: file.clone(), + range: r, + }; + let spans = vec![ + span("c::T", 0..100), + span("c::T::m", 10..20), + // 정점이 아닌 ID는 usr가 될 수 없다. + span("c::Ghost", 30..40), + ]; + let ix = OwnerIndex::new(&doc, &spans); + let root = Path::new("/nonexistent-root"); + let mut facts = vec![ + fact_at("src/lib.rs", 15), + fact_at("src/lib.rs", 35), + fact_at("src/lib.rs", 150), + fact_at("src/other.rs", 15), + ]; + let missing = attach_symbols(&mut facts, root, Some(&ix)); + let usrs: Vec> = facts + .iter() + .map(|f| f.symbol.as_ref().map(|s| s.usr.as_str())) + .collect(); + assert_eq!(usrs, vec![Some("c::T::m"), Some("c::T"), None, None]); + assert_eq!(missing, 2); + // 그래프가 없으면 전부 못 단다. + let mut facts = vec![fact_at("src/lib.rs", 15)]; + assert_eq!(attach_symbols(&mut facts, root, None), 1); + } + + #[test] + fn missing_usr_limitation_names_the_cause() { + let mut scan = SchemaScan { + missing_usrs: 3, + ..Default::default() + }; + assert!(scan + .limitations() + .iter() + .any(|l| l.starts_with("missing-relation-usrs: 3 ") + && l.contains("outside any graph item"))); + scan.graph_failed = true; + assert!(scan + .limitations() + .iter() + .any(|l| l.contains("could not be harvested"))); + } } diff --git a/tests/fixture-schema/schema_app/src/lib.rs b/tests/fixture-schema/schema_app/src/lib.rs index f90042b..c8cc90a 100644 --- a/tests/fixture-schema/schema_app/src/lib.rs +++ b/tests/fixture-schema/schema_app/src/lib.rs @@ -129,3 +129,38 @@ pub mod unimported { let _ = other_query!("SELECT * FROM flagged_t"); } } + +/// 사실의 symbol.usr 귀속 — impl 메서드·트레이트 impl 메서드·연관 상수· +/// 트레이트 기본 메서드·cfg 모듈 안 함수가 각자 그래프 정점 ID를 싣는다. +pub struct Repo; + +impl Repo { + /// 메서드 밖 연관 상수는 self 타입 정점에 귀속된다. + pub const LIST: &'static str = "SELECT * FROM repo_consts"; + + pub fn list(&self) { + let _ = sqlx::query!("SELECT * FROM repo_items"); + } +} + +pub trait Loader { + fn load(&self); + /// 트레이트 기본 메서드는 트레이트 소유 메서드 정점이다. + fn fallback(&self) { + let _ = sqlx::query!("SELECT * FROM loader_defaults"); + } +} + +impl Loader for Repo { + fn load(&self) { + let _ = sqlx::query!("SELECT * FROM loaded_items"); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn reads_fixture_table() { + let _ = sqlx::query!("SELECT * FROM test_only_t"); + } +} diff --git a/tests/fixture-schema/schema_app/src/orphan.rs b/tests/fixture-schema/schema_app/src/orphan.rs new file mode 100644 index 0000000..a5c2d47 --- /dev/null +++ b/tests/fixture-schema/schema_app/src/orphan.rs @@ -0,0 +1,5 @@ +//! mod 선언이 없는 orphan 파일 — 그래프 정점이 없어 사실에 usr가 없다. + +pub fn stray() { + let _ = sqlx::query!("SELECT * FROM orphan_t"); +} diff --git a/tests/schema.rs b/tests/schema.rs index 3fa3112..4bab1c5 100644 --- a/tests/schema.rs +++ b/tests/schema.rs @@ -306,3 +306,127 @@ fn cli_schema_rejects_unsupported_flags() { assert!(out.get_ref().is_empty(), "{extra:?}: no document on error"); } } + +/// 같은 워크스페이스의 심볼 그래프 정점 ID — impact·reach가 쓰는 id 공간이다. +fn graph_ids() -> std::collections::BTreeSet { + let g = rustograph::source::load( + &fixture(), + &rustograph::source::Options { + symbol_level: true, + ..Default::default() + }, + ) + .expect("graph harvest failed"); + g.vertices.into_iter().map(|v| v.id).collect() +} + +#[test] +fn every_usr_is_a_graph_vertex() { + // usr는 isthmus가 순회 문서의 id와 문자열 그대로 잇는다 — 그래프에 + // 없는 문자열이면 trace가 사실을 핸들러 도달과 영영 잇지 못한다. + let d = doc(); + let ids = graph_ids(); + let mut with_usr = 0; + for f in d["facts"].as_array().unwrap() { + let Some(sym) = f.get("symbol") else { continue }; + let usr = sym["usr"].as_str().expect("symbol carries usr"); + assert!(ids.contains(usr), "usr {usr} is not a graph vertex"); + assert_eq!(sym["qualifiedName"].as_str(), Some(usr)); + with_usr += 1; + } + assert!(with_usr > 30, "most facts must carry a usr, got {with_usr}"); +} + +/// (경로 접미사, 채널, 컬럼)의 첫 사실이 싣는 usr. +fn usr_of( + d: &serde_json::Value, + path: &str, + channel: &str, + method: Option<&str>, +) -> Option { + let f = d["facts"] + .as_array() + .unwrap() + .iter() + .find(|f| { + f["location"]["path"] + .as_str() + .is_some_and(|p| p.ends_with(path)) + && f["channel"].as_str() == Some(channel) + && f["method"].as_str() == method + }) + .unwrap_or_else(|| panic!("fact {channel} {method:?} not found")); + f.get("symbol") + .map(|s| s["usr"].as_str().unwrap().to_string()) +} + +#[test] +fn usr_follows_the_enclosing_graph_item() { + let d = doc(); + let lib = "src/lib.rs"; + let cases: &[(&str, Option<&str>, Option<&str>)] = &[ + // 함수 본문. + ("app.orders", None, Some("schema_app::find_user")), + // 인라인 모듈 안 함수. + ("flagged_t", None, Some("schema_app::unimported::f")), + // 고유 impl 메서드와 트레이트 impl 메서드(`Type::::m`). + ("repo_items", None, Some("schema_app::Repo::list")), + ( + "loaded_items", + None, + Some("schema_app::Repo::::load"), + ), + // 트레이트 기본 메서드. + ( + "loader_defaults", + None, + Some("schema_app::Loader::fallback"), + ), + // 메서드 밖 연관 상수는 self 타입. + ("repo_consts", None, Some("schema_app::Repo")), + // 모듈 수준 const 초기화식. + ("public.members", None, Some("schema_app::LIST_SQL")), + // 구조체 어트리뷰트·필드 컬럼은 구조체 정점. + ("users", Some("nick_name"), Some("schema_app::User")), + ("sea_only", Some("id"), Some("schema_app::SeaOnly")), + // cfg(test) 모듈도 그래프 정점이다(조건은 정점의 cfg로 남는다). + ( + "test_only_t", + None, + Some("schema_app::tests::reads_fixture_table"), + ), + // 최상위 table! 매크로 호출은 감싸는 정점이 없다. + ("public.users", Some("name"), None), + ]; + for (channel, method, want) in cases { + assert_eq!( + usr_of(&d, lib, channel, *method).as_deref(), + *want, + "usr of {channel} {method:?}" + ); + } + // mod 선언이 없는 orphan 파일은 그래프 밖이다. + assert_eq!(usr_of(&d, "src/orphan.rs", "orphan_t", None), None); +} + +#[test] +fn facts_without_usr_are_counted_as_missing() { + let d = doc(); + let missing = d["facts"] + .as_array() + .unwrap() + .iter() + .filter(|f| f.get("symbol").is_none()) + .count(); + assert!(missing > 0); + let want = format!("missing-relation-usrs: {missing} relation-use fact(s)"); + assert!( + d["limitations"] + .as_array() + .unwrap() + .iter() + .any(|l| l.as_str().is_some_and(|s| s.starts_with(&want))), + "limitations must count {missing} facts without usr: {}", + d["limitations"] + ); +} From 0ec041e41dd5c7355efaeed51480bb42d2d74516 Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 09:02:49 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix(source):=20owner=20span=20=EB=B0=B0?= =?UTF-8?q?=EC=84=A0=EC=9D=98=20=EC=9D=B4=EB=A6=84=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?=EB=A1=9C=20=EC=83=9D=EA=B8=B4=20=EC=9E=90=EA=B8=B0=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EA=B1=B0=EC=A7=93=20=EC=88=9C=ED=99=98=EC=9D=84=20?= =?UTF-8?q?=EC=97=86=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자기 분석 `cycles --level symbol --strict`가 두 순환을 잡았다. harvest의 지역 변수 owner_spans와 attach_symbols의 매개변수 facts가 같은 이름의 fn으로 이름 해석돼 harvest ↔ owner_spans, attach_symbols ↔ facts 간선이 생겼다. 지역명·매개변수명을 바꿔 구문 수확이 함수 정점으로 오독하지 않게 한다. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/source.rs | 7 ++++--- src/source/schema.rs | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/source.rs b/src/source.rs index 2084257..7f354cb 100644 --- a/src/source.rs +++ b/src/source.rs @@ -431,7 +431,8 @@ fn harvest( let mut impls: Vec = Vec::new(); let mut attr_refs: Vec = Vec::new(); let mut test_roots: Vec = Vec::new(); - let mut owner_spans: Vec = Vec::new(); + // 지역명이 fn owner_spans와 같으면 이름 해석이 함수 정점으로 읽어 거짓 순환이 된다. + let mut item_spans: Vec = Vec::new(); for mp in tree.modules.keys().cloned().collect::>() { let mh = harvest_module(&mut tree, &arena, &mp, &mut harvest, &dep_vertices); vertices.push(mh.vertex); @@ -442,7 +443,7 @@ fn harvest( bodies.extend(mh.decls.bodies); attr_refs.extend(mh.decls.attr_refs); impls.extend(mh.decls.impls); - owner_spans.extend(mh.decls.owner_spans); + item_spans.extend(mh.decls.owner_spans); } for block in &impls { let krate = modtree::crate_of(&block.items_module); @@ -454,7 +455,7 @@ fn harvest( bodies.extend(bs); } if let Some(out) = spans { - collect_owner_spans(&tree, &impls, &bodies, owner_spans, out); + collect_owner_spans(&tree, &impls, &bodies, item_spans, out); } // 메서드 이름 → ID 인덱스 — 이름 팬아웃 폴백에 쓴다(semantic에서는 diff --git a/src/source/schema.rs b/src/source/schema.rs index c77b8b8..649d9a5 100644 --- a/src/source/schema.rs +++ b/src/source/schema.rs @@ -210,9 +210,10 @@ impl OwnerIndex { } /// 사실마다 감싸는 그래프 정점을 symbol로 단다. 못 단 사실 수를 돌려준다. -fn attach_symbols(facts: &mut [RelationFact], root: &Path, index: Option<&OwnerIndex>) -> usize { +/// 매개변수 이름을 `facts`로 두면 이름 해석이 fn facts로 읽어 거짓 순환이 된다. +fn attach_symbols(list: &mut [RelationFact], root: &Path, index: Option<&OwnerIndex>) -> usize { let mut missing = 0; - for f in facts.iter_mut() { + for f in list.iter_mut() { let file = root.join(&f.location.path); match index.and_then(|ix| ix.owner(&file, f.location.byte)) { Some(id) => { From 12f77bfb154dcf9684921ff9e135db2810262957 Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 09:03:24 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(traversal):=20=EB=8B=A4=EC=A4=91=20roo?= =?UTF-8?q?t=20=EB=8B=A8=EC=9D=BC=20=ED=8C=A8=EC=8A=A4=20=EC=88=9C?= =?UTF-8?q?=ED=9A=8C=EC=99=80=20root=EB=B3=84=20=ED=95=98=ED=95=9C=20?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=20=EB=93=B1=EA=B8=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isthmus language-traversal v1의 도달 의미를 문서(graph)만으로 계산하는 traversal 모듈을 더한다. 계열 생산자(pythograph·tsograph)와 같은 알고리즘이다. - 모든 root를 한 번에 출발시키는 단계 동기 BFS. 정점이 자기 자신이 아닌 더 작은 인덱스 root를 65개 이상 가지면 큰 인덱스 root의 전파를 멈춘다 — 그 정점을 지나는 경로는 작은 root들도 같거나 짧게 지나므로 출력(작은 인덱스 64개·depth·via)이 바뀌지 않는다. - via는 가장 가까운 root(동률이면 작은 인덱스)에서 depth-1 거리의 선행 정점 중 UTF-16 순으로 가장 작은 것. 정점 번호를 UTF-16 순으로 매겨 비교를 줄였다. - 근거 등급: tentative 간선(이름 팬아웃·dyn/제네릭 트레이트 impl 후보)은 candidate, 나머지는 direct. root마다 비트 하나로 확정 간선 그래프와 전체 그래프의 깊이 제한 도달을 비교해 root별 하한을 구하고, 메모리 상한을 넘으면 부풀리지 않는 근사로 바꾼다. - graphRevision용 SHA-256을 의존 없이 두고 NIST 벡터로 검증한다. - 무작위 그래프를 root별 BFS 오라클과 대조하는 테스트(root 70개 이상 가지치기 포함)와 변이 확인을 거쳤다. 자기 분석 규칙에 traversal: [graph]를 더했다. Co-Authored-By: Claude Opus 5.5 (1M context) --- .rustograph.yml | 7 +- src/lib.rs | 1 + src/traversal.rs | 494 ++++++++++++++++++++++++++++++++++++++++ src/traversal/sha256.rs | 106 +++++++++ src/traversal/tests.rs | 381 +++++++++++++++++++++++++++++++ 5 files changed, 987 insertions(+), 2 deletions(-) create mode 100644 src/traversal.rs create mode 100644 src/traversal/sha256.rs create mode 100644 src/traversal/tests.rs diff --git a/.rustograph.yml b/.rustograph.yml index 6709af0..a37cc89 100644 --- a/.rustograph.yml +++ b/.rustograph.yml @@ -17,6 +17,7 @@ components: harvest: ["rustograph::harvest/**"] graph: ["rustograph::graph/**"] analysis: ["rustograph::analysis/**"] + traversal: ["rustograph::traversal/**"] rules: ["rustograph::rules/**"] export: ["rustograph::export/**"] sarif: ["rustograph::sarif/**"] @@ -27,7 +28,7 @@ components: deps_report: ["rustograph::deps/**"] deps: app: [cli] - cli: [source, analysis, rules, export, sarif, config, graph, mcp, cli_args, deps_report] + cli: [source, analysis, traversal, rules, export, sarif, config, graph, mcp, cli_args, deps_report] mcp: [cli_args, analysis, rules, export, config, graph, deps_report] cli_args: [source, export, graph] source: [graph, harvest, modtree, cargo_meta, sem, cfgeval] @@ -35,6 +36,8 @@ deps: sem: [graph, cargo_meta] harvest: [graph, modtree] analysis: [graph] + # 다중 root 순회는 문서만 소비한다 — 수확·입출력을 모른다. + traversal: [graph] rules: [graph, config] export: [graph] sarif: [graph, rules] @@ -49,7 +52,7 @@ deps: config: [] deny: # 도메인은 수확 경계와 CLI를 절대 모른다 — 순수성이 테스트 가능성이다. - graph: [cli, source, harvest, modtree, cargo_meta, analysis, rules, export, sarif, config, app, sem] + graph: [cli, source, harvest, modtree, cargo_meta, analysis, traversal, rules, export, sarif, config, app, sem] # cli는 cargo/syn/ra_ap 경계를 직접 만지지 않는다 — source를 통한다. cli: [cargo_meta, harvest, modtree, sem] # mcp도 마찬가지 — 수확 경계는 source 뒤에만 있다. diff --git a/src/lib.rs b/src/lib.rs index 24b31c8..afadfa3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,3 +21,4 @@ pub mod sarif; #[cfg(feature = "semantic")] pub mod sem; pub mod source; +pub mod traversal; diff --git a/src/traversal.rs b/src/traversal.rs new file mode 100644 index 0000000..6221244 --- /dev/null +++ b/src/traversal.rs @@ -0,0 +1,494 @@ +//! 여러 root에서 한 번에 그래프를 정방향(dependencies) 또는 역방향(dependents)으로 훑는다. +//! +//! isthmus `language-traversal` v1(`docs/LANGUAGE-TRAVERSAL.md`)의 도달 의미를 그대로 계산한다 — +//! 계열 생산자(pythograph `graph/traversal.py`, tsograph `src/graph/traversal.ts`)와 같은 알고리즘이다. +//! +//! - `reached`는 자기 자신이 아닌 root에서 간선 1개 이상으로(깊이 상한 안에서) 닿은 정점 전부다. +//! root이기도 한 정점도 싣되 그 `roots`에는 그에 닿는 다른 root만 넣는다. 자기 자신에게서만 닿는 +//! root는 싣지 않는다. +//! - `depth`는 그 root들 중 가장 가까운 것까지의 거리, `via`는 가장 가까운 root(같은 거리면 작은 +//! 인덱스)에서 depth-1 거리에 있는 선행 정점 중 id가 가장 작은 것이다(UTF-16 코드 단위 순서). +//! - 구현은 모든 root를 한 번에 출발시키는 단계 동기 너비 우선 패스다. 정점이 자기 자신이 아닌 더 작은 +//! 인덱스 root를 65개 이상 가졌으면 더 큰 인덱스 root는 그 정점에서 전파를 멈춘다 — 그 정점을 지나는 +//! 경로는 작은 root들도 같거나 짧은 거리로 지나므로 출력(작은 인덱스 64개·depth·via)을 바꿀 수 없다. +//! - `evidence`는 root별 하한이다: 정점에 닿는 모든 root 각각의 가장 강한 등급 중 가장 약한 것. +//! 추정 간선(`tentative` — 이름 팬아웃·`dyn`/제네릭 트레이트 impl 후보)은 `candidate`, 나머지는 +//! `direct`다. 비트 집합이 메모리 상한을 넘으면 약하게 적을 수는 있어도 부풀리지 않는 근사로 바꾼다. +//! +//! 이 모듈은 문서(graph)만 소비한다 — 수확·입출력을 모른다. + +pub mod sha256; + +use crate::graph::Document; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +/// 정점 하나가 싣는 root 인덱스의 상한이다(계약의 `rootsTruncated`). +pub const MAX_ROOTS_PER_NODE: usize = 64; +/// 계약이 허용하는 최대 깊이다. +pub const MAX_DEPTH: usize = 128; +/// 계약이 허용하는 최대 도달 정점 수다. +pub const MAX_REACHED: usize = 100_000; +/// 계약이 허용하는 최대 root 수다. +pub const MAX_ROOTS: usize = 10_000; +/// 전파를 멈추게 하는 더 작은 root 수 — 정점이 자기 인덱스를 빼도 64개가 남도록 하나 더 둔다. +const DOMINATING_ROOTS: usize = MAX_ROOTS_PER_NODE + 1; +/// 정확한 등급 비교(root당 비트 하나)가 쓸 수 있는 메모리 상한(바이트)이다. +pub const EVIDENCE_MEMORY_BYTES: usize = 64 * 1024 * 1024; + +/// 순회 방향 — 계약 문자열과 1:1이다. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + /// root가 기대는 쪽(호출·참조 대상). + Dependencies, + /// root에 기대는 쪽(호출자·참조자). + Dependents, +} + +impl Direction { + /// 계약의 `direction` 문자열이다. + pub fn as_str(self) -> &'static str { + match self { + Direction::Dependencies => "dependencies", + Direction::Dependents => "dependents", + } + } +} + +/// 근거 등급 — 간선 집합이 포개진다(`direct ⊂ candidate`). bound는 만들지 않는다. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Evidence { + /// 구문·타입이 해석한 간선만으로 닿는다. + Direct, + /// 가능성만 있는 추정 간선을 하나 이상 거친다. + Candidate, +} + +impl Evidence { + /// 계약의 `evidence` 문자열이다. + pub fn as_str(self) -> &'static str { + match self { + Evidence::Direct => "direct", + Evidence::Candidate => "candidate", + } + } +} + +/// 순회 요청. root는 모두 문서 정점이고 서로 다르다(호출자가 거른다). +pub struct Request<'a> { + /// root id — 순서가 `roots` 인덱스의 뜻이다. + pub roots: &'a [String], + pub direction: Direction, + /// 최대 깊이(1~128). + pub max_depth: usize, + /// 최대 도달 정점 수 — 넘으면 (depth, id) 순 앞쪽만 남기고 `max-reached`로 잘린다. + pub max_reached: usize, + /// 등급 비교 메모리 상한(테스트 주입용). + pub evidence_memory_bytes: usize, +} + +/// 도달 정점 하나. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Reached { + pub id: String, + /// 최단 경로의 직전 정점(root id 또는 다른 도달 정점 id). + pub via: String, + /// 가장 가까운 root까지의 간선 수. + pub depth: usize, + /// 닿는 root 인덱스(오름차순, 최대 64개). + pub roots: Vec, + /// via와 이 정점 사이 간선 종류(원래 방향 기준, 정렬). + pub relationships: Vec, + /// root별 하한 근거 등급. + pub evidence: Evidence, +} + +/// 순회 결과. +#[derive(Debug, Clone)] +pub struct Outcome { + /// (depth, UTF-16 id) 순 도달 정점. + pub reached: Vec, + /// 정렬한 잘림 이유(`depth`·`max-reached`). + pub truncation_reasons: Vec<&'static str>, + /// 정점당 root 64개 상한이 적용됐는지. + pub roots_truncated: bool, + /// 근거 등급을 보수적으로 근사했는지. + pub evidence_approximated: bool, +} + +/// UTF-16 코드 단위 순서 비교다(계약의 정렬 규칙, locale 무관). +/// Rust 문자열 비교는 UTF-8 바이트(=코드 포인트) 순이라 U+E000~FFFF와 보충 평면 문자의 +/// 순서가 계약과 뒤집힌다. +pub fn utf16_cmp(a: &str, b: &str) -> std::cmp::Ordering { + a.encode_utf16().cmp(b.encode_utf16()) +} + +/// 정점을 정수로 바꾼 인접 구조 — 정점 번호는 UTF-16 순서라 번호 비교가 곧 id 비교다. +struct Graph { + ids: Vec, + index: HashMap, + /// 순회 방향의 이웃(중복 없음). `firm`은 확정 간선이 하나라도 있는 쌍이다. + full: Vec>, + firm: Vec>, + /// 순회 방향의 선행 정점(번호 오름차순 = UTF-16 순). + predecessors: Vec>, + /// (출발, 도착) → 원래 방향 간선 종류(정렬). + kinds: HashMap<(usize, usize), BTreeSet>, + /// 추정 간선만 있는 쌍의 순회 방향 출발점 — 등급 비교 대상 root를 고르는 재료. + weak_tails: Vec, + /// 추정 간선만 있는 쌍(출발, 도착). + weak_pairs: Vec<(usize, usize)>, +} + +impl Graph { + /// 문서의 의존 간선으로 방향에 맞춘 인접 구조를 만든다. 양 끝이 정점인 간선만 쓴다 — + /// 저장 문서의 dangling 간선이 없는 정점을 지어내지 않게 한다. + fn new(doc: &Document, direction: Direction) -> Graph { + let mut ids: Vec = doc.vertex_ids().into_iter().map(str::to_string).collect(); + ids.sort_by(|a, b| utf16_cmp(a, b)); + let index: HashMap = ids + .iter() + .enumerate() + .map(|(i, id)| (id.clone(), i)) + .collect(); + let n = ids.len(); + // 쌍별 (확정 존재, 종류) — 같은 쌍의 여러 간선은 하나로 합친다. + let mut pairs: BTreeMap<(usize, usize), (bool, BTreeSet)> = BTreeMap::new(); + for e in doc.edges.iter().filter(|e| e.kind.is_dependency()) { + let (Some(&from), Some(&to)) = (index.get(&e.from), index.get(&e.to)) else { + continue; + }; + let key = match direction { + Direction::Dependencies => (from, to), + Direction::Dependents => (to, from), + }; + let entry = pairs.entry(key).or_insert((false, BTreeSet::new())); + entry.0 |= !e.tentative; + entry.1.insert(edge_kind_name(e.kind)); + } + let mut g = Graph { + ids, + index, + full: vec![Vec::new(); n], + firm: vec![Vec::new(); n], + predecessors: vec![Vec::new(); n], + kinds: HashMap::new(), + weak_tails: Vec::new(), + weak_pairs: Vec::new(), + }; + for ((a, b), (firm, kinds)) in pairs { + g.full[a].push(b); + g.predecessors[b].push(a); + if firm { + g.firm[a].push(b); + } else { + g.weak_tails.push(a); + g.weak_pairs.push((a, b)); + } + g.kinds.insert((a, b), kinds); + } + for p in &mut g.predecessors { + p.sort_unstable(); + } + g.weak_tails.sort_unstable(); + g.weak_tails.dedup(); + g + } +} + +/// 간선 종류의 계약 문자열(serde 이름과 같다). +fn edge_kind_name(kind: crate::graph::EdgeKind) -> String { + serde_json::to_value(kind) + .ok() + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_default() +} + +/// 정점별 (root 인덱스 → 처음 닿은 단계)다. +type Levels = Vec>; + +/// 순회한다. +pub fn traverse(doc: &Document, req: &Request) -> Outcome { + let g = Graph::new(doc, req.direction); + let roots: Vec = req + .roots + .iter() + .filter_map(|r| g.index.get(r).copied()) + .collect(); + debug_assert_eq!(roots.len(), req.roots.len(), "roots must be vertices"); + let (levels, depth_cut, pruned) = propagate(&g, &roots, req.max_depth); + let rows = reached_rows(&g, &levels, &roots); + let over = rows.len() > req.max_reached; + let kept: Vec = rows.into_iter().take(req.max_reached).collect(); + let mut reasons = Vec::new(); + if depth_cut { + reasons.push("depth"); + } + if over { + reasons.push("max-reached"); + } + let roots_truncated = pruned || kept.iter().any(|r| r.roots.len() > MAX_ROOTS_PER_NODE); + let (evidence, approximated) = evidence_tiers(&g, &roots, req, &levels); + let reached = kept + .into_iter() + .map(|row| Reached { + id: g.ids[row.node].clone(), + via: g.ids[row.via].clone(), + depth: row.depth, + relationships: g + .kinds + .get(&(row.via, row.node)) + .map(|k| k.iter().take(32).cloned().collect()) + .unwrap_or_default(), + evidence: evidence(row.node), + roots: row.roots.into_iter().take(MAX_ROOTS_PER_NODE).collect(), + }) + .collect(); + Outcome { + reached, + truncation_reasons: reasons, + roots_truncated, + evidence_approximated: approximated, + } +} + +/// 모든 root에서 단계 동기로 전파한다. (단계 기록, 깊이 잘림, 전파 중단 여부)를 돌려준다. +fn propagate(g: &Graph, roots: &[usize], max_depth: usize) -> (Levels, bool, bool) { + let mut levels: Levels = vec![HashMap::new(); g.ids.len()]; + let owners: HashMap = roots.iter().enumerate().map(|(i, &n)| (n, i)).collect(); + let mut frontier: BTreeMap> = BTreeMap::new(); + for (i, &n) in roots.iter().enumerate() { + levels[n].insert(i, 0); + frontier.insert(n, vec![i]); + } + let mut pruned = false; + for level in 0..max_depth { + if frontier.is_empty() { + break; + } + let mut next: BTreeMap> = BTreeMap::new(); + for (&node, held_roots) in &frontier { + for &nb in &g.full[node] { + pruned |= spread( + &mut levels, + owners.get(&nb).copied(), + nb, + held_roots, + level + 1, + &mut next, + ); + } + } + frontier = next; + } + let cut = frontier.iter().any(|(&node, held_roots)| { + g.full[node] + .iter() + .any(|&nb| held_roots.iter().any(|r| !levels[nb].contains_key(r))) + }); + (levels, cut, pruned) +} + +/// 한 간선으로 root 인덱스들을 이웃에 전파한다. 전파를 멈춘 쌍이 있었으면 true다. +fn spread( + levels: &mut Levels, + owner: Option, + nb: usize, + roots: &[usize], + level: usize, + next: &mut BTreeMap>, +) -> bool { + let mut pruned = false; + for &r in roots { + if levels[nb].contains_key(&r) { + continue; + } + if dominated(&levels[nb], owner, r) { + pruned = true; + continue; + } + levels[nb].insert(r, level); + next.entry(nb).or_default().push(r); + } + pruned +} + +/// 정점이 자기 자신이 아닌 root 중 이 root보다 작은 인덱스를 이미 65개 이상 가졌는지 본다. +fn dominated(held: &HashMap, owner: Option, root: usize) -> bool { + if held.len() - usize::from(owner.is_some_and(|o| held.contains_key(&o))) < DOMINATING_ROOTS { + return false; + } + held.keys() + .filter(|&&i| Some(i) != owner && i < root) + .count() + >= DOMINATING_ROOTS +} + +/// 출력 전 도달 행. +struct Row { + node: usize, + via: usize, + depth: usize, + roots: Vec, +} + +/// 단계 기록에서 도달 행을 만든다. (depth, UTF-16 id) 순이다. +fn reached_rows(g: &Graph, levels: &Levels, roots: &[usize]) -> Vec { + let owners: HashMap = roots.iter().enumerate().map(|(i, &n)| (n, i)).collect(); + let mut rows = Vec::new(); + for (node, held) in levels.iter().enumerate() { + let owner = owners.get(&node).copied(); + let mut rs: Vec = held.keys().copied().filter(|&i| Some(i) != owner).collect(); + if rs.is_empty() { + continue; + } + rs.sort_unstable(); + let depth = rs.iter().map(|i| held[i]).min().unwrap_or(0); + let nearest = *rs.iter().find(|i| held[i] == depth).unwrap_or(&rs[0]); + // 가장 가까운 root에서 depth-1 거리의 선행 정점은 반드시 있다 — 이 정점이 그 단계에 + // 전파받았다는 것이 곧 그런 선행 정점의 존재다. 번호 순이 UTF-16 순이다. + let Some(&via) = g.predecessors[node] + .iter() + .find(|&&p| levels[p].get(&nearest) == Some(&(depth - 1))) + else { + continue; + }; + rows.push(Row { + node, + via, + depth, + roots: rs, + }); + } + rows.sort_by_key(|r| (r.depth, r.node)); + rows +} + +/// 정점 번호 → 등급 함수와 근사 여부. +type EvidenceFn = Box Evidence>; + +/// 정점의 root별 하한 근거 등급을 구하는 함수를 만든다. +fn evidence_tiers( + g: &Graph, + roots: &[usize], + req: &Request, + levels: &Levels, +) -> (EvidenceFn, bool) { + let compared = weak_touching_roots(g, roots, req.max_depth); + if compared.is_empty() { + return (Box::new(|_| Evidence::Direct), false); + } + let words = compared.len().div_ceil(64); + let estimate = g.ids.len().saturating_mul(words * 8 + 32).saturating_mul(3); + if estimate > req.evidence_memory_bytes { + let marks = approximate_marks(g, levels); + return ( + Box::new(move |n| { + if marks[n] { + Evidence::Candidate + } else { + Evidence::Direct + } + }), + true, + ); + } + let full = reach_bits(&g.full, &compared, words, req.max_depth); + let firm = reach_bits(&g.firm, &compared, words, req.max_depth); + ( + Box::new(move |n| { + if full[n] == firm[n] { + Evidence::Direct + } else { + Evidence::Candidate + } + }), + false, + ) +} + +/// 깊이 상한 안에서 추정 간선의 출발점에 닿을 수 있는 root다(입력 순서). +/// 닿지 못하는 root는 모든 등급에서 같게 닿으므로 비교에서 뺀다. +fn weak_touching_roots(g: &Graph, roots: &[usize], max_depth: usize) -> Vec { + let mut seen = vec![false; g.ids.len()]; + let mut frontier: Vec = g.weak_tails.clone(); + for &t in &frontier { + seen[t] = true; + } + for _ in 0..max_depth.saturating_sub(1) { + if frontier.is_empty() { + break; + } + let mut next = Vec::new(); + for &n in &frontier { + for &p in &g.predecessors[n] { + if !seen[p] { + seen[p] = true; + next.push(p); + } + } + } + frontier = next; + } + roots.iter().copied().filter(|&r| seen[r]).collect() +} + +/// root마다 비트 하나를 두고 단계 동기로 전파해, 깊이 상한 안에서 각 정점에 닿는 root 비트 집합을 구한다. +fn reach_bits( + adj: &[Vec], + roots: &[usize], + words: usize, + max_depth: usize, +) -> Vec> { + let mut seen = vec![vec![0u64; words]; adj.len()]; + let mut frontier: BTreeMap> = BTreeMap::new(); + for (pos, &r) in roots.iter().enumerate() { + seen[r][pos / 64] |= 1 << (pos % 64); + frontier.entry(r).or_insert_with(|| vec![0; words])[pos / 64] |= 1 << (pos % 64); + } + for _ in 0..max_depth { + if frontier.is_empty() { + break; + } + let mut next: BTreeMap> = BTreeMap::new(); + for (&n, bits) in &frontier { + for &nb in &adj[n] { + let fresh: Vec = bits.iter().zip(&seen[nb]).map(|(b, s)| b & !s).collect(); + if fresh.iter().any(|w| *w != 0) { + for (s, f) in seen[nb].iter_mut().zip(&fresh) { + *s |= f; + } + let slot = next.entry(nb).or_insert_with(|| vec![0; words]); + for (s, f) in slot.iter_mut().zip(&fresh) { + *s |= f; + } + } + } + } + frontier = next; + } + seen +} + +/// 메모리 상한을 넘을 때의 보수적 등급 표시다. root에서 닿은 출발점을 가진 추정 간선의 도착점에서 +/// 깊이 제한 없이 닿는 정점을 candidate로 표시한다. 표시되지 않은 정점은 모든 root에서 추정 간선 +/// 없이 닿으므로 정확히 direct다 — 약하게 적을 수는 있어도 부풀리지 않는다. +fn approximate_marks(g: &Graph, levels: &Levels) -> Vec { + let mut marks = vec![false; g.ids.len()]; + let mut stack: Vec = g + .weak_pairs + .iter() + .filter(|(tail, _)| !levels[*tail].is_empty()) + .map(|&(_, head)| head) + .collect(); + while let Some(n) = stack.pop() { + if marks[n] { + continue; + } + marks[n] = true; + stack.extend(g.full[n].iter().copied().filter(|&m| !marks[m])); + } + marks +} + +#[cfg(test)] +mod tests; diff --git a/src/traversal/sha256.rs b/src/traversal/sha256.rs new file mode 100644 index 0000000..5ee4328 --- /dev/null +++ b/src/traversal/sha256.rs @@ -0,0 +1,106 @@ +//! SHA-256(FIPS 180-4) — 순회 문서의 `graphRevision`(그래프 산출물 신원)용. +//! +//! 해시 하나를 위해 의존을 늘리지 않으려고 표준 알고리즘을 직접 둔다. 보안 용도가 아니라 +//! "같은 그래프면 같은 문자열"이라는 신원 비교용이며, NIST 테스트 벡터로 검증한다. + +/// 라운드 상수 — 처음 64개 소수의 세제곱근 소수부 32비트. +const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +/// 초기 해시 값 — 처음 8개 소수의 제곱근 소수부 32비트. +const H0: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]; + +/// 바이트열의 SHA-256을 소문자 16진 문자열로 돌려준다. +pub fn hex_digest(data: &[u8]) -> String { + let mut h = H0; + let bit_len = (data.len() as u64).wrapping_mul(8); + let mut msg = data.to_vec(); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_be_bytes()); + for block in msg.chunks_exact(64) { + compress(&mut h, block); + } + h.iter().map(|w| format!("{w:08x}")).collect() +} + +/// 64바이트 블록 하나로 상태를 갱신한다. +fn compress(h: &mut [u32; 8], block: &[u8]) { + let mut w = [0u32; 64]; + for (i, chunk) in block.chunks_exact(4).enumerate() { + w[i] = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = *h; + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ (!e & g); + let t1 = hh + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let t2 = s0.wrapping_add(maj); + hh = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + for (slot, v) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) { + *slot = slot.wrapping_add(v); + } +} + +#[cfg(test)] +mod tests { + use super::hex_digest; + + #[test] + fn nist_vectors() { + // FIPS 180-4 예제와 NIST CAVP 짧은 메시지 벡터. + assert_eq!( + hex_digest(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + hex_digest(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + hex_digest(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); + // 블록 경계(55·56·64바이트)에서 패딩이 다음 블록으로 넘어가는 경우. + assert_eq!( + hex_digest(&[b'a'; 1_000_000]), + "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0" + ); + assert_eq!(hex_digest(&[0u8; 55]).len(), 64); + assert_ne!(hex_digest(&[0u8; 56]), hex_digest(&[0u8; 55])); + } +} diff --git a/src/traversal/tests.rs b/src/traversal/tests.rs new file mode 100644 index 0000000..dd61e9d --- /dev/null +++ b/src/traversal/tests.rs @@ -0,0 +1,381 @@ +//! 다중 root 단일 패스를 root별 너비 우선 탐색(오라클)과 무작위 그래프로 대조한다. + +use super::*; +use crate::graph::{self, Edge, EdgeKind, Kind, Level, Vertex}; +use std::collections::VecDeque; + +/// 의존성 없는 결정적 난수(xorshift64*). +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } +} + +fn vertex(id: &str) -> Vertex { + Vertex { + id: id.to_string(), + kind: Kind::Fn, + krate: "c".into(), + module: "c".into(), + position: None, + exported: false, + generated: false, + cfg: None, + unsafe_: false, + } +} + +/// 정점 이름 — UTF-16 순서가 바이트 순서와 다른 문자(U+E000대·보충 평면)를 섞는다. +fn name(i: usize) -> String { + match i % 4 { + 0 => format!("c::n{i}"), + 1 => format!("c::\u{e000}{i}"), + 2 => format!("c::\u{10400}{i}"), + _ => format!("c::N{i}"), + } +} + +fn random_doc(rng: &mut Rng, n: usize, m: usize) -> Document { + let vertices = (0..n).map(|i| vertex(&name(i))).collect(); + let mut edges = Vec::new(); + for _ in 0..m { + let (a, b) = (rng.below(n), rng.below(n)); + let kind = [EdgeKind::Call, EdgeKind::References, EdgeKind::Signature][rng.below(3)]; + let e = if rng.below(4) == 0 { + Edge::maybe(name(a), name(b), kind) + } else { + Edge::new(name(a), name(b), kind) + }; + edges.push(e); + } + // 소유 간선은 의존이 아니다 — 순회가 따라가면 안 된다. + edges.push(Edge::new(name(0), name(1), EdgeKind::Contains)); + graph::document( + Level::Symbol, + ".".into(), + None, + vec![], + vertices, + edges, + vec![], + ) +} + +/// 방향에 맞춘 (출발 → 도착) 인접 목록 — firm_only면 확정 간선만. +fn oracle_adj(doc: &Document, dir: Direction, firm_only: bool) -> BTreeMap> { + let mut adj: BTreeMap> = BTreeMap::new(); + for e in &doc.edges { + if !e.kind.is_dependency() || (firm_only && e.tentative) { + continue; + } + let (a, b) = match dir { + Direction::Dependencies => (e.from.clone(), e.to.clone()), + Direction::Dependents => (e.to.clone(), e.from.clone()), + }; + adj.entry(a).or_default().push(b); + } + adj +} + +fn bfs(adj: &BTreeMap>, root: &str, limit: usize) -> BTreeMap { + let mut dist = BTreeMap::from([(root.to_string(), 0)]); + let mut q = VecDeque::from([root.to_string()]); + while let Some(n) = q.pop_front() { + let d = dist[&n]; + if d == limit { + continue; + } + for m in adj.get(&n).into_iter().flatten() { + if !dist.contains_key(m) { + dist.insert(m.clone(), d + 1); + q.push_back(m.clone()); + } + } + } + dist +} + +/// 한 경우를 오라클과 대조한다. +fn check_case(doc: &Document, roots: &[String], dir: Direction, max_depth: usize) { + let out = traverse( + doc, + &Request { + roots, + direction: dir, + max_depth, + max_reached: MAX_REACHED, + evidence_memory_bytes: EVIDENCE_MEMORY_BYTES, + }, + ); + let full = oracle_adj(doc, dir, false); + let firm = oracle_adj(doc, dir, true); + let dists: Vec<_> = roots.iter().map(|r| bfs(&full, r, max_depth)).collect(); + let firm_dists: Vec<_> = roots.iter().map(|r| bfs(&firm, r, max_depth)).collect(); + // 기대 도달 집합: 자기 자신이 아닌 root에서 거리 1 이상으로 닿은 정점. + let mut expected: BTreeMap> = BTreeMap::new(); + for (i, d) in dists.iter().enumerate() { + for v in d.keys() { + if *v != roots[i] { + expected.entry(v.clone()).or_default().push(i); + } + } + } + let got: BTreeSet<&str> = out.reached.iter().map(|r| r.id.as_str()).collect(); + let want: BTreeSet<&str> = expected.keys().map(String::as_str).collect(); + assert_eq!(got, want, "reached set differs"); + // (depth, UTF-16 id) 정렬. + for w in out.reached.windows(2) { + assert!(w[0] + .depth + .cmp(&w[1].depth) + .then(utf16_cmp(&w[0].id, &w[1].id)) + .is_lt()); + } + for row in &out.reached { + let rs = &expected[&row.id]; + let depth = rs.iter().map(|&i| dists[i][&row.id]).min().unwrap(); + assert_eq!(row.depth, depth, "depth of {}", row.id); + assert_eq!(row.roots, rs.iter().copied().take(64).collect::>()); + let nearest = *rs.iter().find(|&&i| dists[i][&row.id] == depth).unwrap(); + let via = full + .iter() + .filter(|(p, ns)| ns.contains(&row.id) && dists[nearest].get(*p) == Some(&(depth - 1))) + .map(|(p, _)| p.clone()) + .min_by(|a, b| utf16_cmp(a, b)) + .unwrap(); + assert_eq!(row.via, via, "via of {}", row.id); + let strongest = |i: usize| { + if firm_dists[i].contains_key(&row.id) { + Evidence::Direct + } else { + Evidence::Candidate + } + }; + let evidence = rs.iter().map(|&i| strongest(i)).max().unwrap(); + assert_eq!(row.evidence, evidence, "evidence of {}", row.id); + // 관계는 원래 방향 via→정점 간선 종류다. + assert!(!row.relationships.is_empty()); + } + assert_eq!( + out.roots_truncated, + expected.values().any(|rs| rs.len() > 64), + "rootsTruncated" + ); + if roots.len() <= 64 { + // 깊이 상한 밖(정확히 max_depth+1)에 정점이 있으면 depth로 잘린다. + let cut = roots + .iter() + .enumerate() + .any(|(i, r)| bfs(&full, r, max_depth + 1).len() > dists[i].len()); + assert_eq!(out.truncation_reasons.contains(&"depth"), cut, "depth cut"); + } +} + +#[test] +fn single_pass_matches_per_root_oracle() { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + for case in 0..300 { + let n = 2 + rng.below(30); + let m = rng.below(n * 3); + let doc = random_doc(&mut rng, n, m); + let k = 1 + rng.below(n.min(6)); + let mut picked: Vec = (0..n).collect(); + for i in (1..picked.len()).rev() { + picked.swap(i, rng.below(i + 1)); + } + let roots: Vec = picked[..k].iter().map(|&i| name(i)).collect(); + let dir = if case % 2 == 0 { + Direction::Dependencies + } else { + Direction::Dependents + }; + check_case(&doc, &roots, dir, 1 + rng.below(6)); + } +} + +#[test] +fn many_roots_prune_without_changing_output() { + // 65개 넘는 root가 한 정점에 모이면 큰 인덱스 root는 전파를 멈춘다 — 출력은 오라클과 같아야 한다. + let mut rng = Rng(42); + for _ in 0..20 { + let n = 90 + rng.below(20); + let doc = random_doc(&mut rng, n, n * 4); + let k = 70 + rng.below(15); + let roots: Vec = (0..k).map(name).collect(); + check_case(&doc, &roots, Direction::Dependencies, 1 + rng.below(4)); + } + // 허브 하나에 root 80개가 모이는 결정적 경우 — rootsTruncated가 선다. + let mut vertices: Vec = (0..80).map(|i| vertex(&name(i))).collect(); + vertices.push(vertex("c::hub")); + vertices.push(vertex("c::leaf")); + let mut edges: Vec = (0..80) + .map(|i| Edge::new(name(i), "c::hub".into(), EdgeKind::Call)) + .collect(); + edges.push(Edge::new("c::hub".into(), "c::leaf".into(), EdgeKind::Call)); + let doc = graph::document( + Level::Symbol, + ".".into(), + None, + vec![], + vertices, + edges, + vec![], + ); + let roots: Vec = (0..80).map(name).collect(); + check_case(&doc, &roots, Direction::Dependencies, 8); +} + +/// 작은 손 그래프 — A(0)·R(1), A→W→V→R, R→V. 계약 문서의 root 항목 예와 같다. +#[test] +fn root_reached_from_other_root_keeps_witness() { + let ids = ["c::A", "c::R", "c::V", "c::W"]; + let doc = graph::document( + Level::Symbol, + ".".into(), + None, + vec![], + ids.iter().map(|i| vertex(i)).collect(), + vec![ + Edge::new("c::A".into(), "c::W".into(), EdgeKind::Call), + Edge::new("c::W".into(), "c::V".into(), EdgeKind::Call), + Edge::new("c::V".into(), "c::R".into(), EdgeKind::Call), + Edge::new("c::R".into(), "c::V".into(), EdgeKind::Call), + ], + vec![], + ); + let roots = vec!["c::A".to_string(), "c::R".to_string()]; + let out = traverse( + &doc, + &Request { + roots: &roots, + direction: Direction::Dependencies, + max_depth: MAX_DEPTH, + max_reached: MAX_REACHED, + evidence_memory_bytes: EVIDENCE_MEMORY_BYTES, + }, + ); + let v = out.reached.iter().find(|r| r.id == "c::V").unwrap(); + assert_eq!( + (v.via.as_str(), v.depth, v.roots.clone()), + ("c::R", 1, vec![0, 1]) + ); + let r = out.reached.iter().find(|r| r.id == "c::R").unwrap(); + assert_eq!( + (r.via.as_str(), r.depth, r.roots.clone()), + ("c::V", 3, vec![0]) + ); + assert!(out.truncation_reasons.is_empty()); +} + +#[test] +fn candidate_evidence_is_a_per_root_lower_bound() { + // A→(추정)X, B→X(확정) — X에 닿는 root 중 A는 추정 간선으로만 닿으니 X는 candidate. + // B만 root면 direct다. + let doc = graph::document( + Level::Symbol, + ".".into(), + None, + vec![], + ["c::A", "c::B", "c::X"].iter().map(|i| vertex(i)).collect(), + vec![ + Edge::maybe("c::A".into(), "c::X".into(), EdgeKind::Call), + Edge::new("c::B".into(), "c::X".into(), EdgeKind::Call), + ], + vec![], + ); + let run = |roots: &[String], bytes: usize| { + traverse( + &doc, + &Request { + roots, + direction: Direction::Dependencies, + max_depth: MAX_DEPTH, + max_reached: MAX_REACHED, + evidence_memory_bytes: bytes, + }, + ) + }; + let both = vec!["c::A".to_string(), "c::B".to_string()]; + let out = run(&both, EVIDENCE_MEMORY_BYTES); + assert_eq!(out.reached[0].evidence, Evidence::Candidate); + assert!(!out.evidence_approximated); + let only_b = vec!["c::B".to_string()]; + assert_eq!( + run(&only_b, EVIDENCE_MEMORY_BYTES).reached[0].evidence, + Evidence::Direct + ); + // 메모리 상한을 넘으면 근사하되 부풀리지 않는다. + let approx = run(&both, 0); + assert!(approx.evidence_approximated); + assert_eq!(approx.reached[0].evidence, Evidence::Candidate); +} + +#[test] +fn approximation_never_inflates_evidence() { + let mut rng = Rng(7); + for _ in 0..100 { + let n = 2 + rng.below(25); + let m = rng.below(n * 3); + let doc = random_doc(&mut rng, n, m); + let roots: Vec = (0..1 + rng.below(n.min(4))).map(name).collect(); + let req = |bytes| Request { + roots: &roots, + direction: Direction::Dependencies, + max_depth: 4, + max_reached: MAX_REACHED, + evidence_memory_bytes: bytes, + }; + let exact = traverse(&doc, &req(EVIDENCE_MEMORY_BYTES)); + let approx = traverse(&doc, &req(0)); + for (e, a) in exact.reached.iter().zip(&approx.reached) { + assert_eq!(e.id, a.id); + assert!(a.evidence >= e.evidence, "approximation inflated {}", e.id); + } + } +} + +#[test] +fn max_reached_truncates_in_order() { + let doc = graph::document( + Level::Symbol, + ".".into(), + None, + vec![], + ["c::a", "c::b", "c::c"].iter().map(|i| vertex(i)).collect(), + vec![ + Edge::new("c::a".into(), "c::b".into(), EdgeKind::Call), + Edge::new("c::b".into(), "c::c".into(), EdgeKind::Call), + ], + vec![], + ); + let roots = vec!["c::a".to_string()]; + let out = traverse( + &doc, + &Request { + roots: &roots, + direction: Direction::Dependencies, + max_depth: MAX_DEPTH, + max_reached: 1, + evidence_memory_bytes: EVIDENCE_MEMORY_BYTES, + }, + ); + assert_eq!(out.reached.len(), 1); + assert_eq!(out.reached[0].id, "c::b"); + assert_eq!(out.truncation_reasons, vec!["max-reached"]); +} + +#[test] +fn direction_strings_are_contract_values() { + assert_eq!(Direction::Dependencies.as_str(), "dependencies"); + assert_eq!(Direction::Dependents.as_str(), "dependents"); + assert_eq!(Evidence::Direct.as_str(), "direct"); + assert_eq!(Evidence::Candidate.as_str(), "candidate"); +} From fc8c29ad84c2f0104f14c4166d386ec1846ae6f3 Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 09:03:37 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(cli):=20reach=C2=B7impact=20--format?= =?UTF-8?q?=20language-traversal=20=EC=88=9C=ED=9A=8C=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=B6=9C=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isthmus trace가 schema 사실의 usr와 핸들러 도달을 잇도록 language-traversal v1 문서를 낸다. reach는 dependencies, impact --format language-traversal은 dependents 방향이다. - root: 위치 인자 + --roots-from (JSON 문자열 배열 또는 bridge-facts 문서의 symbol.usr), 처음 나온 순서로 중복 제거. `--` 뒤는 모두 위치 인자다. - 계열 계약대로 사용법 오류(빈·제어 문자 id, 10,000개 초과, 잘못된 --max-depth/--max-reached/--revision/--generated-at, 허용 밖 플래그)는 표준 출력을 비운 채 64. 정점이 아닌 root는 symbol 없이 싣고 root-not-found를 밝힌 문서를 쓴 뒤 64. 수확 실패는 기존대로 2. - project는 schema 문서와 같은 realpath, revision은 --revision 또는 작업 트리가 깨끗할 때의 git HEAD(선택 잠금·fsmonitor 없이), graphRevision은 root를 project로 맞춘 그래프 JSON의 SHA-256 — --dir 표기가 달라도 같다. - 모든 도달 정점에 evidence를 싣고, 완전성을 주장할 수 없어 dispatch· unresolvedCalls는 싣지 않는다. - 도구 버전을 인자로 넘겨 cli ↔ cli::reach 모듈 순환을 피했다. - verify-cli-contract에 순회 종료 코드·빈 표준 출력·schema usr 전량 root 수용 검사를 더했다(pipefail에서 64를 파이프로 넘기지 않도록 먼저 담는다). Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/verify-cli-contract.sh | 30 ++ src/cli.rs | 54 ++- src/cli/reach.rs | 659 +++++++++++++++++++++++++++++++++ src/cli_args.rs | 38 +- src/source/schema.rs | 3 +- tests/traversal.rs | 407 ++++++++++++++++++++ 6 files changed, 1185 insertions(+), 6 deletions(-) create mode 100644 src/cli/reach.rs create mode 100644 tests/traversal.rs diff --git a/scripts/verify-cli-contract.sh b/scripts/verify-cli-contract.sh index a892514..0af4e75 100755 --- a/scripts/verify-cli-contract.sh +++ b/scripts/verify-cli-contract.sh @@ -137,6 +137,36 @@ if [ "$got" -ne 0 ] || ! grep -q '"target": "persistence"' "$FIX/facts.json" 2>/ fails=$((fails+1)) fi +# reach·impact --format language-traversal — isthmus 순회 문서. 계열 계약대로 +# 사용법 오류는 표준 출력을 비운 채 64, 정점이 아닌 root가 섞이면 문서를 +# 쓰고 64다. schema 사실의 usr는 전부 root로 받아져야 한다(0). +check 0 "reach" reach fixture_app::main +check 0 "impact traversal" impact --format language-traversal fixture_core::Used fixture_core::entry +check 64 "reach root-not-found" reach fixture_app::main no_such::root +check 64 "reach no roots" reach +check 64 "reach control char" reach "$(printf 'a\007b')" +check 64 "reach bad depth" reach fixture_app::main --max-depth 129 +check 64 "reach bad flag" reach fixture_app::main --max 3 +check 64 "impact trav no roots" impact --format language-traversal +trav_out="$("$BIN" reach --dir "$FIX/fixture" 2>/dev/null)" || true +if [ -n "$trav_out" ]; then + echo "FAIL reach usage error: stdout must be empty" >&2 + fails=$((fails+1)) +fi +# pipefail이라 64를 파이프로 넘기면 grep 결과와 무관하게 실패한다 — 먼저 담는다. +rnf_out="$("$BIN" reach --dir "$FIX/fixture" fixture_app::main no_such::root 2>/dev/null)" || true +echo "$rnf_out" | grep -q '"root-not-found"' || { + echo "FAIL reach root-not-found: document not written" >&2 + fails=$((fails+1)) +} +got=0 +"$BIN" impact --format language-traversal --dir "$FIX/fixture-schema" \ + --roots-from "$FIX/facts.json" >/dev/null 2>&1 || got=$? +if [ "$got" -ne 0 ]; then + echo "FAIL schema usr roots: expected 0 (every usr is a vertex), got $got" >&2 + fails=$((fails+1)) +fi + # --dir를 붙이지 않는 검사 — check()는 항상 fixture dir을 뒤에 붙이므로 # 나쁜 --dir 검증은 마지막 인자가 이기는(last-wins) 구조상 여기서 따로 한다. for c in "graph --dir /nonexistent-xyz" "schema --dir /nonexistent-xyz"; do diff --git a/src/cli.rs b/src/cli.rs index e20012a..89db85a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,8 +3,10 @@ //! 종료 코드: 0 정상, 1 strict 위반 발견, 2 사용법/분석 오류. //! 플래그 파서는 외부 크레이트 없이 직접 만든다 — 명령이 적고 계약이 단순해서다. +mod reach; + use crate::cli_args::{self, Args}; -use crate::{analysis, config, deps, export, mcp, rules, sarif, source}; +use crate::{analysis, config, deps, export, mcp, rules, sarif, source, traversal}; use std::io::Write; use std::path::{Path, PathBuf}; @@ -24,6 +26,9 @@ usage: rustograph deps [--strict] [--format text|json] rustograph query ID [--depth N] [--max N] rustograph impact ID [--depth N] [--max N] + rustograph reach ID... [--roots-from FILE|-] [--max-depth N] [--max-reached N] + [--revision REV] [--generated-at TIMESTAMP] + rustograph impact --format language-traversal ID... (same options as reach) rustograph paths FROM TO [--max N] [--budget N] rustograph search QUERY [--max N] rustograph mcp [--dir DIR] [--graph FILE] [--config FILE] [--deps] [--tests] @@ -36,7 +41,12 @@ shared flags: --deps --tests --retain-public --semantic --no-cache semantics (requires a build with `--features semantic`; results cached in .rustograph/semantic-cache.json unless --no-cache) -exit codes: 0 ok · 1 strict violation found · 2 usage/analysis error"; +reach / impact --format language-traversal emit an isthmus language-traversal +v1 document (dependencies / dependents) for many roots in one pass; ids that +are not graph vertices are listed without symbol and the command exits 64. + +exit codes: 0 ok · 1 strict violation found · 2 usage/analysis error + (traversal output: 64 usage error with empty stdout, or root-not-found)"; /// 명령을 실행하고 종료 코드를 돌려준다. /// os::exit 대신 반환값을 쓰는 것은 종료 코드 계약을 테스트하기 위함이다. @@ -51,7 +61,15 @@ pub fn run(args: &[String], stdout: &mut dyn Write, stderr: &mut dyn Write) -> i } fn run_inner(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> Result { - let a = cli_args::parse(args).map_err(|e| format!("{e}\n{USAGE}"))?; + let a = match cli_args::parse(args) { + Ok(a) => a, + // 순회 명령은 계열 계약대로 사용법 오류가 64다 — 전역 파서 실패도 마찬가지. + Err(e) if reach::is_traversal_argv(args) => { + let _ = writeln!(err, "error: {e}"); + return Ok(reach::EXIT_USAGE); + } + Err(e) => return Err(format!("{e}\n{USAGE}")), + }; match a.cmd.as_str() { "version" => { writeln!(out, "rustograph {VERSION}").ok(); @@ -63,7 +81,11 @@ fn run_inner(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> Resul "rules" => cmd_rules(&a, out), "deps" => cmd_deps(&a, out), "query" => cmd_query(&a, out, false), + "impact" if a.get("format") == Some("language-traversal") => { + cmd_traversal(&a, traversal::Direction::Dependents, out, err) + } "impact" => cmd_query(&a, out, true), + "reach" => cmd_traversal(&a, traversal::Direction::Dependencies, out, err), "paths" => cmd_paths(&a, out), "search" => cmd_search(&a, out), "schema" => cmd_schema(&a, out), @@ -76,6 +98,32 @@ fn run_inner(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> Resul } } +/// `reach`·`impact --format language-traversal` — 순회 문서를 낸다. +/// 사용법 오류는 표준 출력을 비운 채 64, 수확 실패는 2다. +fn cmd_traversal( + a: &Args, + direction: traversal::Direction, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + match reach::cmd(a, VERSION, direction, &mut std::io::stdin().lock(), out) { + Ok(code) => { + if code == reach::EXIT_USAGE { + let _ = writeln!( + err, + "error: some roots are not rustograph graph vertices (root-not-found); the document lists them without symbol" + ); + } + Ok(code) + } + Err(reach::Failure::Usage(m)) => { + let _ = writeln!(err, "error: {m}"); + Ok(reach::EXIT_USAGE) + } + Err(reach::Failure::Analysis(m)) => Err(m), + } +} + /// `schema` — isthmus persistence 도메인의 bridge-facts 문서를 낸다. /// 그래프 문서가 아니라 교환 문서라 export 직렬화만 재사용한다. fn cmd_schema(a: &Args, out: &mut dyn Write) -> Result { diff --git a/src/cli/reach.rs b/src/cli/reach.rs new file mode 100644 index 0000000..14edf2a --- /dev/null +++ b/src/cli/reach.rs @@ -0,0 +1,659 @@ +//! `reach`·`impact --format language-traversal` — isthmus `language-traversal` v1 문서를 낸다. +//! +//! isthmus `trace`가 relation-use 사실의 `symbol.usr`와 핸들러·호출부 도달을 문자열 그대로 잇는다. +//! 계열 규칙(pythograph·tsograph·cartograph)과 같은 종료 코드를 쓴다: 사용법 오류는 표준 출력을 +//! 비운 채 64, 그래프 정점이 아닌 root가 섞이면 나머지 root로 순회한 문서를 쓰고 64, 수확 실패는 2. + +use crate::cli_args::{self, Args}; +use crate::graph::{Document, Vertex}; +use crate::traversal::{self, Direction}; +use crate::{export, source}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +/// 사용법 오류 종료 코드(BSD sysexits `EX_USAGE`) — 계열 순회 명령과 같다. +pub(crate) const EXIT_USAGE: i32 = 64; + +/// `--roots-from` 입력 상한(바이트). +const MAX_ROOTS_INPUT: u64 = 16 * 1024 * 1024; +/// `--revision` 최대 길이(문자). +const MAX_REVISION_CHARS: usize = 256; + +/// 순회 명령이 받는 플래그 — 허용 목록이라 새 플래그가 조용히 무시되지 않는다. +const ALLOWED: &[&str] = &[ + "dir", + "graph", + "deps", + "tests", + "semantic", + "no-cache", + "focus", + "target", + "exclude-tests", + "format", + "depth", + "max-depth", + "max-reached", + "roots-from", + "revision", + "generated-at", +]; + +/// 순회 명령 실패 — 사용법(64, 표준 출력 비움)과 분석(2)을 가른다. +pub(crate) enum Failure { + Usage(String), + Analysis(String), +} + +/// 인자 목록이 순회 명령인가 — 전역 파서가 실패해도 64 계약을 지키기 위해 본다. +pub(crate) fn is_traversal_argv(args: &[String]) -> bool { + args.first().is_some_and(|c| c == "reach") + || (args.first().is_some_and(|c| c == "impact") + && args + .windows(2) + .any(|w| w[0] == "--format" && w[1] == "language-traversal")) +} + +/// 순회 명령을 실행한다. 반환값은 종료 코드다. +/// 도구 버전은 호출자가 넘긴다 — `crate::cli::VERSION`을 직접 읽으면 cli ↔ cli::reach +/// 모듈 순환이 된다(자기 분석 `cycles --strict`). +pub(crate) fn cmd( + a: &Args, + version: &'static str, + direction: Direction, + stdin: &mut dyn Read, + out: &mut dyn Write, +) -> Result { + let opts = Options::parse(a, direction, stdin)?; + let doc = cli_args::document_for(a, true).map_err(Failure::Analysis)?; + let project = std::fs::canonicalize(a.get("dir").unwrap_or(".")) + .map_err(|e| Failure::Analysis(format!("cannot resolve --dir: {e}")))?; + let document = build(&doc, &opts, &project, version); + write!(out, "{}", export::to_json(&document.body)).ok(); + Ok(if document.missing > 0 { EXIT_USAGE } else { 0 }) +} + +/// 검증한 순회 옵션. +struct Options { + direction: Direction, + roots: Vec, + max_depth: usize, + max_reached: usize, + revision: Option, + generated_at: String, +} + +impl Options { + /// 인자를 검증한다 — 수확 전에 끝나야 사용법 오류가 표준 출력을 비운 채 64가 된다. + fn parse(a: &Args, direction: Direction, stdin: &mut dyn Read) -> Result { + if let Some(bad) = a.unsupported_flag(ALLOWED) { + return Err(Failure::Usage(format!( + "{bad} is not supported by traversal output" + ))); + } + if let Some(k) = ALLOWED.iter().find(|k| a.repeated(k)) { + return Err(Failure::Usage(format!("--{k} may be given only once"))); + } + let format = a.get("format").unwrap_or("language-traversal"); + if format != "language-traversal" { + return Err(Failure::Usage(format!( + "--format {format} is not supported — traversal output is language-traversal" + ))); + } + let roots = collect_roots(a, stdin).map_err(Failure::Usage)?; + Ok(Options { + direction, + roots, + max_depth: depth_of(a).map_err(Failure::Usage)?, + max_reached: bounded(a.get("max-reached"), traversal::MAX_REACHED, "max-reached") + .map_err(Failure::Usage)?, + revision: revision_of(a).map_err(Failure::Usage)?, + generated_at: match a.get("generated-at") { + Some(t) if is_timestamp(t) => t.to_string(), + Some(_) => { + return Err(Failure::Usage( + "--generated-at must be an RFC 3339 timestamp such as 2026-09-30T00:00:00Z" + .to_string(), + )) + } + None => source::schema::rfc3339_utc_now(), + }, + }) + } +} + +/// 깊이 — `--max-depth N`(1~128) 또는 impact와 같은 `--depth N`(0은 상한 128). 기본은 128. +fn depth_of(a: &Args) -> Result { + match (a.get("max-depth"), a.get("depth")) { + (Some(_), Some(_)) => Err("give either --max-depth or --depth, not both".to_string()), + (Some(v), None) => bounded(Some(v), traversal::MAX_DEPTH, "max-depth"), + (None, Some("0")) => Ok(traversal::MAX_DEPTH), + (None, Some(v)) => bounded(Some(v), traversal::MAX_DEPTH, "depth"), + (None, None) => Ok(traversal::MAX_DEPTH), + } +} + +/// 1~limit 정수 인자(없으면 limit). +fn bounded(value: Option<&str>, limit: usize, name: &str) -> Result { + let Some(v) = value else { + return Ok(limit); + }; + match v.parse::() { + Ok(n) if (1..=limit).contains(&n) && !v.starts_with('+') => Ok(n), + _ => Err(format!("--{name} takes an integer from 1 to {limit}")), + } +} + +/// `--revision` — 없으면 작업 트리가 깨끗할 때의 git HEAD. +fn revision_of(a: &Args) -> Result, String> { + match a.get("revision") { + Some(r) if r.is_empty() || r.chars().count() > MAX_REVISION_CHARS || has_forbidden(r) => { + Err(format!( + "--revision must be 1-{MAX_REVISION_CHARS} characters without control characters" + )) + } + Some(r) => Ok(Some(r.to_string())), + None => Ok(None), + } +} + +/// id·revision에 올 수 없는 문자다(제어 문자, U+2028/2029) — isthmus의 안전 문자열 규칙과 같다. +fn has_forbidden(s: &str) -> bool { + s.chars().any(|c| { + let u = c as u32; + u <= 0x1f || (0x7f..=0x9f).contains(&u) || u == 0x2028 || u == 0x2029 + }) +} + +/// 위치 인자 다음에 `--roots-from`의 id를 잇고, 중복은 처음 나온 순서로 하나만 남긴다 +/// (그 순서가 `reached[].roots` 인덱스의 뜻이다). +fn collect_roots(a: &Args, stdin: &mut dyn Read) -> Result, String> { + let mut ids: Vec = a.positional.clone(); + if let Some(src) = a.get("roots-from") { + ids.extend(parse_roots_text(&read_roots(src, stdin)?)?); + } + let mut seen = BTreeSet::new(); + ids.retain(|id| seen.insert(id.clone())); + if ids.is_empty() { + return Err( + "give at least one symbol id as an argument or through --roots-from".to_string(), + ); + } + if ids.iter().any(|id| id.is_empty() || has_forbidden(id)) { + return Err("symbol ids must be non-empty and free of control characters".to_string()); + } + if ids.len() > traversal::MAX_ROOTS { + return Err(format!( + "at most {} roots are allowed per run; split the roots into several runs", + traversal::MAX_ROOTS + )); + } + Ok(ids) +} + +/// `--roots-from` 입력을 읽는다(`-`는 표준 입력). 원문은 오류에 싣지 않는다. +fn read_roots(src: &str, stdin: &mut dyn Read) -> Result { + let mut text = String::new(); + let res = if src == "-" { + stdin.take(MAX_ROOTS_INPUT + 1).read_to_string(&mut text) + } else { + std::fs::File::open(src).and_then(|f| f.take(MAX_ROOTS_INPUT + 1).read_to_string(&mut text)) + }; + res.map_err(|_| { + "--roots-from could not be read as UTF-8 text; pass a readable file or - for stdin" + .to_string() + })?; + if text.len() as u64 > MAX_ROOTS_INPUT { + return Err( + "--roots-from is larger than 16 MiB; split the roots into several runs".to_string(), + ); + } + Ok(text) +} + +/// JSON 문자열 배열 또는 bridge-facts 문서(사실의 `symbol.usr`)에서 id를 꺼낸다. +fn parse_roots_text(text: &str) -> Result, String> { + let bad = + || "--roots-from must be a JSON array of strings or a bridge-facts document".to_string(); + let value: serde_json::Value = serde_json::from_str(text).map_err(|_| bad())?; + if let Some(items) = value.as_array() { + return items + .iter() + .map(|v| v.as_str().map(str::to_string).ok_or_else(bad)) + .collect(); + } + if value["format"] == "bridge-facts" { + if let Some(facts) = value["facts"].as_array() { + return Ok(facts + .iter() + .filter_map(|f| f["symbol"]["usr"].as_str().map(str::to_string)) + .collect()); + } + } + Err(bad()) +} + +/// RFC 3339 타임스탬프인지 본다 — isthmus bridge-facts의 generatedAt 문법과 같다. +fn is_timestamp(s: &str) -> bool { + let b = s.as_bytes(); + let digits = |r: std::ops::Range| { + b.get(r.clone()) + .filter(|x| x.iter().all(u8::is_ascii_digit)) + .and_then(|x| { + std::str::from_utf8(x) + .ok() + .and_then(|t| t.parse::().ok()) + }) + }; + if b.len() < 20 + || b[4] != b'-' + || b[7] != b'-' + || b[10] != b'T' + || b[13] != b':' + || b[16] != b':' + { + return false; + } + let (Some(y), Some(mo), Some(d), Some(h), Some(mi), Some(se)) = ( + digits(0..4), + digits(5..7), + digits(8..10), + digits(11..13), + digits(14..16), + digits(17..19), + ) else { + return false; + }; + let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; + let days = [ + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + if !(1..=12).contains(&mo) + || d == 0 + || d > days[mo as usize - 1] + || h > 23 + || mi > 59 + || se > 59 + { + return false; + } + let mut rest = &s[19..]; + if let Some(frac) = rest.strip_prefix('.') { + let n = frac.bytes().take_while(u8::is_ascii_digit).count(); + if n == 0 { + return false; + } + rest = &frac[n..]; + } + let zone = rest.as_bytes(); + rest == "Z" + || (zone.len() == 6 + && (zone[0] == b'+' || zone[0] == b'-') + && zone[3] == b':' + && zone[1..3].iter().chain(&zone[4..6]).all(u8::is_ascii_digit)) +} + +/// 순회 문서와 정점이 아닌 root 수. +struct Built { + body: TraversalDocument, + missing: usize, +} + +/// 계약의 문서 — 키 순서는 계약 문서의 나열 순서다. +#[derive(Serialize)] +struct TraversalDocument { + format: &'static str, + version: u8, + tool: Tool, + #[serde(rename = "generatedAt")] + generated_at: String, + platform: &'static str, + project: String, + #[serde(skip_serializing_if = "Option::is_none")] + revision: Option, + #[serde(rename = "graphRevision")] + graph_revision: String, + direction: &'static str, + roots: Vec, + reached: Vec, + #[serde(rename = "rootsTruncated", skip_serializing_if = "std::ops::Not::not")] + roots_truncated: bool, + truncated: bool, + #[serde(rename = "truncationReasons", skip_serializing_if = "Vec::is_empty")] + truncation_reasons: Vec<&'static str>, + limitations: Vec, +} + +#[derive(Serialize)] +struct Tool { + name: &'static str, + version: &'static str, +} + +#[derive(Serialize)] +struct RootEntry { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + symbol: Option, +} + +#[derive(Serialize)] +struct ReachedEntry { + symbol: Symbol, + via: String, + depth: usize, + roots: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + relationships: Vec, + evidence: &'static str, +} + +#[derive(Serialize)] +struct Symbol { + usr: String, + #[serde(rename = "qualifiedName")] + qualified_name: String, + kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + location: Option, +} + +#[derive(Serialize)] +struct Location { + path: String, + #[serde(skip_serializing_if = "Option::is_none")] + line: Option, +} + +/// 정점 id → 대표 정점(같은 id의 cfg 변형 중 정렬상 첫째) — 이름·종류·위치 출처. +fn vertex_map(doc: &Document) -> BTreeMap<&str, &Vertex> { + let mut map = BTreeMap::new(); + for v in &doc.vertices { + map.entry(v.id.as_str()).or_insert(v); + } + map +} + +/// 문서를 조립한다. 정점인 root로만 순회하고 root 인덱스를 요청 순서로 옮긴다. +fn build(doc: &Document, opts: &Options, project: &Path, version: &'static str) -> Built { + let vertices = vertex_map(doc); + let resolved: Vec = opts + .roots + .iter() + .filter(|r| vertices.contains_key(r.as_str())) + .cloned() + .collect(); + let missing = opts.roots.len() - resolved.len(); + let position: BTreeMap<&str, usize> = opts + .roots + .iter() + .enumerate() + .map(|(i, r)| (r.as_str(), i)) + .collect(); + let mapping: Vec = resolved.iter().map(|r| position[r.as_str()]).collect(); + let outcome = if resolved.is_empty() { + None + } else { + Some(traversal::traverse( + doc, + &traversal::Request { + roots: &resolved, + direction: opts.direction, + max_depth: opts.max_depth, + max_reached: opts.max_reached, + evidence_memory_bytes: traversal::EVIDENCE_MEMORY_BYTES, + }, + )) + }; + let mut locator = Locator::new(project); + let mut reasons: Vec<&'static str> = outcome + .as_ref() + .map(|o| o.truncation_reasons.clone()) + .unwrap_or_default(); + if missing > 0 { + reasons.push("root-not-found"); + } + reasons.sort_unstable(); + reasons.dedup(); + let mut limitations = doc.limitations.clone(); + if missing > 0 { + limitations.push(format!( + "root-not-found: {missing} requested root id(s) are not rustograph graph vertices; they are listed in roots without symbol" + )); + } + if outcome.as_ref().is_some_and(|o| o.evidence_approximated) { + limitations.push( + "evidence-approximated: per-root evidence comparison exceeded its memory budget; some symbols are reported candidate conservatively" + .to_string(), + ); + } + let roots = opts + .roots + .iter() + .map(|id| RootEntry { + id: id.clone(), + symbol: vertices + .get(id.as_str()) + .map(|v| symbol_of(v, &mut locator)), + }) + .collect(); + let reached = outcome + .as_ref() + .map(|o| { + o.reached + .iter() + .map(|r| ReachedEntry { + symbol: symbol_of(vertices[r.id.as_str()], &mut locator), + via: r.via.clone(), + depth: r.depth, + roots: r.roots.iter().map(|&i| mapping[i]).collect(), + relationships: r.relationships.clone(), + evidence: r.evidence.as_str(), + }) + .collect() + }) + .unwrap_or_default(); + Built { + body: TraversalDocument { + format: "language-traversal", + version: 1, + tool: Tool { + name: "rustograph", + version, + }, + generated_at: opts.generated_at.clone(), + platform: "rust", + project: project.display().to_string(), + revision: opts.revision.clone().or_else(|| git_revision(project)), + graph_revision: graph_revision(doc, project), + direction: opts.direction.as_str(), + roots, + reached, + roots_truncated: outcome.as_ref().is_some_and(|o| o.roots_truncated), + truncated: !reasons.is_empty(), + truncation_reasons: reasons, + limitations, + }, + missing, + } +} + +/// 정점의 계약 심볼 — usr와 qualifiedName은 정점 id 그대로다. +fn symbol_of(v: &Vertex, locator: &mut Locator) -> Symbol { + Symbol { + usr: v.id.clone(), + qualified_name: v.id.clone(), + kind: serde_json::to_value(v.kind) + .ok() + .and_then(|k| k.as_str().map(str::to_string)) + .unwrap_or_default(), + location: v.position.as_deref().and_then(|p| locator.locate(p)), + } +} + +/// 정점 위치(`file:line`)를 project 상대 위치로 바꾼다 — 파일 정규화는 캐시한다. +struct Locator { + project: PathBuf, + cache: BTreeMap>, +} + +impl Locator { + fn new(project: &Path) -> Locator { + Locator { + project: project.to_path_buf(), + cache: BTreeMap::new(), + } + } + + /// project 밖(외부 크레이트·`#[path]`로 벗어난 파일)이면 위치를 싣지 않는다. + fn locate(&mut self, position: &str) -> Option { + let (file, line) = position.rsplit_once(':')?; + let project = &self.project; + let rel = self + .cache + .entry(file.to_string()) + .or_insert_with(|| { + let canon = std::fs::canonicalize(file).ok()?; + let rel = canon.strip_prefix(project).ok()?; + let text = rel.to_str()?.replace('\\', "/"); + (!text.is_empty()).then_some(text) + }) + .clone()?; + Some(Location { + path: rel, + line: line.parse::().ok().filter(|n| *n > 0), + }) + } +} + +/// 그래프 산출물의 신원 — root를 project로 맞춘 그래프 JSON의 SHA-256. +/// `--dir .`과 절대 경로처럼 같은 그래프를 다른 문자열로 연 실행이 같은 값을 내야 +/// isthmus가 같은 플랫폼 분석끼리 stale로 오판하지 않는다. +fn graph_revision(doc: &Document, project: &Path) -> String { + let mut canonical = doc.clone(); + canonical.root = project.display().to_string(); + traversal::sha256::hex_digest(export::to_json(&canonical).as_bytes()) +} + +/// 작업 트리가 깨끗할 때만 git HEAD를 돌려준다. 커밋하지 않은 변경·추적하지 않는 파일이 +/// 있으면 HEAD는 분석한 소스가 아니므로 싣지 않는다 — isthmus trace가 revision 없는 분석을 +/// `analysis-revision-unknown`으로 드러낸다. 선택 잠금·fsmonitor 없이 실행해 저장소를 고치지 않는다. +fn git_revision(project: &Path) -> Option { + let status = git( + project, + &[ + "status", + "--porcelain=v1", + "--untracked-files=normal", + "--ignore-submodules=none", + ], + )?; + if !status.trim().is_empty() { + return None; + } + let head = git(project, &["rev-parse", "--verify", "HEAD"])?; + let head = head.trim(); + let hex = head + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); + (hex && (head.len() == 40 || head.len() == 64)).then(|| head.to_string()) +} + +/// git 명령 하나 — 실패(없음·저장소 아님)는 None이다. revision 없음이 곧 신호다. +fn git(project: &Path, args: &[&str]) -> Option { + let out = std::process::Command::new("git") + .args([ + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + ]) + .args(args) + .current_dir(project) + .env("GIT_OPTIONAL_LOCKS", "0") + .env("GIT_TERMINAL_PROMPT", "0") + .env("LC_ALL", "C") + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + String::from_utf8(out.stdout).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn timestamps_follow_bridge_grammar() { + for ok in [ + "2026-09-30T00:00:00Z", + "2024-02-29T23:59:59.123Z", + "2026-09-30T00:00:00+09:00", + ] { + assert!(is_timestamp(ok), "{ok}"); + } + for bad in [ + "2026-09-30", + "2025-02-29T00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-09-30T24:00:00Z", + "2026-09-30T00:00:00", + "2026-09-30T00:00:00.Z", + "2026-09-30T00:00:00+0900", + "2026-09-30T00:00:00Z", + ] { + assert!(!is_timestamp(bad), "{bad}"); + } + } + + #[test] + fn roots_text_reads_arrays_and_bridge_facts() { + assert_eq!( + parse_roots_text(r#"["a","b"]"#).unwrap(), + vec!["a".to_string(), "b".to_string()] + ); + let facts = r#"{"format":"bridge-facts","facts":[{"symbol":{"usr":"c::f"}},{"kind":"x"}]}"#; + assert_eq!(parse_roots_text(facts).unwrap(), vec!["c::f".to_string()]); + assert!(parse_roots_text(r#"[1]"#).is_err()); + assert!(parse_roots_text(r#"{"format":"other"}"#).is_err()); + assert!(parse_roots_text("not json").is_err()); + } + + #[test] + fn forbidden_characters_cover_contract_set() { + assert!(has_forbidden("a\u{0}")); + assert!(has_forbidden("a\u{85}")); + assert!(has_forbidden("a\u{2028}")); + assert!(!has_forbidden("c::::m")); + } + + #[test] + fn traversal_argv_detection() { + let v = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::>(); + assert!(is_traversal_argv(&v(&["reach", "x"]))); + assert!(is_traversal_argv(&v(&[ + "impact", + "--format", + "language-traversal" + ]))); + assert!(!is_traversal_argv(&v(&["impact", "x"]))); + assert!(!is_traversal_argv(&v(&[]))); + } +} diff --git a/src/cli_args.rs b/src/cli_args.rs index 7b409bd..b1ee3d4 100644 --- a/src/cli_args.rs +++ b/src/cli_args.rs @@ -34,6 +34,18 @@ impl Args { pub(crate) fn has(&self, key: &str) -> bool { self.flags.contains(key) } + /// `allowed` 밖의 플래그를 하나 돌려준다 — 위치 인자는 보지 않는다. + pub(crate) fn unsupported_flag(&self, allowed: &[&str]) -> Option { + self.values + .keys() + .chain(self.flags.iter()) + .find(|k| !allowed.contains(&k.as_str())) + .map(|k| format!("--{k}")) + } + /// 값 플래그가 두 번 이상 주어졌는가 — 순회 명령은 반복 값을 조용히 덮지 않는다. + pub(crate) fn repeated(&self, key: &str) -> bool { + self.values.get(key).is_some_and(|v| v.len() > 1) + } /// `allowed` 밖의 플래그나 위치 인자가 있으면 첫 번째를 사람이 읽는 /// 형태로 돌려준다. 거부 목록이 아니라 허용 목록인 이유: 나중에 /// 추가되는 플래그가 이 명령에서 조용히 무시되는 일을 막는다. @@ -50,8 +62,25 @@ impl Args { } const VALUE_FLAGS: &[&str] = &[ - "dir", "level", "format", "out", "graph", "root", "explain", "config", "depth", "max", "focus", - "target", "budget", "baseline", + "dir", + "level", + "format", + "out", + "graph", + "root", + "explain", + "config", + "depth", + "max", + "focus", + "target", + "budget", + "baseline", + "roots-from", + "max-depth", + "max-reached", + "revision", + "generated-at", ]; const BOOL_FLAGS: &[&str] = &[ "deps", @@ -77,6 +106,11 @@ pub(crate) fn parse(args: &[String]) -> Result { let mut i = 1; while i < args.len() { let arg = &args[i]; + if arg == "--" { + // `--` 뒤는 모두 위치 인자다 — `-`로 시작하는 정점 ID를 플래그로 읽지 않게 한다. + a.positional.extend(args[i + 1..].iter().cloned()); + break; + } if let Some(name) = arg.strip_prefix("--") { if BOOL_FLAGS.contains(&name) { a.flags.insert(name.to_string()); diff --git a/src/source/schema.rs b/src/source/schema.rs index 649d9a5..820eaa3 100644 --- a/src/source/schema.rs +++ b/src/source/schema.rs @@ -2011,7 +2011,8 @@ fn is_ident_part(c: u8) -> bool { /// bridge-facts 계약이 요구하는 RFC 3339 UTC 타임스탬프를 만든다. /// 달력 변환은 외부 의존 없이 표준 civil 알고리즘으로 처리한다. -fn rfc3339_utc_now() -> String { +/// 지금 시각의 계약 타임스탬프 — 순회 문서의 generatedAt도 같은 문법을 쓴다. +pub fn rfc3339_utc_now() -> String { rfc3339_utc(std::time::SystemTime::now()) } diff --git a/tests/traversal.rs b/tests/traversal.rs new file mode 100644 index 0000000..a74a03b --- /dev/null +++ b/tests/traversal.rs @@ -0,0 +1,407 @@ +//! `reach`·`impact --format language-traversal` CLI 계약 — 실제 fixture 워크스페이스로 +//! 문서 필드·종료 코드(0/64/2)·root 입력 규칙·revision을 검증한다. + +use rustograph::cli; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +fn fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixture") +} + +fn schema_fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixture-schema") +} + +/// (종료 코드, 표준 출력, 표준 오류). +fn run(args: &[&str]) -> (i32, String, String) { + let argv: Vec = args.iter().map(|s| s.to_string()).collect(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = cli::run(&argv, &mut out, &mut err); + ( + code, + String::from_utf8(out).unwrap(), + String::from_utf8(err).unwrap(), + ) +} + +fn json(text: &str) -> serde_json::Value { + serde_json::from_str(text).expect("traversal output must be JSON") +} + +fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("rustograph-trav-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn reach_writes_dependencies_document() { + let dir = fixture().display().to_string(); + let (code, out, err) = run(&[ + "reach", + "--dir", + &dir, + "--generated-at", + "2026-09-30T00:00:00Z", + "fixture_app::main", + ]); + assert_eq!(code, 0, "stderr: {err}"); + let d = json(&out); + assert_eq!(d["format"], "language-traversal"); + assert_eq!(d["version"], 1); + assert_eq!(d["tool"]["name"], "rustograph"); + assert_eq!(d["platform"], "rust"); + assert_eq!(d["direction"], "dependencies"); + assert_eq!(d["generatedAt"], "2026-09-30T00:00:00Z"); + // project는 schema 문서의 project와 같은 realpath다 — isthmus가 문자열로 비교한다. + assert_eq!( + d["project"], + fixture().canonicalize().unwrap().display().to_string() + ); + let rev = d["graphRevision"].as_str().unwrap(); + assert_eq!(rev.len(), 64); + assert!(rev + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())); + assert_eq!(d["roots"][0]["id"], "fixture_app::main"); + assert_eq!(d["roots"][0]["symbol"]["usr"], "fixture_app::main"); + let reached = d["reached"].as_array().unwrap(); + let entry = reached + .iter() + .find(|r| r["symbol"]["usr"] == "fixture_core::entry") + .expect("main reaches fixture_core::entry"); + assert_eq!(entry["via"], "fixture_app::main"); + assert_eq!(entry["depth"], 1); + assert_eq!(entry["roots"], serde_json::json!([0])); + assert_eq!(entry["evidence"], "direct"); + assert!(entry["relationships"] + .as_array() + .unwrap() + .contains(&"call".into())); + // 위치는 project 상대 경로다. + assert_eq!( + entry["symbol"]["location"]["path"], + "fixture_core/src/lib.rs" + ); + // 모든 정점이 등급을 싣는다 — dispatch·unresolvedCalls는 완전성을 주장하지 않아 없다. + assert!(reached.iter().all(|r| r["evidence"].is_string())); + assert!(d.get("dispatch").is_none()); + assert!(reached.iter().all(|r| r.get("unresolvedCalls").is_none())); + assert_eq!(d["truncated"], false); + assert!(d.get("truncationReasons").is_none()); +} + +#[test] +fn fixed_generated_at_is_byte_identical_and_graph_revision_ignores_dir_spelling() { + let dir = fixture().display().to_string(); + let dotted = format!("{dir}/."); + let args = |d: &str| { + run(&[ + "impact", + "--format", + "language-traversal", + "--dir", + d, + "--generated-at", + "2026-09-30T00:00:00Z", + "fixture_core::Used", + "fixture_core::entry", + ]) + }; + let (c1, a, _) = args(&dir); + let (c2, b, _) = args(&dir); + let (c3, c, _) = args(&dotted); + assert_eq!((c1, c2, c3), (0, 0, 0)); + assert_eq!(a, b); + assert_eq!(json(&a)["graphRevision"], json(&c)["graphRevision"]); + assert_eq!(json(&a)["direction"], "dependents"); +} + +#[test] +fn root_not_found_writes_document_and_exits_64() { + let dir = fixture().display().to_string(); + let (code, out, err) = run(&[ + "impact", + "--format", + "language-traversal", + "--dir", + &dir, + "no_such::root", + "fixture_core::Used", + ]); + assert_eq!(code, 64); + assert!(err.contains("root-not-found")); + let d = json(&out); + assert_eq!(d["roots"][0]["id"], "no_such::root"); + assert!(d["roots"][0].get("symbol").is_none()); + assert_eq!(d["roots"][1]["symbol"]["usr"], "fixture_core::Used"); + assert_eq!(d["truncated"], true); + assert!(d["truncationReasons"] + .as_array() + .unwrap() + .contains(&"root-not-found".into())); + assert!(d["limitations"] + .as_array() + .unwrap() + .iter() + .any(|l| l.as_str().unwrap().starts_with("root-not-found:"))); + // 도달 정점의 root 인덱스는 요청 순서 기준이다 — 찾은 root는 1번이다. + let reached = d["reached"].as_array().unwrap(); + assert!(!reached.is_empty()); + assert!(reached.iter().all(|r| r["roots"] == serde_json::json!([1]))); +} + +#[test] +fn usage_errors_exit_64_with_empty_stdout() { + let dir = fixture().display().to_string(); + let missing_file = temp_dir("missing").join("absent.json"); + let bad_json = temp_dir("badjson").join("roots.json"); + std::fs::write(&bad_json, "{\"format\":\"other\"}").unwrap(); + let cases: Vec> = vec![ + vec!["reach".into()], + vec!["reach".into(), "a\u{7}b".into()], + vec!["reach".into(), "a\u{2028}".into()], + vec!["reach".into(), "".into()], + vec!["reach".into(), "x".into(), "--max-depth".into(), "0".into()], + vec![ + "reach".into(), + "x".into(), + "--max-depth".into(), + "129".into(), + ], + vec![ + "reach".into(), + "x".into(), + "--max-reached".into(), + "0".into(), + ], + vec![ + "reach".into(), + "x".into(), + "--depth".into(), + "3".into(), + "--max-depth".into(), + "3".into(), + ], + vec![ + "reach".into(), + "x".into(), + "--revision".into(), + "r\u{1}".into(), + ], + vec![ + "reach".into(), + "x".into(), + "--revision".into(), + "r".repeat(257), + ], + vec![ + "reach".into(), + "x".into(), + "--generated-at".into(), + "yesterday".into(), + ], + vec!["reach".into(), "x".into(), "--format".into(), "json".into()], + vec!["reach".into(), "x".into(), "--max".into(), "3".into()], + vec![ + "reach".into(), + "x".into(), + "--max-depth".into(), + "2".into(), + "--max-depth".into(), + "3".into(), + ], + vec!["reach".into(), "x".into(), "--bogus".into()], + vec![ + "reach".into(), + "--roots-from".into(), + missing_file.display().to_string(), + ], + vec![ + "reach".into(), + "--roots-from".into(), + bad_json.display().to_string(), + ], + vec![ + "impact".into(), + "--format".into(), + "language-traversal".into(), + "--bogus".into(), + ], + vec![ + "impact".into(), + "--format".into(), + "language-traversal".into(), + ], + ]; + for mut argv in cases { + argv.extend(["--dir".to_string(), dir.clone()]); + let refs: Vec<&str> = argv.iter().map(String::as_str).collect(); + let (code, out, err) = run(&refs); + assert_eq!(code, 64, "{argv:?} stderr: {err}"); + assert!(out.is_empty(), "{argv:?} must not write a document"); + assert!(err.starts_with("error:"), "{argv:?}"); + } + // 10,000개를 넘는 root도 사용법 오류다. + let many = temp_dir("many").join("roots.json"); + let ids: Vec = (0..10_001).map(|i| format!("x::f{i}")).collect(); + std::fs::write(&many, serde_json::to_string(&ids).unwrap()).unwrap(); + let (code, out, _) = run(&[ + "reach", + "--dir", + &dir, + "--roots-from", + &many.display().to_string(), + ]); + assert_eq!((code, out.is_empty()), (64, true)); + // 수확 실패는 사용법이 아니라 분석 오류(2)다. + let (code, out, _) = run(&["reach", "--dir", "/nonexistent-rustograph-dir", "x"]); + assert_eq!((code, out.is_empty()), (2, true)); +} + +#[test] +fn every_schema_usr_is_accepted_as_a_root() { + // schema 사실의 usr는 순회의 정점 id와 같은 문자열이어야 한다 — bridge-facts 문서를 + // --roots-from으로 그대로 넘겨 root-not-found가 하나도 없어야 한다. + let schema_dir = schema_fixture().display().to_string(); + let (code, facts, err) = run(&["schema", "--dir", &schema_dir]); + assert_eq!(code, 0, "{err}"); + let facts_path = temp_dir("facts").join("schema.json"); + std::fs::write(&facts_path, &facts).unwrap(); + let (code, out, err) = run(&[ + "impact", + "--format", + "language-traversal", + "--dir", + &schema_dir, + "--roots-from", + &facts_path.display().to_string(), + ]); + assert_eq!(code, 0, "stderr: {err}"); + let d = json(&out); + let usrs: std::collections::BTreeSet = json(&facts)["facts"] + .as_array() + .unwrap() + .iter() + .filter_map(|f| f["symbol"]["usr"].as_str().map(str::to_string)) + .collect(); + let roots = d["roots"].as_array().unwrap(); + assert_eq!(roots.len(), usrs.len(), "roots are deduplicated usrs"); + assert!(roots.iter().all(|r| r["symbol"]["usr"] == r["id"])); + // schema 문서와 순회 문서의 project가 같아야 trace가 둘을 한 project로 읽는다. + assert_eq!(d["project"], json(&facts)["project"]); +} + +#[test] +fn separator_and_stdin_roots() { + let bin = env!("CARGO_BIN_EXE_rustograph"); + let dir = fixture().display().to_string(); + let mut child = Command::new(bin) + .args([ + "reach", + "--dir", + &dir, + "--roots-from", + "-", + "--", + "fixture_app::main", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(br#"["fixture_core::entry", "fixture_app::main"]"#) + .unwrap(); + let out = child.wait_with_output().unwrap(); + assert_eq!(out.status.code(), Some(0)); + let d = json(&String::from_utf8(out.stdout).unwrap()); + let ids: Vec<&str> = d["roots"] + .as_array() + .unwrap() + .iter() + .map(|r| r["id"].as_str().unwrap()) + .collect(); + // 위치 인자가 먼저, 중복은 처음 나온 자리에만. + assert_eq!(ids, vec!["fixture_app::main", "fixture_core::entry"]); +} + +/// git 저장소 안의 최소 워크스페이스를 만든다. +fn git_workspace(tag: &str) -> PathBuf { + let dir = temp_dir(tag); + std::fs::create_dir_all(dir.join("app/src")).unwrap(); + std::fs::write( + dir.join("Cargo.toml"), + "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n", + ) + .unwrap(); + std::fs::write( + dir.join("app/Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + std::fs::write( + dir.join("app/src/lib.rs"), + "pub fn a() { b() }\npub fn b() {}\n", + ) + .unwrap(); + std::fs::write(dir.join(".gitignore"), "target/\nCargo.lock\n").unwrap(); + dir +} + +fn git(dir: &Path, args: &[&str]) -> bool { + Command::new("git") + .args([ + "-c", + "user.name=t", + "-c", + "user.email=t@example.invalid", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .current_dir(dir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +#[test] +fn revision_is_head_only_on_a_clean_tree() { + let dir = git_workspace("git"); + if !git(&dir, &["init", "-q"]) { + eprintln!("git unavailable — skipping revision test"); + return; + } + assert!(git(&dir, &["add", "-A"]) && git(&dir, &["commit", "-q", "-m", "init"])); + let head = String::from_utf8( + Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&dir) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + let d = dir.display().to_string(); + let (code, out, err) = run(&["reach", "--dir", &d, "app::a"]); + assert_eq!(code, 0, "{err}"); + assert_eq!(json(&out)["revision"], head.trim()); + // 추적하지 않는 파일이 생기면 HEAD는 분석한 소스가 아니다. + std::fs::write(dir.join("app/src/extra.rs"), "pub fn c() {}\n").unwrap(); + let (_, out, _) = run(&["reach", "--dir", &d, "app::a"]); + assert!(json(&out).get("revision").is_none()); + // 명시 --revision은 그대로 싣는다. + let (_, out, _) = run(&["reach", "--dir", &d, "--revision", "rev-7", "app::a"]); + assert_eq!(json(&out)["revision"], "rev-7"); + let _ = std::fs::remove_dir_all(&dir); +} From 4843166d203b4bf836465e596a3b9749243fa08e Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 09:04:31 +0900 Subject: [PATCH 5/8] =?UTF-8?q?docs:=20schema=20usr=20=EA=B7=80=EC=86=8D?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=EA=B3=BC=20=EC=88=9C=ED=9A=8C=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C(reach=C2=B7impact)=20=EA=B3=84=EC=95=BD=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README(영·한)에 schema 사실의 symbol.usr 귀속 규칙과 missing-relation-usrs, reach·impact --format language-traversal의 root 입력·근거 등급·한계·종료 코드 (64)를 적고, rust route-decl을 isthmus가 아직 받지 않아 route 선택 trace가 불가능하다는 사실을 밝힌다. HANDOFF에 진행 상황을 남긴다. Co-Authored-By: Claude Opus 5.5 (1M context) --- HANDOFF.md | 18 ++++++++++++++ README.ko.md | 41 ++++++++++++++++++++++++++++++++ README.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/HANDOFF.md b/HANDOFF.md index ac5b305..e3faaaa 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,6 +2,24 @@ 세션을 이어받는 에이전트가 먼저 읽는 문서입니다. +## 진행 중 — feature/schema-usr (2026-09-30, API 영향 프로그램 Phase 7c) + +isthmus `trace`가 Rust 백엔드의 핸들러 도달을 관계 사용과 잇도록 두 가지를 더했다. + +- `schema` 사실에 `symbol: {qualifiedName, usr}` — usr는 `impact`와 같은 정점 ID. + 같은 syn 수확을 돌려 정점별 소스 범위(`harvest::OwnerSpan`)를 받고, 사실 바이트 + 오프셋을 감싸는 가장 안쪽 정점을 고른다(정점 집합으로 검증). 구조체 어트리뷰트· + 필드 컬럼은 구조체, 메서드 밖 연관 상수는 impl self 타입. 최상위 `table!`·orphan + 파일은 usr 없이 `missing-relation-usrs:`로 센다. +- `reach`·`impact --format language-traversal` — language-traversal v1, 다중 root + 단일 패스(`src/traversal.rs`, pythograph 알고리즘 포팅), tentative 간선 = candidate + root별 하한, `dispatch`·`unresolvedCalls` 미신고, 사용법 오류·root-not-found 64. +- 자기 분석이 이름 충돌 거짓 순환 둘(지역명 `owner_spans`, 매개변수 `facts`)과 + cli ↔ cli::reach 모듈 순환을 잡아 고쳤다 — 새 식별자는 같은 이름의 fn과 겹치지 않게. +- 남은 것: isthmus가 rust `route-decl`을 받지 않아 route 선택 trace는 불가(relation· + 심볼 선택은 동작). `type` 레벨 cycles는 main부터 10개(투영 산물)이고 이번에 3개가 + 같은 종류로 늘었다(게이트 아님). + ## 직전 세션 요약 (2026-09-25) **목표.** HANDOFF의 보류 항목을 정리하고 main에 쌓인 기능을 배포한다. diff --git a/README.ko.md b/README.ko.md index 071106d..ad471f1 100644 --- a/README.ko.md +++ b/README.ko.md @@ -71,6 +71,8 @@ rustograph dead --exclude-tests # #[cfg(test)] 서브트리 제외 rustograph graph --target x86_64-pc-windows-msvc # cfg(트리플) 평가 rustograph mcp # MCP stdio 서버 — 에이전트가 되묻는 통로 rustograph schema --dir . --out schema-facts.json # isthmus persistence 사실 +rustograph reach mycrate::api::list_users # isthmus language-traversal(정방향) +rustograph impact --format language-traversal --roots-from schema-facts.json # 역방향 ``` `#[cfg]` 조건은 메타데이터로 그래프에 실립니다 — 정점의 `cfg`는 자기 @@ -90,6 +92,9 @@ rustograph schema --dir . --out schema-facts.json # isthmus persistence 사실 조용히 새 수확으로 돌아갑니다. `--no-cache`로 끌 수 있습니다. 종료 코드: `0` 정상 · `1` strict 위반/발견 · `2` 사용법/분석 오류. +순회 명령(`reach`, `impact --format language-traversal`)은 isthmus 계열 +계약을 따릅니다 — 사용법 오류는 표준 출력을 비운 채 `64`, 그래프 정점이 +아닌 root가 섞이면 문서를 쓴 뒤 `64`입니다. ## persistence 사실 — `schema` @@ -112,6 +117,16 @@ rustograph schema --dir . --out schema-facts.json # isthmus persistence 사실 `users::columns::name` — 워크스페이스에 선언된 `table!` 이름과 맞물릴 때만 정적으로 인정하고, 안 맞는 같은 모양 경로는 `dynamic` +사실마다 `symbol: {qualifiedName, usr}`을 싣고 `usr`는 감싸는 그래프 +정점 ID입니다 — `impact`/`reach`와 같은 ID라 isthmus `trace`가 핸들러 +도달과 관계 사용을 잇습니다. fn·impl 메서드(`Type::method`, 트레이트 impl은 +`Type::::method`)·트레이트 기본 메서드·const/static 초기화식은 그 +정점, 구조체 어트리뷰트·필드 컬럼은 구조체, 메서드 밖 연관 상수는 impl의 +self 타입입니다. 감싸는 정점이 없는 사실(최상위 `table!` 호출, 모듈 트리 +밖 파일)은 `symbol` 없이 내고 `missing-relation-usrs:`로 셉니다. ID는 +`impact`와 같은 syn 수확에서 받아 오고(다시 유도하지 않음) 그래프 정점 +집합으로 확인합니다. + 비한정 이름(`query!`, `sql_query`, `table!`)은 그 파일이 sqlx/diesel에서 import할 때만 인정합니다. 파싱 실패 파일·문법이 다른 `table!`·테이블 바인딩 없는 @@ -119,6 +134,32 @@ import할 때만 인정합니다. 파싱 실패 파일·문법이 다른 `table! 스캔은 추측하지 않습니다 — 정적으로 해석할 수 없는 것은 지어내지 않고 센 것입니다. +## 순회 문서 — `reach` / `impact --format language-traversal` + +isthmus [`language-traversal` v1](https://github.com/ictechgy/isthmus/blob/main/docs/LANGUAGE-TRAVERSAL.md) +문서를 냅니다. `reach`는 root가 기대는 쪽(`dependencies`), `impact`는 root에 +기대는 쪽(`dependents`)입니다. + +- root는 위치 인자 다음 `--roots-from`(JSON 문자열 배열 또는 bridge-facts + 문서의 `symbol.usr`, `-`는 표준 입력) 순서로, 처음 나온 자리에만 남깁니다. + 빈 id·제어 문자 id·10,000개 초과는 사용법 오류(64, 표준 출력 비움)입니다. +- 모든 root를 한 번에 훑어 정점마다 닿는 root 목록(64개까지 + `rootsTruncated`), + 가장 가까운 깊이, 최단 경로 목격(`via`)을 싣습니다. root별 BFS 오라클과 + 무작위 그래프로 대조합니다. +- 근거 등급: `tentative` 간선(이름 팬아웃·`dyn`/제네릭 트레이트 impl 후보)은 + `candidate`, 나머지는 `direct`이고 정점마다 root별 하한을 싣습니다. + 미해석 호출 수가 완전하다고 말할 수 없어 `dispatch`·`unresolvedCalls`는 + 싣지 않습니다. +- `--max-depth` 1~128(기본 128, `--depth 0`은 128), `--max-reached` 1~100,000. +- `project`는 `schema`와 같은 realpath, `revision`은 `--revision` 또는 작업 + 트리가 깨끗할 때의 git HEAD, `graphRevision`은 그래프 JSON의 SHA-256입니다. +- 그래프 정점이 아닌 root는 `symbol` 없이 싣고 `root-not-found:` limitation을 + 더한 문서를 쓴 뒤 64로 끝납니다. + +isthmus는 `platform: "rust"` 문서의 `route-decl`을 받지 않아 Rust 핸들러에서 +시작하는 route 선택 `trace`는 아직 불가능합니다. relation·심볼 선택(역방향 +순회)은 지금 동작합니다. + ## 개발 ```bash diff --git a/README.md b/README.md index 71f8bf2..5c97fd9 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,17 @@ rustograph mcp --graph .rustograph/graph.json # Emit bridge-facts for isthmus' persistence join (SQL relation uses) rustograph schema --dir . --out schema-facts.json + +# isthmus language-traversal v1 for `isthmus trace` (many roots, one pass) +rustograph reach mycrate::api::list_users mycrate::api::create_user +rustograph impact --format language-traversal --roots-from schema-facts.json ``` Exit codes: `0` ok · `1` strict violation/finding · `2` usage or analysis -error. +error. The traversal commands (`reach`, `impact --format +language-traversal`) follow the isthmus family contract instead: `64` for a +usage error (stdout stays empty) or when some roots are not graph vertices +(the document is still written). ## Graph model @@ -199,6 +206,22 @@ Extracted references: `users::columns::name` — matched against the workspace's declared `table!` names; same-shaped paths that match nothing stay `dynamic` +Each fact carries `symbol: {qualifiedName, usr}` where `usr` is the id of +the enclosing graph vertex — the same id `impact`/`reach` use, so isthmus +`trace` can join handler reach to relation uses: + +- `fn`, impl method (`crate::m::Type::method`, trait impls + `crate::m::Type::::method`), trait default method, and + `const`/`static` initializer → that vertex +- struct-level facts (`table_name` attributes, field columns) → the struct +- impl-level facts outside any method (associated consts) → the impl's + self type +- no enclosing vertex (top-level `table!` invocations, files outside the + module tree) → no `symbol`; counted as `missing-relation-usrs:` + +The ids come from the same syn harvest `impact` uses (not re-derived), and +every attached `usr` is checked against the graph's vertex set. + Unqualified names (`query!`, `sql_query`, `table!`) count only when the file imports them from `sqlx`/`diesel`. Unparseable files, off-grammar `table!` bodies, @@ -206,6 +229,48 @@ and column attributes without a table binding surface as `limitations`, not silence. The name-based scan never guesses: what cannot be resolved statically is counted, not invented. +## Traversal documents — `reach` / `impact --format language-traversal` + +```bash +rustograph reach ID... [--roots-from FILE|-] [--max-depth N] [--max-reached N] + [--revision REV] [--generated-at TIMESTAMP] [-- ID...] +rustograph impact --format language-traversal ID... (same options) +``` + +Both write an isthmus +[`language-traversal` v1](https://github.com/ictechgy/isthmus/blob/main/docs/LANGUAGE-TRAVERSAL.md) +document: `reach` the symbols the roots depend on (`dependencies`), +`impact` the symbols that depend on them (`dependents`). Ids are the same +strings as `symbol.usr` in `schema`. + +- **Roots**: positional ids, then `--roots-from` (a JSON array of strings or + a bridge-facts document — its facts' `symbol.usr`; `-` reads stdin), + deduplicated in first-seen order (that order is the meaning of + `reached[].roots`). Empty or control-character ids and more than 10,000 + roots are usage errors (`64`, empty stdout). +- **One pass over all roots**: every reached symbol lists every root that + reaches it (`roots`, first 64 plus `rootsTruncated`), the nearest depth, + and a shortest-path witness (`via`). A root reached from another root is + listed without its own index. Checked against a per-root BFS oracle on + random graphs. +- **Evidence**: `tentative` edges (name fan-out, `dyn`/generic trait impl + candidates) are `candidate`, all other edges `direct`; each symbol carries + the per-root lower bound. `dispatch` and `unresolvedCalls` are not emitted + — rustograph cannot claim its unresolved-call counts are complete. +- **Limits**: `--max-depth` 1–128 (default 128; `--depth 0` means 128), + `--max-reached` 1–100,000; cuts set `truncated` with `depth` / + `max-reached`. +- **Identity**: `project` is the same realpath `schema` writes; `revision` + is `--revision` or git `HEAD` when the work tree is clean; `graphRevision` + is the SHA-256 of the graph JSON (with `root` normalized to the project). +- **root-not-found**: ids that are not graph vertices are listed without + `symbol`, a `root-not-found:` limitation is added, and the command exits + `64` after writing the document. + +isthmus rejects `route-decl` facts from `platform: "rust"` documents, so +Rust handlers cannot yet start a route-selection `trace`; relation and +symbol selections (reverse traversal) work today. + ## Agent output contract `query`/`impact` JSON reports `level`, `depth`, `truncated`, all edge kinds From d783d70c5e1e6041229e35ced76f57519b8f8d5c Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 09:40:24 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix(cli):=20=EB=B2=94=EC=9C=84=20=EB=B0=96?= =?UTF-8?q?=20--generated-at=20=EC=98=A4=ED=94=84=EC=85=8B=EA=B3=BC=20=3D?= =?UTF-8?q?=20=ED=91=9C=EA=B8=B0=20=EC=88=9C=ED=9A=8C=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=EC=9D=84=2064=EB=A1=9C=20=EA=B1=B0?= =?UTF-8?q?=EB=B6=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GLM 리뷰 반영. --generated-at의 시간대 오프셋은 모양만 봐서 +99:99 같은 값이 문서에 그대로 실렸다 — isthmus가 generatedAt을 검증하며 문서 전체를 거부한다. 오프셋 시·분 범위를 확인한다. impact --format=language-traversal 표기의 파싱 실패는 순회 명령으로 인식되지 않아 2로 끝났다(띄어 쓴 표기는 64) — 같은 요청을 같은 계약으로 다룬다. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/cli/reach.rs | 18 +++++++++++++++--- tests/traversal.rs | 13 +++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/cli/reach.rs b/src/cli/reach.rs index 14edf2a..ed320e2 100644 --- a/src/cli/reach.rs +++ b/src/cli/reach.rs @@ -51,9 +51,11 @@ pub(crate) enum Failure { pub(crate) fn is_traversal_argv(args: &[String]) -> bool { args.first().is_some_and(|c| c == "reach") || (args.first().is_some_and(|c| c == "impact") - && args + && (args .windows(2) - .any(|w| w[0] == "--format" && w[1] == "language-traversal")) + .any(|w| w[0] == "--format" && w[1] == "language-traversal") + // `=` 표기도 같은 요청이다 — 파싱이 실패해도 순회 명령의 64 계약을 따른다. + || args.iter().any(|a| a == "--format=language-traversal"))) } /// 순회 명령을 실행한다. 반환값은 종료 코드다. @@ -299,11 +301,18 @@ fn is_timestamp(s: &str) -> bool { rest = &frac[n..]; } let zone = rest.as_bytes(); + // 오프셋도 시각이다 — +99:99처럼 범위 밖이면 isthmus가 문서 전체를 거부한다. + let offset_ok = || { + let hh = std::str::from_utf8(&zone[1..3]).ok()?.parse::().ok()?; + let mm = std::str::from_utf8(&zone[4..6]).ok()?.parse::().ok()?; + Some(hh <= 23 && mm <= 59) + }; rest == "Z" || (zone.len() == 6 && (zone[0] == b'+' || zone[0] == b'-') && zone[3] == b':' - && zone[1..3].iter().chain(&zone[4..6]).all(u8::is_ascii_digit)) + && zone[1..3].iter().chain(&zone[4..6]).all(u8::is_ascii_digit) + && offset_ok() == Some(true)) } /// 순회 문서와 정점이 아닌 root 수. @@ -617,6 +626,9 @@ mod tests { "2026-09-30T00:00:00", "2026-09-30T00:00:00.Z", "2026-09-30T00:00:00+0900", + "2026-09-30T00:00:00+99:99", + "2026-09-30T00:00:00-00:60", + "2026-09-30T00:00:00+24:00", "2026-09-30T00:00:00Z", ] { assert!(!is_timestamp(bad), "{bad}"); diff --git a/tests/traversal.rs b/tests/traversal.rs index a74a03b..cab3ed0 100644 --- a/tests/traversal.rs +++ b/tests/traversal.rs @@ -237,6 +237,19 @@ fn usage_errors_exit_64_with_empty_stdout() { "--format".into(), "language-traversal".into(), ], + // `=` 표기도 순회 형식 요청이다 — 파싱 실패는 2가 아니라 64다. + vec![ + "impact".into(), + "--format=language-traversal".into(), + "--bogus".into(), + "x".into(), + ], + vec![ + "reach".into(), + "x".into(), + "--generated-at".into(), + "2026-09-30T00:00:00+99:99".into(), + ], ]; for mut argv in cases { argv.extend(["--dir".to_string(), dir.clone()]); From 55b0ac41022628eee9ca80d8f1c40aec2a463238 Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 09:40:39 +0900 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20HANDOFF=EC=97=90=20Rust=20e2e=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EC=99=80=20=EB=AA=A8=EB=93=88=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=A7=80=EC=97=AD=20=EB=B3=80=EC=88=98=20=EA=B3=BC?= =?UTF-8?q?=EB=8C=80=20=EA=B7=BC=EC=82=AC=20=EB=B0=9C=EA=B2=AC=EC=9D=84=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- HANDOFF.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/HANDOFF.md b/HANDOFF.md index e3faaaa..d6da6d3 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -19,6 +19,15 @@ isthmus `trace`가 Rust 백엔드의 핸들러 도달을 관계 사용과 잇도 - 남은 것: isthmus가 rust `route-decl`을 받지 않아 route 선택 trace는 불가(relation· 심볼 선택은 동작). `type` 레벨 cycles는 main부터 10개(투영 산물)이고 이번에 3개가 같은 종류로 늘었다(게이트 아님). +- 합성 e2e(axum·sqlx·sea_orm 스텁, scratch): `isthmus check --pairs` 5 matches, + relation 선택 trace는 relation-use → 역방향 순회까지 이어지고 route-decl이 없어 + `non-http-entry`. isthmus를 scratch에서 고쳐 손으로 만든 route-decl로 route 선택도 + 핸들러 → 테이블 도달을 확인했다. +- 발견(기존 수확 결함, 이번 범위 밖): 본문의 단일 식별자 경로가 지역 변수여도 크레이트 + 루트 모듈 이름과 같으면 모듈 정점 references 간선이 된다(`fn f(repo: &dyn R) { repo.all() }` + → `f → crate::repo`). 순회가 그 모듈의 `uses` 간선을 따라 모듈이 import한 아이템 전부에 + 닿는 과대 근사가 된다. 표현식 경로는 모듈일 수 없으므로 모듈로 해석되면 버리는 것이 후보. + syn 모드에서 메서드 호출은 이름 팬아웃(tentative)이라 구체 수신자 호출도 candidate다. ## 직전 세션 요약 (2026-09-25) From 9d6a426c1ef3aa4d900e48b6d3488d9472c083cd Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 09:43:13 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix(traversal):=20sha256=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=EB=B6=84=ED=95=A0=EC=9D=84=20as=5Fchunks=EB=A1=9C?= =?UTF-8?q?=20=EB=B0=94=EA=BF=94=20CI=20clippy(1.98)=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC=EC=8B=9C=ED=82=A8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI의 clippy 1.98이 상수 크기 chunks_exact에 chunks_exact_to_as_chunks를 낸다(로컬 1.96·1.97에는 없는 lint). as_chunks로 고정 크기 배열을 받아 블록 경계도 타입으로 보장한다. NIST 벡터 테스트는 그대로 통과한다. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/traversal/sha256.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/traversal/sha256.rs b/src/traversal/sha256.rs index 5ee4328..d821991 100644 --- a/src/traversal/sha256.rs +++ b/src/traversal/sha256.rs @@ -30,17 +30,19 @@ pub fn hex_digest(data: &[u8]) -> String { msg.push(0); } msg.extend_from_slice(&bit_len.to_be_bytes()); - for block in msg.chunks_exact(64) { + // 패딩 뒤 길이는 늘 64의 배수라 나머지는 비어 있다. + let (blocks, _) = msg.as_chunks::<64>(); + for block in blocks { compress(&mut h, block); } h.iter().map(|w| format!("{w:08x}")).collect() } /// 64바이트 블록 하나로 상태를 갱신한다. -fn compress(h: &mut [u32; 8], block: &[u8]) { +fn compress(h: &mut [u32; 8], block: &[u8; 64]) { let mut w = [0u32; 64]; - for (i, chunk) in block.chunks_exact(4).enumerate() { - w[i] = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + for (i, chunk) in block.as_chunks::<4>().0.iter().enumerate() { + w[i] = u32::from_be_bytes(*chunk); } for i in 16..64 { let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);