From 6797611b73b4383e924a67aea8a002da6dd8a8fd Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 15:27:09 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(source):=20url-compose=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=20=EA=B7=9C=EC=B9=99=EA=B3=BC=20http-wrappers=20?= =?UTF-8?q?=EC=84=A0=EC=96=B8=20=ED=8C=8C=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 클라이언트 route-call 생산자의 순수 계층이다. syn을 모르는 두 모듈로 두어 isthmus 공유 벡터를 제품 함수로 그대로 실행할 수 있게 했다. - compose: 경로 조각(리터럴·값·query 꼬리)을 정규 템플릿으로 조립한다. 세그먼트 전체 보간만 {}, 첫 ?·# 뒤 제거, 끝 query 지역 변수, 점 세그먼트, 고엔트로피·웹훅 마스킹, dynamic의 channelPrefix. URL 결합은 WHATWG 문자열 연결(reqwest·ureq 2), url::Url::join(RFC 3986 병합), http::Uri 연결(ureq 3, 점 세그먼트 보존) 세 갈래이고 기대값은 url 2.5.8 실행 결과로 고정했다. base 없는 상대 URL·http 밖 scheme은 요청이 아니다. - wrappers: http-wrappers v1을 모르는 필드까지 선언 오류로 읽고, wrapper.method 규칙(레이블 우선·index·기본 동사·methodEnum)으로 동사를 정한다. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/source/routes.rs | 2 + src/source/routes/compose.rs | 955 ++++++++++++++++++++++++++++++++++ src/source/routes/wrappers.rs | 326 ++++++++++++ 3 files changed, 1283 insertions(+) create mode 100644 src/source/routes/compose.rs create mode 100644 src/source/routes/wrappers.rs diff --git a/src/source/routes.rs b/src/source/routes.rs index d2498ba..3b86db5 100644 --- a/src/source/routes.rs +++ b/src/source/routes.rs @@ -15,9 +15,11 @@ mod actix; mod axum; mod common; +pub mod compose; mod pattern; pub mod template; pub mod validate; +pub mod wrappers; use crate::cargo_meta; use crate::source::schema::{rfc3339_utc_now, BridgeFactsTool, BridgeLocation, FactSymbol}; diff --git a/src/source/routes/compose.rs b/src/source/routes/compose.rs new file mode 100644 index 0000000..a2c1b5a --- /dev/null +++ b/src/source/routes/compose.rs @@ -0,0 +1,955 @@ +//! 클라이언트 URL 조립 — isthmus `url-compose` 규칙(HTTP-WRAPPERS "공통 해석 규칙")의 +//! Rust 구현. +//! +//! syn을 모르는 순수 계층이다. 추출기(`client`)가 문자열 식을 [`Piece`] 목록으로 +//! 바꿔 넘기면, 이 모듈이 라이브러리 의미대로 base와 경로를 결합하고 정규 템플릿· +//! `pathAnchor`·`authority`·`queryTailStripped`·`maskedSegments`·`channelPrefix`를 +//! 확정한다. 공유 벡터(`conformance/url-compose.json`)가 같은 함수로 실행된다. +//! +//! Rust 클라이언트의 결합 방식은 세 갈래다(근거와 오라클 기록은 docs/HTTP-ROUTES.md). +//! +//! - [`Join::WhatwgConcat`]: 문자열을 이어 붙인 뒤 `url::Url::parse`(WHATWG URL +//! Standard)로 해석한다 — reqwest의 `IntoUrl for &str/String`, ureq 2.x. 점 +//! 세그먼트를 지우고 `//`는 보존한다. base 리터럴이면 실제 결과, 미상 base 뒤 +//! `/x`는 base, 상대 경로는 dynamic + `ambiguous-base-join:`(dio 행과 같은 결과). +//! - [`Join::WhatwgJoin`]: `url::Url::join` — WHATWG 상대 해석. http(s)에서는 +//! RFC 3986 병합과 같다(`/x`는 root, `x`는 base 마지막 세그먼트를 바꾼다). +//! - [`Join::HttpUriConcat`]: 문자열을 이어 붙인 뒤 `http::Uri`로 해석한다 — +//! ureq 3.x. 점 세그먼트를 지우지 않는다. + +use super::template::{normalize_uri_path, render, template_problem, Seg}; + +/// 값 조각의 출처 — `http-wrapper-undeclared:` 판정과 `baseRef`에 쓴다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Origin { + /// 출처를 모르는 값. + Unknown, + /// 감싸는 함수의 매개변수(이름) — 선언되지 않은 래퍼 싱크의 표지다. + Param(String), + /// 구조체 필드나 상수·static — 그 생산자 id를 `baseRef`로 싣는다. + Base(String), +} + +/// 문자열 식의 조각 하나다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Piece { + /// 값을 아는 리터럴 조각. + Lit(String), + /// 정적으로 모르는 값(보간·매개변수 등). + Value(Origin), + /// 값이 모두 `?`로 시작하거나 비어 있음을 증명한 지역 변수(`compose.suffix`). + QueryTail, +} + +/// 템플릿이 서버 루트부터 확정됐는지(`root`) 모르는 base 뒤인지(`base`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum PathAnchor { + Root, + Base, +} + +impl PathAnchor { + /// 계약 문자열. + pub fn as_str(self) -> &'static str { + match self { + PathAnchor::Root => "root", + PathAnchor::Base => "base", + } + } +} + +/// 확정한 정적 템플릿. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Template { + pub template: String, + pub anchor: PathAnchor, + pub authority: Option, + pub query_tail_stripped: bool, + pub masked_segments: usize, +} + +/// 조립 결과다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + /// 정적 템플릿. + Template(Template), + /// 템플릿으로 확정하지 못한 호출. `prefix`는 증명한 리터럴 접두사 템플릿(마스킹 + /// 적용), `ambiguous`는 미상 base 뒤 상대 경로라 `ambiguous-base-join:`으로 센다. + Dynamic { + prefix: Option, + anchor: PathAnchor, + ambiguous: bool, + masked_segments: usize, + }, + /// 요청이 될 수 없는 URL — base 없는 상대 URL(`url::Url::parse`가 거부한다). + Unrequestable, +} + +/// 이 모듈이 아는 base 결합 방식이다. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Join { + /// 문자열 연결 후 `url::Url::parse`(reqwest, ureq 2.x). + WhatwgConcat, + /// `url::Url::join`(WHATWG 상대 해석 = http(s)에서 RFC 3986). + WhatwgJoin, + /// 문자열 연결 후 `http::Uri`(ureq 3.x) — 점 세그먼트를 지우지 않는다. + HttpUriConcat, + /// base 끝 `/`와 경로 앞 `/`를 하나로 합친다. Rust 라이브러리 중 이 방식을 쓰는 + /// 것은 모델링하지 않았고, 공유 벡터의 `slash-join` 사례를 위해 둔다. + SlashJoin, +} + +/// 조립 중인 URL 값 — `url::Url`이나 URL 문자열을 해석한 상태다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UrlVal { + /// 소문자 scheme(`http`·`https`) — 모르면 None. + pub scheme: Option, + /// 리터럴 authority(`host[:port]`) — 모르거나 동적이면 None. + pub authority: Option, + pub path: UrlPath, + /// query·fragment를 떼어 냈는가. + pub query: bool, + /// dot 세그먼트를 지우는 해석기인가(`http::Uri`는 지우지 않는다). + pub dots: bool, +} + +/// URL의 경로 상태다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UrlPath { + /// `/`로 시작하는 경로 조각들과 그 앵커. + Known { + anchor: PathAnchor, + pieces: Vec, + }, + /// 경로를 전혀 모르는 base(`Url::parse(미상)`) — 상대 해석만 뒤에 잇는다. + Opaque, + /// 템플릿을 포기한 경로. + Dynamic { + prefix: Option<(PathAnchor, Vec)>, + ambiguous: bool, + }, + /// base 없는 상대 URL — 요청이 되지 않는다. + Unrequestable, +} + +impl UrlVal { + /// 경로·authority를 모두 모르는 URL(매개변수로 받은 `Url` 등). + pub fn opaque(dots: bool) -> UrlVal { + UrlVal { + scheme: None, + authority: None, + path: UrlPath::Opaque, + query: false, + dots, + } + } + + /// 조각을 이어 붙인 문자열을 절대 URL로 해석한다(`Url::parse`·`http::Uri`). + /// + /// 앞 리터럴이 `http(s)://authority`를 모두 담으면 경로는 root다. authority + /// 자리에 값이 끼면 그 값이 경로를 담을 수 있으므로 뒤의 첫 `/`부터 base다. + /// 값으로 시작하면 그 값이 base이고, 뒤 리터럴이 `/`로 시작하지 않으면 base에 + /// 달라붙어 모호하다. 리터럴이 scheme 없이 시작하면 요청이 될 수 없다. + pub fn parse(pieces: &[Piece], dots: bool) -> UrlVal { + let pieces = preprocess(pieces); + let mut out = UrlVal { + scheme: None, + authority: None, + path: UrlPath::Dynamic { + prefix: None, + ambiguous: false, + }, + query: false, + dots, + }; + match pieces.first() { + None => out.path = UrlPath::Unrequestable, + Some(Piece::Lit(first)) => match split_scheme(first) { + Some((scheme, rest)) if scheme == "http" || scheme == "https" => { + out.scheme = Some(scheme); + let rest = rest.trim_start_matches('/').to_string(); + let mut tail = vec![Piece::Lit(rest)]; + tail.extend(pieces[1..].iter().cloned()); + out.parse_authority(&tail); + } + // mailto:·file: 등 http가 아닌 scheme — reqwest·ureq가 보내기 전에 거부한다. + Some(_) => out.path = UrlPath::Unrequestable, + None => out.path = UrlPath::Unrequestable, + }, + Some(_) => { + let rest = &pieces[1..]; + out.path = match rest.first() { + Some(Piece::Lit(l)) if l.starts_with('/') => UrlPath::Known { + anchor: PathAnchor::Base, + pieces: rest.to_vec(), + }, + Some(Piece::Lit(l)) if !l.starts_with(['?', '#']) => UrlPath::Dynamic { + prefix: None, + ambiguous: true, + }, + _ => UrlPath::Dynamic { + prefix: None, + ambiguous: false, + }, + }; + } + } + out.strip_query(); + out + } + + /// `scheme://` 뒤 조각에서 authority와 경로를 가른다. + fn parse_authority(&mut self, tail: &[Piece]) { + let Some(Piece::Lit(head)) = tail.first() else { + return; + }; + if let Some(end) = head.find(['/', '?', '#']) { + self.authority = authority_of(&head[..end]); + let mut pieces = vec![Piece::Lit(head[end..].to_string())]; + pieces.extend(tail[1..].iter().cloned()); + if let Some(Piece::Lit(l)) = pieces.first_mut() { + if l.starts_with(['?', '#']) { + l.insert(0, '/'); + } + } + self.path = UrlPath::Known { + anchor: PathAnchor::Root, + pieces, + }; + return; + } + if tail.len() == 1 { + self.authority = authority_of(head); + self.path = UrlPath::Known { + anchor: PathAnchor::Root, + pieces: vec![Piece::Lit("/".to_string())], + }; + return; + } + // authority 안에 값이 있다 — 계약: host가 동적이면 base다. + for (i, p) in tail.iter().enumerate().skip(1) { + let Piece::Lit(l) = p else { continue }; + if let Some(end) = l.find(['/', '?', '#']) { + if l[end..].starts_with('/') { + let mut pieces = vec![Piece::Lit(l[end..].to_string())]; + pieces.extend(tail[i + 1..].iter().cloned()); + self.path = UrlPath::Known { + anchor: PathAnchor::Base, + pieces, + }; + } + return; + } + } + } + + /// 경로 조각의 첫 `?`·`#`부터 끝까지를 떼고 `query`를 표시한다 — base 경로의 + /// query가 상대 병합의 마지막 `/` 판정에 섞이지 않게 한다. + fn strip_query(&mut self) { + if let UrlPath::Known { pieces, .. } = &mut self.path { + if cut_query(pieces) { + self.query = true; + } + } + } + + /// `url::Url::join`(WHATWG 상대 해석)으로 경로를 잇는다. + pub fn join(&self, input: &[Piece]) -> UrlVal { + let input = preprocess(input); + // 해석에 실패한 base(`Url::parse`가 Err)는 `?`로 빠져 요청이 되지 않는다. + if self.path == UrlPath::Unrequestable { + return self.clone(); + } + let mut out = self.clone(); + out.query = false; + let first = match input.first() { + None => return out, + Some(Piece::Lit(l)) => l.clone(), + Some(_) => return out.into_dynamic(false), + }; + if let Some((scheme, rest)) = split_scheme(&first) { + let same = self.scheme.as_deref() == Some(scheme.as_str()); + if same && !rest.starts_with('/') && (scheme == "http" || scheme == "https") { + // WHATWG: 같은 special scheme의 `http:x`는 상대 참조다. + let mut rel = vec![Piece::Lit(rest.to_string())]; + rel.extend(input[1..].iter().cloned()); + return self.join(&rel); + } + return UrlVal::parse(&input, self.dots); + } + if first.starts_with("//") { + return match &self.scheme { + Some(s) => { + let mut abs = vec![Piece::Lit(format!("{s}:{first}"))]; + abs.extend(input[1..].iter().cloned()); + UrlVal::parse(&abs, self.dots) + } + None => out.into_dynamic(false), + }; + } + if first.starts_with('/') { + out.path = UrlPath::Known { + anchor: PathAnchor::Root, + pieces: input, + }; + } else if first.starts_with(['?', '#']) || first.is_empty() { + out.query = true; + if matches!(self.path, UrlPath::Opaque) { + return out.into_dynamic(false); + } + } else { + out.path = match &self.path { + UrlPath::Known { anchor, pieces } => UrlPath::Known { + anchor: *anchor, + pieces: merge(pieces, &input), + }, + UrlPath::Opaque => { + let mut pieces = vec![Piece::Lit("/".to_string())]; + pieces.extend(input); + UrlPath::Known { + anchor: PathAnchor::Base, + pieces, + } + } + other => other.clone(), + }; + } + out.strip_query(); + out + } + + /// 경로를 dynamic으로 바꾼다. + fn into_dynamic(mut self, ambiguous: bool) -> UrlVal { + self.path = UrlPath::Dynamic { + prefix: None, + ambiguous, + }; + self + } + + /// 이 URL을 다시 문자열 조각으로 쓴다(`as_str()`·`format!("{url}")`). + pub fn to_pieces(&self) -> Vec { + let mut out = Vec::new(); + match (&self.path, &self.scheme, &self.authority) { + ( + UrlPath::Known { + anchor: PathAnchor::Root, + pieces, + }, + Some(s), + Some(a), + ) => { + out.push(Piece::Lit(format!("{s}://{a}"))); + out.extend(pieces.iter().cloned()); + } + (UrlPath::Known { pieces, .. }, _, _) => { + out.push(Piece::Value(Origin::Unknown)); + out.extend(pieces.iter().cloned()); + } + _ => out.push(Piece::Value(Origin::Unknown)), + } + if self.query { + out.push(Piece::QueryTail); + } + out + } + + /// 계약 사실로 확정한다. + pub fn outcome(&self) -> Outcome { + match &self.path { + UrlPath::Known { anchor, pieces } => { + let authority = match anchor { + PathAnchor::Root => self.authority.clone(), + PathAnchor::Base => None, + }; + let mut out = compose_path(*anchor, pieces, authority.as_deref(), self.dots); + if let Outcome::Template(t) = &mut out { + t.query_tail_stripped |= self.query; + } + out + } + UrlPath::Opaque => Outcome::Dynamic { + prefix: None, + anchor: PathAnchor::Base, + ambiguous: false, + masked_segments: 0, + }, + UrlPath::Dynamic { prefix, ambiguous } => { + let (prefix, anchor, masked) = match prefix { + Some((anchor, pieces)) => { + let (p, m) = prefix_of(pieces, self.authority.as_deref()); + (p, *anchor, m) + } + None => (None, PathAnchor::Base, 0), + }; + Outcome::Dynamic { + prefix, + anchor, + ambiguous: *ambiguous, + masked_segments: masked, + } + } + UrlPath::Unrequestable => Outcome::Unrequestable, + } + } +} + +/// base와 경로를 결합 방식대로 잇는다 — 벡터 러너와 추출기가 같이 쓴다. base가 +/// None이면 미상 base다. +pub fn join(join: Join, base: Option<&str>, path: &[Piece]) -> Outcome { + let dots = join != Join::HttpUriConcat; + match join { + Join::WhatwgJoin => { + let base = match base { + Some(b) => UrlVal::parse(&[Piece::Lit(b.to_string())], dots), + None => UrlVal::opaque(dots), + }; + base.join(path).outcome() + } + Join::WhatwgConcat | Join::HttpUriConcat => { + let mut pieces = vec![match base { + Some(b) => Piece::Lit(b.to_string()), + None => Piece::Value(Origin::Unknown), + }]; + pieces.extend(path.iter().cloned()); + UrlVal::parse(&pieces, dots).outcome() + } + Join::SlashJoin => { + let mut rest = path.to_vec(); + if let Some(Piece::Lit(l)) = rest.first_mut() { + *l = l.trim_start_matches('/').to_string(); + } + match base { + Some(b) => { + let mut pieces = vec![Piece::Lit(format!("{}/", b.trim_end_matches('/')))]; + pieces.extend(rest); + UrlVal::parse(&pieces, dots).outcome() + } + None => { + let mut pieces = vec![Piece::Lit("/".to_string())]; + pieces.extend(rest); + compose_path(PathAnchor::Base, &pieces, None, dots) + } + } + } + } +} + +/// WHATWG 전처리 — 앞뒤 C0·공백을 자르고 탭·줄바꿈을 지우며 `\`를 `/`로 읽는다 +/// (http(s)는 special scheme이다). 빈 리터럴은 버리고 이웃 리터럴은 합친다. +fn preprocess(pieces: &[Piece]) -> Vec { + let mut out: Vec = Vec::new(); + for p in pieces { + match p { + Piece::Lit(l) => { + let cleaned: String = l + .chars() + .filter(|c| !matches!(c, '\t' | '\n' | '\r')) + .map(|c| if c == '\\' { '/' } else { c }) + .collect(); + if let Some(Piece::Lit(prev)) = out.last_mut() { + prev.push_str(&cleaned); + } else if !cleaned.is_empty() { + out.push(Piece::Lit(cleaned)); + } + } + other => out.push(other.clone()), + } + } + let is_c0_or_space = |c: char| c <= ' '; + if let Some(Piece::Lit(l)) = out.first_mut() { + *l = l.trim_start_matches(is_c0_or_space).to_string(); + } + if let Some(Piece::Lit(l)) = out.last_mut() { + *l = l.trim_end_matches(is_c0_or_space).to_string(); + } + out.retain(|p| !matches!(p, Piece::Lit(l) if l.is_empty())); + out +} + +/// `scheme:` 접두사를 떼어 (소문자 scheme, 나머지)로 돌려준다. +fn split_scheme(text: &str) -> Option<(String, &str)> { + let colon = text.find(':')?; + let scheme = &text[..colon]; + let mut chars = scheme.chars(); + let first = chars.next()?; + if !first.is_ascii_alphabetic() + || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return None; + } + Some((scheme.to_ascii_lowercase(), &text[colon + 1..])) +} + +/// 리터럴 authority에서 userinfo를 떼고 소문자 `host[:port]`만 남긴다. 계약이 +/// 받는 모양(ASCII host 문자, IPv6 괄호, 숫자 port)이 아니면 싣지 않는다. +fn authority_of(raw: &str) -> Option { + let host_port = raw.rsplit_once('@').map_or(raw, |(_, h)| h); + let lower = host_port.to_ascii_lowercase(); + let (host, port) = if lower.starts_with('[') { + let close = lower.find(']')?; + let rest = &lower[close + 1..]; + (&lower[..=close], rest.strip_prefix(':')) + } else { + match lower.rsplit_once(':') { + Some((h, p)) => (h, Some(p)), + None => (lower.as_str(), None), + } + }; + let host_ok = if host.starts_with('[') { + host[1..host.len() - 1] + .chars() + .all(|c| c.is_ascii_hexdigit() || c == ':' || c == '.') + } else { + !host.is_empty() + && host + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') + }; + let port_ok = port.is_none_or(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())); + (host_ok && port_ok).then_some(lower) +} + +/// 조각의 첫 `?`·`#`부터 끝까지 뗀다. 뗐으면 true. +fn cut_query(pieces: &mut Vec) -> bool { + for i in 0..pieces.len() { + if let Piece::Lit(l) = &pieces[i] { + if let Some(at) = l.find(['?', '#']) { + let head = l[..at].to_string(); + pieces.truncate(i); + if !head.is_empty() { + pieces.push(Piece::Lit(head)); + } + return true; + } + } + } + false +} + +/// RFC 3986 병합 — base 경로의 마지막 `/`까지 남기고 상대 경로를 붙인다. +fn merge(base: &[Piece], rel: &[Piece]) -> Vec { + let mut out: Vec = Vec::new(); + for (i, p) in base.iter().enumerate().rev() { + if let Piece::Lit(l) = p { + if let Some(at) = l.rfind('/') { + out.extend(base[..i].iter().cloned()); + out.push(Piece::Lit(l[..=at].to_string())); + break; + } + } + } + if out.is_empty() { + out.push(Piece::Lit("/".to_string())); + } + out.extend(rel.iter().cloned()); + out +} + +/// 경로 세그먼트 하나 — 리터럴 또는 세그먼트 전체 보간. +#[derive(Clone, Debug, PartialEq, Eq)] +enum PSeg { + Lit(String), + Param, +} + +/// 경로 조각을 정규 템플릿으로 조립한다(`compose.*` 규칙). +/// +/// 1. 첫 `?`·`#`부터 뗀다(`compose.query-tail`). 끝의 [`Piece::QueryTail`]도 뗀다 +/// (`compose.suffix`) — 중간에 오면 보통 값이다. +/// 2. 값은 세그먼트 전체를 채울 때만 `{}`다(`compose.interpolation`). 아니면 그 +/// 값 앞까지의 조립 결과를 `channelPrefix`로 싣는 dynamic이다. +/// 3. `dots`면 점 세그먼트를 지운다. base 앵커에서 알려진 경로 위로 올라가면 +/// dynamic이다. +/// 4. 정규화(`compose.normalize`)·마스킹(`compose.mask`)을 적용한다. +pub fn compose_path( + anchor: PathAnchor, + pieces: &[Piece], + authority: Option<&str>, + dots: bool, +) -> Outcome { + let mut pieces = preprocess_path(pieces); + let mut query = cut_query(&mut pieces); + if matches!(pieces.last(), Some(Piece::QueryTail)) { + pieces.pop(); + query = true; + } + let dynamic = |prefix: Option, masked: usize| Outcome::Dynamic { + prefix, + anchor, + ambiguous: false, + masked_segments: masked, + }; + match pieces.first() { + Some(Piece::Lit(l)) if l.starts_with('/') => {} + _ => return dynamic(None, 0), + } + let segs = match segments(&pieces) { + Ok(segs) => segs, + Err(prefix_pieces) => { + let (prefix, masked) = prefix_of(&prefix_pieces, authority); + return dynamic(prefix, masked); + } + }; + let segs = if dots { + match remove_dots(segs, anchor) { + Some(s) => s, + None => return dynamic(None, 0), + } + } else { + segs + }; + let mut rendered = to_template_segs(&segs); + let masked = mask(authority, &mut rendered); + let template = render(&rendered); + if template_problem(&template).is_some() { + return dynamic(None, 0); + } + Outcome::Template(Template { + template, + anchor, + authority: authority.map(str::to_string), + query_tail_stripped: query, + masked_segments: masked, + }) +} + +/// 경로 조각의 리터럴을 합친다(빈 리터럴 제거). `\`는 이미 전처리됐다고 본다. +fn preprocess_path(pieces: &[Piece]) -> Vec { + let mut out: Vec = Vec::new(); + for p in pieces { + match (p, out.last_mut()) { + (Piece::Lit(l), Some(Piece::Lit(prev))) => prev.push_str(l), + (Piece::Lit(l), _) if l.is_empty() => {} + (other, _) => out.push(other.clone()), + } + } + out +} + +/// 조각을 세그먼트로 나눈다. 세그먼트 일부만 채우는 값을 만나면 그 값 앞까지의 +/// 조각(접두사 후보)을 Err로 돌려준다. +/// +/// 호출자는 첫 조각이 `/`로 시작하는 리터럴임을 보장한다. +fn segments(pieces: &[Piece]) -> Result, Vec> { + let mut segs: Vec = Vec::new(); + // 지금 채우는 세그먼트 — 빈 리터럴이면 막 `/`를 지난 자리다. + let mut cur = PSeg::Lit(String::new()); + for (i, p) in pieces.iter().enumerate() { + match p { + Piece::Lit(l) => { + let body = if i == 0 { &l[1..] } else { l.as_str() }; + for (j, part) in body.split('/').enumerate() { + if j > 0 { + segs.push(std::mem::replace(&mut cur, PSeg::Lit(String::new()))); + } + if part.is_empty() { + continue; + } + match &mut cur { + PSeg::Lit(text) => text.push_str(part), + // 값 바로 뒤에 `/` 없는 리터럴 — 아래 값 검사가 먼저 막는다. + PSeg::Param => return Err(pieces[..i].to_vec()), + } + } + } + Piece::Value(_) | Piece::QueryTail => { + let starts_segment = cur == PSeg::Lit(String::new()); + let ends_segment = match pieces.get(i + 1) { + None => true, + Some(Piece::Lit(next)) => next.starts_with('/'), + Some(_) => false, + }; + if !(starts_segment && ends_segment) { + return Err(pieces[..i].to_vec()); + } + cur = PSeg::Param; + } + } + } + segs.push(cur); + Ok(segs) +} + +/// WHATWG 점 세그먼트 제거(`.`·`..`와 `%2e` 변형). 끝 세그먼트가 점이면 빈 +/// 세그먼트(끝 슬래시)를 남긴다. base 앵커에서 첫 세그먼트 위로 오르면 None. +fn remove_dots(segs: Vec, anchor: PathAnchor) -> Option> { + let n = segs.len(); + let mut out: Vec = Vec::new(); + for (i, s) in segs.into_iter().enumerate() { + let last = i + 1 == n; + match &s { + PSeg::Lit(t) if is_single_dot(t) => { + if last { + out.push(PSeg::Lit(String::new())); + } + } + PSeg::Lit(t) if is_double_dot(t) => { + if out.pop().is_none() && anchor == PathAnchor::Base { + return None; + } + if last { + out.push(PSeg::Lit(String::new())); + } + } + _ => out.push(s), + } + } + if out.is_empty() { + out.push(PSeg::Lit(String::new())); + } + Some(out) +} + +/// `.` 또는 `%2e`(대소문자 무관). +fn is_single_dot(s: &str) -> bool { + s == "." || s.eq_ignore_ascii_case("%2e") +} + +/// `..`와 그 퍼센트 인코딩 변형. +fn is_double_dot(s: &str) -> bool { + let lower = s.to_ascii_lowercase(); + matches!(lower.as_str(), ".." | ".%2e" | "%2e." | "%2e%2e") +} + +/// 경로 세그먼트를 템플릿 세그먼트로 바꾼다. +fn to_template_segs(segs: &[PSeg]) -> Vec { + segs.iter() + .map(|s| match s { + PSeg::Lit(t) => Seg::Lit(t.clone()), + PSeg::Param => Seg::Param { + prefix: String::new(), + suffix: String::new(), + constraint: None, + }, + }) + .collect() +} + +/// 접두사 조각을 마스킹한 정규 템플릿으로 쓴다. `/`로 시작하지 않으면 None. +fn prefix_of(pieces: &[Piece], authority: Option<&str>) -> (Option, usize) { + let pieces = preprocess_path(pieces); + match pieces.first() { + Some(Piece::Lit(l)) if l.starts_with('/') => {} + _ => return (None, 0), + } + let Ok(segs) = segments(&pieces) else { + return (None, 0); + }; + let mut rendered = to_template_segs(&segs); + let masked = mask(authority, &mut rendered); + let text = render(&rendered); + if template_problem(&text).is_some() { + return (None, 0); + } + (Some(text), masked) +} + +/// 알려진 웹훅 host와 고엔트로피 리터럴 세그먼트를 `{}`로 바꾸고 바꾼 수를 센다. +pub fn mask(authority: Option<&str>, segs: &mut [Seg]) -> usize { + let host = authority.map(|a| { + let h = a.rsplit_once(':').map_or(a, |(h, _)| h); + h.to_ascii_lowercase() + }); + let webhook_from = match host.as_deref() { + Some("hooks.slack.com") => Some(0), + Some("discord.com" | "discordapp.com") => { + let lit = |i: usize, want: &str| matches!(segs.get(i), Some(Seg::Lit(t)) if t == want); + (lit(0, "api") && lit(1, "webhooks")).then_some(2) + } + _ => None, + }; + let mut count = 0; + for (i, seg) in segs.iter_mut().enumerate() { + let Seg::Lit(text) = seg else { continue }; + let webhook = webhook_from.is_some_and(|from| i >= from) && !text.is_empty(); + if webhook || is_high_entropy(text) { + *seg = Seg::Param { + prefix: String::new(), + suffix: String::new(), + constraint: None, + }; + count += 1; + } + } + count +} + +/// 퍼센트 디코드한 값이 16자 이상이고 ASCII 글자와 숫자를 모두 담는가. +fn is_high_entropy(text: &str) -> bool { + let decoded = percent_decode(&normalize_uri_path(text)); + decoded.chars().count() >= 16 + && decoded.chars().any(|c| c.is_ascii_alphabetic()) + && decoded.chars().any(|c| c.is_ascii_digit()) +} + +/// `%XX`를 바이트로 풀어 UTF-8(손실 허용)로 읽는다. +fn percent_decode(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + // 바이트로 검사한다 — 문자열 슬라이스는 다중 바이트 문자 경계에서 패닉한다. + if bytes[i] == b'%' + && i + 2 < bytes.len() + && bytes[i + 1].is_ascii_hexdigit() + && bytes[i + 2].is_ascii_hexdigit() + { + let hex = |b: u8| (b as char).to_digit(16).unwrap_or(0) as u8; + out.push(hex(bytes[i + 1]) * 16 + hex(bytes[i + 2])); + i += 3; + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// 정규 템플릿 문자열을 세그먼트로 읽는다(`{}`만 파라미터) — 벡터의 `compose.mask` +/// 입력용이다. +pub fn parse_template(template: &str) -> Vec { + template + .strip_prefix('/') + .unwrap_or(template) + .split('/') + .map(|s| { + if s == "{}" { + Seg::Param { + prefix: String::new(), + suffix: String::new(), + constraint: None, + } + } else { + Seg::Lit(s.to_string()) + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lit(s: &str) -> Piece { + Piece::Lit(s.to_string()) + } + + fn val() -> Piece { + Piece::Value(Origin::Unknown) + } + + fn tpl(o: &Outcome) -> (&str, PathAnchor) { + match o { + Outcome::Template(t) => (t.template.as_str(), t.anchor), + other => panic!("not a template: {other:?}"), + } + } + + #[test] + fn whatwg_join_matches_url_crate() { + // 기대값은 url 2.5.8 `Url::join` 실행 결과다(docs/HTTP-ROUTES.md). + let cases = [ + ("http://h/api", "x", "/x"), + ("http://h/api/", "x", "/api/x"), + ("http://h/a/b/c", "../x", "/a/x"), + ("http://h/api/", "a/./b/../c", "/api/a/c"), + ("http://h/api", "\\x", "/x"), + ("http://h/api/", "x//y", "/api/x//y"), + ("http://h/api/", "http:x", "/api/x"), + ("http://h/api", "?q=1", "/api"), + ("http://h", "x", "/x"), + ]; + for (base, path, want) in cases { + let o = join(Join::WhatwgJoin, Some(base), &[lit(path)]); + assert_eq!(tpl(&o), (want, PathAnchor::Root), "{base} + {path}"); + } + let other = join(Join::WhatwgJoin, Some("http://h/api"), &[lit("//Other/x")]); + match other { + Outcome::Template(t) => { + assert_eq!(t.template, "/x"); + assert_eq!(t.authority.as_deref(), Some("other")); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn opaque_base_relative_and_dots() { + let o = join(Join::WhatwgJoin, None, &[lit("a/b")]); + assert_eq!(tpl(&o), ("/a/b", PathAnchor::Base)); + let up = join(Join::WhatwgJoin, None, &[lit("../x")]); + assert!(matches!(up, Outcome::Dynamic { .. }), "{up:?}"); + let q = join(Join::WhatwgJoin, None, &[lit("?x")]); + assert!(matches!(q, Outcome::Dynamic { .. })); + // 두 번 잇기 — 미상 base 뒤 `api/` + `users`. + let u = UrlVal::opaque(true) + .join(&[lit("api/")]) + .join(&[lit("users")]); + assert_eq!(tpl(&u.outcome()), ("/api/users", PathAnchor::Base)); + } + + #[test] + fn concat_parses_like_url_parse() { + let o = join(Join::WhatwgConcat, Some("http://h/a/../b"), &[lit("/c")]); + assert_eq!(tpl(&o), ("/b/c", PathAnchor::Root)); + let keep = join(Join::HttpUriConcat, Some("http://h/a/../b"), &[lit("/c")]); + assert_eq!(tpl(&keep), ("/a/../b/c", PathAnchor::Root)); + let dynamic_host = UrlVal::parse(&[lit("http://"), val(), lit("/users/"), val()], true); + assert_eq!( + tpl(&dynamic_host.outcome()), + ("/users/{}", PathAnchor::Base) + ); + let relative = UrlVal::parse(&[lit("/users")], true); + assert_eq!(relative.outcome(), Outcome::Unrequestable); + let glued = UrlVal::parse(&[val(), lit("users")], true); + assert!(matches!( + glued.outcome(), + Outcome::Dynamic { + ambiguous: true, + .. + } + )); + let ftp = UrlVal::parse(&[lit("ftp://h/x")], true); + assert_eq!(ftp.outcome(), Outcome::Unrequestable); + assert_eq!( + relative.join(&[lit("/x")]).outcome(), + Outcome::Unrequestable + ); + let query_only = UrlVal::parse(&[lit("https://h?x=1")], true); + match query_only.outcome() { + Outcome::Template(t) => { + assert_eq!(t.template, "/"); + assert!(t.query_tail_stripped); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn authorities_are_validated() { + assert_eq!( + authority_of("u:p@API.Example.com:8080").as_deref(), + Some("api.example.com:8080") + ); + assert_eq!(authority_of("[::1]:3000").as_deref(), Some("[::1]:3000")); + assert_eq!(authority_of("caf\u{e9}.com"), None); + assert_eq!(authority_of("h:"), None); + } + + #[test] + fn url_round_trips_through_pieces() { + let u = UrlVal::parse(&[lit("http://h/v1/")], true).join(&[lit("items")]); + let again = UrlVal::parse(&u.to_pieces(), true); + assert_eq!(tpl(&again.outcome()), ("/v1/items", PathAnchor::Root)); + let opaque = UrlVal::opaque(true).to_pieces(); + assert_eq!(opaque, vec![val()]); + } + + #[test] + fn percent_decode_handles_edges() { + assert_eq!(percent_decode("a%41"), "aA"); + assert_eq!(percent_decode("a%4"), "a%4"); + assert_eq!(percent_decode("%zz"), "%zz"); + } +} diff --git a/src/source/routes/wrappers.rs b/src/source/routes/wrappers.rs new file mode 100644 index 0000000..75c56fe --- /dev/null +++ b/src/source/routes/wrappers.rs @@ -0,0 +1,326 @@ +//! isthmus `http-wrappers` v1 선언 — 파싱과 동사·인자 바인딩(`wrapper.*` 규칙). +//! +//! 정본은 isthmus docs/HTTP-WRAPPERS.md다. 모르는 필드·잘못된 값은 선언 오류다 — +//! 낡은 선언을 조용히 무시하면 호출 0건이 "호출 없음"으로 읽힌다. 이 모듈은 syn을 +//! 모른다. 추출기가 호출 인자를 [`CallArg`]로 바꿔 넘긴다. +//! +//! Rust 이름 규칙(`"language": "rust"` 항목만 적용): +//! +//! - `function`: `owner::name`이 rustograph 정점 ID다. 자유 함수는 모듈 경로 +//! (`app::net` + `send`), 메서드·연관 함수는 타입 ID(`app::api::ApiClient` + +//! `request`), 트레이트 impl 메서드는 `app::api::ApiClient::` + 이름. +//! - `constructor`: `owner`는 타입 ID다. `name`이 타입 이름과 같으면 구조체 리터럴 +//! (`Endpoint { method, path }` — `label`은 필드 이름)이나 튜플 구조체 생성 +//! (`Endpoint(m, p)` — `index`)이고, 다르면 연관 함수(`Endpoint::new`)다. +//! - 인자: Rust 함수에는 이름 붙은 인자가 없으므로 함수·메서드는 `index`(메서드는 +//! 수신자를 빼고 0부터)로 묶는다. `label`은 구조체 리터럴 필드에만 맞는다. + +use serde_json::Value; +use std::collections::BTreeMap; + +/// 계약 동사. +pub const VERBS: &[&str] = &[ + "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", +]; + +/// 동사 인자·경로 인자 위치 지정. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ArgSpec { + pub index: Option, + pub label: Option, +} + +/// 래퍼 선언의 종류. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WrapperKind { + Constructor, + Function, +} + +/// 래퍼 선언 하나. +#[derive(Clone, Debug)] +pub struct Wrapper { + /// 선언 파일의 `wrappers[n]` 위치 — 한계 문구에 쓴다. + pub position: usize, + pub language: String, + pub kind: WrapperKind, + pub owner: String, + pub name: String, + pub method_arg: Option, + pub path_arg: ArgSpec, + pub default_method: Option, + pub method_enum: BTreeMap, + /// `root`·`base`. + pub path_anchor: String, + pub service: Option, +} + +impl Wrapper { + /// 동사 바인딩 명세. + pub fn method_spec(&self) -> MethodSpec { + MethodSpec { + method_arg: self.method_arg.clone(), + default_method: self.default_method.clone(), + method_enum: self.method_enum.clone(), + } + } +} + +/// 동사를 정하는 선언 부분 — 벡터(`wrapper.method`)가 이 형태만 준다. +#[derive(Clone, Debug, Default)] +pub struct MethodSpec { + pub method_arg: Option, + pub default_method: Option, + pub method_enum: BTreeMap, +} + +/// 호출 인자 값의 모양. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ArgValue { + /// 문자열 리터럴(상수 치환 포함). + Literal(String), + /// enum case·연관 상수 경로의 마지막 이름(`Method::GET` → `GET`). + EnumCase(String), + /// 그 밖의 식. + Other, +} + +/// 호출 인자 하나 — 구조체 리터럴 필드면 `label`이 있다. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CallArg { + pub label: Option, + pub value: ArgValue, +} + +/// 인자 찾기(`wrapper.method` 1): `label`이 같은 인자를 먼저, 없으면 `index` 위치의 +/// 인자를 쓰되 그 인자가 다른 레이블을 달고 있으면 쓰지 않는다. +pub fn find_arg<'a>(spec: &ArgSpec, args: &'a [CallArg]) -> Option<&'a CallArg> { + if let Some(label) = &spec.label { + if let Some(a) = args.iter().find(|a| a.label.as_ref() == Some(label)) { + return Some(a); + } + } + let a = args.get(spec.index?)?; + match &a.label { + Some(l) if Some(l) != spec.label.as_ref() => None, + _ => Some(a), + } +} + +/// 동사를 정한다 — None이면 `methodDynamic`이다(`wrapper.method` 2·3). +/// +/// 인자가 없으면 `defaultMethod`, 인자가 있으면 정확한 대문자 동사 리터럴이나 +/// `methodEnum`에 매핑된 enum case·리터럴만 동사다. 리터럴이 아닌 식은 기본값을 +/// 쓰지 않는다. +pub fn bind_method(spec: &MethodSpec, args: &[CallArg]) -> Option { + let found = spec.method_arg.as_ref().and_then(|m| find_arg(m, args)); + let Some(arg) = found else { + return spec.default_method.clone(); + }; + match &arg.value { + ArgValue::Literal(s) if VERBS.contains(&s.as_str()) => Some(s.clone()), + ArgValue::Literal(s) | ArgValue::EnumCase(s) => spec.method_enum.get(s).cloned(), + ArgValue::Other => None, + } +} + +/// 선언 파일을 읽는다. 오류 문구는 원인과 고칠 자리를 담는다. +pub fn parse(text: &str) -> Result, String> { + let root: Value = serde_json::from_str(text) + .map_err(|e| format!("the http-wrappers file is not valid JSON ({e})"))?; + let obj = root + .as_object() + .ok_or("the http-wrappers file must be a JSON object")?; + for key in obj.keys() { + if !matches!(key.as_str(), "format" | "version" | "wrappers") { + return Err(format!( + "unknown field {key:?} in the http-wrappers file (allowed: format, version, wrappers)" + )); + } + } + if obj.get("format").and_then(Value::as_str) != Some("http-wrappers") { + return Err("the http-wrappers file must declare \"format\": \"http-wrappers\"".into()); + } + if obj.get("version").and_then(Value::as_u64) != Some(1) { + return Err("only http-wrappers \"version\": 1 is supported".into()); + } + let list = obj + .get("wrappers") + .and_then(Value::as_array) + .ok_or("the http-wrappers file needs a \"wrappers\" array")?; + list.iter() + .enumerate() + .map(|(i, w)| parse_wrapper(i, w).map_err(|e| format!("wrappers[{i}]: {e}"))) + .collect() +} + +/// 선언 항목 하나를 읽는다. +fn parse_wrapper(position: usize, w: &Value) -> Result { + const FIELDS: &[&str] = &[ + "language", + "kind", + "owner", + "name", + "methodArg", + "pathArg", + "defaultMethod", + "methodEnum", + "pathAnchor", + "service", + ]; + let obj = w.as_object().ok_or("each wrapper must be a JSON object")?; + if let Some(bad) = obj.keys().find(|k| !FIELDS.contains(&k.as_str())) { + return Err(format!("unknown field {bad:?}")); + } + let text = |key: &str| -> Result { + obj.get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("{key:?} must be a non-empty string")) + }; + let kind = match text("kind")?.as_str() { + "constructor" => WrapperKind::Constructor, + "function" => WrapperKind::Function, + other => { + return Err(format!( + "\"kind\" must be constructor or function, not {other:?}" + )) + } + }; + let method_arg = obj.get("methodArg").map(arg_spec).transpose()?; + let path_arg = arg_spec(obj.get("pathArg").ok_or("\"pathArg\" is required")?)?; + let default_method = obj.get("defaultMethod").map(verb).transpose()?; + if method_arg.is_none() && default_method.is_none() { + return Err("declare \"methodArg\" or \"defaultMethod\" (a wrapper needs a verb)".into()); + } + let method_enum = match obj.get("methodEnum") { + None => BTreeMap::new(), + Some(Value::Object(m)) => m + .iter() + .map(|(k, v)| Ok((k.clone(), verb(v)?))) + .collect::>()?, + Some(_) => return Err("\"methodEnum\" must be an object of case → verb".into()), + }; + let path_anchor = text("pathAnchor")?; + if path_anchor != "root" && path_anchor != "base" { + return Err(format!( + "\"pathAnchor\" must be root or base, not {path_anchor:?}" + )); + } + let service = match obj.get("service") { + None => None, + Some(_) => Some(text("service")?), + }; + Ok(Wrapper { + position, + language: text("language")?, + kind, + owner: text("owner")?, + name: text("name")?, + method_arg, + path_arg, + default_method, + method_enum, + path_anchor, + service, + }) +} + +/// `{ index }`·`{ label }`·둘 다. +fn arg_spec(v: &Value) -> Result { + let obj = v + .as_object() + .ok_or("an argument spec must be an object with index and/or label")?; + if let Some(bad) = obj.keys().find(|k| *k != "index" && *k != "label") { + return Err(format!("unknown argument spec field {bad:?}")); + } + let index = match obj.get("index") { + None => None, + Some(i) => Some( + i.as_u64() + .ok_or("\"index\" must be a non-negative integer")? as usize, + ), + }; + let label = match obj.get("label") { + None => None, + Some(l) => Some( + l.as_str() + .filter(|s| !s.is_empty()) + .ok_or("\"label\" must be a non-empty string")? + .to_string(), + ), + }; + if index.is_none() && label.is_none() { + return Err("an argument spec needs index or label".into()); + } + Ok(ArgSpec { index, label }) +} + +/// 계약 동사 문자열. +fn verb(v: &Value) -> Result { + match v.as_str() { + Some(s) if VERBS.contains(&s) => Ok(s.to_string()), + _ => Err(format!("{v} is not a contract verb ({})", VERBS.join(", "))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn declaration_errors_name_the_cause() { + let bad = [ + (r#"[]"#, "JSON object"), + (r#"{"format":"x","version":1,"wrappers":[]}"#, "format"), + ( + r#"{"format":"http-wrappers","version":2,"wrappers":[]}"#, + "version", + ), + (r#"{"format":"http-wrappers","version":1}"#, "wrappers"), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[],"x":1}"#, + "unknown field", + ), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"a","name":"b","pathArg":{"index":0},"pathAnchor":"root"}]}"#, + "methodArg", + ), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"a","name":"b","pathArg":{},"defaultMethod":"GET","pathAnchor":"root"}]}"#, + "index or label", + ), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"method","owner":"a","name":"b","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root"}]}"#, + "kind", + ), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"a","name":"b","pathArg":{"index":0},"defaultMethod":"get","pathAnchor":"root"}]}"#, + "contract verb", + ), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"a","name":"b","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"top"}]}"#, + "pathAnchor", + ), + ( + r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"a","name":"b","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root","extra":1}]}"#, + "unknown field", + ), + ]; + for (text, needle) in bad { + let err = parse(text).expect_err(text); + assert!(err.contains(needle), "{err} should mention {needle}"); + } + } + + #[test] + fn full_declaration_parses() { + let text = r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"constructor","owner":"app::Endpoint","name":"Endpoint","methodArg":{"index":0,"label":"method"},"pathArg":{"label":"path"},"methodEnum":{"Get":"GET"},"pathAnchor":"base","service":"api"}]}"#; + let w = &parse(text).unwrap()[0]; + assert_eq!(w.kind, WrapperKind::Constructor); + assert_eq!(w.method_enum.get("Get").map(String::as_str), Some("GET")); + assert_eq!(w.service.as_deref(), Some("api")); + assert_eq!(w.method_spec().method_arg.unwrap().index, Some(0)); + } +} From eec60f550f0507bb382b444b4108dcefbd182d0b Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 15:27:30 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(source):=20routes=20--role=20client=20?= =?UTF-8?q?=E2=80=94=20reqwest=C2=B7ureq=C2=B7=EC=84=A0=EC=96=B8=20?= =?UTF-8?q?=EB=9E=98=ED=8D=BC=EC=9D=98=20route-call=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isthmus http 도메인의 호출 측 생산자다. impact와 같은 syn 수확 위에서 함수 본문을 두 번 훑는다 — 구조체 리터럴의 필드 값을 모은 뒤, 그 표로 self.base_url 같은 필드 base를 풀어 호출 사실을 낸다. - 인식: reqwest::get·blocking::get, Client 동사 메서드·request(Method::X)· Request::new, ureq 자유 함수·Agent(2.x는 url::Url, 3.x는 http::Uri), http-wrappers의 rust 선언(owner::name = 정점 ID, 구조체 리터럴 생성자). - 값: format!·concat!·+·상수·static·연관 상수·지역 변수(스코프 스택, 수정되는 이름은 믿지 않음)·Url::parse/join. 필드는 모든 생성 위치가 같은 상수일 때만 값이고, 아니면 base 앵커와 baseRef다. - 수신자: 구문 타입 추론(생성자·주석·필드·반환·static 타입). 증명하지 못한 수신자의 요청 모양 호출은 사실이 아니라 route-call-coverage로 센다. - symbol.usr는 감싸는 정점, 위치 열은 계약대로 UTF-8 바이트다 (Ctx::locate_utf8). dynamic 사실은 channel을 null로 두어 URL의 userinfo·query가 문서에 실리지 않게 한다. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/source/routes.rs | 2 + src/source/routes/client.rs | 429 +++++++ src/source/routes/client/index.rs | 316 +++++ src/source/routes/client/scan.rs | 1826 +++++++++++++++++++++++++++++ src/source/routes/common.rs | 20 +- 5 files changed, 2586 insertions(+), 7 deletions(-) create mode 100644 src/source/routes/client.rs create mode 100644 src/source/routes/client/index.rs create mode 100644 src/source/routes/client/scan.rs diff --git a/src/source/routes.rs b/src/source/routes.rs index 3b86db5..d5d2aae 100644 --- a/src/source/routes.rs +++ b/src/source/routes.rs @@ -1,5 +1,6 @@ //! isthmus `bridge-facts` v1 http 도메인의 서버 측 생산자(`rustograph routes //! --role server`) — axum·actix-web 라우트 선언을 `route-decl` 사실로 낸다. +//! 호출 측(`--role client`, reqwest·ureq `route-call`)은 [`client`]에 있다. //! //! 계약의 정본은 ../isthmus의 docs/GRAPH-EXCHANGE.md "HTTP 경계"다. 프레임워크 //! 규칙과 공식 소스 근거는 docs/HTTP-ROUTES.md에 있다. 흐름: @@ -14,6 +15,7 @@ mod actix; mod axum; +pub mod client; mod common; pub mod compose; mod pattern; diff --git a/src/source/routes/client.rs b/src/source/routes/client.rs new file mode 100644 index 0000000..5e8fe43 --- /dev/null +++ b/src/source/routes/client.rs @@ -0,0 +1,429 @@ +//! isthmus `bridge-facts` v1 http 도메인의 호출 측 생산자(`rustograph routes --role +//! client`) — reqwest·ureq 요청과 선언된 래퍼 호출을 `route-call` 사실로 낸다. +//! +//! 흐름: +//! +//! 1. `cargo metadata`로 멤버 크레이트별 reqwest·ureq(과 모델링하지 않는 클라이언트) +//! 의존과 버전을 찾는다 — ureq는 메이저에 따라 URL 해석기가 다르다. +//! 2. `impact`와 같은 syn 수확으로 모듈 트리·AST·정점 집합을 얻는다 — `symbol.usr`가 +//! `reach`/`impact`의 정점 ID와 같아야 isthmus `trace`가 호출부에서 이어 간다. +//! 3. 스캐너가 두 번 돈다(필드 값 수집 → 호출 사실). URL 조립은 `compose`가, +//! 래퍼 동사 바인딩은 `wrappers`가 한다. +//! 4. 사실을 정렬·검사하고 호출 측 공백을 닫힌 접두사 목록의 한계로 낸다. + +mod index; +mod scan; + +use super::common::Ctx; +use super::compose::{Outcome, PathAnchor}; +use super::template::template_problem; +use super::wrappers::Wrapper; +use crate::cargo_meta; +use crate::source::schema::{rfc3339_utc_now, BridgeFactsTool, BridgeLocation, FactSymbol}; +use index::Index; +use scan::{scan_crate, CallSite, Collected, Libs, Mode, Shared}; +use serde::Serialize; +use std::collections::BTreeSet; +use std::path::Path; + +/// 이름으로 사용을 세는, 모델링하지 않는 HTTP 클라이언트 패키지(cargo_meta가 `-`를 +/// `_`로 정규화한 이름). +const UNMODELLED: &[&str] = &[ + "hyper", + "hyper_util", + "surf", + "awc", + "isahc", + "attohttpc", + "minreq", + "curl", + "ehttp", + "gloo_net", + "reqwasm", + "http_client", + "reqwest_middleware", +]; + +/// `routes --role client` 옵션. +#[derive(Debug, Default)] +pub struct ClientOptions { + /// `http-wrappers` v1 선언(모든 언어 항목 — `rust`만 적용한다). + pub wrappers: Vec, + /// 문서 수준 `service`. + pub service: Option, +} + +/// route-call 사실 하나. 키 순서는 서버 사실과 같은 계약 나열 순서다. +#[derive(Serialize)] +pub struct CallFact { + pub kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(rename = "methodDynamic", skip_serializing_if = "Option::is_none")] + pub method_dynamic: Option, + /// dynamic이면 null — 원문 식은 URL의 userinfo·query를 담을 수 있어 싣지 않는다. + pub channel: Option, + pub dynamic: bool, + #[serde(rename = "pathAnchor")] + pub path_anchor: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub authority: Option, + #[serde(rename = "baseRef", skip_serializing_if = "Option::is_none")] + pub base_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option, + #[serde(rename = "queryTailStripped", skip_serializing_if = "Option::is_none")] + pub query_tail_stripped: Option, + #[serde(rename = "channelPrefix", skip_serializing_if = "Option::is_none")] + pub channel_prefix: Option, + #[serde(rename = "maskedSegments", skip_serializing_if = "Option::is_none")] + pub masked_segments: Option, + pub location: BridgeLocation, + #[serde(skip_serializing_if = "Option::is_none")] + pub symbol: Option, +} + +/// 문서의 `sourceSets`. +#[derive(Serialize, Debug)] +pub struct ClientSourceSets { + pub tests: &'static str, +} + +/// isthmus bridge-facts v1 http 클라이언트 문서다. +#[derive(Serialize)] +pub struct ClientDocument { + pub format: &'static str, + pub version: u8, + pub tool: BridgeFactsTool, + #[serde(rename = "generatedAt")] + pub generated_at: String, + pub platform: &'static str, + /// roles가 있는 http 문서는 사실 0건이어도 `http`다(스캔했으나 호출 없음). + pub target: &'static str, + pub project: String, + pub roles: Vec<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option, + #[serde(rename = "sourceSets")] + pub source_sets: ClientSourceSets, + pub facts: Vec, + pub limitations: Vec, +} + +/// 멤버 크레이트 하나 — 수확 루트와 의존 라이브러리. +struct Member { + name: String, + roots: Vec, + libs: Libs, + /// (패키지, 버전) — 확인한 버전 밖이면 한계로 센다. + versions: Vec<(String, String)>, +} + +/// 워크스페이스의 HTTP 요청 호출을 http 클라이언트 문서로 낸다. +pub fn client_routes( + dir: &Path, + tool_version: &str, + opts: &ClientOptions, +) -> Result { + let root = dir + .canonicalize() + .map_err(|e| format!("cannot resolve {}: {e}", dir.display()))?; + let meta = cargo_meta::load(dir)?; + let members = members(&meta); + let parts = crate::source::harvest_parts(dir, &meta)?; + let ctx = Ctx::new(&parts, root.clone()); + let krates: Vec = members.iter().flat_map(|m| m.roots.clone()).collect(); + let index = Index::build(&ctx, &krates); + let wrappers: Vec = opts + .wrappers + .iter() + .filter(|w| w.language == "rust") + .cloned() + .collect(); + check_services(&wrappers, opts.service.as_deref())?; + let sh = Shared::new(&ctx, &index, &wrappers); + for mode in [Mode::Collect, Mode::Emit] { + for m in &members { + for r in &m.roots { + scan_crate(&sh, r, &m.libs, mode); + } + } + if mode == Mode::Collect { + sh.finish_collect(); + } + } + let collected = std::mem::take(&mut *sh.out.borrow_mut()); + let mut limitations = coverage(&members, &collected); + limitations.extend(wrapper_gaps(&sh, &wrappers, &collected)); + let (facts, missing) = facts(&ctx, &collected.calls, &mut limitations)?; + if missing > 0 { + limitations.push(format!("missing-route-usrs: {missing} route call(s) have no enclosing workspace function or method; they carry no symbol")); + } + for l in &meta.limitations { + limitations.push(format!("route-call-coverage: {l}")); + } + limitations.sort(); + limitations.dedup(); + Ok(ClientDocument { + format: "bridge-facts", + version: 1, + tool: BridgeFactsTool { + name: "rustograph", + version: tool_version.to_string(), + }, + generated_at: rfc3339_utc_now(), + platform: "rust", + target: "http", + project: root.display().to_string(), + roles: vec!["client"], + service: opts.service.clone(), + source_sets: ClientSourceSets { tests: "excluded" }, + facts, + limitations, + }) +} + +/// 래퍼 선언의 service가 문서 service와 다르면 isthmus가 문서를 거부한다 — 먼저 막는다. +fn check_services(wrappers: &[Wrapper], service: Option<&str>) -> Result<(), String> { + let Some(doc) = service else { return Ok(()) }; + for w in wrappers { + if let Some(s) = &w.service { + if s != doc { + return Err(format!( + "wrappers[{}] declares service {s:?} but --service is {doc:?}; isthmus rejects a fact whose service differs from the document's — drop --service or align the declaration", + w.position + )); + } + } + } + Ok(()) +} + +/// 워크스페이스 멤버와 그 HTTP 클라이언트 의존. +fn members(meta: &cargo_meta::Metadata) -> Vec { + let mut out = Vec::new(); + for (id, &pi) in &meta.by_id { + let pkg = &meta.packages[pi]; + if !pkg.workspace_member { + continue; + } + let roots: Vec = pkg + .targets + .iter() + .filter(|t| matches!(t.kind.as_str(), "lib" | "bin")) + .map(|t| t.name.clone()) + .collect::>() + .into_iter() + .collect(); + let mut libs = Libs::default(); + let mut versions = Vec::new(); + for d in meta + .dep_edges + .iter() + .filter(|d| &d.from == id && d.kind.is_empty()) + { + let Some(&ti) = meta.by_id.get(&d.to) else { + continue; + }; + let to = &meta.packages[ti]; + match to.name.as_str() { + "reqwest" => { + libs.reqwest = Some(d.lib_name.clone()); + versions.push((to.name.clone(), to.version.clone())); + } + "ureq" => { + // 메이저를 읽지 못하면 최신(3.x, http::Uri) 규칙으로 읽고 버전 한계로 센다. + let major = to + .version + .split('.') + .next() + .and_then(|m| m.parse().ok()) + .unwrap_or(3); + libs.ureq = Some((d.lib_name.clone(), major)); + versions.push((to.name.clone(), to.version.clone())); + } + name if UNMODELLED.contains(&name) => libs.unmodelled.push(d.lib_name.clone()), + _ => {} + } + } + out.push(Member { + name: pkg.name.clone(), + roots, + libs, + versions, + }); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +/// 호출 측 커버리지 한계(`route-call-coverage:`). +fn coverage(members: &[Member], c: &Collected) -> Vec { + let mut out = Vec::new(); + for m in members { + for (name, version) in &m.versions { + let verified = match name.as_str() { + "reqwest" => version.starts_with("0.13.") || version.starts_with("0.12."), + _ => version.starts_with("3."), + }; + if !verified { + out.push(format!( + "route-call-coverage: {} depends on {name} {version}, outside the versions checked against a live server (reqwest 0.12/0.13, ureq 3); its URLs were read with the same rules", + m.name + )); + } + } + } + for (lib, n) in &c.unmodelled { + out.push(format!( + "route-call-coverage: {n} use(s) of {lib}, an HTTP client rustograph does not model; their requests are not reported" + )); + } + if c.unproven > 0 { + out.push(format!( + "route-call-coverage: {} request-shaped call(s) are sent from a receiver that is not a proven reqwest::Client or ureq::Agent; they are not reported", + c.unproven + )); + } + if c.unrequestable > 0 { + out.push(format!( + "route-call-coverage: {} call(s) pass a relative URL with no base or a non-http(s) URL, which the client rejects before sending; they are not reported", + c.unrequestable + )); + } + let ambiguous = c + .calls + .iter() + .filter(|s| { + matches!( + s.outcome, + Outcome::Dynamic { + ambiguous: true, + .. + } + ) + }) + .count(); + if ambiguous > 0 { + out.push(format!("ambiguous-base-join: {ambiguous} call(s) glue a relative path to a base URL whose path is unknown; they are dynamic")); + } + if c.undeclared > 0 { + out.push(format!("http-wrapper-undeclared: {} dynamic call(s) build their URL or verb from a parameter of an undeclared function; declare it in an http-wrappers file to resolve its callers", c.undeclared)); + } + out +} + +/// 선언된 래퍼의 공백 — 정점에 닿지 않거나 호출이 0건. +fn wrapper_gaps(sh: &Shared, wrappers: &[Wrapper], c: &Collected) -> Vec { + let mut out = Vec::new(); + for (i, w) in wrappers.iter().enumerate() { + let calls = c.wrapper_calls.get(&i).copied().unwrap_or(0); + let target = format!("{}::{}", w.owner, w.name); + if !sh.wrapper_resolved(i) { + out.push(format!("http-wrapper-unresolved: wrappers[{}] ({target}) does not name a rustograph function, method or struct", w.position)); + } else if calls == 0 { + out.push(format!( + "http-wrapper-unresolved: wrappers[{}] ({target}) has no calls", + w.position + )); + } + } + out +} + +/// 호출을 사실로 바꾸고 정렬·중복 제거·문법 검사를 한다. (사실, usr 없는 수). +fn facts( + ctx: &Ctx, + calls: &[CallSite], + limitations: &mut Vec, +) -> Result<(Vec, usize), String> { + let mut out = Vec::new(); + let mut missing = 0; + let mut unlocated = 0; + for c in calls { + let Some(location) = ctx.locate_utf8(&c.loc) else { + unlocated += 1; + continue; + }; + let symbol = ctx.owner_of(&c.loc).map(|id| FactSymbol { + qualified_name: id.clone(), + usr: id, + }); + if symbol.is_none() { + missing += 1; + } + out.push(to_fact(c, location, symbol)?); + } + if unlocated > 0 { + limitations.push(format!( + "route-call-coverage: {unlocated} route call(s) had no source location and were not emitted" + )); + } + out.sort_by_key(fact_key); + out.dedup_by(|a, b| fact_key(a) == fact_key(b)); + Ok((out, missing)) +} + +/// 호출 하나를 계약 사실로 쓴다. 정적 템플릿이 문법을 어기면 생산자 결함이다. +fn to_fact( + c: &CallSite, + location: BridgeLocation, + symbol: Option, +) -> Result { + let mut fact = CallFact { + kind: "route-call", + method: c.method.clone(), + method_dynamic: c.method.is_none().then_some(true), + channel: None, + dynamic: true, + path_anchor: PathAnchor::Base.as_str(), + authority: None, + base_ref: c.base_ref.clone(), + service: c.service.clone(), + query_tail_stripped: None, + channel_prefix: None, + masked_segments: None, + location, + symbol, + }; + match &c.outcome { + Outcome::Template(t) => { + if let Some(problem) = template_problem(&t.template) { + return Err(format!("internal error: route-call template {:?} is not canonical ({problem}); please report this with the call source", t.template)); + } + fact.channel = Some(t.template.clone()); + fact.dynamic = false; + fact.path_anchor = t.anchor.as_str(); + fact.authority = t.authority.clone(); + fact.query_tail_stripped = t.query_tail_stripped.then_some(true); + fact.masked_segments = (t.masked_segments > 0).then_some(t.masked_segments); + } + Outcome::Dynamic { + prefix, + anchor, + masked_segments, + .. + } => { + fact.path_anchor = anchor.as_str(); + fact.channel_prefix = prefix.clone(); + fact.masked_segments = + (*masked_segments > 0 && prefix.is_some()).then_some(*masked_segments); + } + Outcome::Unrequestable => { + return Err("internal error: an unrequestable call reached fact assembly".into()) + } + } + Ok(fact) +} + +/// 사실의 결정적 정렬 키. +fn fact_key(f: &CallFact) -> (String, u32, u32, Option, Option, bool) { + ( + f.location.path.clone(), + f.location.line, + f.location.column, + f.method.clone(), + f.channel.clone(), + f.dynamic, + ) +} diff --git a/src/source/routes/client/index.rs b/src/source/routes/client/index.rs new file mode 100644 index 0000000..822e34e --- /dev/null +++ b/src/source/routes/client/index.rs @@ -0,0 +1,316 @@ +//! 클라이언트 추출기의 워크스페이스 색인 — 구조체 필드 타입, impl 메서드, 자유 +//! 함수, 문자열 상수·static, 모듈별 `use` 표. +//! +//! 타입은 경로 문자열로 다룬다. 워크스페이스 아이템은 rustograph 정점 ID +//! (`app::api::ApiClient`), 외부 타입은 `use`를 펼친 경로(`reqwest::Client`)다. +//! 제네릭은 버리고, 역참조로 같은 메서드를 부르는 포장(`&`·`Arc`·`Rc`·`Box`· +//! `Lazy`·`LazyLock`·`OnceLock`·`Result`)은 벗긴다 — 수신자가 reqwest 클라이언트인지 +//! 가리는 데 필요한 만큼만 본다. + +use super::super::common::{is_test_item, Ctx, Imports}; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// 역참조·언랩으로 안쪽 타입의 메서드를 그대로 부르는 포장 타입 이름. +const TRANSPARENT: &[&str] = &[ + "Arc", "Rc", "Box", "Lazy", "LazyLock", "LazyCell", "OnceLock", "OnceCell", "Result", "Cow", +]; + +/// 함수 하나의 서명과 위치. +pub(super) struct FnInfo { + pub module: String, + pub sig: &'static syn::Signature, + /// impl 메서드면 그 self 타입 ID. + pub self_ty: Option, +} + +/// 문자열 상수·static 하나. +pub(super) struct ConstInfo { + pub module: String, + pub expr: &'static syn::Expr, + pub ty: &'static syn::Type, + pub self_ty: Option, +} + +/// 구조체 하나 — 이름 붙은 필드의 타입과 선언 모듈. +pub(super) struct StructInfo { + pub module: String, + pub fields: BTreeMap, + /// `#[derive(Default)]` — 모든 필드가 기본값(빈 문자열)인 생성 경로가 있다. + pub derives_default: bool, +} + +/// 워크스페이스 색인. +pub(super) struct Index { + pub structs: BTreeMap, + /// (self 타입 ID, 메서드 이름) → 서명들(고유 impl과 트레이트 impl). + pub methods: BTreeMap<(String, String), Vec>, + pub fns: BTreeMap, + pub consts: BTreeMap, + /// 스캔하는 멤버 크레이트 루트 이름. + pub roots: std::collections::BTreeSet, + imports: RefCell>>, +} + +impl Index { + /// 크레이트 루트들의 테스트가 아닌 모듈을 훑어 색인을 만든다. + pub fn build(ctx: &Ctx, krates: &[String]) -> Index { + let mut index = Index { + structs: BTreeMap::new(), + methods: BTreeMap::new(), + fns: BTreeMap::new(), + consts: BTreeMap::new(), + roots: krates.iter().cloned().collect(), + imports: RefCell::new(BTreeMap::new()), + }; + for krate in krates { + for module in ctx.crate_modules(krate) { + for (_, items) in ctx.parts.module_items(&module) { + for item in items { + index.add_item(ctx, &module, item); + } + } + } + } + index + } + + /// 모듈 수준 아이템 하나를 색인에 넣는다. + fn add_item(&mut self, ctx: &Ctx, module: &str, item: &'static syn::Item) { + match item { + syn::Item::Struct(s) if !is_test_item(&s.attrs) => { + let fields = match &s.fields { + syn::Fields::Named(n) => n + .named + .iter() + .filter_map(|f| Some((f.ident.as_ref()?.to_string(), &f.ty))) + .collect(), + _ => BTreeMap::new(), + }; + self.structs.insert( + format!("{module}::{}", s.ident), + StructInfo { + module: module.to_string(), + fields, + derives_default: derives_default(&s.attrs), + }, + ); + } + syn::Item::Fn(f) if !is_test_item(&f.attrs) => { + self.fns.insert( + format!("{module}::{}", f.sig.ident), + FnInfo { + module: module.to_string(), + sig: &f.sig, + self_ty: None, + }, + ); + } + syn::Item::Const(c) => self.insert_const( + format!("{module}::{}", c.ident), + module, + &c.expr, + &c.ty, + None, + ), + syn::Item::Static(s) => self.insert_const( + format!("{module}::{}", s.ident), + module, + &s.expr, + &s.ty, + None, + ), + syn::Item::Impl(i) if !is_test_item(&i.attrs) => self.add_impl(ctx, module, i), + _ => {} + } + } + + /// impl 블록의 메서드와 연관 상수를 넣는다. + fn add_impl(&mut self, ctx: &Ctx, module: &str, i: &'static syn::ItemImpl) { + let Some(self_ty) = self.resolve_type(ctx, module, None, &i.self_ty) else { + return; + }; + for item in &i.items { + match item { + syn::ImplItem::Fn(f) if !is_test_item(&f.attrs) => { + self.methods + .entry((self_ty.clone(), f.sig.ident.to_string())) + .or_default() + .push(FnInfo { + module: module.to_string(), + sig: &f.sig, + self_ty: Some(self_ty.clone()), + }); + } + syn::ImplItem::Const(c) => self.insert_const( + format!("{self_ty}::{}", c.ident), + module, + &c.expr, + &c.ty, + Some(self_ty.clone()), + ), + _ => {} + } + } + } + + fn insert_const( + &mut self, + id: String, + module: &str, + expr: &'static syn::Expr, + ty: &'static syn::Type, + self_ty: Option, + ) { + self.consts.insert( + id, + ConstInfo { + module: module.to_string(), + expr, + ty, + self_ty, + }, + ); + } + + /// 모듈의 `use` 표(캐시). + pub fn imports(&self, ctx: &Ctx, module: &str) -> std::rc::Rc { + if let Some(i) = self.imports.borrow().get(module) { + return i.clone(); + } + let groups = ctx.parts.module_items(module); + let imports = std::rc::Rc::new(Imports::of(&groups)); + self.imports + .borrow_mut() + .insert(module.to_string(), imports.clone()); + imports + } + + /// 경로를 타입 문자열로 — 워크스페이스 아이템이면 정점 ID, 아니면 `use`를 펼친 + /// 외부 경로. `Self`는 self 타입이다. + pub fn resolve_path( + &self, + ctx: &Ctx, + module: &str, + self_ty: Option<&str>, + segs: &[String], + ) -> Option { + let (first, rest) = segs.split_first()?; + if first == "Self" { + let mut out = self_ty?.to_string(); + for s in rest { + out.push_str("::"); + out.push_str(s); + } + return Some(out); + } + let dep = crate::modtree::DepCrates::new(); + // 가장 긴 워크스페이스 접두사를 찾는다 — `ApiClient::new`는 타입까지만 모듈 + // 트리에 있고 연관 함수 이름은 그 뒤에 붙는다. + for cut in (1..=segs.len()).rev() { + if let Some(id) = workspace_resolve(ctx, self, module, &segs[..cut], &dep) { + let mut out = id; + for s in &segs[cut..] { + out.push_str("::"); + out.push_str(s); + } + return Some(out); + } + } + let full = self.imports(ctx, module).expand(segs); + (!full.is_empty()).then(|| full.join("::")) + } + + /// 타입 식을 타입 문자열로 — 참조·괄호·포장 타입을 벗긴다. + pub fn resolve_type( + &self, + ctx: &Ctx, + module: &str, + self_ty: Option<&str>, + ty: &syn::Type, + ) -> Option { + match ty { + syn::Type::Reference(r) => self.resolve_type(ctx, module, self_ty, &r.elem), + syn::Type::Paren(p) => self.resolve_type(ctx, module, self_ty, &p.elem), + syn::Type::Group(g) => self.resolve_type(ctx, module, self_ty, &g.elem), + syn::Type::Path(p) => { + let last = p.path.segments.last()?; + if TRANSPARENT.contains(&last.ident.to_string().as_str()) { + if let syn::PathArguments::AngleBracketed(a) = &last.arguments { + let inner = a.args.iter().find_map(|g| match g { + syn::GenericArgument::Type(t) => Some(t), + _ => None, + })?; + return self.resolve_type(ctx, module, self_ty, inner); + } + } + let segs = crate::harvest::path_segments(&p.path); + self.resolve_path(ctx, module, self_ty, &segs) + } + _ => None, + } + } + + /// 서명의 반환 타입. + pub fn return_type(&self, ctx: &Ctx, f: &FnInfo) -> Option { + match &f.sig.output { + syn::ReturnType::Type(_, ty) => { + self.resolve_type(ctx, &f.module, f.self_ty.as_deref(), ty) + } + syn::ReturnType::Default => None, + } + } + + /// 구조체 필드의 타입. + pub fn field_type(&self, ctx: &Ctx, owner: &str, field: &str) -> Option { + let s = self.structs.get(owner)?; + let ty = s.fields.get(field)?; + self.resolve_type(ctx, &s.module, Some(owner), ty) + } +} + +/// 경로를 모듈 트리로 풀되 스캔하는 멤버 크레이트 안의 ID만 받는다 — 트리는 외부 +/// 크레이트 이름(`reqwest`)도 크레이트 정점으로 풀고, 워크스페이스 밖 path 의존도 +/// 모듈로 담을 수 있다. +pub(super) fn workspace_resolve( + ctx: &Ctx, + index: &Index, + module: &str, + segs: &[String], + dep: &crate::modtree::DepCrates, +) -> Option { + let id = ctx.parts.tree.resolve(module, segs, dep)?; + index + .roots + .contains(&crate::modtree::crate_of(&id)) + .then_some(id) +} + +/// `#[derive(.., Default, ..)]`인가. +fn derives_default(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|a| { + a.path().is_ident("derive") + && a.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + ) + .is_ok_and(|list| { + list.iter() + .any(|p| p.segments.last().is_some_and(|s| s.ident == "Default")) + }) + }) +} + +/// 모듈 파일 목록(테스트 모듈 제외) — 스캐너가 아이템을 훑는 단위다. +pub(super) fn module_groups( + ctx: &Ctx, + krate: &str, +) -> Vec<(String, PathBuf, &'static [syn::Item])> { + let mut out = Vec::new(); + for module in ctx.crate_modules(krate) { + for (file, items) in ctx.parts.module_items(&module) { + out.push((module.clone(), file, items)); + } + } + out +} diff --git a/src/source/routes/client/scan.rs b/src/source/routes/client/scan.rs new file mode 100644 index 0000000..f700ae7 --- /dev/null +++ b/src/source/routes/client/scan.rs @@ -0,0 +1,1826 @@ +//! 함수 본문 스캐너 — 문자열·URL 값과 수신자 타입을 따라가며 HTTP 요청 호출을 찾는다. +//! +//! 두 번 돈다. `Collect`는 구조체 리터럴의 필드 값(생성자에서 base URL을 리터럴· +//! 상수로 채우는지)을 모으고, `Emit`은 그 표로 `self.base_url` 같은 필드를 풀어 +//! 호출 사실을 낸다. 해석은 구문 수준이다 — 증명하지 못한 값은 [`Origin`]을 단 +//! 값 조각으로 남고, 조립 규칙(`compose`)이 dynamic으로 판정한다. + +use super::super::common::{is_test_item, Ctx, Imports, Loc}; +use super::super::compose::{Origin, Outcome, PathAnchor, Piece, UrlVal}; +use super::super::wrappers::{self, ArgValue, CallArg, Wrapper, WrapperKind}; +use super::index::{module_groups, workspace_resolve, Index}; +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::rc::Rc; +use syn::spanned::Spanned; +use syn::visit::Visit; + +/// 계약 동사. +const VERBS: &[&str] = wrappers::VERBS; + +/// RequestBuilder에서 요청을 보내는 메서드 — 수신자를 증명하지 못한 요청 모양 호출을 센다. +const SEND_METHODS: &[&str] = &[ + "send", + "call", + "send_string", + "send_json", + "send_form", + "send_bytes", + "send_empty", +]; + +/// 요청 빌더를 이어 가는 메서드 — 보내기 호출에서 동사 호출까지 거슬러 오를 때 건넌다. +const BUILDER_METHODS: &[&str] = &[ + "header", + "headers", + "query", + "json", + "form", + "body", + "timeout", + "basic_auth", + "bearer_auth", + "multipart", + "version", + "set", + "config", + "content_type", +]; + +/// 값을 제자리에서 바꾸는 메서드 — 그 지역 변수·필드의 값은 믿지 않는다. +const MUTATORS: &[&str] = &[ + "push_str", + "push", + "insert", + "insert_str", + "clear", + "truncate", + "extend", + "retain", + "drain", + "remove", + "replace_range", + "set_path", + "set_query", + "set_fragment", + "set_host", + "set_port", + "set_scheme", + "set_username", + "set_password", + "path_segments_mut", + "query_pairs_mut", + "make_ascii_lowercase", + "make_ascii_uppercase", + "as_mut_str", +]; + +/// 값을 그대로 넘기는 메서드(문자열·URL·Result). +const PASS_THROUGH: &[&str] = &[ + "to_string", + "to_owned", + "into", + "as_str", + "as_ref", + "clone", + "borrow", + "as_deref", + "into_owned", + "unwrap", + "expect", +]; + +/// 스캔 단계. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Mode { + Collect, + Emit, +} + +/// 크레이트 하나가 직접 의존하는 HTTP 클라이언트 라이브러리(lib 이름). +#[derive(Clone, Debug, Default)] +pub(super) struct Libs { + pub reqwest: Option, + /// (lib 이름, 메이저 버전) — 2.x는 `url::Url`, 3.x는 `http::Uri`로 해석한다. + pub ureq: Option<(String, u64)>, + /// 모델링하지 않는 클라이언트 크레이트의 lib 이름. + pub unmodelled: Vec, +} + +/// 요청 라이브러리 종류. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Client { + Reqwest, + Ureq, +} + +/// 식을 평가한 값. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Val { + /// 문자열 조각과 앞머리의 `baseRef` 후보(필드·상수 id). + Str(Vec, Option), + /// `url::Url` 값. + Url(UrlVal, Option), + /// 문자열·URL로 모르는 값. + Other(Origin), +} + +impl Val { + /// 문자열 문맥의 조각. + fn pieces(&self) -> Vec { + match self { + Val::Str(p, _) => p.clone(), + Val::Url(u, _) => u.to_pieces(), + Val::Other(o) => vec![Piece::Value(o.clone())], + } + } + + /// 앞머리 값의 `baseRef`. + fn base_ref(&self) -> Option { + match self { + Val::Str(_, r) | Val::Url(_, r) => r.clone(), + Val::Other(Origin::Base(id)) => Some(id.clone()), + Val::Other(_) => None, + } + } + + /// `baseRef`를 바꾼 값. + fn with_ref(self, r: Option) -> Val { + match self { + Val::Str(p, _) => Val::Str(p, r), + Val::Url(u, _) => Val::Url(u, r), + other => other, + } + } + + fn unknown() -> Val { + Val::Other(Origin::Unknown) + } +} + +/// 찾은 호출 하나. +#[derive(Clone, Debug)] +pub(super) struct CallSite { + pub loc: Loc, + /// None이면 `methodDynamic`. + pub method: Option, + pub outcome: Outcome, + pub base_ref: Option, + pub service: Option, +} + +/// 스캔 결과와 계수. +#[derive(Default)] +pub(super) struct Collected { + pub calls: Vec, + /// 수신자를 reqwest·ureq 클라이언트로 증명하지 못한 요청 모양 호출. + pub unproven: usize, + /// base 없는 상대 URL이라 요청이 되지 않는 호출. + pub unrequestable: usize, + /// 모델링하지 않는 클라이언트 lib 이름 → 사용 위치 수. + pub unmodelled: BTreeMap, + /// 선언 위치 → 찾은 호출 수. + pub wrapper_calls: BTreeMap, + /// 매개변수를 URL·동사로 흘려보내는 선언되지 않은 래퍼 싱크. + pub undeclared: usize, +} + +/// 필드 값 누적 — 모든 생성 위치가 같은 값이어야 확정이다. +#[derive(Default)] +struct FieldAcc { + value: Option, + conflict: bool, +} + +/// 스캔 전체가 공유하는 상태. +pub(super) struct Shared<'a> { + pub ctx: &'a Ctx<'a>, + pub index: &'a Index, + pub wrappers: &'a [Wrapper], + /// 함수·연관 함수 래퍼: 정점 ID → 선언 순번. + fn_wrappers: BTreeMap, + /// 구조체 리터럴·튜플 생성 래퍼: 구조체 ID → 선언 순번. + ctor_wrappers: BTreeMap, + fields: RefCell>, + /// 확정한 필드 값(`Emit` 단계). + resolved: RefCell>, + /// 어디선가 제자리 수정되는 필드 — (소유 구조체, 이름). 소유 타입을 모르면 None이라 + /// 그 이름의 모든 필드를 믿지 않는다. + poisoned: RefCell, String)>>, + consts: RefCell>>, + pub out: RefCell, +} + +impl<'a> Shared<'a> { + /// 선언 순번 표를 만든다. `rust` 선언 중 정점에 닿는 것만 매칭 대상이다. + pub fn new(ctx: &'a Ctx<'a>, index: &'a Index, wrappers: &'a [Wrapper]) -> Shared<'a> { + let mut fn_wrappers = BTreeMap::new(); + let mut ctor_wrappers = BTreeMap::new(); + for (i, w) in wrappers.iter().enumerate() { + let type_name = w.owner.rsplit("::").next().unwrap_or(&w.owner); + if w.kind == WrapperKind::Constructor && w.name == type_name { + if index.structs.contains_key(&w.owner) { + ctor_wrappers.insert(w.owner.clone(), i); + } + continue; + } + let id = format!("{}::{}", w.owner, w.name); + if ctx.ids.contains(id.as_str()) { + fn_wrappers.insert(id, i); + } + } + let mut fields: BTreeMap<(String, String), FieldAcc> = BTreeMap::new(); + for (id, s) in &index.structs { + if s.derives_default { + for f in s.fields.keys() { + fields.entry((id.clone(), f.clone())).or_default().conflict = true; + } + } + } + Shared { + ctx, + index, + wrappers, + fn_wrappers, + ctor_wrappers, + fields: RefCell::new(fields), + resolved: RefCell::new(BTreeMap::new()), + poisoned: RefCell::new(BTreeSet::new()), + consts: RefCell::new(BTreeMap::new()), + out: RefCell::new(Collected::default()), + } + } + + /// 선언이 정점·구조체에 닿았는가. + pub fn wrapper_resolved(&self, i: usize) -> bool { + self.fn_wrappers.values().any(|&j| j == i) || self.ctor_wrappers.values().any(|&j| j == i) + } + + /// `Collect` 결과를 확정 표로 바꾼다. + pub fn finish_collect(&self) { + let poisoned = self.poisoned.borrow(); + let mut resolved = self.resolved.borrow_mut(); + for ((s, f), acc) in self.fields.borrow().iter() { + let hit = poisoned.contains(&(None, f.clone())) + || poisoned.contains(&(Some(s.clone()), f.clone())); + if acc.conflict || hit { + continue; + } + if let Some(v) = &acc.value { + if is_constant(v) { + resolved.insert((s.clone(), f.clone()), v.clone()); + } + } + } + } + + /// 필드 값 하나를 누적한다(생성 위치마다). + fn add_field(&self, owner: &str, field: &str, v: Val) { + let v = v.with_ref(None); + let mut fields = self.fields.borrow_mut(); + let acc = fields + .entry((owner.to_string(), field.to_string())) + .or_default(); + match &acc.value { + None => acc.value = Some(v), + Some(prev) if *prev == v => {} + Some(_) => acc.conflict = true, + } + } +} + +/// 크레이트 하나를 스캔한다. +pub(super) fn scan_crate(sh: &Shared, krate: &str, libs: &Libs, mode: Mode) { + for (module, file, items) in module_groups(sh.ctx, krate) { + let imports = sh.index.imports(sh.ctx, &module); + let mut s = Scanner { + sh, + mode, + module, + file, + imports, + libs, + self_ty: None, + env: Env::default(), + mutated: BTreeSet::new(), + in_wrapper: false, + }; + for item in items { + s.scan_item(item); + } + } +} + +/// 이름 하나의 바인딩. +#[derive(Clone, Debug)] +struct Binding { + val: Val, + ty: Option, +} + +/// 어휘 스코프 스택. +#[derive(Default)] +struct Env { + scopes: Vec>, +} + +impl Env { + fn push(&mut self) { + self.scopes.push(BTreeMap::new()); + } + + fn pop(&mut self) { + self.scopes.pop(); + } + + fn bind(&mut self, name: String, b: Binding) { + if self.scopes.is_empty() { + self.push(); + } + if let Some(top) = self.scopes.last_mut() { + top.insert(name, b); + } + } + + fn get(&self, name: &str) -> Option<&Binding> { + self.scopes.iter().rev().find_map(|s| s.get(name)) + } +} + +/// 모듈 파일 하나를 훑는 방문자. +struct Scanner<'s, 'a> { + sh: &'s Shared<'a>, + mode: Mode, + module: String, + file: PathBuf, + imports: Rc, + libs: &'s Libs, + self_ty: Option, + env: Env, + /// 지금 함수에서 제자리 수정되는 지역 변수 이름. + mutated: BTreeSet, + /// 지금 함수가 선언된 래퍼 본문인가 — 그 안의 dynamic 호출은 래퍼 호출 사실이 대신한다. + in_wrapper: bool, +} + +impl Scanner<'_, '_> { + /// 모듈 수준 아이템 하나. + fn scan_item(&mut self, item: &syn::Item) { + match item { + syn::Item::Fn(f) if !is_test_item(&f.attrs) => { + self.scan_fn(None, &f.sig, &f.block); + } + syn::Item::Impl(i) if !is_test_item(&i.attrs) => self.scan_impl(i), + syn::Item::Trait(t) if !is_test_item(&t.attrs) => { + for ti in &t.items { + if let syn::TraitItem::Fn(f) = ti { + if let Some(body) = &f.default { + self.scan_fn(None, &f.sig, body); + } + } + } + } + syn::Item::Const(c) => self.scan_detached(&c.expr), + syn::Item::Static(s) => self.scan_detached(&s.expr), + _ => {} + } + } + + /// impl 블록의 메서드들. + fn scan_impl(&mut self, i: &syn::ItemImpl) { + let self_ty = self + .sh + .index + .resolve_type(self.sh.ctx, &self.module, None, &i.self_ty); + for item in &i.items { + if let syn::ImplItem::Fn(f) = item { + if !is_test_item(&f.attrs) { + self.scan_fn(self_ty.clone(), &f.sig, &f.block); + } + } + } + } + + /// 함수 밖의 식(상수·static 초기식) — 빈 환경으로 본다. + fn scan_detached(&mut self, e: &syn::Expr) { + let saved = ( + std::mem::take(&mut self.env), + self.self_ty.take(), + self.in_wrapper, + ); + self.in_wrapper = false; + self.env.push(); + self.visit_expr(e); + (self.env, self.self_ty, self.in_wrapper) = saved; + } + + /// 함수 하나 — 매개변수를 바인딩하고 본문을 방문한다. 중첩 함수도 여기로 온다. + fn scan_fn(&mut self, self_ty: Option, sig: &syn::Signature, block: &syn::Block) { + let saved_env = std::mem::take(&mut self.env); + let saved_ty = std::mem::replace(&mut self.self_ty, self_ty); + let saved_mut = std::mem::replace(&mut self.mutated, mutations(block)); + let saved_wrapper = self.in_wrapper; + let own = Loc { + file: self.file.clone(), + span: sig.ident.span(), + }; + self.in_wrapper = self + .sh + .ctx + .owner_of(&own) + .is_some_and(|id| self.sh.fn_wrappers.contains_key(&id)); + self.env.push(); + for input in &sig.inputs { + if let syn::FnArg::Typed(t) = input { + let ty = self.resolve_type(&t.ty); + for name in pat_names(&t.pat) { + let b = Binding { + val: Val::Other(Origin::Param(name.clone())), + ty: ty.clone(), + }; + self.env.bind(name, b); + } + } + } + self.visit_block(block); + self.env = saved_env; + self.self_ty = saved_ty; + self.mutated = saved_mut; + self.in_wrapper = saved_wrapper; + } + + fn resolve_type(&self, ty: &syn::Type) -> Option { + self.sh + .index + .resolve_type(self.sh.ctx, &self.module, self.self_ty.as_deref(), ty) + } + + fn resolve_path(&self, segs: &[String]) -> Option { + self.sh + .index + .resolve_path(self.sh.ctx, &self.module, self.self_ty.as_deref(), segs) + } + + /// 경로를 `use`로 펼친다(외부 API 판정). + fn expand(&self, segs: &[String]) -> Vec { + self.imports.expand(segs) + } + + /// 경로가 워크스페이스 아이템을 가리키는가 — 같은 이름의 외부 API로 읽지 않기 위해서다. + fn is_workspace(&self, segs: &[String]) -> bool { + let dep = crate::modtree::DepCrates::new(); + segs.first().is_some_and(|f| f == "Self") + || (1..=segs.len()).any(|cut| { + workspace_resolve(self.sh.ctx, self.sh.index, &self.module, &segs[..cut], &dep) + .is_some() + }) + } + + // ── 타입 ───────────────────────────────────────────── + + /// 식의 타입(경로 문자열) — 수신자 판정에 필요한 만큼만 추론한다. + fn ty(&self, e: &syn::Expr) -> Option { + match e { + syn::Expr::Path(p) => { + let segs = crate::harvest::path_segments(&p.path); + if let [one] = segs.as_slice() { + if one == "self" { + return self.self_ty.clone(); + } + if let Some(b) = self.env.get(one) { + return b.ty.clone(); + } + } + let id = self.resolve_path(&segs)?; + let c = self.sh.index.consts.get(&id)?; + self.sh + .index + .resolve_type(self.sh.ctx, &c.module, c.self_ty.as_deref(), c.ty) + } + syn::Expr::Field(f) => { + let owner = self.ty(&f.base)?; + let syn::Member::Named(name) = &f.member else { + return None; + }; + self.sh + .index + .field_type(self.sh.ctx, &owner, &name.to_string()) + } + syn::Expr::Reference(r) => self.ty(&r.expr), + syn::Expr::Paren(p) => self.ty(&p.expr), + syn::Expr::Group(g) => self.ty(&g.expr), + syn::Expr::Unary(u) if matches!(u.op, syn::UnOp::Deref(_)) => self.ty(&u.expr), + syn::Expr::Try(t) => self.ty(&t.expr), + syn::Expr::Await(a) => self.ty(&a.base), + syn::Expr::Struct(s) => self.resolve_path(&crate::harvest::path_segments(&s.path)), + syn::Expr::Call(c) => self.call_ty(c), + syn::Expr::MethodCall(m) => self.method_ty(m), + _ => None, + } + } + + /// 호출식의 타입 — 알려진 생성자와 워크스페이스 함수의 반환 타입. + fn call_ty(&self, c: &syn::ExprCall) -> Option { + let syn::Expr::Path(p) = &*c.func else { + return None; + }; + let segs = crate::harvest::path_segments(&p.path); + if !self.is_workspace(&segs) { + let full = self.expand(&segs); + if let Some(t) = self.library_ctor(&full) { + return Some(t); + } + if matches!(full.as_slice(), [.., w, n] if n == "new" && matches!(w.as_str(), "Arc" | "Rc" | "Box")) + { + return c.args.first().and_then(|a| self.ty(a)); + } + } + let id = self.resolve_path(&segs)?; + if let Some(f) = self.sh.index.fns.get(&id) { + return self.sh.index.return_type(self.sh.ctx, f); + } + let (owner, name) = id.rsplit_once("::")?; + if let Some(fs) = self + .sh + .index + .methods + .get(&(owner.to_string(), name.to_string())) + { + return fs + .iter() + .find_map(|f| self.sh.index.return_type(self.sh.ctx, f)); + } + // 외부 타입의 `T::new()`·`T::default()`는 T다. + if matches!(name, "new" | "default") && !self.is_workspace(&segs) { + return Some(owner.to_string()); + } + None + } + + /// 라이브러리 생성자 경로의 타입(reqwest·ureq). + fn library_ctor(&self, full: &[String]) -> Option { + let parts: Vec<&str> = full.iter().map(String::as_str).collect(); + if let Some(lib) = &self.libs.reqwest { + let tail = match parts.split_first() { + Some((first, tail)) if first == lib => tail, + _ => &[][..], + }; + let t = match tail { + ["Client", "new" | "default"] => Some("Client"), + ["blocking", "Client", "new" | "default"] => Some("blocking::Client"), + ["Client", "builder"] | ["ClientBuilder", "new"] => Some("ClientBuilder"), + ["blocking", "Client", "builder"] | ["blocking", "ClientBuilder", "new"] => { + Some("blocking::ClientBuilder") + } + _ => None, + }; + if let Some(t) = t { + return Some(format!("{lib}::{t}")); + } + } + if let Some((lib, _)) = &self.libs.ureq { + let tail = match parts.split_first() { + Some((first, tail)) if first == lib => tail, + _ => &[][..], + }; + let t = match tail { + ["agent"] | ["Agent", "new" | "new_with_defaults" | "new_with_config"] => { + Some("Agent") + } + ["builder"] | ["AgentBuilder", "new"] => Some("AgentBuilder"), + ["Agent", "config_builder"] | ["config", "Config", "builder"] => { + Some("ConfigBuilder") + } + _ => None, + }; + if let Some(t) = t { + return Some(format!("{lib}::{t}")); + } + } + None + } + + /// 메서드 호출식의 타입. + fn method_ty(&self, m: &syn::ExprMethodCall) -> Option { + let name = m.method.to_string(); + let recv = self.ty(&m.receiver); + let pass = PASS_THROUGH.contains(&name.as_str()) + || matches!( + name.as_str(), + "get_or_init" | "get_or_try_init" | "lock" | "read" + ) + || (name == "get" && m.args.is_empty()); + if pass { + return recv; + } + let recv = recv?; + if let Some(t) = self.builder_step(&recv, &name) { + return Some(t); + } + self.sh + .index + .methods + .get(&(recv, name))? + .iter() + .find_map(|f| self.sh.index.return_type(self.sh.ctx, f)) + } + + /// 클라이언트 빌더 체인의 다음 타입. + fn builder_step(&self, recv: &str, method: &str) -> Option { + if let Some(lib) = &self.libs.reqwest { + for (builder, client) in [ + ("ClientBuilder", "Client"), + ("blocking::ClientBuilder", "blocking::Client"), + ] { + if recv == format!("{lib}::{builder}") { + return Some(if method == "build" { + format!("{lib}::{client}") + } else { + recv.to_string() + }); + } + } + } + if let Some((lib, _)) = &self.libs.ureq { + if recv == format!("{lib}::AgentBuilder") { + return Some(if method == "build" { + format!("{lib}::Agent") + } else { + recv.to_string() + }); + } + if recv == format!("{lib}::ConfigBuilder") { + return Some(if method == "build" { + format!("{lib}::Config") + } else { + recv.to_string() + }); + } + if recv == format!("{lib}::Config") && method == "new_agent" { + return Some(format!("{lib}::Agent")); + } + } + None + } + + /// 타입이 요청 클라이언트인가. + fn client_of(&self, ty: Option<&str>) -> Option { + let ty = ty?; + if let Some(lib) = &self.libs.reqwest { + if ty == format!("{lib}::Client") || ty == format!("{lib}::blocking::Client") { + return Some(Client::Reqwest); + } + } + if let Some((lib, _)) = &self.libs.ureq { + if ty == format!("{lib}::Agent") { + return Some(Client::Ureq); + } + } + None + } + + // ── 값 ─────────────────────────────────────────────── + + /// 식의 문자열·URL 값. + fn eval(&self, e: &syn::Expr) -> Val { + self.eval_depth(e, 0) + } + + fn eval_depth(&self, e: &syn::Expr, depth: usize) -> Val { + if depth > 32 { + return Val::unknown(); + } + let d = depth + 1; + match e { + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + }) => Val::Str(vec![Piece::Lit(s.value())], None), + syn::Expr::Path(p) => self.eval_path(&crate::harvest::path_segments(&p.path)), + syn::Expr::Field(f) => self.eval_field(f), + syn::Expr::Macro(m) => self.eval_macro(&m.mac, d), + syn::Expr::Binary(b) if matches!(b.op, syn::BinOp::Add(_)) => { + concat(self.eval_depth(&b.left, d), self.eval_depth(&b.right, d)) + } + syn::Expr::Reference(r) => self.eval_depth(&r.expr, d), + syn::Expr::Paren(p) => self.eval_depth(&p.expr, d), + syn::Expr::Group(g) => self.eval_depth(&g.expr, d), + syn::Expr::Unary(u) if matches!(u.op, syn::UnOp::Deref(_)) => { + self.eval_depth(&u.expr, d) + } + syn::Expr::Try(t) => self.eval_depth(&t.expr, d), + syn::Expr::MethodCall(m) => self.eval_method(m, d), + syn::Expr::Call(c) => self.eval_call(c, d), + _ => Val::unknown(), + } + } + + /// 경로 값 — 지역 변수, 문자열 상수·static. + fn eval_path(&self, segs: &[String]) -> Val { + if let [one] = segs { + if let Some(b) = self.env.get(one) { + return b.val.clone(); + } + } + match self.resolve_path(segs) { + Some(id) if self.sh.index.consts.contains_key(&id) => self.eval_const(&id), + _ => Val::unknown(), + } + } + + /// 상수·static의 값(순환 방지 캐시). + fn eval_const(&self, id: &str) -> Val { + if let Some(cached) = self.sh.consts.borrow().get(id) { + return cached + .clone() + .unwrap_or_else(|| Val::Other(Origin::Base(id.to_string()))); + } + self.sh.consts.borrow_mut().insert(id.to_string(), None); + let Some(c) = self.sh.index.consts.get(id) else { + return Val::unknown(); + }; + let scanner = Scanner { + sh: self.sh, + mode: self.mode, + module: c.module.clone(), + file: self.file.clone(), + imports: self.sh.index.imports(self.sh.ctx, &c.module), + libs: self.libs, + self_ty: c.self_ty.clone(), + env: Env::default(), + mutated: BTreeSet::new(), + in_wrapper: false, + }; + let v = scanner.eval(c.expr); + let known = match v { + Val::Str(..) | Val::Url(..) => Some(v.with_ref(Some(id.to_string()))), + Val::Other(_) => None, + }; + self.sh + .consts + .borrow_mut() + .insert(id.to_string(), known.clone()); + known.unwrap_or_else(|| Val::Other(Origin::Base(id.to_string()))) + } + + /// 필드 값 — 생성자들이 모두 같은 리터럴·상수로 채운 필드만 값이 있다. + fn eval_field(&self, f: &syn::ExprField) -> Val { + let syn::Member::Named(name) = &f.member else { + return Val::unknown(); + }; + let Some(owner) = self.ty(&f.base) else { + return Val::unknown(); + }; + if !self.sh.index.structs.contains_key(&owner) { + return Val::unknown(); + } + let name = name.to_string(); + let id = format!("{owner}::{name}"); + match self.sh.resolved.borrow().get(&(owner, name)) { + Some(v) => v.clone().with_ref(Some(id)), + None => Val::Other(Origin::Base(id)), + } + } + + /// 메서드 호출 값 — 문자열 변환, `Url::join`. + fn eval_method(&self, m: &syn::ExprMethodCall, d: usize) -> Val { + let name = m.method.to_string(); + if PASS_THROUGH.contains(&name.as_str()) { + return self.eval_depth(&m.receiver, d); + } + if name == "join" && m.args.len() == 1 { + if let Val::Url(u, r) = self.eval_depth(&m.receiver, d) { + let arg = self.eval_depth(&m.args[0], d); + return Val::Url(u.join(&arg.pieces()), r); + } + } + Val::unknown() + } + + /// 호출 값 — `Url::parse`, `String::from`, `String::new`. + fn eval_call(&self, c: &syn::ExprCall, d: usize) -> Val { + let syn::Expr::Path(p) = &*c.func else { + return Val::unknown(); + }; + let segs = crate::harvest::path_segments(&p.path); + if self.is_workspace(&segs) { + return Val::unknown(); + } + let full = self.expand(&segs); + let parts: Vec<&str> = full.iter().map(String::as_str).collect(); + let url_lib = |l: &str| l == "url" || self.libs.reqwest.as_deref() == Some(l); + match parts.as_slice() { + [lib, "Url", "parse"] if url_lib(lib) && c.args.len() == 1 => { + let arg = self.eval_depth(&c.args[0], d); + let r = arg.base_ref(); + Val::Url(UrlVal::parse(&arg.pieces(), true), r) + } + ["String", "from"] | ["std", "string", "String", "from"] if c.args.len() == 1 => { + self.eval_depth(&c.args[0], d) + } + ["String", "new"] | ["String", "default"] => Val::Str(Vec::new(), None), + _ => Val::unknown(), + } + } + + /// 매크로 값 — `format!` 계열과 `concat!`. + fn eval_macro(&self, mac: &syn::Macro, d: usize) -> Val { + let name = mac + .path + .segments + .last() + .map(|s| s.ident.to_string()) + .unwrap_or_default(); + let args = mac.parse_body_with( + syn::punctuated::Punctuated::::parse_terminated, + ); + let Ok(args) = args else { + return Val::unknown(); + }; + let args: Vec = args.into_iter().collect(); + match name.as_str() { + "format" | "format_args" => self.eval_format(&args, d), + "concat" => { + let mut pieces = Vec::new(); + for a in &args { + match a { + syn::Expr::Lit(l) => match &l.lit { + syn::Lit::Str(s) => pieces.push(Piece::Lit(s.value())), + syn::Lit::Int(i) => pieces.push(Piece::Lit(i.base10_digits().into())), + syn::Lit::Char(c) => pieces.push(Piece::Lit(c.value().to_string())), + syn::Lit::Bool(b) => pieces.push(Piece::Lit(b.value.to_string())), + _ => pieces.push(Piece::Value(Origin::Unknown)), + }, + _ => pieces.push(Piece::Value(Origin::Unknown)), + } + } + Val::Str(pieces, None) + } + _ => Val::unknown(), + } + } + + /// `format!("..", args)` — Display 자리표시자만 값을 잇고, 서식 지정이 있으면 모르는 + /// 값이다(`{:?}`는 따옴표를 붙인다). + fn eval_format(&self, args: &[syn::Expr], d: usize) -> Val { + let Some((fmt, rest)) = args.split_first() else { + return Val::unknown(); + }; + let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(fmt), + .. + }) = fmt + else { + return Val::unknown(); + }; + let mut positional: Vec<&syn::Expr> = Vec::new(); + let mut named: BTreeMap = BTreeMap::new(); + for a in rest { + match a { + syn::Expr::Assign(asg) => { + if let syn::Expr::Path(p) = &*asg.left { + if let Some(id) = p.path.get_ident() { + named.insert(id.to_string(), &asg.right); + continue; + } + } + return Val::unknown(); + } + other => positional.push(other), + } + } + let Some(segments) = parse_format(&fmt.value()) else { + return Val::unknown(); + }; + let mut pieces = Vec::new(); + let mut next = 0usize; + let mut lead_ref = None; + for (i, seg) in segments.into_iter().enumerate() { + match seg { + FmtSeg::Lit(t) => pieces.push(Piece::Lit(t)), + FmtSeg::Arg { key, spec } => { + let v = match key { + FmtKey::Next => { + next += 1; + positional.get(next - 1).map(|e| self.eval_depth(e, d)) + } + FmtKey::Index(n) => positional.get(n).map(|e| self.eval_depth(e, d)), + FmtKey::Name(n) => match named.get(&n) { + Some(e) => Some(self.eval_depth(e, d)), + None => Some(self.eval_path(&[n])), + }, + }; + let Some(v) = v else { + return Val::unknown(); + }; + if i == 0 { + lead_ref = v.base_ref(); + } + if spec.is_empty() { + pieces.extend(v.pieces()); + } else { + let origin = match v { + Val::Other(o) => o, + _ => Origin::Unknown, + }; + pieces.push(Piece::Value(origin)); + } + } + } + } + Val::Str(pieces, lead_ref) + } + + /// 지역 변수 초기식 — 모든 가지가 `?`로 시작하거나 빈 문자열이면 query 꼬리다. + fn eval_local(&self, e: &syn::Expr) -> Val { + if matches!(e, syn::Expr::If(_) | syn::Expr::Match(_)) { + let mut branches = Vec::new(); + if branch_tails(e, &mut branches) + && !branches.is_empty() + && branches.iter().all(|b| self.is_query_like(b)) + { + return Val::Str(vec![Piece::QueryTail], None); + } + return Val::unknown(); + } + self.eval(e) + } + + /// 값이 빈 문자열이거나 `?` 리터럴로 시작하는가. + fn is_query_like(&self, e: &syn::Expr) -> bool { + match self.eval(e) { + Val::Str(p, _) => match p.first() { + None => true, + Some(Piece::Lit(l)) => l.is_empty() || l.starts_with('?'), + Some(_) => false, + }, + _ => false, + } + } + + // ── 호출 사실 ───────────────────────────────────────── + + /// 호출 위치의 CallSite를 기록한다(`Emit` 단계만). + /// + /// `method_param`은 동사 식이 매개변수인지다 — 동사를 흘려보내는 함수도 선언되지 + /// 않은 래퍼 싱크다. + fn record( + &self, + span: proc_macro2::Span, + method: Option, + v: &Val, + lib: Client, + method_param: bool, + ) { + let dots = !(lib == Client::Ureq && self.libs.ureq.as_ref().is_some_and(|(_, m)| *m >= 3)); + let outcome = match v { + Val::Str(p, _) => UrlVal::parse(p, dots).outcome(), + Val::Url(u, _) => u.outcome(), + Val::Other(_) => dynamic(), + }; + let dynamic_path = matches!(outcome, Outcome::Dynamic { .. }); + let param_driven = (dynamic_path && passes_param(&v.pieces())) || method_param; + self.push_call( + span, + method, + outcome, + v.base_ref(), + None, + None, + param_driven, + ); + } + + /// 식이 감싸는 함수의 매개변수 그대로인가. + fn is_param(&self, e: &syn::Expr) -> bool { + match strip(e) { + syn::Expr::Path(p) => p.path.get_ident().is_some_and(|id| { + matches!( + self.env.get(&id.to_string()), + Some(Binding { + val: Val::Other(Origin::Param(_)), + .. + }) + ) + }), + _ => false, + } + } + + /// 사실 하나를 결과에 넣는다. 선언된 래퍼 본문의 dynamic 호출은 싣지 않는다. + #[allow(clippy::too_many_arguments)] + fn push_call( + &self, + span: proc_macro2::Span, + method: Option, + outcome: Outcome, + base_ref: Option, + service: Option, + wrapper: Option, + param_driven: bool, + ) { + if self.mode != Mode::Emit { + return; + } + let mut out = self.sh.out.borrow_mut(); + if let Some(i) = wrapper { + *out.wrapper_calls.entry(i).or_default() += 1; + } + if outcome == Outcome::Unrequestable { + out.unrequestable += 1; + return; + } + let unproven = matches!(outcome, Outcome::Dynamic { .. }) || method.is_none(); + if unproven && self.in_wrapper && wrapper.is_none() { + return; + } + if unproven && param_driven && wrapper.is_none() { + out.undeclared += 1; + } + out.calls.push(CallSite { + loc: Loc { + file: self.file.clone(), + span, + }, + method, + outcome, + base_ref, + service, + }); + } + + /// `reqwest::Method::GET`·`http::Method::GET` 경로의 동사 — 라이브러리 상수만 푼다. + fn library_method(&self, e: &syn::Expr) -> Option { + let syn::Expr::Path(p) = strip(e) else { + return None; + }; + let full = self.expand(&crate::harvest::path_segments(&p.path)); + let parts: Vec<&str> = full.iter().map(String::as_str).collect(); + let lib_ok = |l: &str| l == "http" || self.libs.reqwest.as_deref() == Some(l); + match parts.as_slice() { + [lib, "Method", v] if lib_ok(lib) && VERBS.contains(v) => Some(v.to_string()), + _ => None, + } + } + + /// 문자열 리터럴(상수 포함) 동사 — ureq `request("GET", url)`. + fn literal_method(&self, e: &syn::Expr) -> Option { + match self.eval(e) { + Val::Str(p, _) => match p.as_slice() { + [Piece::Lit(s)] if VERBS.contains(&s.as_str()) => Some(s.clone()), + _ => None, + }, + _ => None, + } + } + + /// 자유 함수·연관 함수 호출이 요청·래퍼 호출인가. + fn check_call(&mut self, c: &syn::ExprCall) { + let syn::Expr::Path(p) = &*c.func else { + return; + }; + let segs = crate::harvest::path_segments(&p.path); + if self.is_workspace(&segs) { + self.check_wrapper_call(c, &segs); + return; + } + let full = self.expand(&segs); + let parts: Vec<&str> = full.iter().map(String::as_str).collect(); + let args: Vec<&syn::Expr> = c.args.iter().collect(); + let span = c.span(); + if let Some(lib) = self.libs.reqwest.clone() { + match (parts.as_slice(), args.as_slice()) { + ([l, "get"] | [l, "blocking", "get"], [url]) if *l == lib => { + let v = self.eval(url); + self.record(span, Some("GET".into()), &v, Client::Reqwest, false); + } + ([l, "Request", "new"] | [l, "blocking", "Request", "new"], [m, url]) + if *l == lib => + { + let method = self.library_method(m); + let v = self.eval(url); + self.record(span, method, &v, Client::Reqwest, self.is_param(m)); + } + _ => {} + } + } + if let Some((lib, _)) = self.libs.ureq.clone() { + match (parts.as_slice(), args.as_slice()) { + ([l, verb], [url]) if *l == lib && is_verb_method(Client::Ureq, verb) => { + let v = self.eval(url); + self.record( + span, + Some(verb.to_ascii_uppercase()), + &v, + Client::Ureq, + false, + ); + } + ([l, "request" | "request_url"], [m, url]) if *l == lib => { + let method = self.literal_method(m); + let v = self.eval(url); + self.record(span, method, &v, Client::Ureq, self.is_param(m)); + } + _ => {} + } + } + } + + /// 메서드 호출이 클라이언트 요청·래퍼 호출인가. 보내기 메서드면 수신자를 + /// 증명하지 못한 요청 모양 호출을 센다. + fn check_method(&mut self, m: &syn::ExprMethodCall) { + let name = m.method.to_string(); + let recv_ty = self.ty(&m.receiver); + let client = self.client_of(recv_ty.as_deref()); + let args: Vec<&syn::Expr> = m.args.iter().collect(); + let span = m.span(); + match (client, name.as_str(), args.as_slice()) { + (Some(k), verb, [url]) if is_verb_method(k, verb) => { + let v = self.eval(url); + self.record(span, Some(verb.to_ascii_uppercase()), &v, k, false); + return; + } + (Some(k), "request", [method_expr, url]) => { + let method = match k { + Client::Reqwest => self.library_method(method_expr), + Client::Ureq => self.literal_method(method_expr), + }; + let v = self.eval(url); + self.record(span, method, &v, k, self.is_param(method_expr)); + return; + } + _ => {} + } + if SEND_METHODS.contains(&name.as_str()) && self.mode == Mode::Emit { + if let Some(verb_call) = request_call_in_chain(&m.receiver) { + if self + .client_of(self.ty(&verb_call.receiver).as_deref()) + .is_none() + && (self.libs.reqwest.is_some() || self.libs.ureq.is_some()) + { + self.sh.out.borrow_mut().unproven += 1; + } + } + } + if let Some(owner) = recv_ty { + self.check_wrapper_method(m, &owner, &name); + } + } + + /// 워크스페이스 함수·연관 함수·튜플 구조체 생성이 선언된 래퍼인가. + fn check_wrapper_call(&mut self, c: &syn::ExprCall, segs: &[String]) { + let Some(id) = self.resolve_path(segs) else { + return; + }; + let args: Vec = c.args.iter().map(|a| self.call_arg(None, a)).collect(); + let exprs: Vec<&syn::Expr> = c.args.iter().collect(); + if let Some(&i) = self.sh.ctor_wrappers.get(&id) { + self.emit_wrapper(i, c.span(), &args, &exprs); + return; + } + let vertex = self.sh.ctx.resolve_vertex(&self.module, segs).unwrap_or(id); + let Some(&i) = self.sh.fn_wrappers.get(&vertex) else { + return; + }; + // 메서드를 경로로 부르면(UFCS) 첫 인자가 수신자다 — 선언 index는 수신자를 뺀다. + let skip = usize::from(self.has_receiver(&vertex)); + self.emit_wrapper( + i, + c.span(), + &args[skip.min(args.len())..], + &exprs[skip.min(exprs.len())..], + ); + } + + /// 정점 ID가 self 수신자를 받는 메서드인가. + fn has_receiver(&self, vertex: &str) -> bool { + let Some((owner, name)) = vertex.rsplit_once("::") else { + return false; + }; + let owner = owner.split("::<").next().unwrap_or(owner); + self.sh + .index + .methods + .get(&(owner.to_string(), name.to_string())) + .is_some_and(|fs| fs.iter().any(|f| f.sig.receiver().is_some())) + } + + /// 수신자 타입이 래퍼 소유 타입인 메서드 호출. + fn check_wrapper_method(&mut self, m: &syn::ExprMethodCall, owner: &str, name: &str) { + let inherent = format!("{owner}::{name}"); + let trait_prefix = format!("{owner}::<"); + let suffix = format!(">::{name}"); + let hit = self.sh.fn_wrappers.iter().find(|(id, _)| { + **id == inherent || (id.starts_with(&trait_prefix) && id.ends_with(&suffix)) + }); + let Some((_, &i)) = hit else { + return; + }; + let args: Vec = m.args.iter().map(|a| self.call_arg(None, a)).collect(); + let exprs: Vec<&syn::Expr> = m.args.iter().collect(); + self.emit_wrapper(i, m.span(), &args, &exprs); + } + + /// 구조체 리터럴 생성 래퍼(`Endpoint { method, path }`). + fn check_wrapper_struct(&mut self, s: &syn::ExprStruct) { + let Some(id) = self.resolve_path(&crate::harvest::path_segments(&s.path)) else { + return; + }; + let Some(&i) = self.sh.ctor_wrappers.get(&id) else { + return; + }; + let mut args = Vec::new(); + let mut exprs = Vec::new(); + for fv in &s.fields { + let label = match &fv.member { + syn::Member::Named(n) => Some(n.to_string()), + syn::Member::Unnamed(_) => None, + }; + args.push(self.call_arg(label, &fv.expr)); + exprs.push(&fv.expr); + } + self.emit_wrapper(i, s.span(), &args, &exprs); + } + + /// 호출 인자 하나를 바인딩 입력으로 바꾼다. + fn call_arg(&self, label: Option, e: &syn::Expr) -> CallArg { + let value = match strip(e) { + syn::Expr::Path(p) => { + let segs = crate::harvest::path_segments(&p.path); + let local = matches!(segs.as_slice(), [one] if self.env.get(one).is_some()); + match self.eval_path(&segs) { + Val::Str(pieces, _) if !local => match pieces.as_slice() { + [Piece::Lit(s)] => ArgValue::Literal(s.clone()), + _ => ArgValue::Other, + }, + _ if local => ArgValue::Other, + _ => segs + .last() + .map_or(ArgValue::Other, |s| ArgValue::EnumCase(s.clone())), + } + } + other => match self.eval(other) { + Val::Str(pieces, _) => match pieces.as_slice() { + [Piece::Lit(s)] => ArgValue::Literal(s.clone()), + _ => ArgValue::Other, + }, + _ => ArgValue::Other, + }, + }; + CallArg { label, value } + } + + /// 선언된 래퍼 호출을 사실로 낸다. + fn emit_wrapper( + &mut self, + i: usize, + span: proc_macro2::Span, + args: &[CallArg], + exprs: &[&syn::Expr], + ) { + let w = &self.sh.wrappers[i]; + let method = wrappers::bind_method(&w.method_spec(), args); + let path_pos = wrappers::find_arg(&w.path_arg, args) + .and_then(|a| args.iter().position(|x| std::ptr::eq(x, a))); + let anchor = if w.path_anchor == "root" { + PathAnchor::Root + } else { + PathAnchor::Base + }; + // 래퍼 경로 인자는 base 뒤의 경로라 baseRef를 싣지 않는다(base는 래퍼 안에 있다). + let outcome = match path_pos.and_then(|p| exprs.get(p)) { + Some(e) => wrapper_outcome(&self.eval(e).pieces(), anchor), + None => dynamic(), + }; + let service = w.service.clone(); + self.push_call(span, method, outcome, None, service, Some(i), false); + } + + /// 모델링하지 않는 클라이언트 크레이트 경로의 사용을 센다. + fn check_unmodelled(&mut self, segs: &[String]) { + if self.mode != Mode::Emit || self.libs.unmodelled.is_empty() || self.is_workspace(segs) { + return; + } + let full = self.expand(segs); + let Some(first) = full.first() else { return }; + if !self.libs.unmodelled.contains(first) { + return; + } + // hyper·hyper-util은 서버에도 쓰인다 — client 경로만 센다. + let server_capable = first == "hyper" || first == "hyper_util"; + if server_capable && !full.iter().any(|s| s == "client" || s == "Client") { + return; + } + *self + .sh + .out + .borrow_mut() + .unmodelled + .entry(first.clone()) + .or_default() += 1; + } + + /// 패턴의 이름들을 바인딩한다. 단순 이름이면 값·타입을 싣고, 분해 패턴은 모르는 값이다. + fn bind_pat(&mut self, pat: &syn::Pat, val: Val, ty: Option) { + match pat { + syn::Pat::Ident(i) if i.subpat.is_none() => { + let name = i.ident.to_string(); + let val = if self.mutated.contains(&name) { + Val::unknown() + } else { + val + }; + self.env.bind(name, Binding { val, ty }); + } + syn::Pat::Type(t) => { + let ty = self.resolve_type(&t.ty).or(ty); + self.bind_pat(&t.pat, val, ty); + } + other => { + for name in pat_names(other) { + self.env.bind( + name, + Binding { + val: Val::unknown(), + ty: None, + }, + ); + } + } + } + } + + /// 조건식 안의 `let` 패턴 이름을 바인딩한다(`if let`·`while let`·let 체인). + fn bind_lets(&mut self, cond: &syn::Expr) { + match cond { + syn::Expr::Let(l) => { + let v = Val::unknown(); + self.bind_pat(&l.pat, v, None); + } + syn::Expr::Binary(b) if matches!(b.op, syn::BinOp::And(_)) => { + self.bind_lets(&b.left); + self.bind_lets(&b.right); + } + syn::Expr::Paren(p) => self.bind_lets(&p.expr), + _ => {} + } + } +} + +impl<'ast> Visit<'ast> for Scanner<'_, '_> { + fn visit_block(&mut self, b: &'ast syn::Block) { + self.env.push(); + syn::visit::visit_block(self, b); + self.env.pop(); + } + + fn visit_local(&mut self, l: &'ast syn::Local) { + if let Some(init) = &l.init { + self.visit_expr(&init.expr); + if let Some((_, div)) = &init.diverge { + self.visit_expr(div); + } + } + let (val, ty) = match &l.init { + Some(init) if init.diverge.is_none() => { + (self.eval_local(&init.expr), self.ty(&init.expr)) + } + _ => (Val::unknown(), None), + }; + self.bind_pat(&l.pat, val, ty); + } + + fn visit_expr_closure(&mut self, c: &'ast syn::ExprClosure) { + self.env.push(); + for input in &c.inputs { + self.bind_pat(input, Val::unknown(), None); + } + self.visit_expr(&c.body); + self.env.pop(); + } + + fn visit_expr_if(&mut self, e: &'ast syn::ExprIf) { + self.visit_expr(&e.cond); + self.env.push(); + self.bind_lets(&e.cond); + self.visit_block(&e.then_branch); + self.env.pop(); + if let Some((_, other)) = &e.else_branch { + self.visit_expr(other); + } + } + + fn visit_expr_while(&mut self, e: &'ast syn::ExprWhile) { + self.visit_expr(&e.cond); + self.env.push(); + self.bind_lets(&e.cond); + self.visit_block(&e.body); + self.env.pop(); + } + + fn visit_expr_for_loop(&mut self, e: &'ast syn::ExprForLoop) { + self.visit_expr(&e.expr); + self.env.push(); + self.bind_pat(&e.pat, Val::unknown(), None); + self.visit_block(&e.body); + self.env.pop(); + } + + fn visit_arm(&mut self, a: &'ast syn::Arm) { + self.env.push(); + self.bind_pat(&a.pat, Val::unknown(), None); + if let Some((_, g)) = &a.guard { + self.visit_expr(g); + } + self.visit_expr(&a.body); + self.env.pop(); + } + + fn visit_item_fn(&mut self, f: &'ast syn::ItemFn) { + if !is_test_item(&f.attrs) { + self.scan_fn(None, &f.sig, &f.block); + } + } + + fn visit_item_impl(&mut self, i: &'ast syn::ItemImpl) { + if !is_test_item(&i.attrs) { + self.scan_impl(i); + } + } + + fn visit_item_mod(&mut self, _: &'ast syn::ItemMod) { + // 모듈은 모듈 트리가 따로 훑는다(테스트 모듈 제외 규칙 포함). + } + + fn visit_macro(&mut self, mac: &'ast syn::Macro) { + // 매크로 인자가 쉼표로 나뉜 식이면 그 안의 요청도 본다(`tokio::join!` 등). + let parsed = mac.parse_body_with( + syn::punctuated::Punctuated::::parse_terminated, + ); + if let Ok(exprs) = parsed { + for e in &exprs { + self.visit_expr(e); + } + } + } + + fn visit_expr_call(&mut self, c: &'ast syn::ExprCall) { + self.check_call(c); + syn::visit::visit_expr_call(self, c); + } + + fn visit_expr_method_call(&mut self, m: &'ast syn::ExprMethodCall) { + if MUTATORS.contains(&m.method.to_string().as_str()) { + self.poison_field(&m.receiver); + } + self.check_method(m); + syn::visit::visit_expr_method_call(self, m); + } + + fn visit_expr_struct(&mut self, s: &'ast syn::ExprStruct) { + match self.mode { + Mode::Collect => self.collect_struct(s), + Mode::Emit => self.check_wrapper_struct(s), + } + syn::visit::visit_expr_struct(self, s); + } + + fn visit_expr_path(&mut self, p: &'ast syn::ExprPath) { + self.check_unmodelled(&crate::harvest::path_segments(&p.path)); + syn::visit::visit_expr_path(self, p); + } + + fn visit_expr_assign(&mut self, a: &'ast syn::ExprAssign) { + self.poison_field(&a.left); + syn::visit::visit_expr_assign(self, a); + } + + fn visit_expr_binary(&mut self, b: &'ast syn::ExprBinary) { + if is_compound_assign(&b.op) { + self.poison_field(&b.left); + } + syn::visit::visit_expr_binary(self, b); + } + + fn visit_expr_reference(&mut self, r: &'ast syn::ExprReference) { + if r.mutability.is_some() { + self.poison_field(&r.expr); + } + syn::visit::visit_expr_reference(self, r); + } +} + +impl Scanner<'_, '_> { + /// 구조체 리터럴의 필드 값을 누적한다(`Collect`). + fn collect_struct(&self, s: &syn::ExprStruct) { + let Some(id) = self.resolve_path(&crate::harvest::path_segments(&s.path)) else { + return; + }; + let Some(info) = self.sh.index.structs.get(&id) else { + return; + }; + let mut listed = BTreeSet::new(); + for fv in &s.fields { + if let syn::Member::Named(n) = &fv.member { + let name = n.to_string(); + self.sh.add_field(&id, &name, self.eval(&fv.expr)); + listed.insert(name); + } + } + if s.rest.is_some() { + for f in info.fields.keys().filter(|f| !listed.contains(*f)) { + self.sh.add_field(&id, f, Val::unknown()); + } + } + } + + /// 필드를 제자리 수정하는 식이면 그 필드 이름을 믿지 않는다(`Collect`). + fn poison_field(&self, target: &syn::Expr) { + if self.mode != Mode::Collect { + return; + } + if let syn::Expr::Field(f) = strip(target) { + if let syn::Member::Named(n) = &f.member { + let owner = self.ty(&f.base); + self.sh.poisoned.borrow_mut().insert((owner, n.to_string())); + } + } + } +} + +/// 리터럴·상수만으로 된 값인가 — 생성 위치의 매개변수·지역 값에서 온 필드는 +/// 인스턴스마다 다르므로 필드 값으로 확정하지 않는다. +fn is_constant(v: &Val) -> bool { + let all_lit = |p: &[Piece]| p.iter().all(|x| matches!(x, Piece::Lit(_))); + match v { + Val::Str(p, _) => all_lit(p), + Val::Url(u, _) => { + u.authority.is_some() + && matches!(&u.path, super::super::compose::UrlPath::Known { anchor: PathAnchor::Root, pieces } if all_lit(pieces)) + } + Val::Other(_) => false, + } +} + +/// 매개변수를 URL로 그대로 흘려보내는 모양인가 — URL 전체가 매개변수이거나, 끝의 +/// 매개변수가 `/` 없이 앞 리터럴(보통 base)에 붙는다. 세그먼트 일부를 채우는 +/// 매개변수(`/files/{name}.json`)는 래퍼 선언으로 풀리지 않으므로 세지 않는다. +fn passes_param(pieces: &[Piece]) -> bool { + match pieces { + [Piece::Value(Origin::Param(_))] => true, + [.., Piece::Lit(prev), Piece::Value(Origin::Param(_))] => !prev.ends_with('/'), + [.., Piece::Value(_), Piece::Value(Origin::Param(_))] => true, + _ => false, + } +} + +/// 요청 라이브러리가 그 동사 메서드를 갖는가. +fn is_verb_method(k: Client, name: &str) -> bool { + let reqwest = ["get", "post", "put", "patch", "delete", "head"]; + let ureq = [ + "get", "post", "put", "patch", "delete", "head", "options", "trace", + ]; + match k { + Client::Reqwest => reqwest.contains(&name), + Client::Ureq => ureq.contains(&name), + } +} + +/// 보내기 호출의 수신자 체인에서 빌더 메서드를 건너 동사·`request` 호출을 찾는다. +fn request_call_in_chain(e: &syn::Expr) -> Option<&syn::ExprMethodCall> { + let syn::Expr::MethodCall(m) = strip(e) else { + return None; + }; + let name = m.method.to_string(); + let verb = ["get", "post", "put", "patch", "delete", "head"].contains(&name.as_str()) + && m.args.len() == 1; + if verb || (name == "request" && m.args.len() == 2) { + return Some(m); + } + if BUILDER_METHODS.contains(&name.as_str()) { + return request_call_in_chain(&m.receiver); + } + None +} + +/// 래퍼 경로 인자의 조립 결과 — 전체 URL이면 절대 해석, `/`로 시작하면 선언 +/// 앵커, 상대 경로는 결합 방식을 모르므로 dynamic(base 앵커면 `ambiguous-base-join:`). +fn wrapper_outcome(pieces: &[Piece], anchor: PathAnchor) -> Outcome { + match pieces.first() { + Some(Piece::Lit(l)) if l.contains("://") => UrlVal::parse(pieces, true).outcome(), + Some(Piece::Lit(l)) if l.starts_with('/') => { + super::super::compose::compose_path(anchor, pieces, None, false) + } + Some(Piece::Lit(_)) => Outcome::Dynamic { + prefix: None, + anchor, + ambiguous: anchor == PathAnchor::Base, + masked_segments: 0, + }, + _ => Outcome::Dynamic { + prefix: None, + anchor, + ambiguous: false, + masked_segments: 0, + }, + } +} + +/// 접두사 없는 dynamic. +fn dynamic() -> Outcome { + Outcome::Dynamic { + prefix: None, + anchor: PathAnchor::Base, + ambiguous: false, + masked_segments: 0, + } +} + +/// 두 값을 문자열로 잇는다(`a + b`). +fn concat(a: Val, b: Val) -> Val { + let r = match &a { + Val::Str(p, _) if p.is_empty() => b.base_ref(), + _ => a.base_ref(), + }; + let mut pieces = a.pieces(); + pieces.extend(b.pieces()); + Val::Str(pieces, r) +} + +/// 괄호·참조·그룹을 벗긴다. +fn strip(e: &syn::Expr) -> &syn::Expr { + match e { + syn::Expr::Paren(p) => strip(&p.expr), + syn::Expr::Reference(r) => strip(&r.expr), + syn::Expr::Group(g) => strip(&g.expr), + other => other, + } +} + +/// 복합 대입 연산자인가(`+=` 등). +fn is_compound_assign(op: &syn::BinOp) -> bool { + use syn::BinOp::*; + matches!( + op, + AddAssign(_) + | SubAssign(_) + | MulAssign(_) + | DivAssign(_) + | RemAssign(_) + | BitXorAssign(_) + | BitAndAssign(_) + | BitOrAssign(_) + | ShlAssign(_) + | ShrAssign(_) + ) +} + +/// 패턴이 묶는 모든 이름. +fn pat_names(p: &syn::Pat) -> Vec { + struct Names(Vec); + impl<'ast> Visit<'ast> for Names { + fn visit_pat_ident(&mut self, i: &'ast syn::PatIdent) { + self.0.push(i.ident.to_string()); + syn::visit::visit_pat_ident(self, i); + } + } + let mut n = Names(Vec::new()); + n.visit_pat(p); + n.0 +} + +/// 함수 본문에서 제자리 수정되는 지역 변수 이름(대입·복합 대입·`&mut`·수정 메서드). +fn mutations(block: &syn::Block) -> BTreeSet { + struct Finder(BTreeSet); + impl Finder { + fn target(&mut self, e: &syn::Expr) { + if let syn::Expr::Path(p) = strip(e) { + if let Some(id) = p.path.get_ident() { + self.0.insert(id.to_string()); + } + } + } + } + impl<'ast> Visit<'ast> for Finder { + fn visit_expr_assign(&mut self, a: &'ast syn::ExprAssign) { + self.target(&a.left); + syn::visit::visit_expr_assign(self, a); + } + fn visit_expr_binary(&mut self, b: &'ast syn::ExprBinary) { + if is_compound_assign(&b.op) { + self.target(&b.left); + } + syn::visit::visit_expr_binary(self, b); + } + fn visit_expr_reference(&mut self, r: &'ast syn::ExprReference) { + if r.mutability.is_some() { + self.target(&r.expr); + } + syn::visit::visit_expr_reference(self, r); + } + fn visit_expr_method_call(&mut self, m: &'ast syn::ExprMethodCall) { + if MUTATORS.contains(&m.method.to_string().as_str()) { + self.target(&m.receiver); + } + syn::visit::visit_expr_method_call(self, m); + } + } + let mut f = Finder(BTreeSet::new()); + f.visit_block(block); + f.0 +} + +/// `if`/`match` 식의 가지 끝 식들. 끝 식이 없는 가지가 있으면 false. +fn branch_tails<'e>(e: &'e syn::Expr, out: &mut Vec<&'e syn::Expr>) -> bool { + match e { + syn::Expr::If(i) => { + let Some((_, other)) = &i.else_branch else { + return false; + }; + block_tail(&i.then_branch).is_some_and(|t| branch_tails(t, out)) + && branch_tails(other, out) + } + syn::Expr::Match(m) => m.arms.iter().all(|a| branch_tails(&a.body, out)), + syn::Expr::Block(b) => block_tail(&b.block).is_some_and(|t| branch_tails(t, out)), + syn::Expr::Paren(p) => branch_tails(&p.expr, out), + other => { + out.push(other); + true + } + } +} + +/// 블록의 끝 식(세미콜론 없는 마지막 식). +fn block_tail(b: &syn::Block) -> Option<&syn::Expr> { + match b.stmts.last() { + Some(syn::Stmt::Expr(e, None)) => Some(e), + _ => None, + } +} + +/// `format!` 서식 문자열 조각. +enum FmtSeg { + Lit(String), + Arg { key: FmtKey, spec: String }, +} + +/// 자리표시자가 가리키는 인자. +enum FmtKey { + Next, + Index(usize), + Name(String), +} + +/// 서식 문자열을 읽는다. 너비·정밀도를 인자로 받는 `$`·`*` 지정처럼 인자 순서를 +/// 바꾸는 서식은 None(값 전체를 모른다). +fn parse_format(fmt: &str) -> Option> { + let mut out = Vec::new(); + let mut lit = String::new(); + let mut chars = fmt.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '{' if chars.peek() == Some(&'{') => { + chars.next(); + lit.push('{'); + } + '}' if chars.peek() == Some(&'}') => { + chars.next(); + lit.push('}'); + } + '{' => { + let mut inner = String::new(); + for c in chars.by_ref() { + if c == '}' { + break; + } + inner.push(c); + } + if !lit.is_empty() { + out.push(FmtSeg::Lit(std::mem::take(&mut lit))); + } + let (name, spec) = inner.split_once(':').unwrap_or((&inner, "")); + if spec.contains(['$', '*']) { + return None; + } + let name = name.trim(); + let key = if name.is_empty() { + FmtKey::Next + } else if let Ok(n) = name.parse::() { + FmtKey::Index(n) + } else { + FmtKey::Name(name.to_string()) + }; + out.push(FmtSeg::Arg { + key, + spec: spec.to_string(), + }); + } + other => lit.push(other), + } + } + if !lit.is_empty() { + out.push(FmtSeg::Lit(lit)); + } + Some(out) +} diff --git a/src/source/routes/common.rs b/src/source/routes/common.rs index 417ff67..d97a016 100644 --- a/src/source/routes/common.rs +++ b/src/source/routes/common.rs @@ -277,6 +277,17 @@ impl<'a> Ctx<'a> { /// 계약의 위치(루트 기준 경로, 1 기반 줄, UTF-16 열)다. pub fn locate(&self, loc: &Loc) -> Option { + self.locate_with(loc, |c| c.len_utf16() as u32) + } + + /// 계약의 위치 — 열은 GRAPH-EXCHANGE가 정한 UTF-8 바이트 오프셋 + 1이다. + /// 호출 측 사실(`wrapper.location`)이 이 열을 쓴다. + pub fn locate_utf8(&self, loc: &Loc) -> Option { + self.locate_with(loc, |c| c.len_utf8() as u32) + } + + /// 열 단위(`unit`: 문자 하나의 길이)를 골라 위치를 계산한다. + fn locate_with(&self, loc: &Loc, unit: fn(char) -> u32) -> Option { let start = loc.span.start(); if start.line == 0 { return None; @@ -289,13 +300,8 @@ impl<'a> Ctx<'a> { .or_insert_with(|| std::fs::read_to_string(&loc.file).ok()) .as_ref()?; let line_text = text.lines().nth(start.line - 1)?; - // proc-macro2의 열은 문자 수다 — UTF-16 코드 단위로 바꾼다. - let column = line_text - .chars() - .take(start.column) - .map(|c| c.len_utf16() as u32) - .sum::() - + 1; + // proc-macro2의 열은 문자 수다 — 요청한 단위로 바꾼다. + let column = line_text.chars().take(start.column).map(unit).sum::() + 1; Some(BridgeLocation { path: rel.to_string_lossy().replace('\\', "/"), line: start.line as u32, From e999beced876ef64061f983c61b8dca5b4f7cdd1 Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 15:27:30 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(cli):=20routes=20--role=20client=C2=B7?= =?UTF-8?q?--wrappers=C2=B7--service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --role은 계속 필수다. client는 --dir·--out·--wrappers·--service만 받고, 선언 파일 오류·빈 service는 사용법 오류(2)다. 역할마다 허용 플래그를 나눠 server의 --framework와 client의 --wrappers가 서로 조용히 먹히지 않게 했다. verify-cli-contract에 client 문서 필드, 선언 오류, 모든 호출 usr가 impact 정점인지(--roots-from)를 더했다. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/verify-cli-contract.sh | 46 ++++++++++++++++++--- src/cli.rs | 74 +++++++++++++++++++++++++--------- src/cli_args.rs | 2 + 3 files changed, 97 insertions(+), 25 deletions(-) diff --git a/scripts/verify-cli-contract.sh b/scripts/verify-cli-contract.sh index 24244c3..a267c14 100755 --- a/scripts/verify-cli-contract.sh +++ b/scripts/verify-cli-contract.sh @@ -167,12 +167,17 @@ if [ "$got" -ne 0 ]; then fails=$((fails+1)) fi -# routes --role server — isthmus http 서버 문서. --role은 필수이고 server만 -# 받는다(생략을 server로 읽으면 client를 더할 때 명령의 뜻이 바뀐다). -# 사실 0건이어도 roles가 있으니 target은 http다(계약의 http 예외). +# routes --role server|client — isthmus http 문서. --role은 필수다(생략을 한쪽으로 +# 읽으면 명령의 뜻이 역할에 따라 흔들린다). 사실 0건이어도 roles가 있으니 target은 +# http다(계약의 http 예외). check 0 "routes" routes --role server check 2 "routes no role" routes -check 2 "routes client role" routes --role client +check 2 "routes bad role" routes --role proxy +check 0 "routes client" routes --role client +check 2 "routes client fw" routes --role client --framework axum +check 2 "routes server wrap" routes --role server --wrappers x.json +check 2 "routes client wrap" routes --role client --wrappers /nonexistent-xyz.json +check 2 "routes client svc" routes --role client --service "" check 2 "routes bad fw" routes --role server --framework rocket check 2 "routes semantic" routes --role server --semantic check 2 "routes positional" routes --role server stray @@ -189,6 +194,37 @@ routes_field "routes platform" '"platform": "rust"' "$FIX/fix routes_field "routes axum" '"dispatch": "specificity"' "$FIX/fixture-routes/axum08" routes_field "routes actix" '"dispatch": "registration-order"' "$FIX/fixture-routes/actix" routes_field "routes decl" '"kind": "route-decl"' "$FIX/fixture-routes/actix" +# 클라이언트 문서 — reqwest·ureq 호출과 선언된 래퍼. 선언 오류는 사용법 오류(2)다. +cp -R tests/fixture-client "$FIX/fixture-client" +client_field() { # client_field <설명> <패턴> <추가 인자...> + local desc="$1" pat="$2"; shift 2 + "$BIN" routes --role client --dir "$FIX/fixture-client/app" "$@" 2>/dev/null | grep -q "$pat" || { + echo "FAIL $desc: missing $pat" >&2 + fails=$((fails+1)) + } +} +client_field "routes client roles" '"client"' +client_field "routes client call" '"kind": "route-call"' +client_field "routes client wrapper" '"/w/orders"' --wrappers "$FIX/fixture-client/http-wrappers.json" +client_field "routes client service" '"service": "mobile"' --service mobile +echo '{"format":"http-wrappers","version":1,"wrappers":[],"extra":1}' > "$FIX/bad-wrappers.json" +got=0 +"$BIN" routes --role client --dir "$FIX/fixture-client/app" --wrappers "$FIX/bad-wrappers.json" >/dev/null 2>&1 || got=$? +if [ "$got" -ne 2 ]; then + echo "FAIL routes client bad wrappers: expected 2, got $got" >&2 + fails=$((fails+1)) +fi +# 호출 usr도 그래프 정점이어야 impact로 이어진다. +"$BIN" routes --role client --dir "$FIX/fixture-client/app" --out "$FIX/client.json" >/dev/null 2>&1 || { + echo "FAIL routes client --out: document not written" >&2 + fails=$((fails+1)) +} +got=0 +"$BIN" impact --format language-traversal --dir "$FIX/fixture-client/app" --roots-from "$FIX/client.json" >/dev/null 2>&1 || got=$? +if [ "$got" -ne 0 ]; then + echo "FAIL routes client usr roots: expected 0 (every call usr is a vertex), got $got" >&2 + fails=$((fails+1)) +fi # 핸들러 usr는 그래프 정점이어야 reach로 이어진다 — 문서의 usr를 root로 준다. "$BIN" routes --role server --dir "$FIX/fixture-routes/axum08" --out "$FIX/routes.json" >/dev/null 2>&1 || { echo "FAIL routes --out: document not written" >&2 @@ -203,7 +239,7 @@ fi # --dir를 붙이지 않는 검사 — check()는 항상 fixture dir을 뒤에 붙이므로 # 나쁜 --dir 검증은 마지막 인자가 이기는(last-wins) 구조상 여기서 따로 한다. -for c in "graph --dir /nonexistent-xyz" "schema --dir /nonexistent-xyz" "routes --role server --dir /nonexistent-xyz"; do +for c in "graph --dir /nonexistent-xyz" "schema --dir /nonexistent-xyz" "routes --role server --dir /nonexistent-xyz" "routes --role client --dir /nonexistent-xyz"; do got=0 # shellcheck disable=SC2086 "$BIN" $c >/dev/null 2>&1 || got=$? diff --git a/src/cli.rs b/src/cli.rs index a083b77..457e2b6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -34,6 +34,8 @@ usage: rustograph mcp [--dir DIR] [--graph FILE] [--config FILE] [--deps] [--tests] rustograph schema [--dir DIR] [--out FILE] rustograph routes --role server [--dir DIR] [--out FILE] [--framework axum|actix] + rustograph routes --role client [--dir DIR] [--out FILE] [--wrappers FILE] + [--service NAME] rustograph version shared flags: --deps --tests --retain-public --semantic --no-cache @@ -151,23 +153,38 @@ fn cmd_schema(a: &Args, out: &mut dyn Write) -> Result { Ok(0) } -/// `routes --role server` — isthmus http 도메인의 서버 route-decl 문서를 낸다. -/// 클라이언트 route-call은 아직 내지 않으므로 `--role`은 server만 받는다 — -/// 생략을 server로 읽으면 나중에 client를 더할 때 같은 명령의 뜻이 바뀐다. +/// `routes --role server|client` — isthmus http 도메인 문서를 낸다. 서버는 axum· +/// actix-web `route-decl`, 클라이언트는 reqwest·ureq·선언된 래퍼의 `route-call`이다. +/// `--role`은 필수다 — 생략을 한쪽으로 읽으면 명령의 뜻이 역할에 따라 흔들린다. fn cmd_routes(a: &Args, out: &mut dyn Write) -> Result { - if let Some(bad) = a.unsupported(&["dir", "out", "role", "framework"]) { - return Err(format!( - "routes takes only --role, --dir, --out and --framework — {bad} is not supported" - )); - } - match a.get("role") { - Some("server") => {} + let text = match a.get("role") { + Some("server") => routes_server(a)?, + Some("client") => routes_client(a)?, Some(other) => { return Err(format!( - "routes --role {other} is not supported; only --role server is implemented" + "routes --role {other} is not supported; use --role server or --role client" )) } - None => return Err("routes requires --role server".to_string()), + None => return Err("routes requires --role server or --role client".to_string()), + }; + match a.get("out") { + Some(p) => { + std::fs::write(p, &text).map_err(|e| format!("cannot write {p}: {e}"))?; + writeln!(out, "wrote {p}").ok(); + } + None => { + write!(out, "{text}").ok(); + } + } + Ok(0) +} + +/// 서버 route-decl 문서(JSON 텍스트). +fn routes_server(a: &Args) -> Result { + if let Some(bad) = a.unsupported(&["dir", "out", "role", "framework"]) { + return Err(format!( + "routes --role server takes only --dir, --out and --framework — {bad} is not supported" + )); } let framework = match a.get("framework") { None => None, @@ -179,17 +196,34 @@ fn cmd_routes(a: &Args, out: &mut dyn Write) -> Result { let dir = PathBuf::from(a.get("dir").unwrap_or(".")); let opts = source::routes::RouteOptions { framework }; let doc = source::routes::routes(&dir, VERSION, &opts)?; - let text = export::to_json(&doc); - match a.get("out") { + Ok(export::to_json(&doc)) +} + +/// 클라이언트 route-call 문서(JSON 텍스트). +fn routes_client(a: &Args) -> Result { + if let Some(bad) = a.unsupported(&["dir", "out", "role", "wrappers", "service"]) { + return Err(format!( + "routes --role client takes only --dir, --out, --wrappers and --service — {bad} is not supported" + )); + } + let wrappers = match a.get("wrappers") { + None => Vec::new(), Some(p) => { - std::fs::write(p, &text).map_err(|e| format!("cannot write {p}: {e}"))?; - writeln!(out, "wrote {p}").ok(); + let text = std::fs::read_to_string(p) + .map_err(|e| format!("cannot read --wrappers {p}: {e}"))?; + source::routes::wrappers::parse(&text).map_err(|e| format!("--wrappers {p}: {e}"))? } - None => { - write!(out, "{text}").ok(); + }; + let service = match a.get("service") { + Some(s) if s.is_empty() || s.chars().any(char::is_control) => { + return Err("--service must be a non-empty name without control characters".into()) } - } - Ok(0) + other => other.map(str::to_string), + }; + let dir = PathBuf::from(a.get("dir").unwrap_or(".")); + let opts = source::routes::client::ClientOptions { wrappers, service }; + let doc = source::routes::client::client_routes(&dir, VERSION, &opts)?; + Ok(export::to_json(&doc)) } fn cmd_graph(a: &Args, out: &mut dyn Write) -> Result { diff --git a/src/cli_args.rs b/src/cli_args.rs index b8c5286..e163fe4 100644 --- a/src/cli_args.rs +++ b/src/cli_args.rs @@ -83,6 +83,8 @@ const VALUE_FLAGS: &[&str] = &[ "generated-at", "role", "framework", + "wrappers", + "service", ]; const BOOL_FLAGS: &[&str] = &[ "deps", From 5bcb9460d2911bb4754a7ba9e4ba9a5c18bd8d43 Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 15:27:30 +0900 Subject: [PATCH 4/8] =?UTF-8?q?test:=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20fixture=C2=B7=EB=AA=A8=EC=9D=98=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=98=A4=EB=9D=BC=ED=81=B4=C2=B7url-compo?= =?UTF-8?q?se=20=EB=B2=A1=ED=84=B0=20=EB=9F=AC=EB=84=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/fixture-client: 규칙마다 요청 하나를 보내는 합성 클라이언트와 이름·버전만 흉내 내는 reqwest·ureq·url 스텁(워크스페이스 밖에 두어 자동 멤버가 되지 않게 했다), http-wrappers 선언. - experiments/client-oracle: 같은 lib.rs를 진짜 reqwest 0.13.5·ureq 3.4.2· url 2.5.8로 컴파일해 127.0.0.1 기록 서버(환경 프록시, ureq 3의 CONNECT 터널 수락)로 보내고 사실과 대조한다. 41 시나리오 일치 37·dynamic 3· 요청 없음 1·불일치 0, 기록을 cargo test가 오프라인으로 대조한다. - tests/client_routes.rs: url-compose 생산자 사례 41건, 여러 줄 호출의 시작 줄·UTF-8 열, 필드·지역·상수·ureq 버전·래퍼·서비스 규칙. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 1 + experiments/client-oracle/Cargo.lock | 892 +++++++++++++++++ experiments/client-oracle/Cargo.toml | 28 + .../client-oracle/recorded/client.json | 910 ++++++++++++++++++ experiments/client-oracle/run.sh | 19 + experiments/client-oracle/src/main.rs | 451 +++++++++ tests/client_routes.rs | 781 +++++++++++++++ tests/fixture-client/app/Cargo.lock | 24 + tests/fixture-client/app/Cargo.toml | 3 + .../fixture-client/app/client_app/Cargo.toml | 12 + .../fixture-client/app/client_app/src/api.rs | 134 +++ .../fixture-client/app/client_app/src/lib.rs | 14 + .../app/client_app/src/scenarios.rs | 190 ++++ .../app/client_app/src/ureq_calls.rs | 30 + .../app/client_app/src/wrappers.rs | 41 + tests/fixture-client/http-wrappers.json | 44 + tests/fixture-client/stubs/reqwest/Cargo.toml | 9 + tests/fixture-client/stubs/reqwest/src/lib.rs | 1 + tests/fixture-client/stubs/ureq/Cargo.toml | 9 + tests/fixture-client/stubs/ureq/src/lib.rs | 1 + tests/fixture-client/stubs/url/Cargo.toml | 9 + tests/fixture-client/stubs/url/src/lib.rs | 1 + tests/routes.rs | 1 + 23 files changed, 3605 insertions(+) create mode 100644 experiments/client-oracle/Cargo.lock create mode 100644 experiments/client-oracle/Cargo.toml create mode 100644 experiments/client-oracle/recorded/client.json create mode 100755 experiments/client-oracle/run.sh create mode 100644 experiments/client-oracle/src/main.rs create mode 100644 tests/client_routes.rs create mode 100644 tests/fixture-client/app/Cargo.lock create mode 100644 tests/fixture-client/app/Cargo.toml create mode 100644 tests/fixture-client/app/client_app/Cargo.toml create mode 100644 tests/fixture-client/app/client_app/src/api.rs create mode 100644 tests/fixture-client/app/client_app/src/lib.rs create mode 100644 tests/fixture-client/app/client_app/src/scenarios.rs create mode 100644 tests/fixture-client/app/client_app/src/ureq_calls.rs create mode 100644 tests/fixture-client/app/client_app/src/wrappers.rs create mode 100644 tests/fixture-client/http-wrappers.json create mode 100644 tests/fixture-client/stubs/reqwest/Cargo.toml create mode 100644 tests/fixture-client/stubs/reqwest/src/lib.rs create mode 100644 tests/fixture-client/stubs/ureq/Cargo.toml create mode 100644 tests/fixture-client/stubs/ureq/src/lib.rs create mode 100644 tests/fixture-client/stubs/url/Cargo.toml create mode 100644 tests/fixture-client/stubs/url/src/lib.rs diff --git a/.gitignore b/.gitignore index 26bc0b4..5701b75 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ coverage/ .serena/ shrimp/ experiments/routes-oracle/target/ +experiments/client-oracle/target/ diff --git a/experiments/client-oracle/Cargo.lock b/experiments/client-oracle/Cargo.lock new file mode 100644 index 0000000..ba17c4b --- /dev/null +++ b/experiments/client-oracle/Cargo.lock @@ -0,0 +1,892 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oracle-client" +version = "0.0.0" +dependencies = [ + "reqwest", + "serde_json", + "tokio", + "ureq", + "url", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/experiments/client-oracle/Cargo.toml b/experiments/client-oracle/Cargo.toml new file mode 100644 index 0000000..3f719d4 --- /dev/null +++ b/experiments/client-oracle/Cargo.toml @@ -0,0 +1,28 @@ +# 클라이언트 오라클 — fixture의 같은 소스를 crates.io의 진짜 reqwest·ureq·url로 +# 컴파일해 127.0.0.1 임시 포트의 기록 서버로 요청을 보내고, rustograph +# `routes --role client`가 낸 사실과 대조한다. rustograph 본체와 별개인 독립 +# 워크스페이스다(본체는 이 의존성을 모른다). +[workspace] + +[package] +name = "oracle-client" +version = "0.0.0" +edition = "2021" +publish = false + +# lib은 fixture 소스 그대로다 — 오라클이 보내는 요청과 rustograph가 읽은 호출이 +# 같은 코드에서 나온다. +[lib] +name = "client_app" +path = "../../tests/fixture-client/app/client_app/src/lib.rs" + +[[bin]] +name = "oracle-client" +path = "src/main.rs" + +[dependencies] +reqwest = { version = "=0.13.5", default-features = false, features = ["blocking"] } +ureq = { version = "=3.4.2", default-features = false } +url = "=2.5.8" +tokio = { version = "1", features = ["rt", "macros"] } +serde_json = "1" diff --git a/experiments/client-oracle/recorded/client.json b/experiments/client-oracle/recorded/client.json new file mode 100644 index 0000000..d032426 --- /dev/null +++ b/experiments/client-oracle/recorded/client.json @@ -0,0 +1,910 @@ +{ + "scenarios": [ + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/items", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "queryTailStripped": true, + "usr": "client_app::scenarios::literal_query" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/items" + } + ], + "result": "match", + "scenario": "literal_query" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/users/{}", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::async_user" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/users/7" + } + ], + "result": "match", + "scenario": "async_user" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/orders", + "dynamic": false, + "method": "POST", + "pathAnchor": "root", + "usr": "client_app::scenarios::concat_post" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "POST", + "path": "/v1/orders" + } + ], + "result": "match", + "scenario": "concat_post" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/items/{}", + "dynamic": false, + "method": "PUT", + "pathAnchor": "root", + "usr": "client_app::scenarios::put_positional" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "PUT", + "path": "/v1/items/5" + } + ], + "result": "match", + "scenario": "put_positional" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/items/{}", + "dynamic": false, + "method": "DELETE", + "pathAnchor": "root", + "usr": "client_app::scenarios::delete_plus" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "DELETE", + "path": "/v1/items/9" + } + ], + "result": "match", + "scenario": "delete_plus" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/profile", + "dynamic": false, + "method": "PATCH", + "pathAnchor": "root", + "usr": "client_app::scenarios::patch_local" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "PATCH", + "path": "/v1/profile" + } + ], + "result": "match", + "scenario": "patch_local" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/health", + "dynamic": false, + "method": "HEAD", + "pathAnchor": "root", + "usr": "client_app::scenarios::head_health" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "HEAD", + "path": "/v1/health" + } + ], + "result": "match", + "scenario": "head_health" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/items", + "dynamic": false, + "method": "OPTIONS", + "pathAnchor": "root", + "usr": "client_app::scenarios::request_options" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "OPTIONS", + "path": "/v1/items" + } + ], + "result": "match", + "scenario": "request_options" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/verbs", + "dynamic": false, + "methodDynamic": true, + "pathAnchor": "root", + "usr": "client_app::scenarios::request_dynamic" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "PATCH", + "path": "/v1/verbs" + } + ], + "result": "match", + "scenario": "request_dynamic" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v2/users/{}", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::join_relative" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v2/users/5" + } + ], + "result": "match", + "scenario": "join_relative" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v2/tags", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::join_replaces_last" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v2/tags" + } + ], + "result": "match", + "scenario": "join_replaces_last" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/root/ping", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::join_absolute_path" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/root/ping" + } + ], + "result": "match", + "scenario": "join_absolute_path" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/b", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::dot_segments" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/b" + } + ], + "result": "match", + "scenario": "dot_segments" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v3//items", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::double_slash" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v3//items" + } + ], + "result": "match", + "scenario": "double_slash" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/search", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "queryTailStripped": true, + "usr": "client_app::scenarios::query_tail" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/search" + } + ], + "result": "match", + "scenario": "query_tail" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/search", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "queryTailStripped": true, + "usr": "client_app::scenarios::query_tail" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/search" + } + ], + "result": "match", + "scenario": "query_tail_empty" + }, + { + "error": false, + "facts": [ + { + "channel": null, + "channelPrefix": "/v1/files/", + "dynamic": true, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::partial_segment" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/files/report.json" + } + ], + "result": "dynamic", + "scenario": "partial_segment" + }, + { + "error": false, + "facts": [ + { + "channel": "/v1/status", + "dynamic": false, + "method": "GET", + "pathAnchor": "base", + "usr": "client_app::scenarios::unknown_base_rooted" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/prefix/v1/status" + } + ], + "result": "match", + "scenario": "unknown_base_rooted" + }, + { + "error": false, + "facts": [ + { + "channel": null, + "dynamic": true, + "method": "GET", + "pathAnchor": "base", + "usr": "client_app::scenarios::unknown_base_glued" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/prefixstatus" + } + ], + "result": "dynamic", + "scenario": "unknown_base_glued" + }, + { + "error": true, + "facts": [], + "requests": [], + "result": "no-request", + "scenario": "relative_url" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/tokens/{}", + "dynamic": false, + "maskedSegments": 1, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::masked_token" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/tokens/a1b2c3d4e5f6a7b8c9d0" + } + ], + "result": "match", + "scenario": "masked_token" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/caf%C3%A9", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::non_ascii" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/caf%C3%A9" + } + ], + "result": "match", + "scenario": "non_ascii" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/secure", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "queryTailStripped": true, + "usr": "client_app::scenarios::userinfo_fragment" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/secure" + } + ], + "result": "match", + "scenario": "userinfo_fragment" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com:8080", + "channel": "/v1/port", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::with_port" + } + ], + "requests": [ + { + "authority": "api.example.com:8080", + "method": "GET", + "path": "/v1/port" + } + ], + "result": "match", + "scenario": "with_port" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/bs", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::backslashes" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/bs" + } + ], + "result": "match", + "scenario": "backslashes" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/built", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::scenarios::builder_client" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/built" + } + ], + "result": "match", + "scenario": "builder_client" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/api/items", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::api::ApiClient::list_items" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/api/items" + } + ], + "result": "match", + "scenario": "struct_field_base" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/api/items/{}", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::api::ApiClient::get_item" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/api/items/42" + } + ], + "result": "match", + "scenario": "struct_field_item" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/api/items", + "dynamic": false, + "method": "POST", + "pathAnchor": "root", + "usr": "client_app::api::ApiClient::create_item" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "POST", + "path": "/api/items" + } + ], + "result": "match", + "scenario": "struct_field_plus" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/api/search", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "queryTailStripped": true, + "usr": "client_app::api::ApiClient::search" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/api/search" + } + ], + "result": "match", + "scenario": "struct_field_query" + }, + { + "error": false, + "facts": [ + { + "channel": "/users/{}", + "dynamic": false, + "method": "GET", + "pathAnchor": "base", + "usr": "client_app::api::RemoteClient::user" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/remote/users/3" + } + ], + "result": "match", + "scenario": "struct_param_base" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/cat/products", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::api::Catalog::products" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/cat/products" + } + ], + "result": "match", + "scenario": "url_field" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/ureq/items", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::ureq_calls::ureq_get" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/ureq/items" + } + ], + "result": "match", + "scenario": "ureq_get" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/ureq/items/{}", + "dynamic": false, + "method": "DELETE", + "pathAnchor": "root", + "usr": "client_app::ureq_calls::ureq_delete" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "DELETE", + "path": "/v1/ureq/items/4" + } + ], + "result": "match", + "scenario": "ureq_delete" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/x/../ureq-dots", + "dynamic": false, + "method": "GET", + "pathAnchor": "root", + "usr": "client_app::ureq_calls::ureq_dots" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/x/../ureq-dots" + } + ], + "result": "match", + "scenario": "ureq_dots" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/ureq/health", + "dynamic": false, + "method": "HEAD", + "pathAnchor": "root", + "usr": "client_app::ureq_calls::ureq_agent_head" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "HEAD", + "path": "/v1/ureq/health" + } + ], + "result": "match", + "scenario": "ureq_agent_head" + }, + { + "error": false, + "facts": [ + { + "authority": "api.example.com", + "channel": "/v1/ureq/orders", + "dynamic": false, + "method": "POST", + "pathAnchor": "root", + "usr": "client_app::ureq_calls::ureq_post" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "POST", + "path": "/v1/ureq/orders" + } + ], + "result": "match", + "scenario": "ureq_post" + }, + { + "error": false, + "facts": [ + { + "channel": "/w/items", + "dynamic": false, + "method": "GET", + "pathAnchor": "base", + "usr": "client_app::wrappers::wrapper_function" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/wb/w/items" + } + ], + "result": "match", + "scenario": "wrapper_function" + }, + { + "error": false, + "facts": [ + { + "channel": "/w/orders", + "dynamic": false, + "method": "POST", + "pathAnchor": "base", + "usr": "client_app::wrappers::wrapper_method" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "POST", + "path": "/api/w/orders" + } + ], + "result": "match", + "scenario": "wrapper_method" + }, + { + "error": false, + "facts": [ + { + "channel": null, + "dynamic": true, + "methodDynamic": true, + "pathAnchor": "base", + "usr": "client_app::api::ApiClient::execute" + }, + { + "channel": "/e/items/{}", + "dynamic": false, + "method": "DELETE", + "pathAnchor": "base", + "usr": "client_app::wrappers::wrapper_constructor" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "DELETE", + "path": "/api/e/items/6" + } + ], + "result": "match", + "scenario": "wrapper_constructor" + }, + { + "error": false, + "facts": [ + { + "channel": null, + "dynamic": true, + "method": "GET", + "pathAnchor": "base", + "usr": "client_app::wrappers::raw_get" + } + ], + "requests": [ + { + "authority": "api.example.com", + "method": "GET", + "path": "/v1/undeclared" + } + ], + "result": "dynamic", + "scenario": "undeclared_sink" + } + ], + "totals": { + "dynamic": 3, + "match": 37, + "mismatch": 0, + "noRequest": 1, + "scenarios": 41 + }, + "unclaimedFacts": [], + "versions": { + "reqwest": "0.13.5", + "ureq": "3.4.2", + "url": "2.5.8" + } +} diff --git a/experiments/client-oracle/run.sh b/experiments/client-oracle/run.sh new file mode 100755 index 0000000..d71a491 --- /dev/null +++ b/experiments/client-oracle/run.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# 오라클 재기록: fixture에 `rustograph routes --role client`를 돌리고, 같은 소스를 진짜 +# reqwest·ureq·url로 실행해 로컬 기록 서버가 받은 요청과 대조한 뒤 recorded/client.json을 +# 쓴다. crates.io 의존성 내려받기 외에 네트워크를 쓰지 않는다(요청은 127.0.0.1 프록시로 간다). +# 불일치·귀속되지 않은 사실이 하나라도 있으면 0이 아닌 코드로 끝난다. 서버는 오라클 +# 프로세스 안의 스레드라 프로세스와 함께 끝난다. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$here/../.." && pwd)" +tmp="$(mktemp -d)" +cargo build --quiet --manifest-path "$repo/Cargo.toml" +cargo build --quiet --release --manifest-path "$here/Cargo.toml" +fixture="$repo/tests/fixture-client" +"$repo/target/debug/rustograph" routes --role client --dir "$fixture/app" \ + --wrappers "$fixture/http-wrappers.json" --out "$tmp/client.json" >/dev/null +status=0 +"$here/target/release/oracle-client" "$tmp/client.json" "$here/recorded/client.json" || status=1 +rm -rf "$tmp" +exit $status diff --git a/experiments/client-oracle/src/main.rs b/experiments/client-oracle/src/main.rs new file mode 100644 index 0000000..7e90c23 --- /dev/null +++ b/experiments/client-oracle/src/main.rs @@ -0,0 +1,451 @@ +//! 클라이언트 오라클 — fixture 시나리오를 진짜 reqwest 0.13.5·ureq 3.4.2·url 2.5.8로 +//! 실행해 127.0.0.1 임시 포트의 기록 서버(HTTP 프록시로 지정)가 받은 method·경로를 +//! rustograph `routes --role client` 사실과 대조한다. +//! +//! 판정: 시나리오의 요청마다 그 시나리오에 귀속된 정적 사실 중 method(또는 +//! `methodDynamic`)와 경로(root는 전체, base는 세그먼트 경계 꼬리, `{}`는 비어 있지 +//! 않은 세그먼트)와 authority가 맞는 것이 있으면 match, 정적 사실 없이 dynamic +//! 사실만 있으면 dynamic, 요청이 없으면(보내기 전 실패) 사실도 없어야 한다. 정적 +//! 사실은 모두 어떤 요청과 맞아야 한다(정밀도). 외부 네트워크를 쓰지 않는다 — +//! 모든 http 요청이 프록시로 지정한 로컬 서버로 간다. +//! +//! 사용: oracle-client + +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; + +/// 서버가 받은 요청 하나. +#[derive(Clone, Debug)] +struct Seen { + method: String, + target: String, + host: String, +} + +type Log = Arc>>; + +fn main() { + let args: Vec = std::env::args().collect(); + let [_, facts_path, out_path] = args.as_slice() else { + eprintln!("usage: oracle-client "); + std::process::exit(2); + }; + let doc: Value = serde_json::from_str( + &std::fs::read_to_string(facts_path).expect("read the routes document"), + ) + .expect("routes document is JSON"); + let log: Log = Arc::new(Mutex::new(Vec::new())); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1"); + let port = listener.local_addr().expect("local addr").port(); + let server_log = log.clone(); + std::thread::spawn(move || serve(listener, server_log)); + // 모든 http 요청을 기록 서버로 보낸다(reqwest·ureq 모두 환경 프록시를 읽는다). + let proxy = format!("http://127.0.0.1:{port}"); + for key in ["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"] { + std::env::set_var(key, &proxy); + } + for key in ["NO_PROXY", "no_proxy"] { + std::env::remove_var(key); + } + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + let mut rows = Vec::new(); + let mut claimed = std::collections::BTreeSet::new(); + let mut failed = 0usize; + for sc in scenarios() { + log.lock().unwrap().clear(); + let error = match &sc.run { + Run::Blocking(f) => f().err(), + Run::Async(f) => rt.block_on(f()).err(), + }; + // 서버 스레드가 기록을 마칠 때까지 잠깐 기다린다(응답 뒤 기록하지 않으므로 짧다). + std::thread::sleep(std::time::Duration::from_millis(30)); + let seen = log.lock().unwrap().clone(); + let facts = facts_for(&doc, &sc.usrs); + for f in &facts { + claimed.insert(f.to_string()); + } + let (result, requests) = judge(&seen, &facts); + if result == "mismatch" { + failed += 1; + } + rows.push(json!({ + "scenario": sc.name, + "requests": requests, + "facts": facts, + "result": result, + "error": error.is_some(), + })); + } + let unclaimed: Vec = doc["facts"] + .as_array() + .into_iter() + .flatten() + .map(summary) + .filter(|f| !claimed.contains(&f.to_string())) + .collect(); + let count = |r: &str| rows.iter().filter(|x| x["result"] == r).count(); + let record = json!({ + "versions": {"reqwest": "0.13.5", "ureq": "3.4.2", "url": "2.5.8"}, + "totals": { + "scenarios": rows.len(), + "match": count("match"), + "dynamic": count("dynamic"), + "noRequest": count("no-request"), + "mismatch": count("mismatch"), + }, + "scenarios": rows, + "unclaimedFacts": unclaimed, + }); + let text = serde_json::to_string_pretty(&record).expect("serialize") + "\n"; + std::fs::write(out_path, text).expect("write the record"); + println!( + "client oracle: {} scenarios — match {}, dynamic {}, no-request {}, mismatch {}, unclaimed facts {}", + record["totals"]["scenarios"], + count("match"), + count("dynamic"), + count("no-request"), + failed, + record["unclaimedFacts"].as_array().map_or(0, Vec::len) + ); + if failed > 0 || !record["unclaimedFacts"].as_array().is_some_and(Vec::is_empty) { + std::process::exit(1); + } +} + +// ── 기록 서버 ──────────────────────────────────────────────── + +/// 연결마다 요청 하나를 읽고 200으로 닫는다. ureq 3은 http 요청도 프록시에 CONNECT +/// 터널을 연다 — 터널을 수락하고 그 안의 평문 요청을 읽는다. +fn serve(listener: TcpListener, log: Log) { + for stream in listener.incoming().flatten() { + let log = log.clone(); + std::thread::spawn(move || { + let mut reader = BufReader::new(&stream); + let Some(mut seen) = read_request(&mut reader) else { + return; + }; + if seen.method == "CONNECT" { + let mut w = &stream; + if w.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n").is_err() { + return; + } + let Some(inner) = read_request(&mut reader) else { + return; + }; + seen = inner; + } + log.lock().unwrap().push(seen); + let mut w = &stream; + let _ = w.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + }); + } +} + +/// 요청 줄·헤더·본문을 읽는다. +fn read_request(reader: &mut BufReader<&TcpStream>) -> Option { + let mut line = String::new(); + reader.read_line(&mut line).ok()?; + let mut parts = line.split_whitespace(); + let method = parts.next()?.to_string(); + let target = parts.next()?.to_string(); + let mut host = String::new(); + let mut length = 0usize; + loop { + let mut h = String::new(); + if reader.read_line(&mut h).ok()? == 0 || h == "\r\n" || h == "\n" { + break; + } + let (k, v) = h.split_once(':')?; + match k.trim().to_ascii_lowercase().as_str() { + "host" => host = v.trim().to_ascii_lowercase(), + "content-length" => length = v.trim().parse().unwrap_or(0), + _ => {} + } + } + let mut body = vec![0u8; length]; + reader.read_exact(&mut body).ok()?; + Some(Seen { + method, + target, + host, + }) +} + +// ── 시나리오 ───────────────────────────────────────────────── + +type BlockingFn = Box Result<(), String>>; +type AsyncFn = Box std::pin::Pin>>>>; + +enum Run { + Blocking(BlockingFn), + Async(AsyncFn), +} + +/// 시나리오 하나 — 요청을 보내는 함수와 그 요청을 설명해야 하는 사실의 usr들. +struct Scenario { + name: &'static str, + usrs: Vec, + run: Run, +} + +fn b(name: &'static str, usrs: &[&str], f: F) -> Scenario +where + F: Fn() -> client_app::Res + 'static, +{ + Scenario { + name, + usrs: usrs.iter().map(|u| format!("client_app::{u}")).collect(), + run: Run::Blocking(Box::new(move || f().map_err(|e| e.to_string()))), + } +} + +fn scenarios() -> Vec { + use client_app::{scenarios as s, ureq_calls as u, wrappers as w}; + let base = "http://api.example.com/prefix"; + vec![ + b("literal_query", &["scenarios::literal_query"], s::literal_query), + Scenario { + name: "async_user", + usrs: vec!["client_app::scenarios::async_user".into()], + run: Run::Async(Box::new(|| { + Box::pin(async { s::async_user(7).await.map_err(|e| e.to_string()) }) + })), + }, + b("concat_post", &["scenarios::concat_post"], s::concat_post), + b("put_positional", &["scenarios::put_positional"], || s::put_positional(5)), + b("delete_plus", &["scenarios::delete_plus"], || s::delete_plus("9")), + b("patch_local", &["scenarios::patch_local"], s::patch_local), + b("head_health", &["scenarios::head_health"], s::head_health), + b("request_options", &["scenarios::request_options"], s::request_options), + b("request_dynamic", &["scenarios::request_dynamic"], || { + s::request_dynamic(reqwest::Method::PATCH) + }), + b("join_relative", &["scenarios::join_relative"], || s::join_relative(5)), + b("join_replaces_last", &["scenarios::join_replaces_last"], s::join_replaces_last), + b("join_absolute_path", &["scenarios::join_absolute_path"], s::join_absolute_path), + b("dot_segments", &["scenarios::dot_segments"], s::dot_segments), + b("double_slash", &["scenarios::double_slash"], s::double_slash), + b("query_tail", &["scenarios::query_tail"], || s::query_tail(Some(2))), + b("query_tail_empty", &["scenarios::query_tail"], || s::query_tail(None)), + b("partial_segment", &["scenarios::partial_segment"], || s::partial_segment("report")), + b("unknown_base_rooted", &["scenarios::unknown_base_rooted"], move || { + s::unknown_base_rooted(base) + }), + b("unknown_base_glued", &["scenarios::unknown_base_glued"], move || { + s::unknown_base_glued(base) + }), + b("relative_url", &["scenarios::relative_url"], s::relative_url), + b("masked_token", &["scenarios::masked_token"], s::masked_token), + b("non_ascii", &["scenarios::non_ascii"], s::non_ascii), + b("userinfo_fragment", &["scenarios::userinfo_fragment"], s::userinfo_fragment), + b("with_port", &["scenarios::with_port"], s::with_port), + b("backslashes", &["scenarios::backslashes"], s::backslashes), + b("builder_client", &["scenarios::builder_client"], s::builder_client), + b("struct_field_base", &["api::ApiClient::list_items"], s::struct_field_base), + b("struct_field_item", &["api::ApiClient::get_item"], s::struct_field_item), + b("struct_field_plus", &["api::ApiClient::create_item"], s::struct_field_plus), + b("struct_field_query", &["api::ApiClient::search"], s::struct_field_query), + b("struct_param_base", &["api::RemoteClient::user"], move || { + s::struct_param_base("http://api.example.com/remote") + }), + b("url_field", &["api::Catalog::products"], s::url_field), + b("ureq_get", &["ureq_calls::ureq_get"], u::ureq_get), + b("ureq_delete", &["ureq_calls::ureq_delete"], || u::ureq_delete(4)), + b("ureq_dots", &["ureq_calls::ureq_dots"], u::ureq_dots), + b("ureq_agent_head", &["ureq_calls::ureq_agent_head"], u::ureq_agent_head), + b("ureq_post", &["ureq_calls::ureq_post"], u::ureq_post), + b("wrapper_function", &["wrappers::wrapper_function"], w::wrapper_function), + b("wrapper_method", &["wrappers::wrapper_method"], w::wrapper_method), + b( + "wrapper_constructor", + &["wrappers::wrapper_constructor", "api::ApiClient::execute"], + || w::wrapper_constructor(6), + ), + b("undeclared_sink", &["wrappers::raw_get"], w::undeclared_sink), + ] +} + +// ── 판정 ───────────────────────────────────────────────────── + +/// 사실을 오라클이 비교하는 필드만 남긴 요약으로. +fn summary(f: &Value) -> Value { + let mut out = json!({ + "channel": f["channel"], + "dynamic": f["dynamic"], + "pathAnchor": f["pathAnchor"], + "usr": f.pointer("/symbol/usr").cloned().unwrap_or(Value::Null), + }); + for key in [ + "method", + "methodDynamic", + "authority", + "channelPrefix", + "queryTailStripped", + "maskedSegments", + ] { + if let Some(v) = f.get(key) { + out[key] = v.clone(); + } + } + out +} + +/// 시나리오 usr들에 귀속된 사실 요약. +fn facts_for(doc: &Value, usrs: &[String]) -> Vec { + doc["facts"] + .as_array() + .into_iter() + .flatten() + .filter(|f| { + f.pointer("/symbol/usr") + .and_then(Value::as_str) + .is_some_and(|u| usrs.iter().any(|x| x == u)) + }) + .map(summary) + .collect() +} + +/// 요청들과 사실들을 대조한다. (결과, 요청 요약). +fn judge(seen: &[Seen], facts: &[Value]) -> (&'static str, Vec) { + let mut requests = Vec::new(); + let statics: Vec<&Value> = facts.iter().filter(|f| f["dynamic"] == false).collect(); + let mut all_matched = true; + let mut any_dynamic_only = false; + let mut used = vec![false; statics.len()]; + for s in seen { + let (authority, path) = split_target(&s.target, &s.host); + let path = normalize(&path); + let hit = statics + .iter() + .position(|f| fact_matches(f, &s.method, &path, &authority)); + match hit { + Some(i) => used[i] = true, + None => { + let dynamic_ok = facts.iter().any(|f| { + f["dynamic"] == true + && method_ok(f, &s.method) + && f["channelPrefix"] + .as_str() + .is_none_or(|p| prefix_ok(f, p, &path)) + }); + if dynamic_ok && statics.is_empty() { + any_dynamic_only = true; + } else { + all_matched = false; + } + } + } + requests.push(json!({"method": s.method, "path": path, "authority": authority})); + } + let precise = used.iter().all(|u| *u); + let result = if seen.is_empty() { + if facts.is_empty() { + "no-request" + } else { + "mismatch" + } + } else if !all_matched || !precise { + "mismatch" + } else if any_dynamic_only { + "dynamic" + } else { + "match" + }; + (result, requests) +} + +/// 프록시 요청(절대 형식)이면 authority와 경로를, 아니면 Host 헤더와 경로를. +fn split_target(target: &str, host: &str) -> (String, String) { + let (authority, rest) = match target.strip_prefix("http://") { + Some(r) => match r.find(['/', '?', '#']) { + Some(i) => (r[..i].to_ascii_lowercase(), r[i..].to_string()), + None => (r.to_ascii_lowercase(), "/".to_string()), + }, + None => (host.to_string(), target.to_string()), + }; + let path = rest.split(['?', '#']).next().unwrap_or("/").to_string(); + let path = if path.is_empty() { "/".into() } else { path }; + (authority, path) +} + +/// 서버가 받은 경로를 정규 표기로(unreserved 디코드, 대문자 hex, 비 pchar 인코딩). +fn normalize(path: &str) -> String { + let bytes = path.as_bytes(); + let mut out = String::new(); + let mut i = 0; + let unreserved = |c: u8| c.is_ascii_alphanumeric() || b"-._~".contains(&c); + let pchar = |c: u8| unreserved(c) || b"!$&'()*+,;=:@/".contains(&c); + while i < bytes.len() { + let c = bytes[i]; + if c == b'%' + && i + 2 < bytes.len() + && bytes[i + 1].is_ascii_hexdigit() + && bytes[i + 2].is_ascii_hexdigit() + { + let hex = |b: u8| (b as char).to_digit(16).unwrap_or(0) as u8; + let v = hex(bytes[i + 1]) * 16 + hex(bytes[i + 2]); + if unreserved(v) { + out.push(v as char); + } else { + out.push_str(&format!("%{v:02X}")); + } + i += 3; + continue; + } + if pchar(c) { + out.push(c as char); + } else { + out.push_str(&format!("%{c:02X}")); + } + i += 1; + } + out +} + +fn method_ok(f: &Value, method: &str) -> bool { + f["methodDynamic"] == true || f["method"] == method +} + +/// 정적 사실이 요청을 설명하는가. +fn fact_matches(f: &Value, method: &str, path: &str, authority: &str) -> bool { + if !method_ok(f, method) { + return false; + } + if let Some(a) = f["authority"].as_str() { + if a != authority { + return false; + } + } + let Some(t) = f["channel"].as_str() else { + return false; + }; + let tsegs: Vec<&str> = t[1..].split('/').collect(); + let psegs: Vec<&str> = path[1..].split('/').collect(); + let seg_ok = |t: &str, p: &str| if t == "{}" { !p.is_empty() } else { t == p }; + match f["pathAnchor"].as_str() { + Some("root") => { + tsegs.len() == psegs.len() && tsegs.iter().zip(&psegs).all(|(t, p)| seg_ok(t, p)) + } + _ => { + psegs.len() >= tsegs.len() + && tsegs + .iter() + .zip(&psegs[psegs.len() - tsegs.len()..]) + .all(|(t, p)| seg_ok(t, p)) + } + } +} + +/// dynamic 사실의 channelPrefix가 요청 경로와 맞는가(root는 앞, base는 어딘가). +fn prefix_ok(f: &Value, prefix: &str, path: &str) -> bool { + let literal = prefix.split("{}").next().unwrap_or(prefix); + match f["pathAnchor"].as_str() { + Some("root") => path.starts_with(literal), + _ => path.contains(literal), + } +} diff --git a/tests/client_routes.rs b/tests/client_routes.rs new file mode 100644 index 0000000..23f4eae --- /dev/null +++ b/tests/client_routes.rs @@ -0,0 +1,781 @@ +//! `rustograph routes --role client` — isthmus url-compose 벡터, 오라클 기록 대조, 추출 규칙. + +use rustograph::source::routes::client::{client_routes, ClientOptions}; +use rustograph::source::routes::compose::{ + compose_path, join, mask, parse_template, Join, Origin, Outcome, PathAnchor, Piece, UrlVal, +}; +use rustograph::source::routes::template::render; +use rustograph::source::routes::wrappers::{self, ArgSpec, ArgValue, CallArg, MethodSpec}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +fn repo() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn load(path: &Path) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).expect("readable")).expect("json") +} + +/// 워크스페이스의 클라이언트 문서(JSON). +fn doc_of(dir: &Path, wrappers_json: Option<&str>) -> Value { + let wrappers = wrappers_json + .map(|t| wrappers::parse(t).expect("wrappers parse")) + .unwrap_or_default(); + let opts = ClientOptions { + wrappers, + service: None, + }; + let d = client_routes(dir, "test", &opts).expect("client extraction"); + serde_json::to_value(&d).expect("serializes") +} + +// ── 공유 벡터(url-compose) ───────────────────────────────── + +fn url_compose_cases() -> Vec { + let suite = load(&repo().join("conformance/url-compose.json")); + suite["cases"] + .as_array() + .expect("cases") + .iter() + .filter(|c| { + c["appliesTo"].as_array().is_some_and(|a| { + a.iter() + .any(|t| t == "producer" || t == "producer:rustograph") + }) + }) + .cloned() + .collect() +} + +fn pieces_of(parts: &Value) -> Vec { + parts + .as_array() + .expect("parts") + .iter() + .map(|p| { + if let Some(l) = p["literal"].as_str() { + Piece::Lit(l.to_string()) + } else if p.get("queryTail").is_some() { + Piece::QueryTail + } else { + Piece::Value(Origin::Unknown) + } + }) + .collect() +} + +/// 벡터의 결합 이름 → Rust 결합. `dio-concat`은 같은 입력에서 결과가 같은 WHATWG +/// 문자열 연결로 실행한다(점 세그먼트·`//` 처리만 다르고 벡터 입력에는 없다). +fn join_of(name: &str) -> Join { + match name { + "rfc3986" => Join::WhatwgJoin, + "slash-join" => Join::SlashJoin, + "dio-concat" => Join::WhatwgConcat, + other => panic!("unclassified join {other}"), + } +} + +fn check_outcome(id: &str, c: &Value, out: &Outcome) { + let expect = &c["expect"]; + if c["expectDynamic"] == true { + let Outcome::Dynamic { + prefix, ambiguous, .. + } = out + else { + panic!("{id}: expected dynamic, got {out:?}"); + }; + if let Some(p) = expect.get("channelPrefix") { + assert_eq!(prefix.as_deref(), p.as_str(), "{id} channelPrefix"); + } + if c["expectLimitation"] == "ambiguous-base-join:" { + assert!(ambiguous, "{id}: ambiguous-base-join expected"); + } + return; + } + let Outcome::Template(t) = out else { + panic!("{id}: expected template, got {out:?}"); + }; + assert_eq!( + Some(t.template.as_str()), + expect["template"].as_str(), + "{id}" + ); + if let Some(a) = expect["pathAnchor"].as_str() { + assert_eq!(t.anchor.as_str(), a, "{id} pathAnchor"); + } + if let Some(a) = expect.get("authority") { + assert_eq!(t.authority.as_deref(), a.as_str(), "{id} authority"); + } + assert_eq!( + t.query_tail_stripped, + expect["queryTailStripped"] == true, + "{id} queryTailStripped" + ); + if let Some(m) = expect["maskedSegments"].as_u64() { + assert_eq!(t.masked_segments as u64, m, "{id} maskedSegments"); + } +} + +fn arg_spec(v: &Value) -> Option { + v.as_object().map(|o| ArgSpec { + index: o.get("index").and_then(Value::as_u64).map(|i| i as usize), + label: o.get("label").and_then(Value::as_str).map(str::to_string), + }) +} + +fn run_wrapper_method(id: &str, c: &Value) { + let d = &c["input"]["declaration"]; + let spec = MethodSpec { + method_arg: arg_spec(&d["methodArg"]), + default_method: d["defaultMethod"].as_str().map(str::to_string), + method_enum: d["methodEnum"] + .as_object() + .map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) + .collect() + }) + .unwrap_or_default(), + }; + let args: Vec = c["input"]["call"]["args"] + .as_array() + .unwrap() + .iter() + .map(|a| CallArg { + label: a["label"].as_str().map(str::to_string), + value: if let Some(l) = a["value"]["literal"].as_str() { + ArgValue::Literal(l.to_string()) + } else if let Some(e) = a["value"]["enumCase"].as_str() { + ArgValue::EnumCase(e.to_string()) + } else { + ArgValue::Other + }, + }) + .collect(); + let method = wrappers::bind_method(&spec, &args); + if c["expectDynamic"] == true { + assert_eq!(method, None, "{id}"); + } else { + assert_eq!(method.as_deref(), c["expect"]["method"].as_str(), "{id}"); + } +} + +#[test] +fn url_compose_producer_cases_pass() { + let cases = url_compose_cases(); + // 조용히 줄지 않게 — 벡터 재벤더링 때 수를 확인한다(producer 41, producer:kartograph 13 제외). + assert_eq!(cases.len(), 41); + let mut by_rule: BTreeMap = BTreeMap::new(); + for c in &cases { + let id = c["id"].as_str().unwrap_or(""); + let input = &c["input"]; + let rule = c["ruleId"].as_str().unwrap_or(""); + *by_rule.entry(rule.to_string()).or_default() += 1; + match rule { + "compose.interpolation" + | "compose.query-tail" + | "compose.suffix" + | "compose.normalize" => { + let out = compose_path(PathAnchor::Root, &pieces_of(&input["parts"]), None, true); + check_outcome(id, c, &out); + } + "compose.base-join" => { + let path = [Piece::Lit(input["path"].as_str().unwrap().to_string())]; + let out = join( + join_of(input["join"].as_str().unwrap()), + input["base"].as_str(), + &path, + ); + check_outcome(id, c, &out); + } + "compose.strip" => { + let url = [Piece::Lit(input["url"].as_str().unwrap().to_string())]; + check_outcome(id, c, &UrlVal::parse(&url, true).outcome()); + } + "compose.mask" => { + let mut segs = parse_template(input["template"].as_str().unwrap()); + let n = mask(input["authority"].as_str(), &mut segs); + assert_eq!( + Some(render(&segs).as_str()), + c["expect"]["template"].as_str(), + "{id}" + ); + assert_eq!( + Some(n as u64), + c["expect"]["maskedSegments"].as_u64(), + "{id}" + ); + } + "wrapper.method" => run_wrapper_method(id, c), + // 실제 스캐너로 확인한다 — `wrapper_location_is_the_call_start_line`. + "wrapper.location" => { + assert_eq!(c["expect"]["line"], input["callStartLine"], "{id}"); + } + other => panic!("unclassified url-compose rule {other} ({id})"), + } + } + assert_eq!(by_rule.get("compose.base-join"), Some(&7)); +} + +/// `wrapper.location`: 여러 줄 호출은 호출식이 시작하는 줄이다. 열은 UTF-8 바이트다. +#[test] +fn wrapper_location_is_the_call_start_line() { + let src = r#" +pub fn send(method: &str, path: &str) {} +pub fn caller() { + /* 한글 */ send( + "GET", + "/x", + ); +} +"#; + let dir = temp_crate("location", &[], &[("lib.rs", src)]); + let wrappers = r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"app","name":"send","methodArg":{"index":0},"pathArg":{"index":1},"pathAnchor":"root"}]}"#; + let doc = doc_of(&dir, Some(wrappers)); + let f = &doc["facts"][0]; + assert_eq!(f["location"]["line"], 4); + // ` /* 한글 */ ` = 4 + 3 + 6(한글 UTF-8) + 4 = 17바이트 → 열 18. + assert_eq!(f["location"]["column"], 18); + assert_eq!(f["method"], "GET"); + assert_eq!(f["channel"], "/x"); + assert_eq!(f["pathAnchor"], "root"); +} + +// ── 오라클 기록 대조 ──────────────────────────────────────── + +/// 오라클 요약과 같은 필드만 남긴다. +fn summary(f: &Value) -> Value { + let mut out = json!({ + "channel": f["channel"], + "dynamic": f["dynamic"], + "pathAnchor": f["pathAnchor"], + "usr": f.pointer("/symbol/usr").cloned().unwrap_or(Value::Null), + }); + for key in [ + "method", + "methodDynamic", + "authority", + "channelPrefix", + "queryTailStripped", + "maskedSegments", + ] { + if let Some(v) = f.get(key) { + out[key] = v.clone(); + } + } + out +} + +fn fixture_doc() -> Value { + let dir = repo().join("tests/fixture-client"); + let wrappers = std::fs::read_to_string(dir.join("http-wrappers.json")).unwrap(); + doc_of(&dir.join("app"), Some(&wrappers)) +} + +/// 지금 출력의 사실이 오라클이 실제 요청으로 확인한 사실과 정확히 같아야 한다. +/// 다시 기록하려면 experiments/client-oracle/run.sh. +#[test] +fn fixture_matches_client_oracle() { + let rec = load(&repo().join("experiments/client-oracle/recorded/client.json")); + let totals = &rec["totals"]; + assert_eq!(totals["mismatch"], 0, "the oracle recorded mismatches"); + assert!(rec["unclaimedFacts"].as_array().unwrap().is_empty()); + assert!( + totals["match"].as_u64().unwrap() >= 30, + "the oracle matched too little" + ); + let recorded: BTreeSet = rec["scenarios"] + .as_array() + .unwrap() + .iter() + .flat_map(|s| s["facts"].as_array().unwrap().iter()) + .map(|f| f.to_string()) + .collect(); + let doc = fixture_doc(); + let now: BTreeSet = doc["facts"] + .as_array() + .unwrap() + .iter() + .map(|f| summary(f).to_string()) + .collect(); + assert_eq!(now, recorded, "re-run experiments/client-oracle/run.sh"); + // 요청이 없던 시나리오(보내기 전 실패)는 사실도 없다. + for s in rec["scenarios"].as_array().unwrap() { + if s["result"] == "no-request" { + assert!( + s["facts"].as_array().unwrap().is_empty(), + "{}", + s["scenario"] + ); + } + } +} + +#[test] +fn fixture_document_shape_and_limitations() { + let doc = fixture_doc(); + assert_eq!(doc["platform"], "rust"); + assert_eq!(doc["target"], "http"); + assert_eq!(doc["roles"], json!(["client"])); + assert_eq!(doc["sourceSets"]["tests"], "excluded"); + assert!(doc.get("dispatch").is_none()); + let lims: Vec<&str> = doc["limitations"] + .as_array() + .unwrap() + .iter() + .map(|l| l.as_str().unwrap()) + .collect(); + assert!( + lims.iter() + .any(|l| l.starts_with("ambiguous-base-join: 1 ")), + "{lims:?}" + ); + assert!( + lims.iter() + .any(|l| l.starts_with("http-wrapper-undeclared: 2 ")), + "{lims:?}" + ); + assert!(lims.iter().any(|l| l.contains("relative URL")), "{lims:?}"); + // 다른 언어 선언(swift)은 적용하지 않고 공백으로도 세지 않는다. + assert!(!lims.iter().any(|l| l.contains("Network")), "{lims:?}"); + // dynamic 사실은 원문 식을 싣지 않는다. + for f in doc["facts"].as_array().unwrap() { + if f["dynamic"] == true { + assert!(f["channel"].is_null()); + } + } + // 모든 usr가 reach 정점이다 — 같은 fixture의 그래프에서 확인한다. + let graph = rustograph::source::load( + &repo().join("tests/fixture-client/app"), + &rustograph::source::Options { + symbol_level: true, + ..Default::default() + }, + ) + .unwrap(); + let ids = graph.vertex_ids(); + for f in doc["facts"].as_array().unwrap() { + let usr = f["symbol"]["usr"].as_str().unwrap(); + assert!(ids.contains(usr), "{usr} is not a graph vertex"); + } +} + +// ── 추출 규칙(임시 크레이트) ──────────────────────────────── + +/// 임시 워크스페이스 — 스텁 의존성의 이름·버전과 소스 파일들. +fn temp_crate(tag: &str, deps: &[(&str, &str)], files: &[(&str, &str)]) -> PathBuf { + let dir = std::env::temp_dir().join(format!("rg-client-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("ws/app/src")).unwrap(); + let mut dep_lines = String::new(); + for (name, version) in deps { + let stub = dir.join(format!("stub-{name}")); + std::fs::create_dir_all(stub.join("src")).unwrap(); + std::fs::write( + stub.join("Cargo.toml"), + format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n"), + ) + .unwrap(); + std::fs::write(stub.join("src/lib.rs"), "").unwrap(); + dep_lines.push_str(&format!("{name} = {{ path = \"../../stub-{name}\" }}\n")); + } + std::fs::write( + dir.join("ws/Cargo.toml"), + "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n", + ) + .unwrap(); + std::fs::write( + dir.join("ws/app/Cargo.toml"), + format!("[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{dep_lines}"), + ) + .unwrap(); + for (path, text) in files { + std::fs::write(dir.join("ws/app/src").join(path), text).unwrap(); + } + dir.join("ws") +} + +/// (usr 끝 이름, method 또는 *, channel 또는 DYN:prefix, anchor). +fn rows(doc: &Value) -> Vec<(String, String, String, String)> { + doc["facts"] + .as_array() + .unwrap() + .iter() + .map(|f| { + let usr = f["symbol"]["usr"].as_str().unwrap_or("-"); + let short = usr.rsplit("::").next().unwrap_or(usr).to_string(); + let channel = match f["channel"].as_str() { + Some(c) => c.to_string(), + None => format!("DYN:{}", f["channelPrefix"].as_str().unwrap_or("")), + }; + ( + short, + f["method"].as_str().unwrap_or("*").to_string(), + channel, + f["pathAnchor"].as_str().unwrap().to_string(), + ) + }) + .collect() +} + +fn has( + rows: &[(String, String, String, String)], + usr: &str, + method: &str, + channel: &str, + anchor: &str, +) -> bool { + rows.iter() + .any(|r| r.0 == usr && r.1 == method && r.2 == channel && r.3 == anchor) +} + +fn limitations(doc: &Value) -> Vec { + doc["limitations"] + .as_array() + .unwrap() + .iter() + .map(|l| l.as_str().unwrap().to_string()) + .collect() +} + +#[test] +fn values_flow_through_locals_consts_and_types() { + let src = r#" +use reqwest::Client; +use std::sync::{Arc, OnceLock}; + +const HOST: &str = "http://h.test"; +const API: &str = concat!("http://h.test", "/api/", 2); +static SHARED: OnceLock = OnceLock::new(); + +pub struct Svc { http: Arc, root: String } + +impl Svc { + const BASE: &'static str = "http://h.test/svc"; + + pub fn make() -> Svc { Svc { http: Arc::new(Client::new()), root: format!("{}/root", HOST) } } + + fn client(&self) -> &Client { &self.http } + + pub fn assoc_const(&self) { self.client().get(format!("{}/a", Self::BASE)); } + pub fn field_root(&self) { self.http.get(self.root.clone() + "/x"); } + pub fn named_args(&self, id: u32) { self.http.get(format!("{h}/n/{id}", h = HOST)); } + pub fn debug_spec(&self, id: u32) { self.http.get(format!("{HOST}/d/{:?}", id)); } + pub fn width_arg(&self, id: u32) { self.http.get(format!("{HOST}/w/{:>1$}", id, 5)); } + pub fn concat_int(&self) { self.http.get(API); } +} + +fn shared() -> &'static Client { SHARED.get_or_init(Client::new) } + +pub fn once_lock() { shared().get(format!("{HOST}/once")); } +pub fn mutated() { let mut u = String::from(HOST); u.push_str("/m"); Client::new().get(u); } +pub fn shadowed() { let u = "http://h.test/one"; let u = format!("{u}/two"); Client::new().get(u); } +pub fn closure_param() { let c = Client::new(); let f = |u: &str| c.get(u); f("http://h.test/c"); } +pub fn loops(items: Vec<&str>) { let c = Client::new(); for it in items { c.delete(format!("{HOST}/l/{it}")); } } +pub fn matched(x: Option<&str>) { + let c = Client::new(); + match x { Some(v) => { c.put(format!("{HOST}/p/{v}")); } None => {} } + if let Some(v) = x { c.patch(format!("{HOST}/q/{v}")); } + while let Some(v) = x { c.head(format!("{HOST}/r/{v}")); break; } +} +pub fn query_match(page: u8) { + let q = match page { 0 => String::new(), n => format!("?p={n}") }; + Client::new().get(format!("{HOST}/qm{q}")); +} +pub fn no_else(page: u8) { + let q = if page > 0 { format!("?p={page}") }; + Client::new().get(format!("{HOST}/ne{q}")); +} +pub fn nested() { + fn inner() { reqwest::Client::new().get("http://h.test/inner"); } + inner(); +} +pub fn in_macro() { let c = Client::new(); let _ = vec![c.get("http://h.test/mac")]; } +pub fn request_new() { let _ = reqwest::Request::new(reqwest::Method::DELETE, "http://h.test/rn".parse().unwrap()); } +pub fn http_method() { Client::new().request(http::Method::TRACE, format!("{HOST}/t")); } + +pub trait Api { fn ping(&self, c: &Client) { c.get("http://h.test/ping"); } } + +#[cfg(test)] +mod tests { fn t() { reqwest::Client::new().get("http://h.test/test-only"); } } + +#[test] +fn test_fn() { reqwest::Client::new().get("http://h.test/test-fn"); } +"#; + let dir = temp_crate( + "values", + &[("reqwest", "0.13.5"), ("http", "1.3.1")], + &[("lib.rs", src)], + ); + let doc = doc_of(&dir, None); + let r = rows(&doc); + let expect = [ + ("assoc_const", "GET", "/svc/a", "root"), + ("field_root", "GET", "/root/x", "root"), + ("named_args", "GET", "/n/{}", "root"), + ("debug_spec", "GET", "/d/{}", "root"), + ("concat_int", "GET", "/api/2", "root"), + ("once_lock", "GET", "/once", "root"), + ("shadowed", "GET", "/one/two", "root"), + ("closure_param", "GET", "DYN:", "base"), + ("loops", "DELETE", "/l/{}", "root"), + ("matched", "PUT", "/p/{}", "root"), + ("matched", "PATCH", "/q/{}", "root"), + ("matched", "HEAD", "/r/{}", "root"), + ("query_match", "GET", "/qm", "root"), + ("no_else", "GET", "DYN:/ne", "root"), + ("mutated", "GET", "DYN:", "base"), + // 중첩 함수는 정점이 아니다 — 감싸는 함수가 usr다. + ("nested", "GET", "/inner", "root"), + ("in_macro", "GET", "/mac", "root"), + ("http_method", "TRACE", "/t", "root"), + ("ping", "GET", "/ping", "root"), + ]; + for (u, m, c, a) in expect { + assert!(has(&r, u, m, c, a), "missing {u} {m} {c} {a} in {r:#?}"); + } + // `{:>1$}`는 인자 순서를 바꾸는 서식이라 URL 전체를 모른다. + assert!(has(&r, "width_arg", "GET", "DYN:", "base"), "{r:#?}"); + // Request::new의 URL은 `.parse()` 결과(Url)라 모르는 값이다 — 동사는 확정한다. + assert!(has(&r, "request_new", "DELETE", "DYN:", "base"), "{r:#?}"); + assert!( + !r.iter().any(|x| x.2.contains("test")), + "test sources leaked: {r:#?}" + ); +} + +#[test] +fn fields_resolve_only_from_constant_constructors() { + let src = r#" +use reqwest::blocking::Client; + +#[derive(Default)] +pub struct Defaulted { base: String, http: Client } +impl Defaulted { pub fn go(&self) { self.http.get(format!("{}/d", self.base)); } } + +pub struct Two { base: String, http: Client } +impl Two { + pub fn a() -> Two { Two { base: "http://one.test".into(), http: Client::new() } } + pub fn b() -> Two { Two { base: "http://two.test".into(), http: Client::new() } } + pub fn go(&self) { self.http.get(format!("{}/t", self.base)); } +} + +pub struct Poisoned { base: String, http: Client } +impl Poisoned { + pub fn new() -> Poisoned { Poisoned { base: "http://p.test".into(), http: Client::new() } } + pub fn rebase(&mut self) { self.base = String::new(); } + pub fn go(&self) { self.http.get(format!("{}/p", self.base)); } +} + +pub struct Rest { base: String, http: Client } +impl Rest { + pub fn new(other: Rest) -> Rest { Rest { http: Client::new(), ..other } } + pub fn go(&self) { self.http.get(format!("{}/r", self.base)); } +} + +pub struct Other { base: String } +pub fn via_instance(o: &Other, c: &Client) { + let o2 = Other { base: "http://o.test/x".to_string() }; + c.get(format!("{}/i", o.base)); + c.get(format!("{}/j", o2.base)); +} +"#; + let dir = temp_crate("fields", &[("reqwest", "0.12.9")], &[("lib.rs", src)]); + let doc = doc_of(&dir, None); + let r = rows(&doc); + // 네 구조체 모두 필드 값이 확정되지 않아 base 앵커다. + let n = r + .iter() + .filter(|x| x.0 == "go" && x.3 == "base" && !x.2.starts_with("DYN")) + .count(); + assert_eq!(n, 4, "{r:#?}"); + assert!(has(&r, "via_instance", "GET", "/x/i", "root"), "{r:#?}"); + assert!(has(&r, "via_instance", "GET", "/x/j", "root"), "{r:#?}"); + let base_refs: BTreeSet<&str> = doc["facts"] + .as_array() + .unwrap() + .iter() + .filter_map(|f| f["baseRef"].as_str()) + .collect(); + assert!(base_refs.contains("app::Two::base"), "{base_refs:?}"); +} + +#[test] +fn ureq_versions_and_agents() { + let src = r#" +pub fn free() { ureq::get("http://u.test/a/../b").call(); } +pub fn agent2() { let a = ureq::AgentBuilder::new().build(); a.put("http://u.test/p").call(); } +pub fn agent3() { let a = ureq::Agent::config_builder().build().new_agent(); a.options("http://u.test/o").call(); } +pub fn request2(m: &str) { + ureq::request("PATCH", "http://u.test/rq").call(); + ureq::request("get", "http://u.test/lower").call(); + ureq::request(m, "http://u.test/param").call(); +} +pub fn unproven(x: &Thing) { x.get("http://u.test/nope").call(); } +pub struct Thing; +"#; + let v2 = doc_of( + &temp_crate("ureq2", &[("ureq", "2.12.1")], &[("lib.rs", src)]), + None, + ); + let r2 = rows(&v2); + // ureq 2는 url::Url이라 점 세그먼트를 지운다. + assert!(has(&r2, "free", "GET", "/b", "root"), "{r2:#?}"); + assert!(has(&r2, "agent2", "PUT", "/p", "root"), "{r2:#?}"); + assert!(has(&r2, "request2", "PATCH", "/rq", "root"), "{r2:#?}"); + assert!(has(&r2, "request2", "*", "/lower", "root"), "{r2:#?}"); + assert!(has(&r2, "request2", "*", "/param", "root"), "{r2:#?}"); + let l2 = limitations(&v2); + assert!(l2.iter().any(|l| l.contains("ureq 2.12.1")), "{l2:?}"); + assert!(l2.iter().any(|l| l.contains("not a proven")), "{l2:?}"); + assert!( + l2.iter() + .any(|l| l.starts_with("http-wrapper-undeclared: 1 ")), + "{l2:?}" + ); + let v3 = doc_of( + &temp_crate("ureq3", &[("ureq", "3.4.2")], &[("lib.rs", src)]), + None, + ); + let r3 = rows(&v3); + assert!(has(&r3, "free", "GET", "/a/../b", "root"), "{r3:#?}"); + assert!(has(&r3, "agent3", "OPTIONS", "/o", "root"), "{r3:#?}"); + assert!(!limitations(&v3).iter().any(|l| l.contains("ureq 3.4.2"))); +} + +#[test] +fn unmodelled_clients_and_versions_are_counted() { + let src = r#" +pub async fn a() { let _ = surf::get("http://s.test/x").await; } +pub fn b() { let _c = hyper_util::client::legacy::Client::builder(); } +pub fn serve() { let _s = hyper::server::conn::http1::Builder::new(); } +pub fn old() { reqwest::get("http://r.test/old"); } +"#; + let dir = temp_crate( + "unmodelled", + &[ + ("surf", "2.3.2"), + ("hyper-util", "0.1.10"), + ("hyper", "1.6.0"), + ("reqwest", "0.11.27"), + ], + &[("lib.rs", src)], + ); + let doc = doc_of(&dir, None); + let l = limitations(&doc); + assert!(l.iter().any(|x| x.contains("1 use(s) of surf")), "{l:?}"); + assert!( + l.iter().any(|x| x.contains("1 use(s) of hyper_util")), + "{l:?}" + ); + // 서버 쪽 hyper 경로는 세지 않는다. + assert!( + !l.iter() + .any(|x| x.contains("of hyper,") || x.contains("of hyper ")), + "{l:?}" + ); + assert!(l.iter().any(|x| x.contains("reqwest 0.11.27")), "{l:?}"); + assert!(has(&rows(&doc), "old", "GET", "/old", "root")); +} + +#[test] +fn wrapper_declarations_resolve_or_report() { + let src = r#" +pub enum M { Get, Put } +pub struct Api; +impl Api { + pub fn call(&self, m: M, path: &str) {} + pub fn raw(m: M, path: &str) {} +} +pub trait Send2 { fn send2(&self, path: &str); } +impl Send2 for Api { fn send2(&self, path: &str) {} } +pub struct Ep(pub M, pub &'static str); +pub fn go(api: &Api, id: u32, dynamic_verb: M) { + api.call(M::Put, "/a"); + Api::call(api, M::Get, "/ufcs"); + Api::raw(M::Get, "https://Other.test/abs?x=1"); + api.send2(&format!("/t/{id}")); + let _ = Ep(M::Put, "/tuple"); + api.call(dynamic_verb, "relative"); +} +"#; + let dir = temp_crate("wrappers", &[], &[("lib.rs", src)]); + let wrappers = r#"{"format":"http-wrappers","version":1,"wrappers":[ + {"language":"rust","kind":"function","owner":"app::Api","name":"call","methodArg":{"index":0},"methodEnum":{"Get":"GET","Put":"PUT"},"pathArg":{"index":1},"pathAnchor":"base","service":"api"}, + {"language":"rust","kind":"function","owner":"app::Api","name":"raw","methodArg":{"index":0},"methodEnum":{"Get":"GET"},"pathArg":{"index":1},"pathAnchor":"root"}, + {"language":"rust","kind":"function","owner":"app::Api::","name":"send2","pathArg":{"index":0},"defaultMethod":"POST","pathAnchor":"root"}, + {"language":"rust","kind":"constructor","owner":"app::Ep","name":"Ep","methodArg":{"index":0},"methodEnum":{"Put":"PUT"},"pathArg":{"index":1},"pathAnchor":"root"}, + {"language":"rust","kind":"function","owner":"app::Missing","name":"nope","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root"}, + {"language":"rust","kind":"constructor","owner":"app::Api","name":"new","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root"}, + {"language":"kotlin","kind":"function","owner":"x","name":"y","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root"} + ]}"#; + let doc = doc_of(&dir, Some(wrappers)); + let r = rows(&doc); + assert!(has(&r, "go", "PUT", "/a", "base"), "{r:#?}"); + assert!(has(&r, "go", "GET", "/ufcs", "base"), "{r:#?}"); + assert!(has(&r, "go", "GET", "/abs", "root"), "{r:#?}"); + assert!(has(&r, "go", "POST", "/t/{}", "root"), "{r:#?}"); + assert!(has(&r, "go", "PUT", "/tuple", "root"), "{r:#?}"); + assert!(has(&r, "go", "*", "DYN:", "base"), "{r:#?}"); + let abs = doc["facts"] + .as_array() + .unwrap() + .iter() + .find(|f| f["channel"] == "/abs") + .unwrap(); + assert_eq!(abs["authority"], "other.test"); + assert_eq!(abs["queryTailStripped"], true); + let svc = doc["facts"] + .as_array() + .unwrap() + .iter() + .filter(|f| f["service"] == "api") + .count(); + assert_eq!(svc, 3); + let l = limitations(&doc); + assert!( + l.iter() + .any(|x| x.contains("wrappers[4] (app::Missing::nope) does not name")), + "{l:?}" + ); + assert!( + l.iter() + .any(|x| x.contains("wrappers[5] (app::Api::new) does not name")), + "{l:?}" + ); + assert!( + l.iter().any(|x| x.starts_with("ambiguous-base-join: 1 ")), + "{l:?}" + ); + assert!(!l.iter().any(|x| x.contains("wrappers[6]")), "{l:?}"); + // 호출 0건 선언. + let zero = r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"app","name":"go","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root"}]}"#; + let l0 = limitations(&doc_of(&dir, Some(zero))); + assert!( + l0.iter() + .any(|x| x.contains("wrappers[0] (app::go) has no calls")), + "{l0:?}" + ); +} + +#[test] +fn service_conflict_is_a_usage_error() { + let dir = temp_crate("service", &[], &[("lib.rs", "pub fn f(p: &str) {}\n")]); + let wrappers = wrappers::parse(r#"{"format":"http-wrappers","version":1,"wrappers":[{"language":"rust","kind":"function","owner":"app","name":"f","pathArg":{"index":0},"defaultMethod":"GET","pathAnchor":"root","service":"a"}]}"#).unwrap(); + let opts = ClientOptions { + wrappers, + service: Some("b".into()), + }; + let err = client_routes(&dir, "test", &opts).err().expect("conflict"); + assert!(err.contains("service"), "{err}"); + let ok = ClientOptions { + wrappers: Vec::new(), + service: Some("b".into()), + }; + let doc = serde_json::to_value(client_routes(&dir, "test", &ok).unwrap()).unwrap(); + assert_eq!(doc["service"], "b"); + assert!(doc["facts"].as_array().unwrap().is_empty()); + assert_eq!(doc["target"], "http"); +} diff --git a/tests/fixture-client/app/Cargo.lock b/tests/fixture-client/app/Cargo.lock new file mode 100644 index 0000000..87b4eed --- /dev/null +++ b/tests/fixture-client/app/Cargo.lock @@ -0,0 +1,24 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "client_app" +version = "0.1.0" +dependencies = [ + "reqwest", + "ureq", + "url", +] + +[[package]] +name = "reqwest" +version = "0.13.5" + +[[package]] +name = "ureq" +version = "3.4.2" + +[[package]] +name = "url" +version = "2.5.8" diff --git a/tests/fixture-client/app/Cargo.toml b/tests/fixture-client/app/Cargo.toml new file mode 100644 index 0000000..f3e472f --- /dev/null +++ b/tests/fixture-client/app/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["client_app"] +resolver = "2" diff --git a/tests/fixture-client/app/client_app/Cargo.toml b/tests/fixture-client/app/client_app/Cargo.toml new file mode 100644 index 0000000..f77f5bf --- /dev/null +++ b/tests/fixture-client/app/client_app/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "client_app" +version = "0.1.0" +edition = "2021" +publish = false + +# 의존성은 버전 판정용 스텁이다(../stubs). 같은 소스를 진짜 reqwest 0.13.5·ureq 3.4.2· +# url 2.5.8로 컴파일해 로컬 서버로 요청을 보내는 오라클은 experiments/client-oracle에 있다. +[dependencies] +reqwest = { path = "../../stubs/reqwest" } +ureq = { path = "../../stubs/ureq" } +url = { path = "../../stubs/url" } diff --git a/tests/fixture-client/app/client_app/src/api.rs b/tests/fixture-client/app/client_app/src/api.rs new file mode 100644 index 0000000..05df2b5 --- /dev/null +++ b/tests/fixture-client/app/client_app/src/api.rs @@ -0,0 +1,134 @@ +//! 구조체 필드에 base URL을 두는 API 클라이언트 — e2e에서 axum08 fixture 서버의 +//! `/api/items` 라우트를 부른다. + +use crate::Res; +use reqwest::blocking::Client; +use url::Url; + +/// 생성자가 base를 상수로 채운다 — 필드 값이 리터럴로 풀린다. +pub struct ApiClient { + base_url: String, + http: Client, +} + +/// API base(경로 포함). +const API_BASE: &str = "http://api.example.com/api"; + +impl ApiClient { + pub fn new() -> Self { + Self { + base_url: API_BASE.to_string(), + http: Client::new(), + } + } + + pub fn list_items(&self) -> Res { + self.http.get(format!("{}/items", self.base_url)).send()?; + Ok(()) + } + + pub fn get_item(&self, id: u64) -> Res { + self.http + .get(format!("{}/items/{id}", self.base_url)) + .send()?; + Ok(()) + } + + pub fn create_item(&self) -> Res { + self.http.post(self.base_url.clone() + "/items").send()?; + Ok(()) + } + + pub fn search(&self, q: &str) -> Res { + self.http + .get(format!("{}/search?q={q}", self.base_url)) + .send()?; + Ok(()) + } + + /// 선언된 래퍼(`http-wrappers.json`) — 동사는 enum, 경로는 base 뒤다. 본문의 동적 + /// 호출은 래퍼 호출 사실이 대신한다. + pub fn send(&self, verb: Verb, path: &str) -> Res { + let method = match verb { + Verb::Get => reqwest::Method::GET, + Verb::Post => reqwest::Method::POST, + Verb::Delete => reqwest::Method::DELETE, + }; + self.http + .request(method, format!("{}{}", self.base_url, path)) + .send()?; + Ok(()) + } + + /// 구조체 리터럴 생성 래퍼 `Endpoint`를 실행한다. + pub fn execute(&self, ep: Endpoint) -> Res { + self.send(ep.verb, &ep.path) + } +} + +impl Default for ApiClient { + fn default() -> Self { + Self::new() + } +} + +/// 래퍼 동사 enum. +pub enum Verb { + Get, + Post, + Delete, +} + +/// 생성자 래퍼(`kind: constructor`, 이름 = 타입 이름) — 필드 레이블로 묶는다. +pub struct Endpoint { + pub verb: Verb, + pub path: String, +} + +/// 생성자가 base를 매개변수로 받는다 — 필드가 풀리지 않아 base 앵커와 baseRef다. +pub struct RemoteClient { + base: String, + http: Client, +} + +impl RemoteClient { + pub fn new(base: &str) -> Self { + RemoteClient { + base: base.to_string(), + http: Client::new(), + } + } + + pub fn user(&self, id: u32) -> Res { + self.http + .get(format!("{}/users/{}", self.base, id)) + .send()?; + Ok(()) + } +} + +/// `url::Url` 필드 — `Url::join`(RFC 3986 병합)으로 잇는다. +pub struct Catalog { + base: Url, + http: Client, +} + +impl Catalog { + pub fn new() -> Self { + Catalog { + base: Url::parse("http://api.example.com/cat/").unwrap(), + http: Client::new(), + } + } + + pub fn products(&self) -> Res { + self.http.get(self.base.join("products")?).send()?; + Ok(()) + } +} + +impl Default for Catalog { + fn default() -> Self { + Self::new() + } +} diff --git a/tests/fixture-client/app/client_app/src/lib.rs b/tests/fixture-client/app/client_app/src/lib.rs new file mode 100644 index 0000000..404c603 --- /dev/null +++ b/tests/fixture-client/app/client_app/src/lib.rs @@ -0,0 +1,14 @@ +//! 합성 HTTP 클라이언트 fixture — rustograph `routes --role client`가 읽고, +//! experiments/client-oracle이 같은 소스를 진짜 reqwest·ureq·url로 컴파일해 로컬 +//! 서버로 요청을 보낸다. 시나리오 함수 하나가 요청 하나를 보낸다. + +pub mod api; +pub mod scenarios; +pub mod ureq_calls; +pub mod wrappers; + +/// 시나리오 결과 — 오라클은 오류도 기록한다(상대 URL은 보내기 전에 실패한다). +pub type Res = Result<(), Box>; + +/// 모든 리터럴 URL의 host. +pub const HOST: &str = "http://api.example.com"; diff --git a/tests/fixture-client/app/client_app/src/scenarios.rs b/tests/fixture-client/app/client_app/src/scenarios.rs new file mode 100644 index 0000000..7f3435b --- /dev/null +++ b/tests/fixture-client/app/client_app/src/scenarios.rs @@ -0,0 +1,190 @@ +//! reqwest 시나리오 — 리터럴·format!·concat!·+·Url::parse/join·지역 변수·static. + +use crate::{Res, HOST}; +use reqwest::blocking::Client; +use std::sync::LazyLock; +use url::Url; + +/// 경로가 있는 base. +const V1: &str = "http://api.example.com/v1"; + +/// 끝 슬래시가 있는 base — 문자열 연결은 `//`를 남긴다. +const V3_SLASH: &str = "http://api.example.com/v3/"; + +/// 전역 클라이언트. +static CLIENT: LazyLock = LazyLock::new(Client::new); + +pub fn literal_query() -> Res { + reqwest::blocking::get("http://api.example.com/v1/items?page=2")?; + Ok(()) +} + +pub async fn async_user(id: u64) -> Result<(), reqwest::Error> { + let client = reqwest::Client::new(); + client.get(format!("{V1}/users/{id}")).send().await?; + Ok(()) +} + +pub fn concat_post() -> Res { + Client::new() + .post(concat!("http://api.example.com", "/v1/orders")) + .send()?; + Ok(()) +} + +pub fn put_positional(id: u32) -> Res { + let client = Client::new(); + client.put(format!("{}/v1/items/{}", HOST, id)).send()?; + Ok(()) +} + +pub fn delete_plus(id: &str) -> Res { + CLIENT + .delete(HOST.to_string() + "/v1/items/" + id) + .send()?; + Ok(()) +} + +pub fn patch_local() -> Res { + let url = format!("{HOST}/v1/profile"); + CLIENT.patch(&url).send()?; + Ok(()) +} + +pub fn head_health() -> Res { + CLIENT.head(format!("{V1}/health")).send()?; + Ok(()) +} + +pub fn request_options() -> Res { + CLIENT + .request(reqwest::Method::OPTIONS, format!("{V1}/items")) + .send()?; + Ok(()) +} + +pub fn request_dynamic(method: reqwest::Method) -> Res { + CLIENT.request(method, format!("{V1}/verbs")).send()?; + Ok(()) +} + +pub fn join_relative(id: u32) -> Res { + let base = Url::parse("http://api.example.com/v2/")?; + CLIENT.get(base.join(&format!("users/{id}"))?).send()?; + Ok(()) +} + +pub fn join_replaces_last() -> Res { + let url = Url::parse("http://api.example.com/v2/catalog")?.join("tags")?; + CLIENT.get(url).send()?; + Ok(()) +} + +pub fn join_absolute_path() -> Res { + let url = Url::parse(V1)?.join("/root/ping")?; + CLIENT.get(url.as_str()).send()?; + Ok(()) +} + +pub fn dot_segments() -> Res { + CLIENT.get("http://api.example.com/v1/a/../b").send()?; + Ok(()) +} + +pub fn double_slash() -> Res { + CLIENT.get(format!("{V3_SLASH}/items")).send()?; + Ok(()) +} + +pub fn query_tail(page: Option) -> Res { + let q = if let Some(p) = page { + format!("?page={p}") + } else { + String::new() + }; + CLIENT.get(format!("{V1}/search{q}")).send()?; + Ok(()) +} + +pub fn partial_segment(name: &str) -> Res { + CLIENT.get(format!("{V1}/files/{name}.json")).send()?; + Ok(()) +} + +pub fn unknown_base_rooted(base: &str) -> Res { + CLIENT.get(format!("{base}/v1/status")).send()?; + Ok(()) +} + +pub fn unknown_base_glued(base: &str) -> Res { + CLIENT.get(format!("{base}status")).send()?; + Ok(()) +} + +pub fn relative_url() -> Res { + CLIENT.get("/v1/relative").send()?; + Ok(()) +} + +pub fn masked_token() -> Res { + CLIENT + .get(format!("{V1}/tokens/a1b2c3d4e5f6a7b8c9d0")) + .send()?; + Ok(()) +} + +pub fn non_ascii() -> Res { + CLIENT.get("http://api.example.com/v1/café").send()?; + Ok(()) +} + +pub fn userinfo_fragment() -> Res { + CLIENT + .get("http://user:pw@API.example.com/v1/secure#top") + .send()?; + Ok(()) +} + +pub fn with_port() -> Res { + CLIENT.get("http://api.example.com:8080/v1/port").send()?; + Ok(()) +} + +pub fn backslashes() -> Res { + CLIENT.get("http://api.example.com\\v1\\bs").send()?; + Ok(()) +} + +pub fn builder_client() -> Res { + let client = reqwest::blocking::Client::builder() + .user_agent("fixture") + .build()?; + client.get(format!("{V1}/built")).send()?; + Ok(()) +} + +pub fn struct_field_base() -> Res { + let api = crate::api::ApiClient::new(); + api.list_items()?; + Ok(()) +} + +pub fn struct_field_item() -> Res { + crate::api::ApiClient::new().get_item(42) +} + +pub fn struct_field_plus() -> Res { + crate::api::ApiClient::new().create_item() +} + +pub fn struct_field_query() -> Res { + crate::api::ApiClient::new().search("x") +} + +pub fn struct_param_base(base: &str) -> Res { + crate::api::RemoteClient::new(base).user(3) +} + +pub fn url_field() -> Res { + crate::api::Catalog::new().products() +} diff --git a/tests/fixture-client/app/client_app/src/ureq_calls.rs b/tests/fixture-client/app/client_app/src/ureq_calls.rs new file mode 100644 index 0000000..f743678 --- /dev/null +++ b/tests/fixture-client/app/client_app/src/ureq_calls.rs @@ -0,0 +1,30 @@ +//! ureq 3 시나리오 — 자유 함수와 Agent. ureq 3은 `http::Uri`로 해석해 점 세그먼트를 +//! 지우지 않는다. + +use crate::{Res, HOST}; + +pub fn ureq_get() -> Res { + ureq::get("http://api.example.com/v1/ureq/items").call()?; + Ok(()) +} + +pub fn ureq_delete(id: u32) -> Res { + ureq::delete(&format!("{HOST}/v1/ureq/items/{id}")).call()?; + Ok(()) +} + +pub fn ureq_dots() -> Res { + ureq::get("http://api.example.com/v1/x/../ureq-dots").call()?; + Ok(()) +} + +pub fn ureq_agent_head() -> Res { + let agent: ureq::Agent = ureq::Agent::new_with_defaults(); + agent.head(format!("{HOST}/v1/ureq/health")).call()?; + Ok(()) +} + +pub fn ureq_post() -> Res { + ureq::post("http://api.example.com/v1/ureq/orders").send_empty()?; + Ok(()) +} diff --git a/tests/fixture-client/app/client_app/src/wrappers.rs b/tests/fixture-client/app/client_app/src/wrappers.rs new file mode 100644 index 0000000..23f087e --- /dev/null +++ b/tests/fixture-client/app/client_app/src/wrappers.rs @@ -0,0 +1,41 @@ +//! 선언된 래퍼(`tests/fixture-client/http-wrappers.json`)와 선언되지 않은 싱크. + +use crate::api::{ApiClient, Endpoint, Verb}; +use crate::{Res, HOST}; +use reqwest::blocking::Client; + +/// 함수 래퍼 base. +const WRAP_BASE: &str = "http://api.example.com/wb"; + +/// 선언된 함수 래퍼 — 기본 동사 GET, 경로는 첫 인자. +pub fn fetch_json(path: &str) -> Res { + Client::new().get(format!("{WRAP_BASE}{path}")).send()?; + Ok(()) +} + +/// 선언되지 않은 싱크 — 매개변수를 URL로 흘려보낸다(`http-wrapper-undeclared:`). +pub fn raw_get(path: &str) -> Res { + Client::new().get(format!("{HOST}{path}")).send()?; + Ok(()) +} + +pub fn wrapper_function() -> Res { + fetch_json("/w/items") +} + +pub fn wrapper_method() -> Res { + let api = ApiClient::new(); + api.send(Verb::Post, "/w/orders") +} + +pub fn wrapper_constructor(id: u32) -> Res { + let api = ApiClient::new(); + api.execute(Endpoint { + verb: Verb::Delete, + path: format!("/e/items/{id}"), + }) +} + +pub fn undeclared_sink() -> Res { + raw_get("/v1/undeclared") +} diff --git a/tests/fixture-client/http-wrappers.json b/tests/fixture-client/http-wrappers.json new file mode 100644 index 0000000..8b536d1 --- /dev/null +++ b/tests/fixture-client/http-wrappers.json @@ -0,0 +1,44 @@ +{ + "format": "http-wrappers", + "version": 1, + "wrappers": [ + { + "language": "rust", + "kind": "function", + "owner": "client_app::wrappers", + "name": "fetch_json", + "pathArg": { "index": 0 }, + "defaultMethod": "GET", + "pathAnchor": "base" + }, + { + "language": "rust", + "kind": "function", + "owner": "client_app::api::ApiClient", + "name": "send", + "methodArg": { "index": 0 }, + "methodEnum": { "Get": "GET", "Post": "POST", "Delete": "DELETE" }, + "pathArg": { "index": 1 }, + "pathAnchor": "base" + }, + { + "language": "rust", + "kind": "constructor", + "owner": "client_app::api::Endpoint", + "name": "Endpoint", + "methodArg": { "label": "verb" }, + "methodEnum": { "Get": "GET", "Post": "POST", "Delete": "DELETE" }, + "pathArg": { "label": "path" }, + "pathAnchor": "base" + }, + { + "language": "swift", + "kind": "function", + "owner": "Network", + "name": "send", + "pathArg": { "label": "path" }, + "defaultMethod": "GET", + "pathAnchor": "root" + } + ] +} diff --git a/tests/fixture-client/stubs/reqwest/Cargo.toml b/tests/fixture-client/stubs/reqwest/Cargo.toml new file mode 100644 index 0000000..f85cfa6 --- /dev/null +++ b/tests/fixture-client/stubs/reqwest/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "reqwest" +version = "0.13.5" +edition = "2021" +publish = false + +# 이름·버전만 흉내 내는 스텁이다. rustograph는 cargo metadata가 해석한 버전으로 +# URL 해석 규칙을 고르고 소스는 syn으로만 읽으므로 이 크레이트를 컴파일하지 않는다. +# 실제 요청은 experiments/client-oracle이 crates.io의 진짜 크레이트로 보낸다. diff --git a/tests/fixture-client/stubs/reqwest/src/lib.rs b/tests/fixture-client/stubs/reqwest/src/lib.rs new file mode 100644 index 0000000..92f1ce9 --- /dev/null +++ b/tests/fixture-client/stubs/reqwest/src/lib.rs @@ -0,0 +1 @@ +//! 버전 판정용 스텁 — 비어 있다. diff --git a/tests/fixture-client/stubs/ureq/Cargo.toml b/tests/fixture-client/stubs/ureq/Cargo.toml new file mode 100644 index 0000000..26277ef --- /dev/null +++ b/tests/fixture-client/stubs/ureq/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "ureq" +version = "3.4.2" +edition = "2021" +publish = false + +# 이름·버전만 흉내 내는 스텁이다. rustograph는 cargo metadata가 해석한 버전으로 +# URL 해석 규칙을 고르고 소스는 syn으로만 읽으므로 이 크레이트를 컴파일하지 않는다. +# 실제 요청은 experiments/client-oracle이 crates.io의 진짜 크레이트로 보낸다. diff --git a/tests/fixture-client/stubs/ureq/src/lib.rs b/tests/fixture-client/stubs/ureq/src/lib.rs new file mode 100644 index 0000000..92f1ce9 --- /dev/null +++ b/tests/fixture-client/stubs/ureq/src/lib.rs @@ -0,0 +1 @@ +//! 버전 판정용 스텁 — 비어 있다. diff --git a/tests/fixture-client/stubs/url/Cargo.toml b/tests/fixture-client/stubs/url/Cargo.toml new file mode 100644 index 0000000..09a659a --- /dev/null +++ b/tests/fixture-client/stubs/url/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "url" +version = "2.5.8" +edition = "2021" +publish = false + +# 이름·버전만 흉내 내는 스텁이다. rustograph는 cargo metadata가 해석한 버전으로 +# URL 해석 규칙을 고르고 소스는 syn으로만 읽으므로 이 크레이트를 컴파일하지 않는다. +# 실제 요청은 experiments/client-oracle이 crates.io의 진짜 크레이트로 보낸다. diff --git a/tests/fixture-client/stubs/url/src/lib.rs b/tests/fixture-client/stubs/url/src/lib.rs new file mode 100644 index 0000000..92f1ce9 --- /dev/null +++ b/tests/fixture-client/stubs/url/src/lib.rs @@ -0,0 +1 @@ +//! 버전 판정용 스텁 — 비어 있다. diff --git a/tests/routes.rs b/tests/routes.rs index 14979ab..cd04be8 100644 --- a/tests/routes.rs +++ b/tests/routes.rs @@ -37,6 +37,7 @@ const SKIPPED: &[&str] = &[ "scope.applies", "framework.openapi.", "framework.spring.", + // 클라이언트 조립 규칙 — tests/client_routes.rs가 실행한다. "compose.", "wrapper.", ]; From 7b5815d44dcf12b30617c7574cb454654fbece84 Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 15:27:30 +0900 Subject: [PATCH 5/8] =?UTF-8?q?docs:=20routes=20--role=20client=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=C2=B7=EC=98=A4=EB=9D=BC=ED=81=B4=20=ED=91=9C?= =?UTF-8?q?=C2=B7HANDOFF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/HTTP-CLIENT.md에 라이브러리별 해석과 소스 근거, 제안하는 Rust 결합 방식 이름(whatwg-concat·whatwg-join·http-uri-concat), 래퍼 선언의 Rust 이름 규칙, 한계 접두사, 오라클 표를 적었다. README 두 언어에 명령과 절을 더하고, isthmus가 rust route-decl을 이미 받는다는 사실로 낡은 문장을 고쳤다. HANDOFF에 scratch isthmus로 돌린 workspace trace e2e를 남겼다. Co-Authored-By: Claude Opus 5.5 (1M context) --- HANDOFF.md | 32 +++++++- README.ko.md | 44 ++++++++++- README.md | 50 +++++++++++- conformance/README.md | 7 +- docs/HTTP-CLIENT.md | 177 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 300 insertions(+), 10 deletions(-) create mode 100644 docs/HTTP-CLIENT.md diff --git a/HANDOFF.md b/HANDOFF.md index fdf33b6..6afb0b7 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,7 +2,37 @@ 세션을 이어받는 에이전트가 먼저 읽는 문서입니다. -## 진행 중 — feature/server-routes (2026-09-30, API 영향 프로그램 Phase 7 후속) +## 진행 중 — feature/client-routes (2026-09-30, API 영향 프로그램 개선 #3 Rust 부분) + +`rustograph routes --role client`(isthmus http `route-call` 생산자). 규칙·근거·오라클 표는 `docs/HTTP-CLIENT.md`. + +- **코드** — `src/source/routes/client.rs`(멤버·의존 버전, 문서 조립, 한계) + `client/{index,scan}.rs`(색인과 두 단계 + 스캐너: 구조체 필드 값 수집 → 호출 사실), `compose.rs`(url-compose 규칙의 순수 구현 — `UrlVal`, WHATWG concat/join, + `http::Uri` concat, 마스킹), `wrappers.rs`(http-wrappers v1 파싱·동사 바인딩). 위치 열은 계약대로 UTF-8 바이트다 + (`Ctx::locate_utf8`). **서버 `route-decl`의 `Ctx::locate`는 UTF-16 열을 낸다 — GRAPH-EXCHANGE는 UTF-8 바이트를 + 요구하므로 비ASCII 줄에서 어긋난다(이번 범위 밖, 후속 수정 후보).** +- **인식** — reqwest(`get`·`blocking::get`·Client 동사·`request`·`Request::new`), ureq 2/3 자유 함수·Agent, 선언된 + 래퍼(`owner::name` = 정점 ID, 구조체 리터럴 생성자는 이름 = 타입 이름). 수신자는 구문 타입 추론(생성자·주석·필드 + 타입·반환 타입·static 타입, `Arc`·`LazyLock` 등 벗김). 증명하지 못한 수신자는 사실 없이 센다. +- **검증** — url-compose 생산자 41건 통과. 오라클 41 시나리오: 일치 37 · dynamic 3 · 요청 없음 1 · 불일치 0 + (reqwest 0.13.5·ureq 3.4.2·url 2.5.8, scratch에서 reqwest 0.12.28도 같음). 오라클이 확인한 것: `Url::join`의 마지막 + 세그먼트 교체, WHATWG `//` 보존·점 세그먼트 제거, **ureq 3의 점 세그먼트 보존**, ureq 3이 http 요청도 프록시에 + CONNECT를 연다(기록 서버가 터널을 수락). 커버리지 92.14%, stable clippy(1.98.1) 0. +- **e2e(scratch)** — isthmus `c395c59` 무패치는 rust route-call 문서를 "Fact kind is not valid for platform"으로 거부한다. + `routeKindPlatforms`의 route-call에 `rust`를 더한 scratch 빌드로 workspace trace(`server` = axum08 fixture + + `reach --roots-from`, `client` = fixture-client + `impact --format language-traversal --roots-from`, link match + hosts `api.example.com`)를 돌렸다: 3 routes · 3 calls · 3 handlers · 3 client symbols. 예: `POST /api/items` — + client `client_app::api::ApiClient::create_item`(api.rs:38, quality exact) → route-decl axum08 routes.rs:13 → + handler `axum_app::handlers::items::create`; client 역방향 `struct_field_plus`(depth 1, candidate — syn 메서드 호출 + 팬아웃). base 앵커 호출 10건은 host가 없어 `unattributed-calls-omitted`. +- **isthmus 쪽 남은 것** — rust route-call 수용과 Rust 결합 이름 벡터(`feature/scopes-upstream-clients`, 이 세션 동안 + 원격에 없음). 이름을 정하면 `tests/client_routes.rs`의 `join_of`를 옮기고 `conformance/`를 재벤더링한다. 제안 이름: + `whatwg-concat`·`whatwg-join`·`http-uri-concat`. +- 알려진 근사: 필드 수정 감지는 소유 타입을 모르면 이름 단위(보수적), `Url` 값의 `set_path` 등은 지역·필드를 믿지 않게 + 만들 뿐 새 값을 계산하지 않는다, 선언된 구조체 래퍼 값을 받아 다른 래퍼를 부르는 함수(`execute`)는 동사 동적 dynamic + 사실을 하나 더 낸다, ureq 3에서 `http::Uri`가 거부하는 문자(공백 등)가 든 리터럴도 사실로 낸다. + +## 직전 — feature/server-routes (2026-09-30, PR #23 머지됨) `rustograph routes --role server`(isthmus http `route-decl` 생산자)와 수확 이름 해석 결함 수정. diff --git a/README.ko.md b/README.ko.md index 6856a93..a22b84e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -72,6 +72,7 @@ rustograph graph --target x86_64-pc-windows-msvc # cfg(트리플) 평가 rustograph mcp # MCP stdio 서버 — 에이전트가 되묻는 통로 rustograph schema --dir . --out schema-facts.json # isthmus persistence 사실 rustograph routes --role server --out routes.json # isthmus http route-decl(axum·actix-web) +rustograph routes --role client --wrappers http-wrappers.json --out calls.json # route-call(reqwest·ureq) rustograph reach mycrate::api::list_users # isthmus language-traversal(정방향) rustograph impact --format language-traversal --roots-from schema-facts.json # 역방향 ``` @@ -165,6 +166,42 @@ import할 때만 인정합니다. 파싱 실패 파일·문법이 다른 `table! 그 기록을 `cargo test`가 오프라인으로 대조합니다. isthmus 공유 벡터는 `conformance/`에 잠금 파일과 함께 벤더링했습니다. +## 클라이언트 호출 — `routes --role client` + +`rustograph routes --role client`는 코드가 만드는 HTTP 요청마다 `route-call` +사실 하나를 담은 isthmus `bridge-facts` v1 문서(`platform: "rust"`, +`target: "http"`, `roles: ["client"]`)를 냅니다. `symbol.usr`는 호출을 감싼 +함수·메서드이고 `impact`의 정점 ID와 같아서 `isthmus trace`가 호출부에서 그 +호출부에 기대는 클라이언트 코드로 이어 갑니다. + +- **reqwest**(0.13, 0.12도 확인): `reqwest::get`·`blocking::get`, + `Client`·`blocking::Client`의 동사 메서드, `request(Method::X, url)`, + `Request::new`. 문자열은 `url::Url::parse`(WHATWG)로 해석합니다 — 점 + 세그먼트는 지우고 `//`는 남깁니다. +- **ureq** 3(2.x는 소스 기준): 자유 함수와 `Agent` 메서드. ureq 3은 + `http::Uri`로 해석해 점 세그먼트를 남깁니다. +- **URL 조립**: 리터럴, `format!`(위치·이름·인라인 인자), `concat!`, `+`, + 상수·static·연관 상수, 지역 변수(그림자 추적, 수정되는 이름은 믿지 않음), + `Url::parse(..)?.join(..)`(RFC 3986 병합 — `…/v2/catalog` + `tags`는 + `/v2/tags`), 모든 생성자가 같은 리터럴·상수로 채우는 구조체 필드 base. + 그 밖은 dynamic 사실(`channel: null`, 증명한 경우 마스킹한 `channelPrefix`) + 이나 센 한계입니다. +- **래퍼**: isthmus `http-wrappers` v1 파일에 선언한 함수·메서드·구조체 + 리터럴 엔드포인트(`"language": "rust"`, `owner::name`이 rustograph 정점 + ID)는 선언한 동사·앵커로 호출 사실이 됩니다. +- **센 공백**: 모델링하지 않는 클라이언트(hyper client·surf·awc·isahc 등), + 클라이언트로 증명하지 못한 수신자의 요청, 클라이언트가 거부하는 상대 + URL, 선언되지 않은 래퍼 싱크, 풀리지 않는 선언은 `route-call-coverage:`· + `ambiguous-base-join:`·`http-wrapper-undeclared:`·`http-wrapper-unresolved:` + 한계입니다. + +규칙, 제안한 결합 방식 이름(`whatwg-concat`·`whatwg-join`·`http-uri-concat`), +오라클 표는 [docs/HTTP-CLIENT.md](docs/HTTP-CLIENT.md)에 있습니다. 모의 서버 +오라클(`experiments/client-oracle/`)이 fixture를 진짜 reqwest·ureq·url로 +컴파일해 로컬 서버가 받은 요청을 기록합니다 — 41개 시나리오 불일치 0, 기록은 +`cargo test`가 오프라인으로 대조합니다. isthmus `url-compose` 벡터의 생산자 +사례 41건을 통과합니다. + ## 순회 문서 — `reach` / `impact --format language-traversal` isthmus [`language-traversal` v1](https://github.com/ictechgy/isthmus/blob/main/docs/LANGUAGE-TRAVERSAL.md) @@ -187,9 +224,10 @@ isthmus [`language-traversal` v1](https://github.com/ictechgy/isthmus/blob/main/ - 그래프 정점이 아닌 root는 `symbol` 없이 싣고 `root-not-found:` limitation을 더한 문서를 쓴 뒤 64로 끝납니다. -isthmus는 `platform: "rust"` 문서의 `route-decl`을 받지 않아 Rust 핸들러에서 -시작하는 route 선택 `trace`는 아직 불가능합니다. relation·심볼 선택(역방향 -순회)은 지금 동작합니다. +isthmus는 Rust `route-decl` 문서를 받아 axum·actix-web 핸들러에서 route 선택 +`trace`를 시작합니다. Rust `route-call`은 아직 받지 않습니다 — 그 변경이 +들어오기 전까지 `routes --role client` 문서는 그것을 허용한 isthmus 빌드로 +검증했습니다(HANDOFF.md). ## 개발 diff --git a/README.md b/README.md index c255415..e3badb6 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,10 @@ rustograph schema --dir . --out schema-facts.json rustograph routes --role server --dir . --out routes.json rustograph reach --roots-from routes.json # handler usrs are graph vertices +# Emit isthmus http route-call facts for reqwest / ureq clients +rustograph routes --role client --dir . --wrappers http-wrappers.json --out calls.json +rustograph impact --format language-traversal --roots-from calls.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 @@ -266,6 +270,45 @@ the real crates and probes them in-process: 100% precision and recall on all three fixtures, recorded and checked offline by `cargo test`. The isthmus conformance vectors are vendored under `conformance/` with a lock. +## Client calls — `routes --role client` + +`rustograph routes --role client` emits an isthmus `bridge-facts` v1 +document with `platform: "rust"`, `target: "http"`, `roles: ["client"]` and +one `route-call` fact per HTTP request the code builds. `symbol.usr` is the +enclosing function or method — the same vertex id `impact` uses, so +`isthmus trace` can continue from a call site into the client code that +depends on it. + +- **reqwest** (0.13; 0.12 checked too): `reqwest::get`, `blocking::get`, + `Client`/`blocking::Client` verb methods, `request(Method::X, url)`, + `Request::new`. Strings go through `url::Url::parse` (WHATWG): dot + segments are removed, `//` is kept. +- **ureq** 3 (2.x read from source): free functions and `Agent` methods. + ureq 3 parses with `http::Uri`, which keeps dot segments. +- **URL building**: literals, `format!` (positional, named and inline + arguments), `concat!`, `+`, consts/statics/associated consts, locals + (shadowing-aware, mutated names untrusted), `Url::parse(..)?.join(..)` + (RFC 3986 merge — `…/v2/catalog` + `tags` is `/v2/tags`), and a base URL + held in a struct field when every constructor fills it with the same + literal or const. Anything else stays a dynamic fact (`channel: null`, a + masked `channelPrefix` when proven) or a counted limitation. +- **Wrappers**: functions, methods and struct-literal endpoints declared in + an isthmus `http-wrappers` v1 file (`"language": "rust"`, `owner::name` is + the rustograph vertex id) become calls with the declared verb and anchor. +- **Measured gaps**: unmodelled clients (hyper client, surf, awc, isahc, …), + requests sent from a receiver that is not a proven client, relative URLs + the client rejects, undeclared wrapper sinks and unresolved declarations + are `route-call-coverage:` / `ambiguous-base-join:` / + `http-wrapper-undeclared:` / `http-wrapper-unresolved:` limitations. + +The rules, the proposed base-join style names (`whatwg-concat`, +`whatwg-join`, `http-uri-concat`) and the oracle table are in +[docs/HTTP-CLIENT.md](docs/HTTP-CLIENT.md). A mock-server oracle +(`experiments/client-oracle/`) compiles the fixture against the real +reqwest/ureq/url crates and records every request at a local server: 41 +scenarios, 0 mismatches, checked offline by `cargo test`. The 41 producer +cases of the isthmus `url-compose` vectors pass. + ## Traversal documents — `reach` / `impact --format language-traversal` ```bash @@ -304,9 +347,10 @@ strings as `symbol.usr` in `schema`. `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. +isthmus accepts Rust `route-decl` documents, so a route-selection `trace` +starts at an axum/actix-web handler. It does not yet accept Rust +`route-call` facts: until that lands, `routes --role client` documents are +validated against an isthmus build that allows them (see HANDOFF.md). ## Agent output contract diff --git a/conformance/README.md b/conformance/README.md index 790c414..22a3a49 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -5,7 +5,7 @@ isthmus(`76b6141e71c84e0ab1026ad1f18f910b9d966dc8`)의 `conformance/`를 그대 복사하고(새 suite 파일 포함) 저장소 루트의 `conformance.lock`에 커밋과 파일별 sha256을 적은 뒤 `cargo test --test routes`로 확인한다. -rustograph가 실행하는 사례(생산자 대상, `rustograph routes --role server`): +rustograph가 실행하는 사례(생산자 대상, `rustograph routes --role server|client`): | suite | ruleId | 검사 | |---|---|---| @@ -13,7 +13,8 @@ rustograph가 실행하는 사례(생산자 대상, `rustograph routes --role se | `http-template` | `template.normalize` | URI 경로 정규화(`normalize_uri_path`)가 같다 | | `http-dispatch` | `dispatch.validate` | `order` 검증기(`source::routes::validate::order_problem`)가 소비자와 같이 판정한다. fixture 출력에도 적용한다 | | `http-limitation-scope` | `scope.validate` | 스코프 검증기(`scope_problem`)가 소비자와 같이 판정한다(생산 문서가 거부되지 않게) | +| `url-compose` | `compose.*`·`wrapper.*`(`producer` 41건) | 클라이언트 조립(`source::routes::compose`)과 래퍼 동사 바인딩(`source::routes::wrappers`)이 같은 결과를 낸다(`tests/client_routes.rs`). `dio-concat`은 결과가 같은 WHATWG 문자열 연결로, `wrapper.location`은 실제 스캐너로 확인한다 | 건너뛰는 사례와 이유: 소비자 전용 사례(`match.*`, `dispatch.match`, `dispatch.shadow`, `scope.applies` — 적용 판정은 -소비자가 한다), 다른 생산자의 프레임워크 변환(`framework.openapi.*`, `framework.spring.*`), `url-compose` 전체(클라이언트 -`route-call` 조립 규칙이며 rustograph는 서버 선언만 낸다). +소비자가 한다), 다른 생산자의 프레임워크 변환(`framework.openapi.*`, `framework.spring.*`), `url-compose`의 +`producer:kartograph` 사례(Spring 클라이언트 결합). diff --git a/docs/HTTP-CLIENT.md b/docs/HTTP-CLIENT.md new file mode 100644 index 0000000..71a6261 --- /dev/null +++ b/docs/HTTP-CLIENT.md @@ -0,0 +1,177 @@ +# HTTP 호출 규칙 (`rustograph routes --role client`) + +rustograph가 Rust 클라이언트 코드에서 isthmus http `route-call` 사실을 만드는 규칙과 근거를 적는다. 계약 정본은 +isthmus [GRAPH-EXCHANGE "HTTP 경계"](https://github.com/ictechgy/isthmus/blob/main/docs/GRAPH-EXCHANGE.md)와 +[HTTP-WRAPPERS](https://github.com/ictechgy/isthmus/blob/main/docs/HTTP-WRAPPERS.md)(url-compose 규칙·래퍼 선언)다. +서버 선언 규칙은 [HTTP-ROUTES.md](HTTP-ROUTES.md)에 있다. + +```sh +rustograph routes --role client [--dir DIR] [--out FILE] [--wrappers http-wrappers.json] [--service NAME] +``` + +- 문서: bridge-facts v1, `platform: "rust"`, `target: "http"`, `roles: ["client"]`, `sourceSets.tests: "excluded"`, + 선택 `service`(`--service`). 사실이 0건이어도 target은 `http`다(스캔했으나 호출 없음). `dispatch`는 없다. +- 스캔: 모든 워크스페이스 멤버의 lib·bin 타깃, `impact`와 같은 syn 수확(모듈 트리·AST·정점 집합). `#[cfg(test)]` 모듈과 + `#[test]` 함수는 뺀다. 컴파일·실행·네트워크 접근이 없다. +- `symbol.usr`: 호출을 감싸는 가장 안쪽 그래프 정점(함수·메서드·트레이트 기본 메서드, 상수·static 초기식) — `impact`· + `reach`의 정점 ID와 같다. 중첩 함수·클로저 안의 호출은 바깥 정점이다. 정점이 없으면 usr 없이 내고 + `missing-route-usrs:`로 센다. `verify-cli-contract.sh`가 `impact --roots-from`으로 모든 usr가 정점임을 확인한다. +- `location`: 호출식이 시작하는 줄(`wrapper.location` — 메서드 체인은 수신자 시작)과 UTF-8 바이트 열 + 1. +- dynamic 사실은 `channel: null`이다 — 원문 식은 URL의 userinfo·query를 담을 수 있어 싣지 않는다. 증명한 리터럴 + 접두사만 마스킹한 `channelPrefix`로 싣는다. +- `authority`: base가 리터럴이고 root 앵커일 때만 싣는 소문자 `host[:port]`(userinfo 제거, 포트는 적힌 대로). +- `baseRef`: base URL이 구조체 필드(`app::api::ApiClient::base_url`)나 상수·static(`app::HOST`)에서 오면 그 id다. + 필드 id는 그래프 정점이 아니다 — workspace 매니페스트 `match.baseRefs`에 쓰는 안정 이름이다. + +## 라이브러리와 결합 방식 + +| 라이브러리(확인 버전) | 인식 | URL 해석 | 근거 | +|---|---|---|---| +| reqwest 0.13.5(0.12.28도 오라클 실행) | `reqwest::get`·`reqwest::blocking::get`, `Client`·`blocking::Client`의 `get`·`post`·`put`·`patch`·`delete`·`head`·`request(Method::X, url)`, `Request::new(Method::X, url)` | 문자열은 `url::Url::parse`(WHATWG), `Url`은 그대로 | `src/into_url.rs` `IntoUrlSealed for &str`(`Url::parse(self)` 뒤 `has_host()` 검사), `src/async_impl/client.rs` `execute_request`(http·https 외 scheme 거부) | +| ureq 3.4.2 | `ureq::get`·`post`·`put`·`delete`·`patch`·`head`·`options`·`trace`, `Agent`의 같은 메서드 | `http::Uri` — **점 세그먼트를 지우지 않는다** | `src/lib.rs` `pub fn get(uri: T) where Uri: TryFrom`, 오라클 `ureq_dots` | +| ureq 2.x | 위와 같은 자유 함수·`Agent`, `request(method, url)`·`request_url` | `url::Url::parse`(소스 기준, 실행 미확인 — 버전 한계를 낸다) | — | +| url 2.5.8 | `Url::parse(s)`(reqwest 재수출 `reqwest::Url` 포함), `url.join(p)` 체인 | WHATWG URL Standard | url 크레이트 실행 결과(아래) | + +**결합 방식 이름(제안).** isthmus url-compose 벡터의 `join` 입력에 Rust 결합을 더할 때 쓸 이름이다. 벡터에 Rust 이름이 +생기면 러너를 그 이름으로 옮긴다. + +| 이름 | 쓰는 곳 | `/x`(미상 base) | `x`(미상 base) | base 리터럴 | +|---|---|---|---|---| +| `whatwg-concat` | `format!`·`+`·`concat!`로 이은 문자열을 reqwest·ureq 2에 넘김 | base | dynamic + `ambiguous-base-join:` | 이은 문자열을 WHATWG로 해석(점 세그먼트 제거, `//` 보존), root | +| `whatwg-join` | `url::Url::join` | root | base(RFC 3986 병합) | WHATWG 상대 해석, root | +| `http-uri-concat` | 이은 문자열을 ureq 3에 넘김 | base | dynamic + `ambiguous-base-join:` | 이은 문자열 그대로(점 세그먼트 보존), root | + +WHATWG 결합은 http(s)에서 RFC 3986과 같다. 다른 점은 셋이고 모두 구현했다: `\`를 `/`로 읽고, 같은 scheme의 +`http:x`는 상대 참조이며, 앞뒤 C0·공백과 탭·줄바꿈을 지운다. url 2.5.8로 실행해 확인한 값(단위 테스트 +`whatwg_join_matches_url_crate`가 고정): `http://h/api` + `x` → `/x`, `http://h/api/` + `x` → `/api/x`, +`http://h/a/b/c` + `../x` → `/a/x`, `http://h/api` + `\x` → `/x`, `http://h/api/` + `http:x` → `/api/x`, +`http://h/api` + `//other/x` → host `other`의 `/x`, `http://h/api` + `?q=1` → `/api`. + +문자열 연결의 base 미상 행은 dio 단순 연결과 결과가 같아 공유 벡터의 `dio-concat` 사례를 `whatwg-concat`으로 실행한다. +`slash-join` 사례는 벡터용 `Join::SlashJoin`으로 실행한다(그 방식을 쓰는 Rust 라이브러리는 모델링하지 않았다). + +## 해석과 증명 + +- 문자열: 리터럴, `format!`/`format_args!`(위치·이름·인라인 캡처 인자, `{{`·`}}`), `concat!`, `a + b`, + `to_string`·`to_owned`·`into`·`as_str`·`clone`·`String::from` 같은 전달, 워크스페이스 `const`·`static`·연관 상수 + (`Self::BASE`)를 따라간다. `{:?}`처럼 서식 지정이 있으면 그 값은 모르는 값이고, `{:>1$}`처럼 인자 순서를 바꾸는 + 서식은 문자열 전체를 모른다. +- 지역 변수: 스코프 스택(블록·클로저·`for`·`match` 팔·`if let`/`while let`·let-else)으로 그림자를 따르고, 함수 안에서 + 대입·`&mut`·`push_str` 같은 수정 메서드가 닿는 이름은 믿지 않는다. 매개변수는 출처(`Origin::Param`)를 단 값이다. +- query 꼬리(`compose.suffix`): 끝에 붙은 지역 변수의 `if`/`match` 모든 가지가 `?` 리터럴로 시작하거나 빈 문자열 + (`String::new()` 포함)이면 떼고 `queryTailStripped`를 단다. `else` 없는 `if`는 증명하지 못한다. +- 구조체 필드 base: 크레이트의 모든 구조체 리터럴(`Self { .. }` 포함)이 그 필드를 **같은 리터럴·상수 값**으로 채우면 + 그 값이다(인스턴스와 무관하게 `self.f`·`x.f` 모두). 생성 위치가 매개변수로 채우거나, 값이 둘 이상이거나, + `..rest` 생략, `#[derive(Default)]`, 필드 대입·`&mut`·수정 메서드가 있으면 값을 버리고 base 앵커 + `baseRef`다. + `Url` 필드도 같다(`Url::parse(리터럴).unwrap()`). +- 수신자 판정: 식의 타입을 구문으로 추론한다 — 생성자(`Client::new()`·`Client::builder()…build()?`·`ureq::agent()`· + `Agent::new_with_defaults()`·`AgentBuilder`·`config_builder()…new_agent()`), 타입 주석이 있는 지역 변수·매개변수, + 구조체 필드 타입, 워크스페이스 함수·메서드의 반환 타입, `static`의 선언 타입. `&`·`Arc`·`Rc`·`Box`·`Lazy`· + `LazyLock`·`OnceLock`·`Result`는 벗긴다. 증명하지 못한 수신자에서 보낸 요청 모양 호출(`x.get(u).send()`)은 사실로 + 내지 않고 센다. +- 동사: 메서드 이름, `reqwest::Method::X`·`http::Method::X` 경로, ureq `request("GET", ..)`의 정확한 대문자 리터럴만 + 동사다. 그 밖은 `methodDynamic: true`다. +- base 없는 상대 URL(`client.get("/x")`)과 http(s)가 아닌 scheme은 클라이언트가 보내기 전에 거부하므로 사실을 내지 않고 + 센다(오라클 `relative_url`: 요청 없음). +- 모델링하지 않는 클라이언트(hyper·hyper-util의 client 경로, surf, awc, isahc, attohttpc, minreq, curl, ehttp, + gloo-net, reqwasm, http-client, reqwest-middleware)는 멤버가 직접 의존할 때 경로 사용 위치를 크레이트별로 센다. + +## 래퍼 선언(`http-wrappers` v1)의 Rust 규칙 + +`"language": "rust"` 항목만 적용한다(다른 언어 항목은 공백으로도 세지 않는다). 선언 오류는 종료 코드 2다. + +- `function`: `owner::name`이 rustograph 정점 ID다. 자유 함수는 모듈 경로(`app::net` + `send`), 메서드·연관 함수는 + 타입 ID(`app::api::ApiClient` + `request`), 트레이트 impl 메서드는 `app::api::ApiClient::` + 이름. +- `constructor`: `owner`는 타입 ID. `name`이 타입 이름과 같으면 구조체 리터럴(`Endpoint { verb, path }` — `label`은 + 필드 이름)이나 튜플 구조체 생성(`Ep(m, p)` — `index`)이고, 다르면 연관 함수(`Endpoint::new`)다. +- 인자: Rust에는 이름 붙은 인자가 없으므로 함수·메서드는 `index`(메서드는 수신자를 빼고 0부터, UFCS 호출도 + 같다)로 묶는다. `label`은 구조체 리터럴 필드에만 맞는다. `methodEnum`은 enum case·연관 상수 경로의 마지막 이름 + (`Verb::Post` → `Post`)과 문자열 리터럴을 case로 본다. +- 경로 인자: 전체 URL이면 절대 해석, `/`로 시작하면 선언 `pathAnchor`, 상대 경로는 래퍼 안 결합을 모르므로 dynamic + (base 앵커면 `ambiguous-base-join:`). 래퍼 경로에는 `baseRef`를 싣지 않는다. +- 선언된 래퍼 본문의 dynamic 호출은 내지 않는다(래퍼 호출 사실이 대신한다). 선언되지 않은 함수가 매개변수를 URL + 전체·base 뒤 꼬리로 흘려보내거나 동사 매개변수를 넘기면 `http-wrapper-undeclared:`로 센다(세그먼트 일부를 채우는 + 매개변수는 래퍼 선언으로 풀리지 않아 세지 않는다). +- 정점·구조체에 닿지 않는 선언과 호출 0건 선언은 `http-wrapper-unresolved:`다. `--service`와 다른 `service`를 선언한 + 래퍼가 있으면 isthmus가 문서를 거부하므로 먼저 사용법 오류로 막는다. + +## limitation + +| 접두사 | 뜻 | +|---|---| +| `route-call-coverage:` | 모델링하지 않는 클라이언트 사용 수(크레이트별), 증명하지 못한 수신자의 요청 모양 호출 수, 보내기 전 거부되는 URL 수, 확인한 버전 밖(reqwest 0.12·0.13, ureq 3 밖)의 의존, 위치를 못 찾은 호출, cargo metadata 한계 | +| `ambiguous-base-join:` | 미상 base 뒤 상대 경로 수 | +| `http-wrapper-undeclared:` | 선언되지 않은 래퍼 싱크 수 | +| `http-wrapper-unresolved:` | 정점에 닿지 않거나 호출이 0건인 rust 래퍼 선언(`wrappers[n]`) | +| `missing-route-usrs:` | usr가 없는 사실 수 | + +호출 측 공백은 증명할 수 있는 요청 범위가 없어 `limitationScopes`를 내지 않는다(문서 전체 효과). + +## 공유 적합성 벡터 + +`conformance/url-compose.json`(isthmus `76b6141`)의 `producer` 사례 41건을 `tests/client_routes.rs`가 제품 함수로 +실행한다(`compose::compose_path`·`join`·`UrlVal::parse`·`mask`, `wrappers::bind_method`). `wrapper.location`은 실제 +스캐너로 여러 줄 호출의 시작 줄과 UTF-8 열을 확인한다. `producer:kartograph`(Spring) 13건은 적용하지 않는다. + +## 모의 서버 오라클 + +`tests/fixture-client/app/`은 각 규칙을 부르는 합성 클라이언트이고, `experiments/client-oracle/`은 그 lib.rs를 +crates.io의 진짜 reqwest 0.13.5·ureq 3.4.2·url 2.5.8로 컴파일해 시나리오를 실행한다. 모든 http 요청은 환경 프록시 +(`HTTP_PROXY`)로 지정한 127.0.0.1 임시 포트의 기록 서버로 간다(ureq 3은 http 요청도 CONNECT 터널을 연다 — 서버가 +터널을 수락하고 안의 평문 요청을 읽는다). 외부 네트워크 요청은 없고, 서버는 오라클 프로세스 안의 스레드라 함께 +끝난다. 판정은 root는 전체 경로, base는 세그먼트 경계 꼬리, `{}`는 비어 있지 않은 세그먼트, authority는 요청 host이고, +정적 사실은 모두 어떤 요청과 맞아야 한다(정밀도). 재기록은 `experiments/client-oracle/run.sh`, 기본 테스트는 커밋된 +기록(`recorded/client.json`)을 스텁 의존(`tests/fixture-client/stubs`, 이름·버전만)으로 해석한 사실과 오프라인 대조한다. + +2026-09-30 기록: **41개 시나리오, 일치 37 · dynamic 3 · 요청 없음 1 · 불일치 0**, 귀속되지 않은 사실 0. 같은 +소스를 reqwest 0.12.28로 바꾼 scratch 실행도 같은 결과였다. + +| 시나리오 | 기록 요청 | route-call 사실 | 결과 | +|---|---|---|---| +| `literal_query` | GET /v1/items | GET `/v1/items` root | 일치 | +| `async_user` | GET /v1/users/7 | GET `/v1/users/{}` root | 일치 | +| `concat_post` | POST /v1/orders | POST `/v1/orders` root | 일치 | +| `put_positional` | PUT /v1/items/5 | PUT `/v1/items/{}` root | 일치 | +| `delete_plus` | DELETE /v1/items/9 | DELETE `/v1/items/{}` root | 일치 | +| `patch_local` | PATCH /v1/profile | PATCH `/v1/profile` root | 일치 | +| `head_health` | HEAD /v1/health | HEAD `/v1/health` root | 일치 | +| `request_options` | OPTIONS /v1/items | OPTIONS `/v1/items` root | 일치 | +| `request_dynamic` | PATCH /v1/verbs | 동사 동적 `/v1/verbs` root | 일치 | +| `join_relative` | GET /v2/users/5 | GET `/v2/users/{}` root | 일치 | +| **`join_replaces_last`**(RFC 3986 병합) | GET /v2/tags | GET `/v2/tags` root | 일치 | +| `join_absolute_path` | GET /root/ping | GET `/root/ping` root | 일치 | +| `dot_segments` | GET /v1/b | GET `/v1/b` root | 일치 | +| **`double_slash`**(`//` 보존) | GET /v3//items | GET `/v3//items` root | 일치 | +| `query_tail` | GET /v1/search | GET `/v1/search` root | 일치 | +| `query_tail_empty` | GET /v1/search | GET `/v1/search` root | 일치 | +| `partial_segment` | GET /v1/files/report.json | GET dynamic, prefix `/v1/files/` | dynamic | +| `unknown_base_rooted` | GET /prefix/v1/status | GET `/v1/status` base | 일치 | +| `unknown_base_glued` | GET /prefixstatus | GET dynamic(`ambiguous-base-join:`) | dynamic | +| `relative_url` | 없음 — 보내기 전 실패 | 없음 | 요청 없음 | +| `masked_token` | GET /v1/tokens/a1b2c3d4e5f6a7b8c9d0 | GET `/v1/tokens/{}` root | 일치 | +| `non_ascii` | GET /v1/caf%C3%A9 | GET `/v1/caf%C3%A9` root | 일치 | +| `userinfo_fragment` | GET /v1/secure | GET `/v1/secure` root | 일치 | +| `with_port` | GET /v1/port | GET `/v1/port` root(authority `api.example.com:8080`) | 일치 | +| `backslashes` | GET /v1/bs | GET `/v1/bs` root | 일치 | +| `builder_client` | GET /v1/built | GET `/v1/built` root | 일치 | +| `struct_field_base` | GET /api/items | GET `/api/items` root | 일치 | +| `struct_field_item` | GET /api/items/42 | GET `/api/items/{}` root | 일치 | +| `struct_field_plus` | POST /api/items | POST `/api/items` root | 일치 | +| `struct_field_query` | GET /api/search | GET `/api/search` root | 일치 | +| `struct_param_base` | GET /remote/users/3 | GET `/users/{}` base | 일치 | +| `url_field` | GET /cat/products | GET `/cat/products` root | 일치 | +| `ureq_get` | GET /v1/ureq/items | GET `/v1/ureq/items` root | 일치 | +| `ureq_delete` | DELETE /v1/ureq/items/4 | DELETE `/v1/ureq/items/{}` root | 일치 | +| **`ureq_dots`**(점 세그먼트 보존) | GET /v1/x/../ureq-dots | GET `/v1/x/../ureq-dots` root | 일치 | +| `ureq_agent_head` | HEAD /v1/ureq/health | HEAD `/v1/ureq/health` root | 일치 | +| `ureq_post` | POST /v1/ureq/orders | POST `/v1/ureq/orders` root | 일치 | +| `wrapper_function` | GET /wb/w/items | GET `/w/items` base | 일치 | +| `wrapper_method` | POST /api/w/orders | POST `/w/orders` base | 일치 | +| `wrapper_constructor` | DELETE /api/e/items/6 | DELETE `/e/items/{}` base(+ `execute`의 동사 동적 dynamic) | 일치 | +| `undeclared_sink` | GET /v1/undeclared | GET dynamic(`http-wrapper-undeclared:`) | dynamic | + +## isthmus 호환 + +isthmus main(`c395c59`)은 rust `route-call`을 받지 않는다(`src/exchange/parse.ts` `routeKindPlatforms`, "Fact kind is +not valid for platform"). 그 집합에 `rust`를 더한 scratch isthmus로 fixture 문서와 axum08 서버 문서를 workspace +`trace`에 넣어 확인했다(HANDOFF의 e2e 절). isthmus가 rust route-call을 받는 변경을 머지하면 이 절을 갱신한다. From bf7a5740d97a7ae9a6c379a48556268582c2ba0f Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 15:38:04 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix(source):=20=EA=B8=B4=20=EC=83=81?= =?UTF-8?q?=EC=88=98=20=EC=82=AC=EC=8A=AC=EC=9D=98=20=EC=8A=A4=ED=83=9D=20?= =?UTF-8?q?=EB=84=98=EC=B9=A8=EA=B3=BC=20=EB=B0=91=EC=A4=84=20host=C2=B7?= =?UTF-8?q?=EB=B9=88=20=ED=8F=AC=ED=8A=B8=20authority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GLM 리뷰에서 나온 것 중 재현한 두 가지다. - 상수가 상수를 가리킬 때 eval_const가 깊이를 0부터 새로 시작해, 3,000개 사슬에서 테스트 스레드 스택이 넘쳤다(CLI는 20,000개에서 abort). 깊이를 사슬에 이어 상한에 닿으면 모르는 값(baseRef)으로 낮춘다. 회귀 테스트는 수정 전 코드에서 넘침을 확인했다. - authority가 `my_api.example.com`처럼 밑줄 host와 `h:`처럼 빈 포트를 거부해 root 사실에서 authority가 빠졌다. url 2.5.8은 둘 다 받고 빈 포트는 포트 없음으로 읽는다 — 같게 맞췄다. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/source/routes/client/scan.rs | 19 ++++++++++--------- src/source/routes/compose.rs | 16 ++++++++++++---- tests/client_routes.rs | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/source/routes/client/scan.rs b/src/source/routes/client/scan.rs index f700ae7..9c52674 100644 --- a/src/source/routes/client/scan.rs +++ b/src/source/routes/client/scan.rs @@ -697,7 +697,7 @@ impl Scanner<'_, '_> { lit: syn::Lit::Str(s), .. }) => Val::Str(vec![Piece::Lit(s.value())], None), - syn::Expr::Path(p) => self.eval_path(&crate::harvest::path_segments(&p.path)), + syn::Expr::Path(p) => self.eval_path(&crate::harvest::path_segments(&p.path), d), syn::Expr::Field(f) => self.eval_field(f), syn::Expr::Macro(m) => self.eval_macro(&m.mac, d), syn::Expr::Binary(b) if matches!(b.op, syn::BinOp::Add(_)) => { @@ -716,21 +716,22 @@ impl Scanner<'_, '_> { } } - /// 경로 값 — 지역 변수, 문자열 상수·static. - fn eval_path(&self, segs: &[String]) -> Val { + /// 경로 값 — 지역 변수, 문자열 상수·static. `depth`는 상수가 상수를 가리키는 사슬에도 + /// 이어져 깊은 사슬이 스택을 넘기지 않게 한다. + fn eval_path(&self, segs: &[String], depth: usize) -> Val { if let [one] = segs { if let Some(b) = self.env.get(one) { return b.val.clone(); } } match self.resolve_path(segs) { - Some(id) if self.sh.index.consts.contains_key(&id) => self.eval_const(&id), + Some(id) if self.sh.index.consts.contains_key(&id) => self.eval_const(&id, depth), _ => Val::unknown(), } } - /// 상수·static의 값(순환 방지 캐시). - fn eval_const(&self, id: &str) -> Val { + /// 상수·static의 값(순환 방지 캐시). 깊이 상한에 닿은 사슬은 모르는 값(`baseRef`)이다. + fn eval_const(&self, id: &str, depth: usize) -> Val { if let Some(cached) = self.sh.consts.borrow().get(id) { return cached .clone() @@ -752,7 +753,7 @@ impl Scanner<'_, '_> { mutated: BTreeSet::new(), in_wrapper: false, }; - let v = scanner.eval(c.expr); + let v = scanner.eval_depth(c.expr, depth); let known = match v { Val::Str(..) | Val::Url(..) => Some(v.with_ref(Some(id.to_string()))), Val::Other(_) => None, @@ -908,7 +909,7 @@ impl Scanner<'_, '_> { FmtKey::Index(n) => positional.get(n).map(|e| self.eval_depth(e, d)), FmtKey::Name(n) => match named.get(&n) { Some(e) => Some(self.eval_depth(e, d)), - None => Some(self.eval_path(&[n])), + None => Some(self.eval_path(&[n], d)), }, }; let Some(v) = v else { @@ -1249,7 +1250,7 @@ impl Scanner<'_, '_> { syn::Expr::Path(p) => { let segs = crate::harvest::path_segments(&p.path); let local = matches!(segs.as_slice(), [one] if self.env.get(one).is_some()); - match self.eval_path(&segs) { + match self.eval_path(&segs, 0) { Val::Str(pieces, _) if !local => match pieces.as_slice() { [Piece::Lit(s)] => ArgValue::Literal(s.clone()), _ => ArgValue::Other, diff --git a/src/source/routes/compose.rs b/src/source/routes/compose.rs index a2c1b5a..a4822fd 100644 --- a/src/source/routes/compose.rs +++ b/src/source/routes/compose.rs @@ -496,6 +496,8 @@ fn authority_of(raw: &str) -> Option { None => (lower.as_str(), None), } }; + // WHATWG: 빈 포트(`h:`)는 포트 없음이다(url 2.5.8 실행 확인). + let port = port.filter(|p| !p.is_empty()); let host_ok = if host.starts_with('[') { host[1..host.len() - 1] .chars() @@ -504,10 +506,13 @@ fn authority_of(raw: &str) -> Option { !host.is_empty() && host .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_')) }; - let port_ok = port.is_none_or(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())); - (host_ok && port_ok).then_some(lower) + let port_ok = port.is_none_or(|p| p.chars().all(|c| c.is_ascii_digit())); + (host_ok && port_ok).then(|| match port { + Some(p) => format!("{host}:{p}"), + None => host.to_string(), + }) } /// 조각의 첫 `?`·`#`부터 끝까지 뗀다. 뗐으면 true. @@ -934,7 +939,10 @@ mod tests { ); assert_eq!(authority_of("[::1]:3000").as_deref(), Some("[::1]:3000")); assert_eq!(authority_of("caf\u{e9}.com"), None); - assert_eq!(authority_of("h:"), None); + // 밑줄 host와 빈 포트는 WHATWG가 받는다(url 2.5.8: `my_api.example.com`, `h:` → 포트 없음). + assert_eq!(authority_of("My_Api.test").as_deref(), Some("my_api.test")); + assert_eq!(authority_of("h:").as_deref(), Some("h")); + assert_eq!(authority_of("h:x1"), None); } #[test] diff --git a/tests/client_routes.rs b/tests/client_routes.rs index 23f4eae..0e77b69 100644 --- a/tests/client_routes.rs +++ b/tests/client_routes.rs @@ -779,3 +779,20 @@ fn service_conflict_is_a_usage_error() { assert!(doc["facts"].as_array().unwrap().is_empty()); assert_eq!(doc["target"], "http"); } + +/// 상수가 상수를 가리키는 긴 사슬도 깊이 상한 안에서 끝난다 — 스택을 넘기지 않고 +/// 모르는 값(baseRef)으로 낮추며, 짧은 사슬은 그대로 푼다. +#[test] +fn deep_const_chains_degrade_instead_of_overflowing() { + let mut src = String::from("const C0: &str = \"http://h.test/x\";\n"); + for i in 1..3000 { + src.push_str(&format!("const C{i}: &str = C{};\n", i - 1)); + } + src.push_str( + "pub fn deep() { reqwest::get(C2999); }\npub fn shallow() { reqwest::get(C3); }\n", + ); + let dir = temp_crate("deepconst", &[("reqwest", "0.13.5")], &[("lib.rs", &src)]); + let r = rows(&doc_of(&dir, None)); + assert!(has(&r, "deep", "GET", "DYN:", "base"), "{r:#?}"); + assert!(has(&r, "shallow", "GET", "/x", "root"), "{r:#?}"); +} From ef49620cdce01951df36bfb3845ff9b08bf8121c Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 15:38:04 +0900 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20isthmus=203a45450=20=EB=B2=A1?= =?UTF-8?q?=ED=84=B0=20=EC=9E=AC=EB=B2=A4=EB=8D=94=EB=A7=81=EA=B3=BC=20?= =?UTF-8?q?=EB=AF=B8=EC=83=81=20base=20rfc3986=20`..`=20=EA=B7=9C=EC=B9=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isthmus #133이 Rust 클라이언트 결합을 정했다 — `Url::join`은 `rfc3986`, reqwest·ureq는 base가 없어 전체 URL 규칙이다. url-compose에 더해진 `producer:rustograph` 7건을 러너에 넣어 48/48을 통과한다. 규칙이 바뀐 곳은 하나다. base를 모르는 `Url::join` 상대 참조에 `..`가 있거나 참조가 비면 지울 세그먼트를 알 수 없으므로 dynamic + ambiguous-base-join이다. 이전에는 `..`가 알려진 세그먼트 안에서 끝나면 템플릿을 냈다. query·fragment 뒤의 `..`는 보지 않는다. 새 ruleId scope.dynamic-validate·dynamic-applies는 서버 선언의 선택 필드 dynamicScope 규칙이고 rustograph는 그 필드를 내지 않으므로 분류만 했다. Co-Authored-By: Claude Opus 5.5 (1M context) --- conformance.lock | 6 +- conformance/SHA256SUMS | 4 +- conformance/http-limitation-scope.json | 856 ++++++++++++++++++++++++- conformance/url-compose.json | 747 +++++++++++++++++++++ src/source/routes/compose.rs | 66 +- tests/client_routes.rs | 11 +- tests/routes.rs | 2 + 7 files changed, 1677 insertions(+), 15 deletions(-) diff --git a/conformance.lock b/conformance.lock index e46fb05..e66c4b4 100644 --- a/conformance.lock +++ b/conformance.lock @@ -1,10 +1,10 @@ { - "commit": "76b6141e71c84e0ab1026ad1f18f910b9d966dc8", + "commit": "3a4545088e8eaf1095b94765a407a9a0b5d823f2", "files": { "http-dispatch.json": "efeecae0622ac8ee4185d10ea117fb9c3c2b2dc504137cae0abd1841f3414f47", - "http-limitation-scope.json": "757200f40b1fa54a30b9486ff24bf5f89c2f7bc4fac26eb9a79d62004a1c847e", + "http-limitation-scope.json": "a7c6c3916bbf6991f24e2640242fdbe71c881858b47b90d74fba2b832f4f4d3c", "http-template.json": "770f3a79986560579c94b2548d392eb66ef877dc95b6284211551eeb27cf53e6", - "url-compose.json": "afb689685e3dc987d37ce6b8ff32088604956a67dfe8a7e1556aa462f09a1c7a" + "url-compose.json": "ef2ab9523906ec13d383752910e2feca406581b1f85a6bac8fb3b57e97966505" }, "format": "isthmus-conformance-lock", "source": "https://github.com/ictechgy/isthmus", diff --git a/conformance/SHA256SUMS b/conformance/SHA256SUMS index ff9672d..d1beac8 100644 --- a/conformance/SHA256SUMS +++ b/conformance/SHA256SUMS @@ -1,4 +1,4 @@ efeecae0622ac8ee4185d10ea117fb9c3c2b2dc504137cae0abd1841f3414f47 http-dispatch.json -757200f40b1fa54a30b9486ff24bf5f89c2f7bc4fac26eb9a79d62004a1c847e http-limitation-scope.json +a7c6c3916bbf6991f24e2640242fdbe71c881858b47b90d74fba2b832f4f4d3c http-limitation-scope.json 770f3a79986560579c94b2548d392eb66ef877dc95b6284211551eeb27cf53e6 http-template.json -afb689685e3dc987d37ce6b8ff32088604956a67dfe8a7e1556aa462f09a1c7a url-compose.json +ef2ab9523906ec13d383752910e2feca406581b1f85a6bac8fb3b57e97966505 url-compose.json diff --git a/conformance/http-limitation-scope.json b/conformance/http-limitation-scope.json index d335586..8122c97 100644 --- a/conformance/http-limitation-scope.json +++ b/conformance/http-limitation-scope.json @@ -2,7 +2,7 @@ "format": "isthmus-conformance", "version": 1, "suite": "http-limitation-scope", - "description": "Consumer reading of http limitationScopes (templates, templatePrefixes, templateSuffixes, methods): which calls (server/contract-side gaps) or declarations (client-side gaps) a scoped limitation applies to, and which scope entries are rejected. Comparisons are conservative over-approximations. Case ids are stable; producers vendor this file with its sha256 from SHA256SUMS.", + "description": "Consumer reading of http limitationScopes (templates, templatePrefixes, templateSuffixes, methods): which calls (server/contract-side gaps) or declarations (client-side gaps) a scoped limitation applies to, and which scope entries are rejected; plus the same reading for dynamic declarations that carry a proven dynamicScope (scope.dynamic-*). Comparisons are conservative over-approximations. Case ids are stable; producers vendor this file with its sha256 from SHA256SUMS.", "contract": "docs/GRAPH-EXCHANGE.md#http-limitation-스코프", "cases": [ { @@ -684,6 +684,860 @@ "expect": { "valid": true } + }, + { + "id": "dynamic-validate/prefix-on-any-with-methods", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ], + "methods": [ + "GET", + "HEAD" + ] + } + } + }, + "expect": { + "valid": true + } + }, + { + "id": "dynamic-validate/templates-on-get", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "GET", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templates": [ + "/legacy/{}.php" + ] + } + } + }, + "expect": { + "valid": true + } + }, + { + "id": "dynamic-validate/contract-suffix", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-contract", + "method": "GET", + "pathAnchor": "base", + "dynamic": true, + "dynamicScope": { + "templateSuffixes": [ + "/reports/{}" + ] + } + } + }, + "expect": { + "valid": true + }, + "note": "Unresolved server variables make a contract base-anchored; its known tail is a suffix." + }, + { + "id": "dynamic-validate/base-root-prefix-with-methods", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "base", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/" + ], + "methods": [ + "POST" + ] + } + } + }, + "expect": { + "valid": true + } + }, + { + "id": "dynamic-validate/static-declaration", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "GET", + "pathAnchor": "root", + "dynamic": false, + "channel": "/static", + "dynamicScope": { + "templatePrefixes": [ + "/static" + ] + } + } + }, + "expect": { + "valid": false + }, + "note": "A static declaration is already bounded by its template." + }, + { + "id": "dynamic-validate/route-call", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-call", + "method": "GET", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/a" + ] + } + } + }, + "expect": { + "valid": false + }, + "note": "Dynamic calls keep channelPrefix; dynamicScope is declaration-side only." + }, + { + "id": "dynamic-validate/methods-on-non-any", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "GET", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/a" + ], + "methods": [ + "GET" + ] + } + } + }, + "expect": { + "valid": false + }, + "note": "A non-ANY declaration is already bounded by its method; restating it is redundant and a different set is contradictory." + }, + { + "id": "dynamic-validate/methods-on-contract", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-contract", + "method": "GET", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/a" + ], + "methods": [ + "GET" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/methods-only", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "methods": [ + "GET" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/empty-object", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": {} + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/limitation-index-key", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "limitationIndex": 0, + "templates": [ + "/a" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/not-an-object", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": [ + "/a" + ] + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/lowercase-percent", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templates": [ + "/a%2fb" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/prefix-trailing-slash", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/admin/" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/prefix-catch-all", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "root", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/admin/{**}" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-validate/base-templates", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "ANY", + "pathAnchor": "base", + "dynamic": true, + "dynamicScope": { + "templates": [ + "/a" + ] + } + } + }, + "expect": { + "valid": false + }, + "note": "Root-relative elements cannot be proven behind an unknown base." + }, + { + "id": "dynamic-validate/base-non-root-prefix", + "ruleId": "scope.dynamic-validate", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "kind": "route-decl", + "method": "GET", + "pathAnchor": "base", + "dynamic": true, + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + } + }, + "expect": { + "valid": false + } + }, + { + "id": "dynamic-applies/unscoped-any-call", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "GET" + }, + "probe": { + "template": "/anything", + "method": "DELETE", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + }, + "note": "A dynamic declaration without dynamicScope is a gap for every call (backward compatible), even of another method." + }, + { + "id": "dynamic-applies/prefix-inside", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/admin/users/{}", + "method": "POST", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "dynamic-applies/prefix-outside", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/administrator", + "method": "POST", + "pathAnchor": "root" + } + }, + "expect": { + "applies": false + }, + "note": "Prefixes are segment-bounded." + }, + { + "id": "dynamic-applies/prefix-case-and-slash-folded", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/Admin/", + "method": "GET", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "dynamic-applies/declaration-method-bounds", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "GET", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/admin/users", + "method": "POST", + "pathAnchor": "root" + } + }, + "expect": { + "applies": false + }, + "note": "With a dynamicScope, a non-ANY declaration only serves its own method." + }, + { + "id": "dynamic-applies/declaration-method-head-as-get", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "GET", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/admin/users", + "method": "HEAD", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "dynamic-applies/declaration-method-options", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "POST", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/admin/users", + "method": "OPTIONS", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "dynamic-applies/scope-methods-exclude", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templatePrefixes": [ + "/" + ], + "methods": [ + "GET", + "HEAD" + ] + } + }, + "probe": { + "template": "/api/orders", + "method": "PUT", + "pathAnchor": "root" + } + }, + "expect": { + "applies": false + } + }, + { + "id": "dynamic-applies/dynamic-verb-call", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templatePrefixes": [ + "/" + ], + "methods": [ + "GET" + ] + } + }, + "probe": { + "template": "/api/orders", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + }, + "note": "A call with a non-literal verb may use any method." + }, + { + "id": "dynamic-applies/partial-segment-template", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templates": [ + "/legacy/{}.php" + ] + } + }, + "probe": { + "template": "/legacy/index.php", + "method": "GET", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "dynamic-applies/partial-segment-other", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templates": [ + "/legacy/{}.php" + ] + } + }, + "probe": { + "template": "/legacy/index.html", + "method": "GET", + "pathAnchor": "root" + } + }, + "expect": { + "applies": false + } + }, + { + "id": "dynamic-applies/base-call-unknown-lead", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "ANY", + "dynamicScope": { + "templatePrefixes": [ + "/admin" + ] + } + }, + "probe": { + "template": "/users", + "method": "GET", + "pathAnchor": "base" + } + }, + "expect": { + "applies": true + }, + "note": "A base-anchored call has an unknown leading part, so it may fall under any prefix." + }, + { + "id": "dynamic-applies/suffix-tail", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "GET", + "dynamicScope": { + "templateSuffixes": [ + "/reports/{}" + ] + } + }, + "probe": { + "template": "/v2/reports/7", + "method": "GET", + "pathAnchor": "root" + } + }, + "expect": { + "applies": true + } + }, + { + "id": "dynamic-applies/suffix-other-tail", + "ruleId": "scope.dynamic-applies", + "provenance": "contract", + "source": "docs/GRAPH-EXCHANGE.md#dynamic-선언의-스코프-dynamicscope", + "appliesTo": [ + "consumer", + "producer" + ], + "input": { + "declaration": { + "method": "GET", + "dynamicScope": { + "templateSuffixes": [ + "/reports/{}" + ] + } + }, + "probe": { + "template": "/v2/orders/7", + "method": "GET", + "pathAnchor": "root" + } + }, + "expect": { + "applies": false + } } ] } diff --git a/conformance/url-compose.json b/conformance/url-compose.json index 3173e31..40f4d11 100644 --- a/conformance/url-compose.json +++ b/conformance/url-compose.json @@ -1196,6 +1196,753 @@ "expect": { "line": 89 } + }, + { + "id": "base-join/go-resolve-reference-base-directory", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1116-L1153", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/", + "path": "users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "(*url.URL).ResolveReference merges a relative reference after the last / of the base path (RFC 3986 5.2.3). Reproduced with go1.27.1; go1.23.0 source is identical." + }, + { + "id": "base-join/go-resolve-reference-base-file", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1116-L1153", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api", + "path": "users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + }, + "note": "Without a trailing slash the last base segment is replaced, not extended." + }, + { + "id": "base-join/go-resolve-reference-rooted", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1116-L1153", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/", + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "An absolute-path reference replaces the whole base path." + }, + { + "id": "base-join/go-resolve-reference-dot-segments", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1116-L1153", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/v1/", + "path": "../users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root" + }, + "note": "remove_dot_segments (RFC 3986 5.2.4) runs after the merge." + }, + { + "id": "base-join/go-resolve-reference-network-path", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1116-L1153", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/", + "path": "//other.example.com/x" + }, + "expect": { + "template": "/x", + "pathAnchor": "root", + "authority": "other.example.com" + }, + "note": "A network-path reference (//host/...) replaces the authority as well as the path." + }, + { + "id": "base-join/go-join-path-relative", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1237-L1256", + "versionRange": ">=1.19", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": "https://api.example.com/api", + "path": "users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "(*url.URL).JoinPath (Go 1.19+) joins with path.Join, so the base path is always extended." + }, + { + "id": "base-join/go-join-path-rooted-element", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1237-L1256", + "versionRange": ">=1.19", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": "https://api.example.com/api/", + "path": "/users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root" + }, + "note": "A leading / on an element does not reset the path (unlike ResolveReference)." + }, + { + "id": "base-join/go-join-path-collapses", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1237-L1256", + "versionRange": ">=1.19", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": "https://api.example.com/api", + "path": "a//b" + }, + "expect": { + "template": "/api/a/b", + "pathAnchor": "root" + }, + "note": "path.Join collapses repeated slashes." + }, + { + "id": "base-join/go-join-path-dot-dot-escapes", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1237-L1256", + "versionRange": ">=1.19", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": "https://api.example.com/api", + "path": "../x" + }, + "expect": { + "template": "/x", + "pathAnchor": "root" + }, + "note": "path.Join cleans .. against the base path, so it can leave the base path." + }, + { + "id": "base-join/go-join-path-trailing-slash", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1237-L1256", + "versionRange": ">=1.19", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": "https://api.example.com/api", + "path": "users/" + }, + "expect": { + "template": "/api/users/", + "pathAnchor": "root" + }, + "note": "JoinPath keeps one trailing slash when the last element ends with /." + }, + { + "id": "base-join/go-join-path-host-only", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/golang/go/blob/go1.23.0/src/net/url/url.go#L1237-L1256", + "versionRange": ">=1.19", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": "https://api.example.com", + "path": "users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root", + "authority": "api.example.com" + } + }, + { + "id": "base-join/go-join-path-unknown-base", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": null, + "path": "/users/" + }, + "expect": { + "template": "/users/", + "pathAnchor": "base" + }, + "note": "Unknown base: the cleaned element path is a tail behind the unknown base path." + }, + { + "id": "base-join/go-join-path-unknown-base-dot-dot", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "go-join-path", + "base": null, + "path": "../users" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:", + "note": "Unknown base: which segment .. removes cannot be known." + }, + { + "id": "base-join/resty-base-trailing-slash-trimmed", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/go-resty/resty/blob/v2.17.2/client.go#L193-L197", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "resty-base-url", + "base": "https://api.example.com/api/", + "path": "users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "SetBaseURL trims trailing slashes and parseRequestURL prefixes a missing / before concatenating (resty v2.17.2; v3.0.0-rc.4 composes the same path). Recorded as the RequestURI seen by a local server." + }, + { + "id": "base-join/resty-rooted-path", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/go-resty/resty/blob/v2.17.2/middleware.go#L103-L126", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "resty-base-url", + "base": "https://api.example.com/api", + "path": "/users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root" + }, + "note": "A leading / does not reset the base path (plain concatenation, not RFC 3986)." + }, + { + "id": "base-join/resty-double-slash-kept", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/go-resty/resty/blob/v2.17.2/middleware.go#L103-L126", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "resty-base-url", + "base": "https://api.example.com/api", + "path": "//users" + }, + "expect": { + "template": "/api//users", + "pathAnchor": "root" + }, + "note": "The concatenation is sent as is; // is not collapsed." + }, + { + "id": "base-join/resty-dot-segments-kept", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/go-resty/resty/blob/v2.17.2/middleware.go#L103-L126", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "resty-base-url", + "base": "https://api.example.com/api/", + "path": "./users" + }, + "expect": { + "template": "/api/./users", + "pathAnchor": "root" + }, + "note": "Dot segments are not removed; the server receives /api/./users." + }, + { + "id": "base-join/resty-unknown-base", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "resty-base-url", + "base": null, + "path": "users" + }, + "expect": { + "template": "/users", + "pathAnchor": "base" + } + }, + { + "id": "base-join/resty-unknown-base-rooted", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph" + ], + "input": { + "join": "resty-base-url", + "base": null, + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "base" + }, + "note": "Unlike rfc3986 a rooted path stays behind the unknown base." + }, + { + "id": "base-join/rust-url-join-base-directory", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/servo/rust-url/blob/v2.5.8/url/src/lib.rs#L413-L473", + "appliesTo": [ + "producer:rustograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/", + "path": "users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "url::Url::join parses the input with the base (WHATWG URL Standard); for these http(s) inputs the result equals RFC 3986 resolution. reqwest has no base URL, so this applies only when the code calls Url::join. Reproduced with url 2.5.8." + }, + { + "id": "base-join/rust-url-join-base-file", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/servo/rust-url/blob/v2.5.8/url/src/lib.rs#L413-L473", + "appliesTo": [ + "producer:rustograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api", + "path": "users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + }, + "note": "Url::join docs: without a trailing slash the last path component is a file name and is replaced." + }, + { + "id": "base-join/rust-url-join-rooted", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/servo/rust-url/blob/v2.5.8/url/src/lib.rs#L413-L473", + "appliesTo": [ + "producer:rustograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/", + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + } + }, + { + "id": "base-join/rust-url-join-dot-segments", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/servo/rust-url/blob/v2.5.8/url/src/lib.rs#L413-L473", + "appliesTo": [ + "producer:rustograph" + ], + "input": { + "join": "rfc3986", + "base": "https://api.example.com/api/", + "path": "./users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root" + } + }, + { + "id": "base-join/rfc3986-unknown-base-dot-segment", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph", + "producer:rustograph", + "producer:pythograph" + ], + "input": { + "join": "rfc3986", + "base": null, + "path": "./users" + }, + "expect": { + "template": "/users", + "pathAnchor": "base" + }, + "note": "./ is removed; the rest is a tail behind the unknown base directory." + }, + { + "id": "base-join/rfc3986-unknown-base-dot-dot", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph", + "producer:rustograph", + "producer:pythograph" + ], + "input": { + "join": "rfc3986", + "base": null, + "path": "../users" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:", + "note": "Unknown base: which segment .. removes cannot be known." + }, + { + "id": "base-join/rfc3986-unknown-base-rooted-dot-segments", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:gartograph", + "producer:rustograph", + "producer:pythograph" + ], + "input": { + "join": "rfc3986", + "base": null, + "path": "/a/../users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + } + }, + { + "id": "base-join/httpx-base-without-trailing-slash", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/encode/httpx/blob/0.28.1/httpx/_client.py#L391-L411", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": "https://api.example.com/api", + "path": "users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "The base_url setter enforces a trailing / and _merge_url appends the relative path (httpx 0.28.1)." + }, + { + "id": "base-join/httpx-rooted-path", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/encode/httpx/blob/0.28.1/httpx/_client.py#L391-L411", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": "https://api.example.com/api/", + "path": "/users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root" + }, + "note": "_merge_url strips every leading / of the relative path, so a rooted path does not reset the base path (unlike RFC 3986)." + }, + { + "id": "base-join/httpx-dot-dot-escapes", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/encode/httpx/blob/0.28.1/httpx/_urlparse.py#L447-L475", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": "https://api.example.com/api", + "path": "../users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + }, + "note": "normalize_path (RFC 3986 5.2.4) runs on the merged URL, so .. can leave the base path." + }, + { + "id": "base-join/httpx-inner-double-slash-kept", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/encode/httpx/blob/0.28.1/httpx/_client.py#L391-L411", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": "https://api.example.com/api/", + "path": "a//b" + }, + "expect": { + "template": "/api/a//b", + "pathAnchor": "root" + } + }, + { + "id": "base-join/httpx-network-path", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/encode/httpx/blob/0.28.1/httpx/_client.py#L391-L411", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": "https://api.example.com/api", + "path": "//users" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:", + "note": "URL('//users') parses users as a host with an empty path; the request goes to the base path. Producers do not claim a route for it." + }, + { + "id": "base-join/httpx-unknown-base", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": null, + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "base" + } + }, + { + "id": "base-join/httpx-unknown-base-dot-dot", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "httpx-base-url", + "base": null, + "path": "../users" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:" + }, + { + "id": "base-join/aiohttp-base-directory-relative", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/aio-libs/aiohttp/blob/v3.14.3/aiohttp/client.py#L531-L535", + "versionRange": ">=3.11", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "aiohttp-base-url", + "base": "https://api.example.com/api/", + "path": "users" + }, + "expect": { + "template": "/api/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "ClientSession._build_url joins with yarl URL.join (RFC 3986). Base URLs with a path and relative paths without a leading / are accepted since aiohttp 3.11. Reproduced with aiohttp 3.14.3 / yarl 1.25.1." + }, + { + "id": "base-join/aiohttp-rooted-replaces-base-path", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/aio-libs/aiohttp/blob/v3.14.3/aiohttp/client.py#L531-L535", + "versionRange": ">=3.11", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "aiohttp-base-url", + "base": "https://api.example.com/api/", + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + }, + "note": "As in RFC 3986 a rooted path replaces the base path (aiohttp docs: request URLs should have no leading slash)." + }, + { + "id": "base-join/aiohttp-host-only-base", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/aio-libs/aiohttp/blob/v3.14.3/aiohttp/client.py#L531-L535", + "versionRange": ">=3.8", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "aiohttp-base-url", + "base": "https://api.example.com", + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root", + "authority": "api.example.com" + }, + "note": "The origin-only form accepted since aiohttp 3.8." + }, + { + "id": "base-join/aiohttp-base-path-without-trailing-slash", + "ruleId": "compose.base-join", + "provenance": "verified-run", + "source": "https://github.com/aio-libs/aiohttp/blob/v3.14.3/aiohttp/client.py#L355-L363", + "versionRange": ">=3.11", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "aiohttp-base-url", + "base": "https://api.example.com/api", + "path": "users" + }, + "expectDynamic": true, + "expectLimitation": "ambiguous-base-join:", + "note": "ClientSession raises ValueError('base_url must have a trailing /'); no request is made." + }, + { + "id": "base-join/aiohttp-unknown-base-rooted", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "aiohttp-base-url", + "base": null, + "path": "/users" + }, + "expect": { + "template": "/users", + "pathAnchor": "root" + } + }, + { + "id": "base-join/aiohttp-unknown-base-relative", + "ruleId": "compose.base-join", + "provenance": "contract", + "source": "docs/HTTP-WRAPPERS.md#go-rust-python-클라이언트", + "appliesTo": [ + "producer:pythograph" + ], + "input": { + "join": "aiohttp-base-url", + "base": null, + "path": "users" + }, + "expect": { + "template": "/users", + "pathAnchor": "base" + } } ] } diff --git a/src/source/routes/compose.rs b/src/source/routes/compose.rs index a4822fd..a0b7acd 100644 --- a/src/source/routes/compose.rs +++ b/src/source/routes/compose.rs @@ -262,7 +262,17 @@ impl UrlVal { } let mut out = self.clone(); out.query = false; + let unknown_base = matches!( + self.path, + UrlPath::Opaque + | UrlPath::Known { + anchor: PathAnchor::Base, + .. + } + ); let first = match input.first() { + // 빈 참조는 base 경로 그대로다 — base를 모르면 경로를 주장할 수 없다. + None if unknown_base => return out.into_dynamic(true), None => return out, Some(Piece::Lit(l)) => l.clone(), Some(_) => return out.into_dynamic(false), @@ -295,8 +305,11 @@ impl UrlVal { } else if first.starts_with(['?', '#']) || first.is_empty() { out.query = true; if matches!(self.path, UrlPath::Opaque) { - return out.into_dynamic(false); + return out.into_dynamic(true); } + } else if unknown_base && has_dot_dot(&input) { + // HTTP-WRAPPERS `rfc3986`, base 미상: `..`는 지울 세그먼트를 알 수 없다. + return out.into_dynamic(true); } else { out.path = match &self.path { UrlPath::Known { anchor, pieces } => UrlPath::Known { @@ -467,6 +480,22 @@ fn preprocess(pieces: &[Piece]) -> Vec { out } +/// 리터럴 조각에 `..`(또는 퍼센트 인코딩 변형) 세그먼트가 있는가. query·fragment 뒤는 보지 않는다. +fn has_dot_dot(pieces: &[Piece]) -> bool { + for p in pieces { + if let Piece::Lit(l) = p { + let path = l.split(['?', '#']).next().unwrap_or(""); + if path.split('/').any(is_double_dot) { + return true; + } + if path.len() != l.len() { + return false; + } + } + } + false +} + /// `scheme:` 접두사를 떼어 (소문자 scheme, 나머지)로 돌려준다. fn split_scheme(text: &str) -> Option<(String, &str)> { let colon = text.find(':')?; @@ -883,10 +912,39 @@ mod tests { fn opaque_base_relative_and_dots() { let o = join(Join::WhatwgJoin, None, &[lit("a/b")]); assert_eq!(tpl(&o), ("/a/b", PathAnchor::Base)); - let up = join(Join::WhatwgJoin, None, &[lit("../x")]); - assert!(matches!(up, Outcome::Dynamic { .. }), "{up:?}"); + let up = join(Join::WhatwgJoin, None, &[lit("a/../x")]); + assert!( + matches!( + up, + Outcome::Dynamic { + ambiguous: true, + .. + } + ), + "{up:?}" + ); let q = join(Join::WhatwgJoin, None, &[lit("?x")]); - assert!(matches!(q, Outcome::Dynamic { .. })); + assert!(matches!( + q, + Outcome::Dynamic { + ambiguous: true, + .. + } + )); + let empty = UrlVal::opaque(true).join(&[]).outcome(); + assert!(matches!( + empty, + Outcome::Dynamic { + ambiguous: true, + .. + } + )); + let known_empty = UrlVal::parse(&[lit("http://h/a")], true) + .join(&[]) + .outcome(); + assert!(matches!(known_empty, Outcome::Template(_))); + let query_dots = join(Join::WhatwgJoin, None, &[lit("x?next=../y")]); + assert_eq!(tpl(&query_dots), ("/x", PathAnchor::Base)); // 두 번 잇기 — 미상 base 뒤 `api/` + `users`. let u = UrlVal::opaque(true) .join(&[lit("api/")]) diff --git a/tests/client_routes.rs b/tests/client_routes.rs index 0e77b69..25ae0b4 100644 --- a/tests/client_routes.rs +++ b/tests/client_routes.rs @@ -66,8 +66,9 @@ fn pieces_of(parts: &Value) -> Vec { .collect() } -/// 벡터의 결합 이름 → Rust 결합. `dio-concat`은 같은 입력에서 결과가 같은 WHATWG -/// 문자열 연결로 실행한다(점 세그먼트·`//` 처리만 다르고 벡터 입력에는 없다). +/// 벡터의 결합 이름 → Rust 결합. `rfc3986`은 isthmus가 Rust `Url::join`에 정한 이름이다. +/// 공통 `dio-concat` 사례는 같은 입력에서 결과가 같은 WHATWG 문자열 연결로 실행한다(점 +/// 세그먼트·`//` 처리만 다르고 벡터 입력에는 없다). fn join_of(name: &str) -> Join { match name { "rfc3986" => Join::WhatwgJoin, @@ -165,8 +166,8 @@ fn run_wrapper_method(id: &str, c: &Value) { #[test] fn url_compose_producer_cases_pass() { let cases = url_compose_cases(); - // 조용히 줄지 않게 — 벡터 재벤더링 때 수를 확인한다(producer 41, producer:kartograph 13 제외). - assert_eq!(cases.len(), 41); + // 조용히 줄지 않게 — 벡터 재벤더링 때 수를 확인한다(producer 41 + producer:rustograph 7, 다른 생산자 전용 제외). + assert_eq!(cases.len(), 48); let mut by_rule: BTreeMap = BTreeMap::new(); for c in &cases { let id = c["id"].as_str().unwrap_or(""); @@ -216,7 +217,7 @@ fn url_compose_producer_cases_pass() { other => panic!("unclassified url-compose rule {other} ({id})"), } } - assert_eq!(by_rule.get("compose.base-join"), Some(&7)); + assert_eq!(by_rule.get("compose.base-join"), Some(&14)); } /// `wrapper.location`: 여러 줄 호출은 호출식이 시작하는 줄이다. 열은 UTF-8 바이트다. diff --git a/tests/routes.rs b/tests/routes.rs index cd04be8..73a8159 100644 --- a/tests/routes.rs +++ b/tests/routes.rs @@ -35,6 +35,8 @@ const SKIPPED: &[&str] = &[ "dispatch.match", "dispatch.shadow", "scope.applies", + // dynamic 선언의 `dynamicScope` — rustograph는 이 선택 필드를 내지 않는다. + "scope.dynamic-", "framework.openapi.", "framework.spring.", // 클라이언트 조립 규칙 — tests/client_routes.rs가 실행한다. From ecdeb4b9096ee183cfc975cd046db10f08f5f525 Mon Sep 17 00:00:00 2001 From: Coden Date: Wed, 30 Sep 2026 15:38:04 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20isthmus=203a45450=20=EA=B2=B0?= =?UTF-8?q?=ED=95=A9=20=EC=9D=B4=EB=A6=84=C2=B7=EB=AC=B4=ED=8C=A8=EC=B9=98?= =?UTF-8?q?=20e2e=C2=B7=EB=A6=AC=EB=B7=B0=20=EC=B2=98=EB=A6=AC=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제안했던 Rust 결합 이름 대신 isthmus가 정한 규칙(`Url::join` = rfc3986, reqwest·ureq는 전체 URL)을 적고, 무패치 isthmus 3a45450으로 다시 돌린 workspace trace e2e와 GLM 리뷰 처리(수정 2·반박 4)를 HANDOFF에 남겼다. README의 isthmus 호환 문장도 route-call 수용으로 고쳤다. Co-Authored-By: Claude Opus 5.5 (1M context) --- HANDOFF.md | 27 ++++++++++++++++----------- README.ko.md | 15 +++++++-------- README.md | 16 ++++++++-------- conformance/README.md | 9 +++++---- docs/HTTP-CLIENT.md | 36 ++++++++++++++++++++++-------------- 5 files changed, 58 insertions(+), 45 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 6afb0b7..fbc45cd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -14,20 +14,25 @@ - **인식** — reqwest(`get`·`blocking::get`·Client 동사·`request`·`Request::new`), ureq 2/3 자유 함수·Agent, 선언된 래퍼(`owner::name` = 정점 ID, 구조체 리터럴 생성자는 이름 = 타입 이름). 수신자는 구문 타입 추론(생성자·주석·필드 타입·반환 타입·static 타입, `Arc`·`LazyLock` 등 벗김). 증명하지 못한 수신자는 사실 없이 센다. -- **검증** — url-compose 생산자 41건 통과. 오라클 41 시나리오: 일치 37 · dynamic 3 · 요청 없음 1 · 불일치 0 +- **검증** — 오라클 41 시나리오: 일치 37 · dynamic 3 · 요청 없음 1 · 불일치 0 (reqwest 0.13.5·ureq 3.4.2·url 2.5.8, scratch에서 reqwest 0.12.28도 같음). 오라클이 확인한 것: `Url::join`의 마지막 세그먼트 교체, WHATWG `//` 보존·점 세그먼트 제거, **ureq 3의 점 세그먼트 보존**, ureq 3이 http 요청도 프록시에 CONNECT를 연다(기록 서버가 터널을 수락). 커버리지 92.14%, stable clippy(1.98.1) 0. -- **e2e(scratch)** — isthmus `c395c59` 무패치는 rust route-call 문서를 "Fact kind is not valid for platform"으로 거부한다. - `routeKindPlatforms`의 route-call에 `rust`를 더한 scratch 빌드로 workspace trace(`server` = axum08 fixture + - `reach --roots-from`, `client` = fixture-client + `impact --format language-traversal --roots-from`, link match - hosts `api.example.com`)를 돌렸다: 3 routes · 3 calls · 3 handlers · 3 client symbols. 예: `POST /api/items` — - client `client_app::api::ApiClient::create_item`(api.rs:38, quality exact) → route-decl axum08 routes.rs:13 → - handler `axum_app::handlers::items::create`; client 역방향 `struct_field_plus`(depth 1, candidate — syn 메서드 호출 - 팬아웃). base 앵커 호출 10건은 host가 없어 `unattributed-calls-omitted`. -- **isthmus 쪽 남은 것** — rust route-call 수용과 Rust 결합 이름 벡터(`feature/scopes-upstream-clients`, 이 세션 동안 - 원격에 없음). 이름을 정하면 `tests/client_routes.rs`의 `join_of`를 옮기고 `conformance/`를 재벤더링한다. 제안 이름: - `whatwg-concat`·`whatwg-join`·`http-uri-concat`. +- **벡터** — isthmus `3a45450`(#133) 재벤더링. url-compose `producer` 41 + `producer:rustograph` 7 = 48/48. 새 규칙에 맞춰 + 미상 base `rfc3986` 상대 참조의 `..`·빈 참조를 dynamic + `ambiguous-base-join:`으로 바꿨다. `scope.dynamic-*`(dynamicScope, + 서버 선언 선택 필드)는 내지 않으므로 분류만 했다. 결합 이름은 isthmus가 `Url::join` = `rfc3986`으로 정했고 reqwest·ureq는 + base가 없어 전체 URL 규칙이다(제안했던 `whatwg-concat` 등은 불필요). +- **e2e(scratch, 무패치 isthmus `3a45450`)** — workspace trace(`server` = axum08 fixture + `reach --roots-from`, + `client` = fixture-client + `impact --format language-traversal --roots-from`, link match hosts `api.example.com`, + selection 3 routes): 3 routes · 3 calls · 3 handlers · 3 client symbols. `POST /api/items` — client + `client_app::api::ApiClient::create_item`(api.rs:38, exact) → handler `axum_app::handlers::items::create` → client 역방향 + `struct_field_plus`(depth 1, candidate — syn 메서드 호출 팬아웃). `GET /api/items/{}`는 `get_item` → `items::show`, + `GET /api/search`는 `search` → `handlers::search`. base 앵커 호출 10건은 host가 없어 `unattributed-calls-omitted`. + isthmus `c395c59`(직전 main)는 같은 client 문서를 "Fact kind is not valid for platform"으로 거부했다. +- **GLM 리뷰** — 재현 후 수정 2건: 상수 사슬 평가가 깊이를 새로 시작해 긴 사슬(3,000개)에서 스택 넘침 → 깊이를 이어 + 모르는 값으로 낮춤(회귀 테스트는 수정 전 코드에서 넘침을 확인), authority가 `_` host·빈 포트를 거부 → WHATWG대로 받음. + 반박 4건: Slack 웹훅 전체 마스킹(벡터 `mask/slack-webhook`이 `/{}/{}/{}/{}`), `--service` 없는 사실 service(계약상 유효 + service는 사실 값), 호출 0건도 `http-wrapper-unresolved:`(HTTP-WRAPPERS 생산자 의무), 중첩 서식 `{:{}}`(Rust 문법 아님). - 알려진 근사: 필드 수정 감지는 소유 타입을 모르면 이름 단위(보수적), `Url` 값의 `set_path` 등은 지역·필드를 믿지 않게 만들 뿐 새 값을 계산하지 않는다, 선언된 구조체 래퍼 값을 받아 다른 래퍼를 부르는 함수(`execute`)는 동사 동적 dynamic 사실을 하나 더 낸다, ureq 3에서 `http::Uri`가 거부하는 문자(공백 등)가 든 리터럴도 사실로 낸다. diff --git a/README.ko.md b/README.ko.md index a22b84e..11da218 100644 --- a/README.ko.md +++ b/README.ko.md @@ -195,12 +195,12 @@ import할 때만 인정합니다. 파싱 실패 파일·문법이 다른 `table! `ambiguous-base-join:`·`http-wrapper-undeclared:`·`http-wrapper-unresolved:` 한계입니다. -규칙, 제안한 결합 방식 이름(`whatwg-concat`·`whatwg-join`·`http-uri-concat`), -오라클 표는 [docs/HTTP-CLIENT.md](docs/HTTP-CLIENT.md)에 있습니다. 모의 서버 +규칙(`Url::join`은 isthmus `rfc3986` 결합, reqwest·ureq는 base URL이 없어 +전체 URL 규칙)과 오라클 표는 [docs/HTTP-CLIENT.md](docs/HTTP-CLIENT.md)에 있습니다. 모의 서버 오라클(`experiments/client-oracle/`)이 fixture를 진짜 reqwest·ureq·url로 컴파일해 로컬 서버가 받은 요청을 기록합니다 — 41개 시나리오 불일치 0, 기록은 -`cargo test`가 오프라인으로 대조합니다. isthmus `url-compose` 벡터의 생산자 -사례 41건을 통과합니다. +`cargo test`가 오프라인으로 대조합니다. isthmus `url-compose` 벡터의 +`producer`·`producer:rustograph` 사례 48건을 모두 통과합니다. ## 순회 문서 — `reach` / `impact --format language-traversal` @@ -224,10 +224,9 @@ isthmus [`language-traversal` v1](https://github.com/ictechgy/isthmus/blob/main/ - 그래프 정점이 아닌 root는 `symbol` 없이 싣고 `root-not-found:` limitation을 더한 문서를 쓴 뒤 64로 끝납니다. -isthmus는 Rust `route-decl` 문서를 받아 axum·actix-web 핸들러에서 route 선택 -`trace`를 시작합니다. Rust `route-call`은 아직 받지 않습니다 — 그 변경이 -들어오기 전까지 `routes --role client` 문서는 그것을 허용한 isthmus 빌드로 -검증했습니다(HANDOFF.md). +isthmus는 Rust `route-decl`과(isthmus #133부터) `route-call` 문서를 받아, +workspace `trace`가 reqwest 클라이언트를 axum·actix-web 핸들러와 잇고 +`impact`로 클라이언트 코드까지 이어 갑니다. ## 개발 diff --git a/README.md b/README.md index e3badb6..cb957d2 100644 --- a/README.md +++ b/README.md @@ -301,13 +301,14 @@ depends on it. are `route-call-coverage:` / `ambiguous-base-join:` / `http-wrapper-undeclared:` / `http-wrapper-unresolved:` limitations. -The rules, the proposed base-join style names (`whatwg-concat`, -`whatwg-join`, `http-uri-concat`) and the oracle table are in +The rules (`Url::join` is the isthmus `rfc3986` join; reqwest and ureq have +no base URL, so full-URL rules apply) and the oracle table are in [docs/HTTP-CLIENT.md](docs/HTTP-CLIENT.md). A mock-server oracle (`experiments/client-oracle/`) compiles the fixture against the real reqwest/ureq/url crates and records every request at a local server: 41 -scenarios, 0 mismatches, checked offline by `cargo test`. The 41 producer -cases of the isthmus `url-compose` vectors pass. +scenarios, 0 mismatches, checked offline by `cargo test`. All 48 +`producer`/`producer:rustograph` cases of the isthmus `url-compose` vectors +pass. ## Traversal documents — `reach` / `impact --format language-traversal` @@ -347,10 +348,9 @@ strings as `symbol.usr` in `schema`. `symbol`, a `root-not-found:` limitation is added, and the command exits `64` after writing the document. -isthmus accepts Rust `route-decl` documents, so a route-selection `trace` -starts at an axum/actix-web handler. It does not yet accept Rust -`route-call` facts: until that lands, `routes --role client` documents are -validated against an isthmus build that allows them (see HANDOFF.md). +isthmus accepts Rust `route-decl` and (since isthmus #133) `route-call` +documents, so a workspace `trace` joins a reqwest client to an axum/actix-web +handler and continues into the client code with `impact`. ## Agent output contract diff --git a/conformance/README.md b/conformance/README.md index 22a3a49..e9761a6 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -1,6 +1,6 @@ # isthmus 공유 적합성 벡터 -isthmus(`76b6141e71c84e0ab1026ad1f18f910b9d966dc8`)의 `conformance/`를 그대로 가져온 사본이다. 정본은 isthmus가 +isthmus(`3a4545088e8eaf1095b94765a407a9a0b5d823f2`)의 `conformance/`를 그대로 가져온 사본이다. 정본은 isthmus가 소유하며, 이 디렉터리 파일을 직접 고치지 않는다. 갱신할 때는 isthmus main의 파일과 `SHA256SUMS`를 함께 다시 복사하고(새 suite 파일 포함) 저장소 루트의 `conformance.lock`에 커밋과 파일별 sha256을 적은 뒤 `cargo test --test routes`로 확인한다. @@ -13,8 +13,9 @@ rustograph가 실행하는 사례(생산자 대상, `rustograph routes --role se | `http-template` | `template.normalize` | URI 경로 정규화(`normalize_uri_path`)가 같다 | | `http-dispatch` | `dispatch.validate` | `order` 검증기(`source::routes::validate::order_problem`)가 소비자와 같이 판정한다. fixture 출력에도 적용한다 | | `http-limitation-scope` | `scope.validate` | 스코프 검증기(`scope_problem`)가 소비자와 같이 판정한다(생산 문서가 거부되지 않게) | -| `url-compose` | `compose.*`·`wrapper.*`(`producer` 41건) | 클라이언트 조립(`source::routes::compose`)과 래퍼 동사 바인딩(`source::routes::wrappers`)이 같은 결과를 낸다(`tests/client_routes.rs`). `dio-concat`은 결과가 같은 WHATWG 문자열 연결로, `wrapper.location`은 실제 스캐너로 확인한다 | +| `url-compose` | `compose.*`·`wrapper.*`(`producer` 41건 + `producer:rustograph` 7건) | 클라이언트 조립(`source::routes::compose`)과 래퍼 동사 바인딩(`source::routes::wrappers`)이 같은 결과를 낸다(`tests/client_routes.rs`). `dio-concat`은 결과가 같은 WHATWG 문자열 연결로, `wrapper.location`은 실제 스캐너로 확인한다 | 건너뛰는 사례와 이유: 소비자 전용 사례(`match.*`, `dispatch.match`, `dispatch.shadow`, `scope.applies` — 적용 판정은 -소비자가 한다), 다른 생산자의 프레임워크 변환(`framework.openapi.*`, `framework.spring.*`), `url-compose`의 -`producer:kartograph` 사례(Spring 클라이언트 결합). +소비자가 한다), 다른 생산자의 프레임워크 변환(`framework.openapi.*`, `framework.spring.*`), `url-compose`의 다른 생산자 +전용 사례(`producer:kartograph`·`producer:gartograph`·`producer:pythograph`), `scope.dynamic-validate`· +`scope.dynamic-applies`(dynamic 선언의 선택 필드 `dynamicScope` — rustograph는 내지 않는다). diff --git a/docs/HTTP-CLIENT.md b/docs/HTTP-CLIENT.md index 71a6261..afdf214 100644 --- a/docs/HTTP-CLIENT.md +++ b/docs/HTTP-CLIENT.md @@ -32,14 +32,19 @@ rustograph routes --role client [--dir DIR] [--out FILE] [--wrappers http-wrappe | ureq 2.x | 위와 같은 자유 함수·`Agent`, `request(method, url)`·`request_url` | `url::Url::parse`(소스 기준, 실행 미확인 — 버전 한계를 낸다) | — | | url 2.5.8 | `Url::parse(s)`(reqwest 재수출 `reqwest::Url` 포함), `url.join(p)` 체인 | WHATWG URL Standard | url 크레이트 실행 결과(아래) | -**결합 방식 이름(제안).** isthmus url-compose 벡터의 `join` 입력에 Rust 결합을 더할 때 쓸 이름이다. 벡터에 Rust 이름이 -생기면 러너를 그 이름으로 옮긴다. +**결합 방식 이름.** isthmus `3a45450`(HTTP-WRAPPERS "Go, Rust, Python 클라이언트")이 정했다. `url::Url::join`은 +`rfc3986`이다. reqwest·ureq에는 base URL 설정이 없으므로 호출 식의 URL이 곧 요청 URL이고, 전체 URL 리터럴은 +`compose.strip`, 문자열 조립은 보간 규칙을 쓴다. 조립한 문자열의 앞머리가 모르는 값(`format!("{base}/x")`)이면 계약의 +"그 밖" 규칙대로 `/`로 시작하는 뒤 리터럴만 base 앵커 꼬리이고, 아니면 dynamic + `ambiguous-base-join:`이다. -| 이름 | 쓰는 곳 | `/x`(미상 base) | `x`(미상 base) | base 리터럴 | -|---|---|---|---|---| -| `whatwg-concat` | `format!`·`+`·`concat!`로 이은 문자열을 reqwest·ureq 2에 넘김 | base | dynamic + `ambiguous-base-join:` | 이은 문자열을 WHATWG로 해석(점 세그먼트 제거, `//` 보존), root | -| `whatwg-join` | `url::Url::join` | root | base(RFC 3986 병합) | WHATWG 상대 해석, root | -| `http-uri-concat` | 이은 문자열을 ureq 3에 넘김 | base | dynamic + `ambiguous-base-join:` | 이은 문자열 그대로(점 세그먼트 보존), root | +| 쓰는 곳 | `/x`(미상 base) | `x`(미상 base) | base 리터럴 | +|---|---|---|---| +| `url::Url::join`(`rfc3986`) | root(점 세그먼트 제거) | base, `./`는 지움, `..`·빈 참조는 dynamic + `ambiguous-base-join:` | WHATWG 상대 해석, root | +| 이은 문자열을 reqwest·ureq 2에 넘김(`Url::parse`) | base | dynamic + `ambiguous-base-join:` | 전체 URL을 WHATWG로 해석(점 세그먼트 제거, `//` 보존), root | +| 이은 문자열을 ureq 3에 넘김(`http::Uri`) | base | dynamic + `ambiguous-base-join:` | 전체 URL 그대로(점 세그먼트 보존), root | + +코드 안의 이름은 `Join::WhatwgJoin`(=`rfc3986`), `Join::WhatwgConcat`, `Join::HttpUriConcat`이다. 벡터에는 뒤의 둘을 +가리키는 결합 이름이 없다(전체 URL 규칙이라 필요 없다). WHATWG 결합은 http(s)에서 RFC 3986과 같다. 다른 점은 셋이고 모두 구현했다: `\`를 `/`로 읽고, 같은 scheme의 `http:x`는 상대 참조이며, 앞뒤 C0·공백과 탭·줄바꿈을 지운다. url 2.5.8로 실행해 확인한 값(단위 테스트 @@ -47,7 +52,8 @@ WHATWG 결합은 http(s)에서 RFC 3986과 같다. 다른 점은 셋이고 모 `http://h/a/b/c` + `../x` → `/a/x`, `http://h/api` + `\x` → `/x`, `http://h/api/` + `http:x` → `/api/x`, `http://h/api` + `//other/x` → host `other`의 `/x`, `http://h/api` + `?q=1` → `/api`. -문자열 연결의 base 미상 행은 dio 단순 연결과 결과가 같아 공유 벡터의 `dio-concat` 사례를 `whatwg-concat`으로 실행한다. +문자열 연결의 base 미상 행은 dio 단순 연결과 결과가 같아 공유 벡터의 공통(`producer`) `dio-concat` 사례를 +`Join::WhatwgConcat`으로 실행한다. `slash-join` 사례는 벡터용 `Join::SlashJoin`으로 실행한다(그 방식을 쓰는 Rust 라이브러리는 모델링하지 않았다). ## 해석과 증명 @@ -109,9 +115,12 @@ WHATWG 결합은 http(s)에서 RFC 3986과 같다. 다른 점은 셋이고 모 ## 공유 적합성 벡터 -`conformance/url-compose.json`(isthmus `76b6141`)의 `producer` 사례 41건을 `tests/client_routes.rs`가 제품 함수로 -실행한다(`compose::compose_path`·`join`·`UrlVal::parse`·`mask`, `wrappers::bind_method`). `wrapper.location`은 실제 -스캐너로 여러 줄 호출의 시작 줄과 UTF-8 열을 확인한다. `producer:kartograph`(Spring) 13건은 적용하지 않는다. +`conformance/url-compose.json`(isthmus `3a45450`)의 `producer` 41건과 `producer:rustograph` 7건(`base-join/rust-url-join-*`· +`rfc3986-unknown-*`)을 `tests/client_routes.rs`가 제품 함수로 실행한다(`compose::compose_path`·`join`·`UrlVal::parse`·`mask`, +`wrappers::bind_method`) — **48/48 통과**. `wrapper.location`은 실제 스캐너로 여러 줄 호출의 시작 줄과 UTF-8 열을 확인한다. +다른 생산자 전용 사례(`producer:kartograph`·`gartograph`·`pythograph`)는 적용하지 않는다. 오라클과 어긋난 벡터는 없다. +벡터를 받으며 고친 것: 미상 base의 `rfc3986` 상대 참조에 `..`가 있거나 참조가 비면 dynamic + `ambiguous-base-join:`이다 +(이전에는 `..`가 알려진 세그먼트 안에서 끝나면 템플릿을 냈다). ## 모의 서버 오라클 @@ -172,6 +181,5 @@ crates.io의 진짜 reqwest 0.13.5·ureq 3.4.2·url 2.5.8로 컴파일해 시나 ## isthmus 호환 -isthmus main(`c395c59`)은 rust `route-call`을 받지 않는다(`src/exchange/parse.ts` `routeKindPlatforms`, "Fact kind is -not valid for platform"). 그 집합에 `rust`를 더한 scratch isthmus로 fixture 문서와 axum08 서버 문서를 workspace -`trace`에 넣어 확인했다(HANDOFF의 e2e 절). isthmus가 rust route-call을 받는 변경을 머지하면 이 절을 갱신한다. +isthmus `3a45450`(#133)부터 rust `route-call`을 받는다(그 전 main `c395c59`는 "Fact kind is not valid for platform"으로 +거부했다). 무패치 `3a45450` 빌드로 fixture 문서와 axum08 서버 문서를 workspace `trace`에 넣어 확인했다(HANDOFF의 e2e 절).