Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 54 additions & 10 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tmp> 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`를 추가했다:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 캐시
Expand Down Expand Up @@ -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 리뷰가 역할을 대신했다.

## 막힌 것 / 주의

Expand Down Expand Up @@ -225,8 +266,9 @@ PR #8(의미 해석) 70ea82e · #10(의미 하드닝) 011c05b · #12(handoff)·
- **독립 리뷰 도구.** `packet-ask review --provider glm --diff <ref>`가
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이다. 외부 패키지명과
Expand All @@ -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 패치가 됐다.
73 changes: 57 additions & 16 deletions src/harvest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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패스 — 선언만).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -811,14 +814,16 @@ fn conjoin(a: &Option<String>, b: &str) -> Option<String> {
/// 한 세그먼트 이름(test·cfg·derive·allow...)은 내장이거나 임포트로
/// 이미 잡히므로 두 세그먼트 이상만 모은다. cfg_attr 안쪽 속성은
/// 그 술어를 cfg로 물려받는다 — 조건 없이 성립한다고 속이면 안 된다.
/// 반환값: 속성 목록을 읽지 못한 cfg_attr 수(unparsed_attrs로 간다).
fn collect_attr_refs(
attrs: &[syn::Attribute],
owner: &str,
module: &str,
mod_decl: bool,
cfg: &Option<String>,
out: &mut Vec<AttrRef>,
) {
) -> usize {
let mut unparsed = 0;
let push = |segs: Vec<String>, cfg: &Option<String>, out: &mut Vec<AttrRef>| {
if segs.len() >= 2
// 도구 네임스페이스 속성(rustfmt·clippy·diagnostic)은 크레이트
Expand Down Expand Up @@ -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,
Expand All @@ -875,7 +889,7 @@ fn collect_meta_refs(
pred: &str,
cfg: &Option<String>,
out: &mut Vec<AttrRef>,
) {
) -> usize {
// 이 참조가 성립하는 조건 — 아이템 cfg와 cfg_attr 술어의 합성.
let cond = conjoin(cfg, pred);
match m {
Expand All @@ -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 {
Expand All @@ -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<syn::Meta>)> {
/// 뒤집힌다. 속성 목록의 파싱 실패는 Err로 남긴다 — 호출자가 세야
/// 안쪽 경로의 유실이 limitation으로 드러난다.
fn split_cfg_attr(
tokens: &proc_macro2::TokenStream,
) -> Option<(String, syn::Result<Vec<syn::Meta>>)> {
let mut pred_ts = proc_macro2::TokenStream::new();
let mut rest_ts = proc_macro2::TokenStream::new();
let mut seen_comma = false;
Expand All @@ -953,8 +974,8 @@ fn split_cfg_attr(tokens: &proc_macro2::TokenStream) -> Option<(String, Vec<syn:
use syn::parse::Parser;
let metas = syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::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 줄을 쓴다.
Expand Down Expand Up @@ -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}");
Expand All @@ -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"]);
}
}
6 changes: 6 additions & 0 deletions src/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,12 @@ fn push_limitations(h: &Harvest, limitations: &mut Vec<String>) {
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)",
Expand Down
31 changes: 31 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading