diff --git a/HANDOFF.md b/HANDOFF.md index 722743d..ac5b305 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,7 +2,47 @@ 세션을 이어받는 에이전트가 먼저 읽는 문서입니다. -## 현재 상태 (2026-09-24) +## 직전 세션 요약 (2026-09-25) + +**목표.** HANDOFF의 보류 항목을 정리하고 main에 쌓인 기능을 배포한다. + +**진행.** +- PR #17 머지 — HANDOFF에 persistence 생산자 현황(자매 PR 번호는 gh로 확인). +- PR #18 머지 — verify-cli-contract에 `schema` 추가. 넣다가 `schema`가 + `--semantic`·`--graph`·`--level`·위치 인자를 종료 코드 0으로 조용히 + 삼키던 결함을 발견해 허용 목록(`Args::unsupported`)으로 거부(2). +- PR #19 머지 + **v0.3.0 배포** — 태그 → release.yml(5 플랫폼·패키지 + 바이너리 계약 검증) → 탭 수동 갱신(tap `d0605f6`) → `brew upgrade` + 설치본으로 계약·자기 분석 재검증. PR #20 머지 — HANDOFF 반영. +- PR #21 머지 — `fix/cfg-attr-unparsed`. `split_cfg_attr`가 `.ok()?`로 + 삼키던 속성 목록 파싱 실패를 `Harvest.unparsed_attrs`로 세고 + limitation 한 줄을 낸다. 이 수정은 v0.3.0 이후라 발행본에 없다. + +**효과 있던 것.** +- 새 검사는 수정 전 바이너리/코드로 돌려 실패를 확인(뮤테이션) — 공허한 + 테스트를 막는다. 수정 전 바이너리는 `git worktree add HEAD~1` + + `CARGO_TARGET_DIR` 분리로 빌드했다. +- 인자 추가 대신 **반환값으로 카운트를 올리는** 배선 — clippy + too_many_arguments(>7)와 타입 변경을 피했다. +- 탭 수동 갱신은 게시된 checksums.txt를 받아 `shasum -c`로 대조한 값만 쓴다. + +**안 된 것 / 반복하지 말 것.** +- zsh에서 `$B $c`로 명령 문자열을 돌리면 단어 분리가 안 돼 전부 + "unknown command"(2)가 나온다 — 계약 탐침은 `bash -c`로. +- 세션 시작 시 로컬 main이 origin보다 4커밋 뒤였고 HANDOFF 미커밋 + 수정분이 원격 PR #16과 겹쳤다 — 작업 전 `git fetch` + 비교부터. +- `split_cfg_attr` 실패 재현에 Rust 2024 `unsafe(...)` 속성을 먼저 + 의심했으나 syn 2.0.119는 받는다. 실패는 rustc도 거부하는 입력뿐. + +**다음 단계.** +1. 이 저장소 안의 보류 항목은 없다. 남은 후보는 자매 저장소 일 — + dartograph persistence 생산자, isthmus 교차 도메인 상관·네트워크 + 도메인. 어느 쪽부터 할지 사용자에게 물을 것. +2. 선택: `deps` 보고서가 harvest limitation(unresolved_paths· + unparsed_attrs)을 싣지 않는 것은 기존 설계 — 필요 시 별도 설계. +3. `HOMEBREW_TAP_TOKEN` 시크릿이 들어오면 탭 갱신이 자동화된다. + +## 현재 상태 (2026-09-25) **feature/schema-facts 머지됨 — PR #15(be116ae).** isthmus persistence 도메인의 두 번째 코드 생산자로 `rustograph schema`를 추가했다: @@ -31,8 +71,8 @@ semantic feature·paths/search/deps/schema·baseline·필터·캐시와 플래 이전: v0.2.0은 자기 분석에서 cli↔mcp 모듈 순환이 잡혀 인자 파서를 cli_args로 분리한 0.2.1로 패치됐다. PR #1~#6 머지됨. -검증 상태: `cargo test` 131개 통과(단위 87 + 통합 29 + schema 15) + -semantic feature 47개, 커버리지 92.59%(게이트 90, fix/schema-contract +검증 상태: `cargo test` 133개 통과(단위 88 + 통합 30 + schema 15) + +semantic feature 47개, 커버리지 92.66%(게이트 90, fix/cfg-attr-unparsed 기준), clippy 클린, verify-cli-contract OK(mcp 9도구 + `schema` 종료 코드·계약 필드), 자기 분석 `rules --strict` 0 위반 / `cycles --strict` 0 — semantic 모드도 동일 0. @@ -68,7 +108,9 @@ PR #8(의미 해석) 70ea82e · #10(의미 하드닝) 011c05b · #12(handoff)· 속성 경로 수확(#[dep::attr]·derive(dep::X)·cfg_attr — dep 사용 증거). cfg_attr 술어는 토큰 원문 보존(split_cfg_attr — 이스케이프 디코드로 조건이 뒤집히는 것 방지), 도구 네임스페이스(rustfmt/clippy/ - diagnostic) 속성은 수집하지 않는다. + diagnostic) 속성은 수집하지 않는다. 속성 목록을 Meta로 못 읽은 + cfg_attr는 `Harvest.unparsed_attrs`로 세고 limitation 한 줄을 낸다 + (deps 보고서는 harvest limitation을 싣지 않는다 — 기존 설계). - `src/source.rs` — 오케스트레이터. AST arena('static 누수), 루트 병합 (lib/bin 같은 이름 → extra_files), 보존 루트(main/#[no_mangle]/ --tests/--retain-public), load(캐시)/harvest 분리, semantic 캐시 @@ -176,10 +218,9 @@ PR #8(의미 해석) 70ea82e · #10(의미 하드닝) 011c05b · #12(handoff)· 상세는 위 "현재 상태" 첫 단락 참고. 후속(fix/schema-contract): schema는 --dir/--out 외 플래그·위치 인자를 허용 목록으로 거부(2)하고, verify-cli-contract가 종료 코드와 target null/persistence 계약을 본다. -8. 다음 우선순위는 사용자가 정한다. 알려진 보류 항목은 아래 - "막힌 것 / 주의"의 LOW들 참고 — `split_cfg_attr` 미계수, - Codex 3차 리뷰 - (사용량 한도 — GLM 리뷰가 역할을 대신했다). +8. ~~`split_cfg_attr` 미계수~~ — 완료. PR #21 머지됨. +9. 다음 우선순위는 사용자가 정한다(위 "직전 세션 요약"의 다음 단계). + Codex 3차 리뷰는 사용량 한도로 GLM 리뷰가 역할을 대신했다. ## 막힌 것 / 주의 @@ -225,8 +266,9 @@ PR #8(의미 해석) 70ea82e · #10(의미 하드닝) 011c05b · #12(handoff)· - **독립 리뷰 도구.** `packet-ask review --provider glm --diff `가 diff를 스크럽해 GLM에 보낸다(패킷만 보고 diff는 실물 repo를 못 본다 — 발견은 반드시 코드 대조 검증). `codex exec`도 쓰지만 사용량 한도가 - 있다. GLM 리뷰에서 보류된 유일 항목: `split_cfg_attr`의 속성 목록이 - Meta로 파싱 안 될 때 unresolved_paths 미계수(LOW, 카운터 배선 비용). + 있다. GLM 리뷰 보류 항목이던 `split_cfg_attr` 미계수는 전용 카운터 + (unparsed_attrs)로 해소됐다 — syn 2.0.119는 `unsafe(...)`도 Meta로 + 받으므로 rustc도 거부하는 입력에서만 나는 경로다. - **deps 보고서의 판정 경계.** `--deps` 문서에만 외부 정점이 있고, dev 의존의 사용은 tests/examples/benches(미수확)에 산다 — 둘 다 증거 불완전이니 finding이 아니라 limitation이다. 외부 패키지명과 @@ -235,6 +277,8 @@ PR #8(의미 해석) 70ea82e · #10(의미 하드닝) 011c05b · #12(handoff)· - 이름 기반 경로 해석은 지역 바인딩을 모른다 — 모듈·타입 이름을 흔한 지역 변수명(`args` 등)으로 지으면 `let args`가 모듈을 가리키는 가짜 참조가 생긴다. `cli_args`라는 이름이 그래서다. +- 셸이 zsh다 — 문자열 변수로 명령을 조립해 실행하면 단어 분리가 안 + 된다. 종료 코드 탐침은 `bash -c`로 돌려라. - 릴리스 직후 설치된 바이너리로 자기 분석(`cycles`/`rules --strict`)을 다시 돌려라 — cli↔mcp 순환은 커밋 시점이 아니라 v0.2.0 배포 바이너리 검증에서 잡혔고 0.2.1 패치가 됐다. diff --git a/src/harvest.rs b/src/harvest.rs index 2de30ff..fa8f637 100644 --- a/src/harvest.rs +++ b/src/harvest.rs @@ -23,6 +23,9 @@ pub struct Harvest { pub external_macros: usize, /// cfg 조건부로 포함한 아이템 수. pub cfg_items: usize, + /// 속성 목록을 Meta로 읽지 못한 cfg_attr 수 — 안쪽 경로가 참조로 + /// 잡히지 않았으니 속성으로만 쓰는 dep이 미사용으로 보일 수 있다. + pub unparsed_attrs: usize, } /// 한 모듈의 아이템 목록을 정점으로 만든다(1패스 — 선언만). @@ -328,7 +331,7 @@ pub fn decls<'a>( .map(|i| format!("{module_path}::{i}")) .unwrap_or_else(|| module_path.to_string()); let mod_decl = matches!(item, syn::Item::Mod(m) if m.content.is_none()); - collect_attr_refs( + harvest.unparsed_attrs += collect_attr_refs( attrs_of(item), &owner, module_path, @@ -417,7 +420,7 @@ pub fn impls<'a>( (a, Some(b)) => conjoin(a, b.as_str()), (a, None) => a.clone(), }; - collect_attr_refs( + harvest.unparsed_attrs += collect_attr_refs( &m.attrs, &mid, &b.items_module, @@ -811,6 +814,7 @@ fn conjoin(a: &Option, b: &str) -> Option { /// 한 세그먼트 이름(test·cfg·derive·allow...)은 내장이거나 임포트로 /// 이미 잡히므로 두 세그먼트 이상만 모은다. cfg_attr 안쪽 속성은 /// 그 술어를 cfg로 물려받는다 — 조건 없이 성립한다고 속이면 안 된다. +/// 반환값: 속성 목록을 읽지 못한 cfg_attr 수(unparsed_attrs로 간다). fn collect_attr_refs( attrs: &[syn::Attribute], owner: &str, @@ -818,7 +822,8 @@ fn collect_attr_refs( mod_decl: bool, cfg: &Option, out: &mut Vec, -) { +) -> usize { + let mut unparsed = 0; let push = |segs: Vec, cfg: &Option, out: &mut Vec| { if segs.len() >= 2 // 도구 네임스페이스 속성(rustfmt·clippy·diagnostic)은 크레이트 @@ -852,21 +857,30 @@ fn collect_attr_refs( } else if a.path().is_ident("cfg_attr") { // #[cfg_attr(pred, meta, ...)] — 첫 인자는 술어, 나머지는 속성. if let syn::Meta::List(l) = &a.meta { - if let Some((pred, metas)) = split_cfg_attr(&l.tokens) { - for m in metas { - collect_meta_refs(&m, owner, module, mod_decl, &pred, cfg, out); + match split_cfg_attr(&l.tokens) { + Some((pred, Ok(metas))) => { + for m in metas { + unparsed += + collect_meta_refs(&m, owner, module, mod_decl, &pred, cfg, out); + } } + // 속성 목록이 Meta 문법이 아니면 안쪽 경로를 읽을 수 없다. + Some((_, Err(_))) => unparsed += 1, + // 쉼표 없는 cfg_attr(pred)는 적용할 속성이 없는 형태다. + None => {} } } } else { push(path_segments(a.path()), cfg, out); } } + unparsed } /// cfg_attr 안쪽 메타 하나를 참조로 모은다 — 술어와 아이템 자신의 /// cfg를 all()로 합성해 단다. `derive(dep::T)` 인자와 중첩 `cfg_attr`도 /// 재귀로 파낸다 — 그 안의 경로도 실제 참조다. +/// 반환값: 중첩 cfg_attr 중 속성 목록을 읽지 못한 수. fn collect_meta_refs( m: &syn::Meta, owner: &str, @@ -875,7 +889,7 @@ fn collect_meta_refs( pred: &str, cfg: &Option, out: &mut Vec, -) { +) -> usize { // 이 참조가 성립하는 조건 — 아이템 cfg와 cfg_attr 술어의 합성. let cond = conjoin(cfg, pred); match m { @@ -901,11 +915,14 @@ fn collect_meta_refs( } // 중첩 cfg_attr — 바깥 술어와 안쪽 술어를 둘 다 성립 조건으로 쌓는다. syn::Meta::List(l) if l.path.is_ident("cfg_attr") => { - if let Some((inner_pred, metas)) = split_cfg_attr(&l.tokens) { - for m in metas { - collect_meta_refs(&m, owner, module, mod_decl, &inner_pred, &cond, out); - } - } + return match split_cfg_attr(&l.tokens) { + Some((inner_pred, Ok(metas))) => metas + .iter() + .map(|m| collect_meta_refs(m, owner, module, mod_decl, &inner_pred, &cond, out)) + .sum(), + Some((_, Err(_))) => 1, + None => 0, + }; } _ => { let path = match m { @@ -925,14 +942,18 @@ fn collect_meta_refs( } } } + 0 } /// cfg_attr의 인자를 (술어 원문, 적용 메타 목록)으로 쪼갠다. /// 첫 최상위 쉼표가 술어와 속성의 경계다. 술어는 토큰 원문 그대로 /// 간다 — `Meta`로 재파싱해 LitStr의 value()를 다시 따옴표로 감싸면 /// `\\x6c` 같은 이스케이프가 디코드된 채 남아 거짓 조건이 참으로 -/// 뒤집힌다. -fn split_cfg_attr(tokens: &proc_macro2::TokenStream) -> Option<(String, Vec)> { +/// 뒤집힌다. 속성 목록의 파싱 실패는 Err로 남긴다 — 호출자가 세야 +/// 안쪽 경로의 유실이 limitation으로 드러난다. +fn split_cfg_attr( + tokens: &proc_macro2::TokenStream, +) -> Option<(String, syn::Result>)> { let mut pred_ts = proc_macro2::TokenStream::new(); let mut rest_ts = proc_macro2::TokenStream::new(); let mut seen_comma = false; @@ -953,8 +974,8 @@ fn split_cfg_attr(tokens: &proc_macro2::TokenStream) -> Option<(String, Vec::parse_terminated .parse2(rest_ts) - .ok()?; - Some((pred_ts.to_string(), metas.into_iter().collect())) + .map(|m| m.into_iter().collect()); + Some((pred_ts.to_string(), metas)) } /// `file:line` 위치 — 아이템의 이름 span 줄을 쓴다. @@ -1096,6 +1117,7 @@ mod tests { panic!("cfg_attr is a list meta") }; let (pred, metas) = split_cfg_attr(&l.tokens).expect("split"); + let metas = metas.expect("derive(Debug) is a valid meta list"); // 원문 이스케이프가 남고 디코드된 값이 섞이지 않아야 한다. assert!(pred.contains("\\x62"), "predicate lost escape: {pred}"); assert!(!pred.contains("\"abc\""), "predicate decoded: {pred}"); @@ -1107,4 +1129,23 @@ mod tests { }; assert!(split_cfg_attr(&l2.tokens).is_none()); } + + /// 속성 목록이 Meta 문법이 아닌 cfg_attr는 안쪽 경로를 읽을 수 없다 — + /// 조용히 버리지 않고 unparsed_attrs로 센다. 중첩 cfg_attr도 같다. + /// 정상 목록은 세지 않고 참조를 그대로 모은다. + #[test] + fn unparsable_cfg_attr_list_is_counted() { + let file: syn::File = syn::parse_str( + "#[cfg_attr(test, 1 + 2)] fn a() {} + #[cfg_attr(unix, cfg_attr(test, 1 + 2))] fn b() {} + #[cfg_attr(test, dep::keep)] fn c() {}", + ) + .expect("fixture parses as a file"); + let groups = [(PathBuf::from("lib.rs"), file.items.as_slice())]; + let mut h = Harvest::default(); + let d = decls("k", "k", &groups, &mut h); + assert_eq!(h.unparsed_attrs, 2); + assert_eq!(d.attr_refs.len(), 1, "valid cfg_attr still harvested"); + assert_eq!(d.attr_refs[0].path, ["dep", "keep"]); + } } diff --git a/src/source.rs b/src/source.rs index 81c0a62..ae89c13 100644 --- a/src/source.rs +++ b/src/source.rs @@ -932,6 +932,12 @@ fn push_limitations(h: &Harvest, limitations: &mut Vec) { h.unresolved_paths )); } + if h.unparsed_attrs > 0 { + limitations.push(format!( + "{} cfg_attr attribute lists could not be parsed; paths inside them are not counted as references", + h.unparsed_attrs + )); + } if h.fanned_method_calls > 0 { limitations.push(format!( "{} method calls resolved by name fan-out (no type information; errs toward alive)", diff --git a/tests/integration.rs b/tests/integration.rs index 83da921..c26edac 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -68,6 +68,37 @@ fn orphan_and_cfg_are_measured() { assert!(d.limitations.iter().any(|l| l.contains("#[cfg]"))); } +#[test] +fn unparsable_cfg_attr_is_measured_and_silent_otherwise() { + // 읽지 못한 cfg_attr 속성 목록은 limitation으로 드러나야 한다 — + // 안쪽 경로가 참조로 안 잡혀 속성으로만 쓰는 dep이 미사용으로 보인다. + // rustc도 거부하는 입력이라 공유 fixture 대신 임시 크레이트를 쓴다. + let tmp = std::env::temp_dir().join(format!("rg-cfgattr-{}", std::process::id())); + std::fs::create_dir_all(tmp.join("src")).unwrap(); + std::fs::write( + tmp.join("Cargo.toml"), + "[package]\nname = \"bad_attr\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + std::fs::write( + tmp.join("src/lib.rs"), + "#[cfg_attr(test, 1 + 2)]\npub fn f() {}\n", + ) + .unwrap(); + let d = source::load(&tmp, &Default::default()).expect("temp crate harvest failed"); + let _ = std::fs::remove_dir_all(&tmp); + assert!( + d.limitations + .iter() + .any(|l| l.starts_with("1 cfg_attr attribute lists could not be parsed")), + "{:?}", + d.limitations + ); + // 셀 것이 없으면 조용해야 한다. + let clean = doc(&Default::default()); + assert!(!clean.limitations.iter().any(|l| l.contains("cfg_attr"))); +} + #[test] fn generated_files_are_marked() { let d = doc(&source::Options {