From 17b2c37a8d6e5ac85e146e86d3d247dca82c8f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:36:48 +0900 Subject: [PATCH 01/18] feat(tags): add implements field and bump schema to v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ファイルごとの「実装先の型名」一覧を tags.json に持てるようにする (#21 の器)。旧 v1 は library update を促すエラーで弾く。 この時点では登録側は常に空を書き込む。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/bundle/inventory.rs | 20 ++++++++--- src/commands/library.rs | 10 ++++-- src/library/tags.rs | 73 +++++++++++++++++++++++++++++++++-------- 3 files changed, 83 insertions(+), 20 deletions(-) diff --git a/src/bundle/inventory.rs b/src/bundle/inventory.rs index ce4fc06..d63cb4f 100644 --- a/src/bundle/inventory.rs +++ b/src/bundle/inventory.rs @@ -192,9 +192,15 @@ mod tests { } fn library_kind(files: &[(&str, &[&str])]) -> TagsKind { - TagsKind::Library { - hash: "sha256:placeholder".to_owned(), - files: files + library_kind_with_implements(files, &[]) + } + + fn library_kind_with_implements( + files: &[(&str, &[&str])], + implements: &[(&str, &[&str])], + ) -> TagsKind { + let to_map = |entries: &[(&str, &[&str])]| { + entries .iter() .map(|(key, names)| { ( @@ -202,7 +208,12 @@ mod tests { names.iter().map(|n| (*n).to_owned()).collect(), ) }) - .collect(), + .collect() + }; + TagsKind::Library { + hash: "sha256:placeholder".to_owned(), + files: to_map(files), + implements: to_map(implements), } } @@ -382,6 +393,7 @@ mod tests { kind: TagsKind::Library { hash: real_hash, files, + implements: BTreeMap::new(), }, } .save(&store.tags_json("lib")) diff --git a/src/commands/library.rs b/src/commands/library.rs index 3a6d30a..f04ce9c 100644 --- a/src/commands/library.rs +++ b/src/commands/library.rs @@ -135,7 +135,11 @@ fn register_library(store: &LocalStore, id: &str, source_root: &Path) -> Result< let hash = hash::aggregate(source_root)?; Tags { path: source_root.to_path_buf(), - kind: TagsKind::Library { hash, files }, + kind: TagsKind::Library { + hash, + files, + implements: Default::default(), + }, } .save(&store.tags_json(id)) } @@ -357,7 +361,7 @@ fn show(store: &LocalStore, id: &str, verbose: bool) -> Result<()> { println!(" {}", compiler.display()); } } - TagsKind::Library { hash, files } => { + TagsKind::Library { hash, files, .. } => { show_field("Kind", "library"); show_field( "Files", @@ -408,7 +412,7 @@ mod tests { assert!(store.is_registered("ac-library")); let tags = Tags::load(&store.tags_json("ac-library")).unwrap(); match tags.kind { - TagsKind::Library { hash, files } => { + TagsKind::Library { hash, files, .. } => { assert!(hash.starts_with("sha256:")); assert!(files["atcoder/modint.hpp"].contains(&"modint".to_owned())); } diff --git a/src/library/tags.rs b/src/library/tags.rs index fdd7032..dd469cc 100644 --- a/src/library/tags.rs +++ b/src/library/tags.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; -const CURRENT_SCHEMA_VERSION: u32 = 1; +const CURRENT_SCHEMA_VERSION: u32 = 2; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Tags { @@ -30,6 +30,11 @@ pub enum TagsKind { hash: String, // 出力順を安定させるため BTreeMap を使う (HashMap だと tags.json の diff が毎回ぶれる)。 files: BTreeMap>, + /// ファイルごとの「実装先の型名」一覧。クラス外の修飾付き定義 (`X<...>::method`) や明示的 + /// 特殊化 (`template <> struct T<...>`) が対象とする型名で、`files` (定義識別子) と対になる。 + /// 定義識別子に現れない依存 (演算子オーバーロード等) をバンドル時に逆引きするために使う。 + /// 実装先を持たないファイルはキー自体を持たない。 + implements: BTreeMap>, }, } @@ -68,6 +73,8 @@ struct RawTags { hash: Option, #[serde(skip_serializing_if = "Option::is_none")] files: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + implements: Option>>, } impl TryFrom for Tags { @@ -82,8 +89,14 @@ impl TryFrom for Tags { ); } let kind = match (raw.compilers, raw.hash, raw.files) { - (Some(compilers), None, None) => TagsKind::Std { compilers }, - (None, Some(hash), Some(files)) => TagsKind::Library { hash, files }, + (Some(compilers), None, None) if raw.implements.is_none() => { + TagsKind::Std { compilers } + } + (None, Some(hash), Some(files)) => TagsKind::Library { + hash, + files, + implements: raw.implements.unwrap_or_default(), + }, _ => { bail!( "invalid tags.json kind (std requires only compilers; a regular library requires both hash and files)" @@ -99,9 +112,18 @@ impl TryFrom for Tags { impl From<&Tags> for RawTags { fn from(tags: &Tags) -> Self { - let (compilers, hash, files) = match &tags.kind { - TagsKind::Std { compilers } => (Some(compilers.clone()), None, None), - TagsKind::Library { hash, files } => (None, Some(hash.clone()), Some(files.clone())), + let (compilers, hash, files, implements) = match &tags.kind { + TagsKind::Std { compilers } => (Some(compilers.clone()), None, None, None), + TagsKind::Library { + hash, + files, + implements, + } => ( + None, + Some(hash.clone()), + Some(files.clone()), + Some(implements.clone()), + ), }; Self { schema_version: CURRENT_SCHEMA_VERSION, @@ -109,6 +131,7 @@ impl From<&Tags> for RawTags { compilers, hash, files, + implements, } } } @@ -128,6 +151,10 @@ mod tests { ("atcoder/modint.hpp".to_owned(), vec!["modint".to_owned()]), ("atcoder/segtree.hpp".to_owned(), vec!["segtree".to_owned()]), ]), + implements: BTreeMap::from([( + "atcoder/modint_impl.hpp".to_owned(), + vec!["modint".to_owned()], + )]), }, } } @@ -171,6 +198,7 @@ mod tests { kind: TagsKind::Library { hash: "sha256:abc".to_owned(), files: BTreeMap::new(), + implements: BTreeMap::new(), }, }; let json = tags.to_json().unwrap(); @@ -178,9 +206,26 @@ mod tests { assert_eq!(Tags::from_json(&json).unwrap(), tags); } + #[test] + fn library_without_implements_parses_as_empty() { + // v2 で implements を省いた JSON も空として受理する (手書き・移行時の寛容さ)。 + let json = r#"{ "schema_version": 2, "path": "/p", "hash": "sha256:abc", "files": {} }"#; + let tags = Tags::from_json(json).unwrap(); + assert!(matches!( + tags.kind, + TagsKind::Library { ref implements, .. } if implements.is_empty() + )); + } + + #[test] + fn std_with_implements_is_rejected() { + let json = r#"{ "schema_version": 2, "path": "/p", "compilers": ["/usr/bin/g++"], "implements": {} }"#; + assert!(Tags::from_json(json).is_err()); + } + #[test] fn parses_spec_std_example() { - let json = r#"{ "schema_version": 1, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/g++"] }"#; + let json = r#"{ "schema_version": 2, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/g++"] }"#; let tags = Tags::from_json(json).unwrap(); assert_eq!( tags.kind, @@ -192,40 +237,42 @@ mod tests { #[test] fn unsupported_schema_version_is_rejected() { - let json = r#"{ "schema_version": 2, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/g++"] }"#; + // v1 (implements 導入前) は再登録を促すエラーになる。 + let json = r#"{ "schema_version": 1, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/g++"] }"#; let error = Tags::from_json(json).unwrap_err(); assert!(error.to_string().contains("schema_version")); + assert!(error.to_string().contains("library update")); } #[test] fn std_without_compilers_is_rejected() { // compilers も hash/files も無い旧 std 形式は不正 (add-std での再登録が必要)。 - let json = r#"{ "schema_version": 1, "path": "/usr/include/c++/12" }"#; + let json = r#"{ "schema_version": 2, "path": "/usr/include/c++/12" }"#; assert!(Tags::from_json(json).is_err()); } #[test] fn hash_without_files_is_rejected() { - let json = r#"{ "schema_version": 1, "path": "/p", "hash": "sha256:abc" }"#; + let json = r#"{ "schema_version": 2, "path": "/p", "hash": "sha256:abc" }"#; assert!(Tags::from_json(json).is_err()); } #[test] fn files_without_hash_is_rejected() { - let json = r#"{ "schema_version": 1, "path": "/p", "files": {} }"#; + let json = r#"{ "schema_version": 2, "path": "/p", "files": {} }"#; assert!(Tags::from_json(json).is_err()); } #[test] fn std_with_hash_is_rejected() { // std (compilers) と通常ライブラリ (hash/files) の情報が混在する形式は不正。 - let json = r#"{ "schema_version": 1, "path": "/p", "compilers": ["/usr/bin/g++"], "hash": "sha256:abc" }"#; + let json = r#"{ "schema_version": 2, "path": "/p", "compilers": ["/usr/bin/g++"], "hash": "sha256:abc" }"#; assert!(Tags::from_json(json).is_err()); } #[test] fn unknown_field_is_rejected() { - let json = r#"{ "schema_version": 1, "path": "/p", "unknown": true }"#; + let json = r#"{ "schema_version": 2, "path": "/p", "unknown": true }"#; assert!(Tags::from_json(json).is_err()); } From e26caf0ecbbdc939c33070fbae2d5194e6dead05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:01:07 +0900 Subject: [PATCH 02/18] feat(identifiers): capture implementation target type names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit クラス外の修飾付き定義 (X<...>::method) の修飾側と、明示的特殊化 (template <> struct T<...>) の主テンプレート名を「実装先の型名」として 登録時に記録し、tags.json の implements に保存する (#21)。 演算子オーバーロードだけの実装ファイルでも実装先が残るのが要点。 show -v に Implements 表示を追加。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/commands/library.rs | 18 +++- src/library/identifiers.rs | 187 +++++++++++++++++++++++++++++++------ 2 files changed, 175 insertions(+), 30 deletions(-) diff --git a/src/commands/library.rs b/src/commands/library.rs index f04ce9c..db0e3d7 100644 --- a/src/commands/library.rs +++ b/src/commands/library.rs @@ -131,14 +131,14 @@ fn register_library(store: &LocalStore, id: &str, source_root: &Path) -> Result< dummy::generate(source_root, &store.dummy_dir(id))?; // 識別子抽出はファイル数に比例して時間がかかるため、処理中のファイル名を逐次表示する。 - let files = identifiers::enumerate(source_root, |relative| eprintln!(" {relative}"))?; + let names = identifiers::enumerate(source_root, |relative| eprintln!(" {relative}"))?; let hash = hash::aggregate(source_root)?; Tags { path: source_root.to_path_buf(), kind: TagsKind::Library { hash, - files, - implements: Default::default(), + files: names.definitions, + implements: names.implements, }, } .save(&store.tags_json(id)) @@ -361,7 +361,11 @@ fn show(store: &LocalStore, id: &str, verbose: bool) -> Result<()> { println!(" {}", compiler.display()); } } - TagsKind::Library { hash, files, .. } => { + TagsKind::Library { + hash, + files, + implements, + } => { show_field("Kind", "library"); show_field( "Files", @@ -373,6 +377,12 @@ fn show(store: &LocalStore, id: &str, verbose: bool) -> Result<()> { for (file, names) in files { println!(" {file}: {}", names.join(", ")); } + if !implements.is_empty() { + println!("Implements:"); + for (file, names) in implements { + println!(" {file}: {}", names.join(", ")); + } + } } } } diff --git a/src/library/identifiers.rs b/src/library/identifiers.rs index f1709b0..181bbf7 100644 --- a/src/library/identifiers.rs +++ b/src/library/identifiers.rs @@ -44,44 +44,61 @@ const SKIP_DESCENT: &[&str] = &[ /// tree-shaking が無効化される。namespace 内のメンバは子の再帰で個別に拾うため取りこぼさない。 const NAME_NODES: &[&str] = &["identifier", "type_identifier", "field_identifier"]; -/// `source_root` 以下のソースファイルを走査し、各ファイルが定義する識別子名を集約する。 -/// -/// キーは `source_root` からの相対パス (`/` 区切り)、値は重複排除・昇順の識別子名一覧。 -/// 定義を一つも持たないファイル (インクルードのみの拡張子なしファイル等) は結果に含めない。 -/// 対象ファイルの選別は [`source::walk_sources`] が担う。 +/// [`enumerate`] の結果。どちらもキーは `source_root` からの相対パス (`/` 区切り)、値は重複排除・ +/// 昇順の名前一覧で、該当する名前を持たないファイルはキー自体を含めない。 +pub struct Enumeration { + /// 各ファイルが定義する識別子名 (tags.json の `files`)。 + pub definitions: BTreeMap>, + /// 各ファイルの「実装先の型名」(tags.json の `implements`)。クラス外の修飾付き定義 + /// (`X<...>::method`) の修飾側や、明示的特殊化 (`template <> struct T<...>`) の主テンプレート名。 + /// 演算子オーバーロードのように定義識別子が残らない実装ファイルでも、依存を逆引きできるように + /// する (#21)。namespace 修飾 (`void ns::f()`) の namespace 名も混ざるが、namespace 名は定義 + /// 識別子として登録されない (上記参照) ため逆引きで一致することがなく、無害である。 + pub implements: BTreeMap>, +} + +/// `source_root` 以下のソースファイルを走査し、各ファイルが定義する識別子名と実装先の型名を +/// 集約する。対象ファイルの選別は [`source::walk_sources`] が担う。 /// /// ファイルを処理する直前に `on_progress(相対パス)` を呼ぶ。登録に時間がかかるため、呼び出し側が /// 進捗を表示できるようにする。 -pub fn enumerate( - source_root: &Path, - mut on_progress: impl FnMut(&str), -) -> Result>> { +pub fn enumerate(source_root: &Path, mut on_progress: impl FnMut(&str)) -> Result { let mut parser = Parser::new(); parser .set_language(&tree_sitter_cpp::LANGUAGE.into()) .context("failed to initialize the C++ parser")?; - let mut files = BTreeMap::new(); + let mut result = Enumeration { + definitions: BTreeMap::new(), + implements: BTreeMap::new(), + }; source::walk_sources(source_root, |relative, content| { let slug = relpath::to_slash(relative)?; on_progress(&slug); - let names = definitions_in(&mut parser, content) + let (definitions, implements) = names_in_file(&mut parser, content) .with_context(|| format!("failed to extract identifiers from {slug}"))?; - if !names.is_empty() { - files.insert(slug, names); + if !definitions.is_empty() { + result.definitions.insert(slug.clone(), definitions); + } + if !implements.is_empty() { + result.implements.insert(slug, implements); } Ok(()) })?; - Ok(files) + Ok(result) } -/// 1 ファイルのソースから、定義された識別子名を重複排除・昇順で返す。 -fn definitions_in(parser: &mut Parser, source: &[u8]) -> Result> { +/// 1 ファイルのソースから、(定義された識別子名, 実装先の型名) をそれぞれ重複排除・昇順で返す。 +fn names_in_file(parser: &mut Parser, source: &[u8]) -> Result<(Vec, Vec)> { let tree = parser .parse(source, None) .ok_or_else(|| anyhow!("failed to parse the source"))?; let mut names = BTreeSet::new(); - collect_definitions(tree.root_node(), source, &mut names); - Ok(names.into_iter().collect()) + let mut implements = BTreeSet::new(); + collect_definitions(tree.root_node(), source, &mut names, &mut implements); + Ok(( + names.into_iter().collect(), + implements.into_iter().collect(), + )) } /// 構文木を辿り、各宣言ノードが導入する名前を `names` に集める。`SKIP_DESCENT` のノードには降りない。 @@ -90,18 +107,23 @@ fn definitions_in(parser: &mut Parser, source: &[u8]) -> Result> { /// (継承元)、`scope` (修飾子) などは既存の名前への参照であり、辿ると使用箇所まで識別子化して /// 逆引きを汚すため辿らない。`type` フィールド内に書かれた埋め込み型定義 (`struct X {} v;`) は、 /// 全子ノードの再帰訪問で当該 `struct` ノードに到達し、その `name` から拾える。 -fn collect_definitions(node: Node, source: &[u8], names: &mut BTreeSet) { +fn collect_definitions( + node: Node, + source: &[u8], + names: &mut BTreeSet, + implements: &mut BTreeSet, +) { if SKIP_DESCENT.contains(&node.kind()) { return; } for field in ["name", "declarator"] { if let Some(child) = node.child_by_field_name(field) { - collect_leaf(child, source, names); + collect_leaf(child, source, names, implements); } } let mut cursor = node.walk(); for child in node.named_children(&mut cursor) { - collect_definitions(child, source, names); + collect_definitions(child, source, names, implements); } } @@ -110,11 +132,33 @@ fn collect_definitions(node: Node, source: &[u8], names: &mut BTreeSet) /// 宣言子は `pointer_declarator` → `function_declarator` → `identifier` のように入れ子になるため、 /// 同名フィールドを辿り続けて末端へ到達する。`qualified_identifier` は `name` 側のみ辿るため、 /// `Foo::bar` からは `bar` を得る (逆引きはトークン単位で行うため修飾子は不要)。 -fn collect_leaf(node: Node, source: &[u8], names: &mut BTreeSet) { +fn collect_leaf( + node: Node, + source: &[u8], + names: &mut BTreeSet, + implements: &mut BTreeSet, +) { + // クラス外の修飾付き定義 (`X<...>::method`) は、修飾側 (`X`) が実装先の型名。定義名の探索とは + // 独立に記録する。name 側の探索は下のフィールド辿りが担う (入れ子の `a::b::f` も再帰で各段の + // 修飾側を拾う)。 + if node.kind() == "qualified_identifier" + && let Some(scope) = node.child_by_field_name("scope") + { + collect_implement_target(scope, source, implements); + } + // 明示的特殊化 (`template <> struct T` / `template <> void f()`) では定義名の位置に + // テンプレート名 + 実引数のノードが来る。主テンプレート `T` / `f` は他ファイルで定義されている + // 見込みが高いので、実装先としても記録する。 + if matches!( + node.kind(), + "template_type" | "template_function" | "template_method" + ) { + collect_implement_target(node, source, implements); + } let mut descended = false; for field in ["name", "declarator"] { if let Some(child) = node.child_by_field_name(field) { - collect_leaf(child, source, names); + collect_leaf(child, source, names, implements); descended = true; } } @@ -124,7 +168,7 @@ fn collect_leaf(node: Node, source: &[u8], names: &mut BTreeSet) { if node.kind() == "operator_name" { let mut cursor = node.walk(); for child in node.named_children(&mut cursor) { - collect_leaf(child, source, names); + collect_leaf(child, source, names, implements); descended = true; } } @@ -136,6 +180,27 @@ fn collect_leaf(node: Node, source: &[u8], names: &mut BTreeSet) { } } +/// 実装先の型名ノードから、その基底の名前を採用する。`FormalPowerSeries` のような +/// テンプレート実引数付きはテンプレート名だけを取り、実引数には降りない (実引数は参照であって +/// 実装先ではない)。 +fn collect_implement_target(node: Node, source: &[u8], implements: &mut BTreeSet) { + match node.kind() { + "namespace_identifier" | "identifier" | "type_identifier" => { + if let Ok(text) = node.utf8_text(source) { + implements.insert(text.to_owned()); + } + } + "template_type" | "template_function" | "template_method" => { + if let Some(name) = node.child_by_field_name("name") { + collect_implement_target(name, source, implements); + } + } + // decltype や依存名などの複雑な修飾は、実装先を静的に特定できないため拾わない + // (拾い漏れは従来通りの挙動に留まるだけで、悪化はしない)。 + _ => {} + } +} + #[cfg(test)] mod tests { use super::*; @@ -156,6 +221,18 @@ mod tests { write_file(&root, "lib.hpp", source); enumerate(&root, |_| {}) .unwrap() + .definitions + .remove("lib.hpp") + .unwrap_or_default() + } + + fn implements_in(source: &str) -> Vec { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("source"); + write_file(&root, "lib.hpp", source); + enumerate(&root, |_| {}) + .unwrap() + .implements .remove("lib.hpp") .unwrap_or_default() } @@ -170,7 +247,7 @@ mod tests { "namespace atcoder { struct segtree {}; }", ); - let files = enumerate(&source, |_| {}).unwrap(); + let files = enumerate(&source, |_| {}).unwrap().definitions; assert!(files["atcoder/segtree.hpp"].contains(&"segtree".to_owned())); } @@ -287,6 +364,62 @@ mod tests { assert!(!names.contains(&"height".to_owned())); } + #[test] + fn out_of_class_definition_records_the_implement_target() { + // クラス外定義 `Foo::bar` は、bar を定義識別子に、Foo を実装先に記録する。 + let source = "void Foo::bar() { }"; + assert_eq!(names_in(source), vec!["bar".to_owned()]); + assert_eq!(implements_in(source), vec!["Foo".to_owned()]); + } + + #[test] + fn templated_out_of_class_definition_records_the_primary_name() { + // FPS パターン (#21)。テンプレート実引数は実装先ではないので mint は拾わない。 + let source = "template \n\ + void FormalPowerSeries::set_fft() { }"; + assert_eq!(implements_in(source), vec!["FormalPowerSeries".to_owned()]); + } + + #[test] + fn operator_only_file_still_gets_an_implement_target() { + // 演算子だけの実装ファイルは定義識別子が空になるが、実装先は残る。これが #21 の核心。 + let source = "template \n\ + FormalPowerSeries& FormalPowerSeries::operator*=(const FormalPowerSeries& r) { return *this; }"; + assert_eq!(names_in(source), Vec::::new()); + assert_eq!(implements_in(source), vec!["FormalPowerSeries".to_owned()]); + } + + #[test] + fn explicit_specialization_records_the_primary_template() { + let source = "template <> struct Trait { static const bool value = true; };"; + assert!(implements_in(source).contains(&"Trait".to_owned())); + } + + #[test] + fn nested_qualifiers_record_each_scope() { + // `a::b::f` は各段の修飾側を拾う。namespace 名が混ざっても、namespace 名は定義識別子に + // 登録されないため逆引きで一致せず無害 (モジュールコメント参照)。 + let source = "void outer::Inner::f() { }"; + assert_eq!( + implements_in(source), + vec!["Inner".to_owned(), "outer".to_owned()] + ); + } + + #[test] + fn in_class_definitions_have_no_implement_target() { + // クラス内で完結する定義は実装先を持たない (自分自身の実装は逆引き不要)。 + let source = "struct V { V operator+(V o) { return o; } void f() { } };"; + assert_eq!(implements_in(source), Vec::::new()); + } + + #[test] + fn type_references_do_not_become_implement_targets() { + // 使用側の修飾 (`internal::barrett bt;` や `Trait::value` の参照) は実装先ではない。 + let source = "struct modint { static internal::barrett bt; };\nint x = Trait::value;"; + assert_eq!(implements_in(source), Vec::::new()); + } + #[test] fn names_are_deduplicated_and_sorted() { // 前方宣言と定義で同名が複数回現れても 1 つにまとまり、昇順で返る。 @@ -301,7 +434,9 @@ mod tests { // AC Library の拡張子なしファイルのように、インクルードのみで定義を持たない。 write_file(&source, "atcoder/modint", "#include "); - assert!(enumerate(&source, |_| {}).unwrap().is_empty()); + let result = enumerate(&source, |_| {}).unwrap(); + assert!(result.definitions.is_empty()); + assert!(result.implements.is_empty()); } #[test] From a10bf8a623d78b46cfbbc4453bad792b46509406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:15:01 +0900 Subject: [PATCH 03/18] feat(bundle): keep implementation files of needed types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 必要ファイルが定義する型を実装先に持つファイル (演算子オーバーロード等、 識別子に現れない依存) を implements の逆引きで必要集合へ加え、 増えなくなるまで -M の必要集合と交互に更新する (#21)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/bundle/inventory.rs | 111 ++++++++++++++++++++++++++++++++++++++-- src/commands/bundle.rs | 15 +++++- 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/src/bundle/inventory.rs b/src/bundle/inventory.rs index d63cb4f..f753371 100644 --- a/src/bundle/inventory.rs +++ b/src/bundle/inventory.rs @@ -4,7 +4,7 @@ //! 「維持指定された (tree-shaking 対象外の) ライブラリと `std` は識別子情報を使わない」という仕様の //! 区別を、各メソッドで一貫して適用する。 -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; @@ -123,20 +123,69 @@ impl Inventory { .collect() } + /// `present` のうち、`needed` 内のいずれかのファイルが定義する型を実装しているファイルを返す。 + /// + /// 「実装している」は登録時に記録した実装先の型名 (`tags.json` の `implements`) と、`needed` 側の + /// 定義識別子との照合で判定する。演算子オーバーロードのような、定義識別子として現れない依存を + /// 拾うための逆引き (#21)。 + pub fn implementation_files( + &self, + needed: &BTreeSet, + present: &BTreeSet, + ) -> BTreeSet { + let needed_names: BTreeSet<&String> = needed + .iter() + .filter_map(|path| self.defined_identifiers(path)) + .flatten() + .collect(); + present + .iter() + .filter(|path| { + self.implement_targets(path) + .is_some_and(|targets| targets.iter().any(|t| needed_names.contains(t))) + }) + .cloned() + .collect() + } + /// realpath 済みパスが属する維持指定外ライブラリを特定し、そのファイルが `tags.json` で定義する - /// 識別子一覧を返す。linemarker の絶対パスを `files` の相対キーへ (`/` 区切り・`path` prefix 除去で) - /// 対応づける処理がここに集約される。定義を持たないファイルや対象外パスは `None`。 + /// 識別子一覧を返す。定義を持たないファイルや対象外パスは `None`。 fn defined_identifiers(&self, canonical: &Path) -> Option<&[String]> { + let (files, _, key) = self.tags_entry(canonical)?; + files.get(&key).map(Vec::as_slice) + } + + /// realpath 済みパスが属する維持指定外ライブラリを特定し、そのファイルの実装先の型名一覧を返す。 + fn implement_targets(&self, canonical: &Path) -> Option<&[String]> { + let (_, implements, key) = self.tags_entry(canonical)?; + implements.get(&key).map(Vec::as_slice) + } + + /// realpath 済みパスが属する維持指定外ライブラリの `files`・`implements` と、そのライブラリ内での + /// 相対キーを返す。linemarker の絶対パスを相対キーへ (`/` 区切り・`path` prefix 除去で) 対応づける + /// 処理がここに集約される。 + #[allow(clippy::type_complexity)] + fn tags_entry( + &self, + canonical: &Path, + ) -> Option<( + &BTreeMap>, + &BTreeMap>, + String, + )> { for lib in &self.libraries { if lib.keep { continue; } - let TagsKind::Library { files, .. } = &lib.kind else { + let TagsKind::Library { + files, implements, .. + } = &lib.kind + else { continue; }; if let Ok(relative) = canonical.strip_prefix(&lib.path) { let key = relpath::to_slash(relative).ok()?; - return files.get(&key).map(Vec::as_slice); + return Some((files, implements, key)); } } None @@ -295,6 +344,58 @@ mod tests { assert_eq!(headers, BTreeSet::from([dsu])); } + #[test] + fn implementation_files_are_found_via_needed_definitions() { + // fps.hpp が FPS を定義し、fps-impl.hpp が FPS を実装先に持つ (#21 の FPS パターン)。 + let local = TempDir::new().unwrap(); + let store = LocalStore::with_root(local.path()); + let lib_path = register( + &store, + "mylib", + library_kind_with_implements( + &[("fps.hpp", &["FPS"]), ("other.hpp", &["other"])], + &[ + ("fps-impl.hpp", &["FPS"]), + ("unrelated-impl.hpp", &["Tree"]), + ], + ), + ); + let fps = lib_path.join("fps.hpp"); + let implementation = lib_path.join("fps-impl.hpp"); + let present = BTreeSet::from([ + fps.clone(), + implementation.clone(), + lib_path.join("other.hpp"), + lib_path.join("unrelated-impl.hpp"), + ]); + + let inventory = Inventory::load(&store, &keep_set(&["std"])).unwrap(); + let files = inventory.implementation_files(&BTreeSet::from([fps]), &present); + + // FPS の実装ファイルだけが選ばれる。Tree の実装や無関係な定義ファイルは巻き込まない。 + assert_eq!(files, BTreeSet::from([implementation])); + } + + #[test] + fn implementation_files_outside_present_are_not_pulled() { + let local = TempDir::new().unwrap(); + let store = LocalStore::with_root(local.path()); + let lib_path = register( + &store, + "mylib", + library_kind_with_implements(&[("fps.hpp", &["FPS"])], &[("fps-impl.hpp", &["FPS"])]), + ); + let fps = lib_path.join("fps.hpp"); + + let inventory = Inventory::load(&store, &keep_set(&["std"])).unwrap(); + // fps-impl.hpp はユーザーが include しておらず present に無いので、注入はしない (#10 の領分)。 + assert!( + inventory + .implementation_files(&BTreeSet::from([fps.clone()]), &BTreeSet::from([fps])) + .is_empty() + ); + } + #[test] fn files_absent_from_output_are_never_pulled_even_if_they_define_the_identifier() { // include されない test/example/*.cpp が `main` を定義していても、present に無ければ依存に diff --git a/src/commands/bundle.rs b/src/commands/bundle.rs index e0cfcf5..8ea8333 100644 --- a/src/commands/bundle.rs +++ b/src/commands/bundle.rs @@ -170,8 +170,19 @@ fn unused_origins( // 出力に現れた維持指定外ライブラリのファイル群。これが逆引きの母集合かつ不要判定の候補になる。 let present: BTreeSet = pruneable.values().cloned().collect(); let used = detect::identifiers(&target_code); - let dependency_headers = inventory.dependency_headers(&used, &present); - let needed = needed_headers(&settings.compiler, compiler_args, &dependency_headers)?; + let mut dependency_headers = inventory.dependency_headers(&used, &present); + + // 必要ファイルが定義する型の実装ファイル (演算子オーバーロード等、識別子に現れない依存) を + // 逆引きで加え、増えなくなるまで `-M` の必要集合と交互に更新する。dependency_headers は単調 + // 増加で present に有界なので必ず停止する。 + let needed = loop { + let needed = needed_headers(&settings.compiler, compiler_args, &dependency_headers)?; + let implementations = inventory.implementation_files(&needed, &present); + if implementations.is_subset(&dependency_headers) { + break needed; + } + dependency_headers.extend(implementations); + }; let unused = prune::unused_headers(&present, &needed); From 176823ec12f69c20ebb10fa0f46ac4c8d0536c23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:23:20 +0900 Subject: [PATCH 04/18] refactor: remove context-dependent comments and update stale docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 番号や時系列 (「従来通り」等) に依存するコメントを、コードだけで 理解できる現在形の記述へ書き換える。inventory のモジュールコメントに 実装ファイル逆引きの問い合わせを追記。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/bundle/inventory.rs | 13 +++++++------ src/library/identifiers.rs | 13 ++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/bundle/inventory.rs b/src/bundle/inventory.rs index f753371..df090e7 100644 --- a/src/bundle/inventory.rs +++ b/src/bundle/inventory.rs @@ -1,6 +1,7 @@ //! 登録済みライブラリの突き合わせ用インベントリ。`tags.json` を読み込み、バンドルの各工程が必要と -//! する 4 つの問い合わせに答える: インクルードパスの組み立て (`-I`)、`-nostdinc` の要否、ハッシュ -//! 検証、そして `識別子 → 依存ヘッダー` の逆引き。維持指定 (keep) と種別 (`std` / 通常) を保持し、 +//! する問い合わせに答える: インクルードパスの組み立て (`-I`)、`-nostdinc` の要否、ハッシュ検証、 +//! `識別子 → 依存ヘッダー` の逆引き、そして `定義した型 → その実装ファイル` の逆引き。維持指定 +//! (keep) と種別 (`std` / 通常) を保持し、 //! 「維持指定された (tree-shaking 対象外の) ライブラリと `std` は識別子情報を使わない」という仕様の //! 区別を、各メソッドで一貫して適用する。 @@ -127,7 +128,7 @@ impl Inventory { /// /// 「実装している」は登録時に記録した実装先の型名 (`tags.json` の `implements`) と、`needed` 側の /// 定義識別子との照合で判定する。演算子オーバーロードのような、定義識別子として現れない依存を - /// 拾うための逆引き (#21)。 + /// 拾うための逆引き。 pub fn implementation_files( &self, needed: &BTreeSet, @@ -346,7 +347,6 @@ mod tests { #[test] fn implementation_files_are_found_via_needed_definitions() { - // fps.hpp が FPS を定義し、fps-impl.hpp が FPS を実装先に持つ (#21 の FPS パターン)。 let local = TempDir::new().unwrap(); let store = LocalStore::with_root(local.path()); let lib_path = register( @@ -372,7 +372,7 @@ mod tests { let inventory = Inventory::load(&store, &keep_set(&["std"])).unwrap(); let files = inventory.implementation_files(&BTreeSet::from([fps]), &present); - // FPS の実装ファイルだけが選ばれる。Tree の実装や無関係な定義ファイルは巻き込まない。 + // needed が定義する FPS の実装ファイルだけが選ばれ、別の型 (Tree) の実装は巻き込まない。 assert_eq!(files, BTreeSet::from([implementation])); } @@ -388,7 +388,8 @@ mod tests { let fps = lib_path.join("fps.hpp"); let inventory = Inventory::load(&store, &keep_set(&["std"])).unwrap(); - // fps-impl.hpp はユーザーが include しておらず present に無いので、注入はしない (#10 の領分)。 + // present に無い (= include されていない) ファイルは、実装先が一致しても補わない。 + // バンドル入力に現れないファイルの注入は本メソッドの責務外。 assert!( inventory .implementation_files(&BTreeSet::from([fps.clone()]), &BTreeSet::from([fps])) diff --git a/src/library/identifiers.rs b/src/library/identifiers.rs index 181bbf7..ca7b116 100644 --- a/src/library/identifiers.rs +++ b/src/library/identifiers.rs @@ -52,8 +52,8 @@ pub struct Enumeration { /// 各ファイルの「実装先の型名」(tags.json の `implements`)。クラス外の修飾付き定義 /// (`X<...>::method`) の修飾側や、明示的特殊化 (`template <> struct T<...>`) の主テンプレート名。 /// 演算子オーバーロードのように定義識別子が残らない実装ファイルでも、依存を逆引きできるように - /// する (#21)。namespace 修飾 (`void ns::f()`) の namespace 名も混ざるが、namespace 名は定義 - /// 識別子として登録されない (上記参照) ため逆引きで一致することがなく、無害である。 + /// する。namespace 修飾 (`void ns::f()`) の namespace 名も混ざるが、namespace 名は定義 + /// 識別子として登録されない ([`NAME_NODES`] 参照) ため逆引きで一致することがなく、無害である。 pub implements: BTreeMap>, } @@ -195,8 +195,8 @@ fn collect_implement_target(node: Node, source: &[u8], implements: &mut BTreeSet collect_implement_target(name, source, implements); } } - // decltype や依存名などの複雑な修飾は、実装先を静的に特定できないため拾わない - // (拾い漏れは従来通りの挙動に留まるだけで、悪化はしない)。 + // decltype や依存名などの複雑な修飾は、実装先を静的に特定できないため拾わない。 + // 拾い漏れてもこの逆引きが効かないだけで、定義識別子による依存検出には影響しない。 _ => {} } } @@ -366,7 +366,6 @@ mod tests { #[test] fn out_of_class_definition_records_the_implement_target() { - // クラス外定義 `Foo::bar` は、bar を定義識別子に、Foo を実装先に記録する。 let source = "void Foo::bar() { }"; assert_eq!(names_in(source), vec!["bar".to_owned()]); assert_eq!(implements_in(source), vec!["Foo".to_owned()]); @@ -374,7 +373,7 @@ mod tests { #[test] fn templated_out_of_class_definition_records_the_primary_name() { - // FPS パターン (#21)。テンプレート実引数は実装先ではないので mint は拾わない。 + // テンプレート実引数 (mint) は参照であって実装先ではないので拾わない。 let source = "template \n\ void FormalPowerSeries::set_fft() { }"; assert_eq!(implements_in(source), vec!["FormalPowerSeries".to_owned()]); @@ -382,7 +381,7 @@ mod tests { #[test] fn operator_only_file_still_gets_an_implement_target() { - // 演算子だけの実装ファイルは定義識別子が空になるが、実装先は残る。これが #21 の核心。 + // 演算子だけの実装ファイルは定義識別子が空になるため、実装先の記録だけが依存の手がかりになる。 let source = "template \n\ FormalPowerSeries& FormalPowerSeries::operator*=(const FormalPowerSeries& r) { return *this; }"; assert_eq!(names_in(source), Vec::::new()); From e284b8ea8e7556b8567651f61cc3aa6b818a4ccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:23:20 +0900 Subject: [PATCH 05/18] test: cover operator implementation files surviving tree-shaking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宣言と演算子実装が分かれたライブラリの縮小再現。実装消失は undefined reference としてリンクで顕在化するため、リンク・実行まで 通して検証する。修正前のコードで FAILED になることを確認済み。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- tests/cli.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/cli.rs b/tests/cli.rs index 8316e6b..5f47f0e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -263,6 +263,54 @@ fn bundle_no_tree_shaking_keeps_unused_headers() { assert_eq!(compile_and_run(&sandbox, &expanded).trim(), "42"); } +#[test] +fn bundle_keeps_operator_implementation_files() { + // 宣言 (vec2.hpp) と演算子の実装 (vec2-ops.hpp) が分かれたライブラリ。実装側は定義識別子を + // 持たないため、実装先の型名 (implements) の逆引きだけが依存の手がかりになる。 + let sandbox = Sandbox::new(); + sandbox.write( + "mylib/vec2.hpp", + "#pragma once\nstruct Vec2 { int x, y; Vec2 operator+(const Vec2& r) const; };\n", + ); + sandbox.write( + "mylib/vec2-ops.hpp", + "#pragma once\n#include \n\ + Vec2 Vec2::operator+(const Vec2& r) const { return {x + r.x, y + r.y}; }\n", + ); + let unused = sandbox.write( + "mylib/unused.hpp", + "#pragma once\nstruct Unused { int unused_value() const { return 1; } };\n", + ); + let lib_root = unused.parent().unwrap().to_path_buf(); + sandbox + .risundle() + .args(["library", "add", "mylib"]) + .arg(&lib_root) + .assert() + .success(); + + sandbox.write( + "main.cpp", + "#include \n#include \n#include \n#include \n\ + int main() { Vec2 a{1, 2}, b{3, 4}; Vec2 c = a + b; std::printf(\"%d\\n\", c.x + c.y); return 0; }\n", + ); + + let bundled = run_bundle(&sandbox, &["-k", STD]); + + // 演算子の使用 (a + b) は識別子として検出できないが、実装ファイルは残るべき。実装が消えた + // 場合は宣言だけでコンパイルは通り、undefined reference としてリンクで初めて顕在化するため、 + // リンクまで行う compile_and_run で検証する。 + assert!( + bundled.contains("Vec2 Vec2::operator+"), + "実装ファイルが tree-shaking で消えてはいけない" + ); + assert!( + !bundled.contains("struct Unused"), + "実装ファイルの救済で無関係な未使用ヘッダーまで残してはいけない" + ); + assert_eq!(compile_and_run(&sandbox, &bundled).trim(), "10"); +} + #[test] fn bundle_keeps_transitively_required_headers() { let sandbox = Sandbox::new(); From 0268ef4bf626cb0b6cb5cc6ac2c3638207e09a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:28:17 +0900 Subject: [PATCH 06/18] docs(spec): describe implementation-file retention and tags.json v2 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- docs/spec.ja.md | 22 ++++++++++++++++++---- docs/spec.md | 22 ++++++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/docs/spec.ja.md b/docs/spec.ja.md index 925c6b7..526e041 100644 --- a/docs/spec.ja.md +++ b/docs/spec.ja.md @@ -17,7 +17,7 @@ README より踏み込んだ、各コマンドの挙動・エラー条件・出 - `add ` — `` をインクルードパスとして登録する。 - `` が `std` の場合はエラー (標準ライブラリは `add-std` を使う)。 - 既に同じ `` が登録済みの場合はエラー。 - - 登録時に、各ファイルの定義識別子一覧と、`` 以下の内容から計算した集約ハッシュを記録する。 + - 登録時に、各ファイルの定義識別子一覧・実装先の型名一覧と、`` 以下の内容から計算した集約ハッシュを記録する。実装先の型名とは、クラス外の修飾付き定義 (`X<...>::method`) の修飾側や明示的特殊化 (`template <> struct T<...>`) の主テンプレート名で、そのファイルが「どの型の実装か」を表す。 - `add-std []` — 標準ライブラリ (`std`) を登録する。 - `` (省略時 `g++`) のシステム include 探索パスを自動検出する。 - 繰り返し呼ぶと、その都度コンパイラを認識集合に加える (加算式)。集合全コンパイラの探索パスを 1 つに統合するため、複数コンパイラを使い分けられる。 @@ -28,7 +28,7 @@ README より踏み込んだ、各コマンドの挙動・エラー条件・出 - `list` — 登録済みライブラリの ID とインクルードパスを一覧する。 - `show [-v | --verbose] ` — ライブラリの詳細を表示する。未登録の場合はエラー。 - 既定では ID・インクルードパス・種別・定義識別子を持つファイル数を表示する。 - - `-v` 指定時は、集約ハッシュと各ファイルの定義識別子一覧も表示する。 + - `-v` 指定時は、集約ハッシュ、各ファイルの定義識別子一覧、実装先の型名一覧も表示する。 ## バンドル @@ -54,6 +54,16 @@ README より踏み込んだ、各コマンドの挙動・エラー条件・出 tree-shaking がうまくいかないときの一時的なフォールバック用途を想定しており、`.risundlerc.toml` からは設定できない (設定ファイルで常用できるようにすると、CLI から tree-shaking を有効に戻すオプションが対で必要になり、仕様が複雑化するため)。 +### 実装ファイルの維持 + +宣言と実装が別ファイルに分かれたライブラリでは、演算子オーバーロードのように識別子として検出できない依存が生じる (利用側は `f * g` と書くだけで、`operator*=` というトークンは現れない)。このため識別子の逆引きに加えて、次の規則を適用する。 + +> 必要と判定されたファイルが定義する型名を「実装先の型名」として持つファイルも、必要とみなす。 + +新たに必要になったファイルがあれば `-M` の必要集合を取り直し、増えなくなるまで繰り返す (対象は出力に現れたファイルに限られ単調増加のため、必ず停止する)。実装先の照合対象はあくまで出力に現れたファイルであり、include されていない実装ファイルを補うことはしない。 + +実装先を静的に特定できない修飾 (decltype・依存名など) は記録されず、この規則の対象にならない。名前の付いた定義もクラス外実装も持たないファイル (自由関数の演算子だけを定義するファイル等) も対象外で、tree-shaking で消え得る。 + ### 出力形式 - 先頭行に `// Bundled with risundle v` のクレジットを付ける。 @@ -72,12 +82,15 @@ tree-shaking がうまくいかないときの一時的なフォールバック ```json { - "schema_version": 1, + "schema_version": 2, "path": "/usr/local/include", "hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "files": { "atcoder/modint.hpp": ["modint", "modint_base", "modint_common"], "atcoder/segtree.hpp": ["segtree"] + }, + "implements": { + "atcoder/modint_impl.hpp": ["modint"] } } ``` @@ -86,7 +99,7 @@ tree-shaking がうまくいかないときの一時的なフォールバック ```json { - "schema_version": 1, + "schema_version": 2, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/x86_64-linux-gnu-g++-14"] } @@ -97,3 +110,4 @@ tree-shaking がうまくいかないときの一時的なフォールバック - `compilers`: `std` のみ。std を認識させたコンパイラの集合 (実体の絶対パスへ正規化済み)。 - `hash`: `path` 以下の全ファイルの相対パスと内容から計算した集約ハッシュ。`std` では持たない。 - `files`: ライブラリルートからの相対パスをキーとし、そのファイルが定義する識別子名の配列を値とする。`std` では持たない。`std` 以外は識別子が無くても空オブジェクト `{}` を持つため、構造上 `std` と区別できる。 +- `implements`: ライブラリルートからの相対パスをキーとし、そのファイルの実装先の型名の配列を値とする。実装先を持たないファイルはキー自体を持たない。`std` では持たない。読み込み時にキーが無い場合は空として扱う。 diff --git a/docs/spec.md b/docs/spec.md index 14be413..bb22e0e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -17,7 +17,7 @@ This document goes deeper than the README, covering each command's behavior, err - `add ` — Register `` as an include path. - Errors if `` is `std` (use `add-std` for the standard library). - Errors if the same `` is already registered. - - On registration, records the list of defined identifiers for each file and an aggregate hash computed from the contents under ``. + - On registration, records the list of defined identifiers for each file, the list of implementation target type names, and an aggregate hash computed from the contents under ``. An implementation target is the qualifier of an out-of-class qualified definition (`X<...>::method`) or the primary template name of an explicit specialization (`template <> struct T<...>`), expressing "which type this file implements". - `add-std []` — Register the standard library (`std`). - Auto-detects the system include search paths of `` (default `g++`). - Calling it repeatedly adds that compiler to the recognized set each time (additive). It merges the search paths of all compilers in the set into one, so you can switch between multiple compilers. @@ -28,7 +28,7 @@ This document goes deeper than the README, covering each command's behavior, err - `list` — List the IDs and include paths of registered libraries. - `show [-v | --verbose] ` — Show details of a library. Errors if not registered. - By default, shows the ID, include path, kind, and the number of files that have defined identifiers. - - With `-v`, also shows the aggregate hash and the list of defined identifiers per file. + - With `-v`, also shows the aggregate hash, the list of defined identifiers per file, and the implementation target type names. ## Bundling @@ -54,6 +54,16 @@ With `--no-tree-shaking`, identifier detection, dependency-header reverse lookup This option is intended as a temporary fallback for when tree-shaking goes wrong, and it cannot be set from `.risundlerc.toml` (allowing it in the configuration file would require a paired CLI option to turn tree-shaking back on, complicating the specification). +### Keeping implementation files + +In a library that splits declarations and implementations across files, some dependencies cannot be detected as identifiers — operator overloads are the typical case (the user writes `f * g`; the token `operator*=` never appears). In addition to the identifier reverse lookup, the following rule therefore applies. + +> A file whose implementation target names include a type defined by an already-needed file is also considered needed. + +Whenever new files become needed, the `-M` required set is recomputed, repeating until nothing is added (the candidates are limited to files present in the output and grow monotonically, so this always terminates). The matching only considers files present in the output; implementation files that are not included are never pulled in. + +Qualifiers whose target cannot be determined statically (decltype, dependent names, etc.) are not recorded and are outside this rule. Files with neither a named definition nor an out-of-class implementation (e.g. a file defining only free-function operators) are also outside it and may still be dropped by tree-shaking. + ### Output format - The first line carries the credit `// Bundled with risundle v`. @@ -72,12 +82,15 @@ When `` is not `std`: ```json { - "schema_version": 1, + "schema_version": 2, "path": "/usr/local/include", "hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "files": { "atcoder/modint.hpp": ["modint", "modint_base", "modint_common"], "atcoder/segtree.hpp": ["segtree"] + }, + "implements": { + "atcoder/modint_impl.hpp": ["modint"] } } ``` @@ -86,7 +99,7 @@ When `` is `std`: ```json { - "schema_version": 1, + "schema_version": 2, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/x86_64-linux-gnu-g++-14"] } @@ -97,3 +110,4 @@ When `` is `std`: - `compilers`: `std` only. The set of compilers that `std` was registered with (normalized to the absolute paths of the actual binaries). - `hash`: An aggregate hash computed from the relative paths and contents of all files under `path`. Not present for `std`. - `files`: Keys are paths relative to the library root, and values are the arrays of identifier names that the file defines. Not present for `std`. Non-`std` libraries always carry an empty object `{}` even when there are no identifiers, so they are structurally distinguishable from `std`. +- `implements`: Keys are paths relative to the library root, and values are the arrays of implementation target type names of the file. Files without implementation targets have no key. Not present for `std`. Treated as empty when the key is missing on load. From 0dbab704ec81e31e1bc279a452bcb2c79476c19e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:29:41 +0900 Subject: [PATCH 07/18] fix(library): let update recover from older tags.json schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update は登録内容を丸ごと作り直すため、tags.json からは登録パス (std ならコンパイラ集合) しか使わない。これらは全スキーマバージョンに 存在するので、スキーマ検証をせずに読み出す MigrationSource を導入し、 「スキーマ不一致は update で再生成」というエラー案内が実際に機能する ようにする。旧実装では update 自体が同じエラーで倒れ、案内が詰みに なっていた。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/commands/library.rs | 42 +++++++++++++++++++++++++++++++++++------ src/library/tags.rs | 28 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/commands/library.rs b/src/commands/library.rs index db0e3d7..99d31b9 100644 --- a/src/commands/library.rs +++ b/src/commands/library.rs @@ -7,7 +7,7 @@ use crate::cli::LibraryCommand; use crate::commands::compiler::resolve as resolve_compiler; use crate::config::Config; use crate::library::local::LocalStore; -use crate::library::tags::{Tags, TagsKind}; +use crate::library::tags::{MigrationSource, Tags, TagsKind}; use crate::library::{dummy, hash, identifiers}; /// `std` として扱うライブラリ ID。識別子情報を持たず、更新検知の対象外とする。 @@ -295,11 +295,14 @@ fn update(store: &LocalStore, id: Option<&str>, path: Option<&Path>) -> Result<( fn update_one(store: &LocalStore, id: &str, path: Option<&Path>) -> Result<()> { validate_id(id)?; ensure_registered(store, id)?; - let tags = Tags::load(&store.tags_json(id))?; + // update は登録内容を丸ごと作り直すため、tags.json からは登録パス (std ならコンパイラ集合) しか + // 使わない。旧スキーマでもそれらは読めるので、スキーマ検証をせずに読み出し、update 自身が + // 「スキーマ不一致は update で再生成」というエラー案内の受け皿になれるようにする。 + let source = MigrationSource::load(&store.tags_json(id))?; eprintln!("updating library `{id}`..."); - match tags.kind { - TagsKind::Std { compilers } => { + match source.compilers { + Some(compilers) => { if path.is_some() { bail!( "a path cannot be specified for the standard library (it is auto-detected from the compiler)" @@ -308,10 +311,10 @@ fn update_one(store: &LocalStore, id: &str, path: Option<&Path>) -> Result<()> { let discovered = discover_all(&compilers)?; register_std(store, &discovered)?; } - TagsKind::Library { .. } => { + None => { let source_root = match path { Some(path) => resolve_source_root(path)?, - None => tags.path, + None => source.path, }; register_library(store, id, &source_root)?; } @@ -578,6 +581,33 @@ mod tests { } } + #[test] + fn update_migrates_tags_from_an_older_schema() { + // 「スキーマ不一致は update で再生成」というエラー案内の受け皿として、update 自身は + // 旧スキーマの tags.json からでも登録パスを読み出して再登録できなければならない。 + let local = TempDir::new().unwrap(); + let store = store_in(&local); + let source = source_with(&[("vec.hpp", "struct Vec {};")]); + add(&store, "mylib", source.path()).unwrap(); + + let tags_path = store.tags_json("mylib"); + let old_json = fs::read_to_string(&tags_path) + .unwrap() + .replace("\"schema_version\": 2", "\"schema_version\": 1"); + fs::write(&tags_path, old_json).unwrap(); + assert!( + Tags::load(&tags_path).is_err(), + "旧スキーマは通常読み込みでは弾かれる前提" + ); + + update(&store, Some("mylib"), None).unwrap(); + + assert!( + Tags::load(&tags_path).is_ok(), + "update 後は現行スキーマで読めるべき" + ); + } + #[test] fn update_with_new_path_reregisters_from_it() { let local = TempDir::new().unwrap(); diff --git a/src/library/tags.rs b/src/library/tags.rs index dd469cc..e378ddb 100644 --- a/src/library/tags.rs +++ b/src/library/tags.rs @@ -60,6 +60,34 @@ impl Tags { } } +/// 旧スキーマの `tags.json` から、再登録に必要な情報だけを取り出したもの。 +/// +/// `library update` がスキーマ不一致から自己復旧するために使う。`path` と (std なら) `compilers` は +/// 全スキーマバージョンに存在するため、それ以外のフィールドは検証せず読み飛ばす。 +pub struct MigrationSource { + pub path: PathBuf, + /// `std` の登録なら `Some` (認識コンパイラ集合)、通常ライブラリなら `None`。 + pub compilers: Option>, +} + +impl MigrationSource { + pub fn load(path: &Path) -> Result { + #[derive(Deserialize)] + struct Probe { + path: PathBuf, + compilers: Option>, + } + let json = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let probe: Probe = serde_json::from_str(&json) + .with_context(|| format!("failed to parse {}", path.display()))?; + Ok(Self { + path: probe.path, + compilers: probe.compilers, + }) + } +} + /// `tags.json` の生のシリアライズ表現。`std` は `compiler` のみ、通常ライブラリは `hash`・`files` を /// 持つ。種別ごとに排他なので、どちらの組が揃っているかで [`TagsKind`] を復元する。 #[derive(Debug, Serialize, Deserialize)] From ac124b43c605454c5c68f0226fa3782ec66a421c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:10:21 +0900 Subject: [PATCH 08/18] fix(library): let add-std recover from mismatched tags.json schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add-std は登録を作り直すため、既存 std の tags.json からは認識コンパイラ 集合しか使わない。update と同様に MigrationSource で検証せずに読み、 スキーマの合わない登録があっても集合を引き継いで再登録できるようにする。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/commands/library.rs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/commands/library.rs b/src/commands/library.rs index 99d31b9..ab25e7e 100644 --- a/src/commands/library.rs +++ b/src/commands/library.rs @@ -109,10 +109,11 @@ fn existing_std_compilers(store: &LocalStore) -> Result> { if !store.is_registered(STD_ID) { return Ok(Vec::new()); } - match Tags::load(&store.tags_json(STD_ID))?.kind { - TagsKind::Std { compilers } => Ok(compilers), - TagsKind::Library { .. } => Ok(Vec::new()), - } + // add-std は登録を作り直すため、既存の tags.json からは認識コンパイラ集合しか使わない。 + // update と同じく、スキーマの合わない登録からでも集合を引き継げるよう検証せずに読む。 + Ok(MigrationSource::load(&store.tags_json(STD_ID))? + .compilers + .unwrap_or_default()) } fn discover_all(compilers: &[PathBuf]) -> Result)>> { @@ -608,6 +609,26 @@ mod tests { ); } + #[test] + fn add_std_keeps_the_compiler_set_from_an_older_schema() { + let local = TempDir::new().unwrap(); + let store = store_in(&local); + let Ok(g) = resolve_compiler(Path::new("g++")) else { + return; // g++ が無い環境ではスキップ + }; + add_std(&store, Some(&g)).unwrap(); + + let tags_path = store.tags_json("std"); + let old_json = fs::read_to_string(&tags_path) + .unwrap() + .replace("\"schema_version\": 2", "\"schema_version\": 1"); + fs::write(&tags_path, old_json).unwrap(); + + // スキーマの合わない登録があっても、add-std は集合を引き継いで作り直せる。 + add_std(&store, Some(&g)).unwrap(); + assert!(Tags::load(&tags_path).is_ok()); + } + #[test] fn update_with_new_path_reregisters_from_it() { let local = TempDir::new().unwrap(); From 09fc322f16c09b2875b5f8d4befc58f25a57ba6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:53:23 +0900 Subject: [PATCH 09/18] refactor(tags): make schema mismatch a typed error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schema_version 不一致を anyhow の文字列エラーから SchemaMismatch 型へ 変更し、呼び出し側が downcast で判別できるようにする。tags.json は ライブラリ実体から再生成できるキャッシュなので、この判別を足がかりに 各コマンドが自動移行や寛容表示で回復できるようにするための土台。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/library/tags.rs | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/library/tags.rs b/src/library/tags.rs index e378ddb..7176c22 100644 --- a/src/library/tags.rs +++ b/src/library/tags.rs @@ -9,6 +9,28 @@ use serde::{Deserialize, Serialize}; const CURRENT_SCHEMA_VERSION: u32 = 2; +/// `tags.json` の schema_version が現行と異なることを表すエラー。 +/// +/// tags.json はライブラリ実体から再生成できるキャッシュであり、形式の不一致は risundle 側の都合 +/// なので、呼び出し側はこのエラーを検知したら自動再登録で回復してよい (バンドル前の自動移行や +/// `list`/`show` の寛容表示が [`anyhow::Error::downcast_ref`] で判別する)。 +#[derive(Debug, PartialEq, Eq)] +pub struct SchemaMismatch { + pub found: u32, +} + +impl std::fmt::Display for SchemaMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "tags.json schema_version {} is not supported (supported: {CURRENT_SCHEMA_VERSION}); regenerate it with `risundle library update`", + self.found + ) + } +} + +impl std::error::Error for SchemaMismatch {} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Tags { pub path: PathBuf, @@ -110,11 +132,10 @@ impl TryFrom for Tags { fn try_from(raw: RawTags) -> Result { if raw.schema_version != CURRENT_SCHEMA_VERSION { - bail!( - "tags.json schema_version {} is not supported (supported: {}); regenerate it with `risundle library update`", - raw.schema_version, - CURRENT_SCHEMA_VERSION - ); + return Err(SchemaMismatch { + found: raw.schema_version, + } + .into()); } let kind = match (raw.compilers, raw.hash, raw.files) { (Some(compilers), None, None) if raw.implements.is_none() => { @@ -264,11 +285,14 @@ mod tests { } #[test] - fn unsupported_schema_version_is_rejected() { - // v1 (implements 導入前) は再登録を促すエラーになる。 + fn unsupported_schema_version_is_reported_as_schema_mismatch() { + // スキーマ不一致は SchemaMismatch として判別でき、呼び出し側が自動再登録で回復できる。 let json = r#"{ "schema_version": 1, "path": "/usr/include/c++/12", "compilers": ["/usr/bin/g++"] }"#; let error = Tags::from_json(json).unwrap_err(); - assert!(error.to_string().contains("schema_version")); + assert_eq!( + error.downcast_ref::(), + Some(&SchemaMismatch { found: 1 }) + ); assert!(error.to_string().contains("library update")); } From ae857ef5533b0b7e8ee2761165c82ef7981ec4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:00:20 +0900 Subject: [PATCH 10/18] feat(library): auto-migrate outdated tags.json before bundling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tags.json はライブラリ実体から再生成できるキャッシュであり、スキーマ 形式の変化は risundle 側の都合なので、バンドル前に schema_version が 現行と合わない登録をライブラリ実体から黙って作り直す (std の初回自動 登録と同じ発想)。ユーザーに update を要求せず、アップグレード後の初回 バンドルだけ数秒伸びる形にする。 update_one から再登録の中核を reregister として切り出し、auto_migrate と 共有する。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/commands/bundle.rs | 1 + src/commands/library.rs | 90 +++++++++++++++++++++++++++++------------ 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/commands/bundle.rs b/src/commands/bundle.rs index 8ea8333..bcf08eb 100644 --- a/src/commands/bundle.rs +++ b/src/commands/bundle.rs @@ -33,6 +33,7 @@ pub fn run(args: BundleArgs) -> Result<()> { if let Err(err) = cmd_library::auto_setup_std(&store) { eprintln!("warning: failed to auto-register the standard library: {err:#}"); } + cmd_library::auto_migrate(&store)?; let settings = Settings::resolve(&args)?; let inventory = Inventory::load(&store, &settings.keep)?; diff --git a/src/commands/library.rs b/src/commands/library.rs index ab25e7e..48f3fe7 100644 --- a/src/commands/library.rs +++ b/src/commands/library.rs @@ -7,7 +7,7 @@ use crate::cli::LibraryCommand; use crate::commands::compiler::resolve as resolve_compiler; use crate::config::Config; use crate::library::local::LocalStore; -use crate::library::tags::{MigrationSource, Tags, TagsKind}; +use crate::library::tags::{MigrationSource, SchemaMismatch, Tags, TagsKind}; use crate::library::{dummy, hash, identifiers}; /// `std` として扱うライブラリ ID。識別子情報を持たず、更新検知の対象外とする。 @@ -296,12 +296,36 @@ fn update(store: &LocalStore, id: Option<&str>, path: Option<&Path>) -> Result<( fn update_one(store: &LocalStore, id: &str, path: Option<&Path>) -> Result<()> { validate_id(id)?; ensure_registered(store, id)?; - // update は登録内容を丸ごと作り直すため、tags.json からは登録パス (std ならコンパイラ集合) しか - // 使わない。旧スキーマでもそれらは読めるので、スキーマ検証をせずに読み出し、update 自身が - // 「スキーマ不一致は update で再生成」というエラー案内の受け皿になれるようにする。 - let source = MigrationSource::load(&store.tags_json(id))?; - eprintln!("updating library `{id}`..."); + reregister(store, id, path)?; + println!("updated library `{id}`"); + Ok(()) +} + +/// バンドル前に呼ぶ。schema_version が現行と合わない登録を、ライブラリ実体から黙って作り直す。 +/// +/// tags.json はライブラリ実体から再生成できるキャッシュであり、形式の不一致は risundle 側の都合 +/// なので、ユーザーに `update` を要求せず自動で回復する (`std` の初回自動登録と同じ発想)。 +/// 現行スキーマで読めるものは触らない。再生成に失敗した場合はエラーを返し、呼び出し側が案内する。 +pub fn auto_migrate(store: &LocalStore) -> Result<()> { + for id in store.library_ids()? { + match Tags::load(&store.tags_json(&id)) { + Ok(_) => continue, + Err(err) if err.downcast_ref::().is_none() => return Err(err), + Err(_) => {} + } + eprintln!("migrating library `{id}` to the current tags format..."); + reregister(store, &id, None)?; + } + Ok(()) +} + +/// tags.json から登録パス (std ならコンパイラ集合) だけを読み出し、ライブラリ実体から登録を作り直す。 +/// +/// スキーマ検証をしないため、旧スキーマの登録からでも回復できる。作り直しに使うのは登録パスと +/// コンパイラ集合のみで、これらは全スキーマバージョンに共通して存在する。 +fn reregister(store: &LocalStore, id: &str, path: Option<&Path>) -> Result<()> { + let source = MigrationSource::load(&store.tags_json(id))?; match source.compilers { Some(compilers) => { if path.is_some() { @@ -320,8 +344,6 @@ fn update_one(store: &LocalStore, id: &str, path: Option<&Path>) -> Result<()> { register_library(store, id, &source_root)?; } } - - println!("updated library `{id}`"); Ok(()) } @@ -415,6 +437,19 @@ mod tests { LocalStore::with_root(local.path()) } + /// 登録済みライブラリの tags.json を、現行と異なる schema_version に書き換える (移行の検証用)。 + fn downgrade_schema(store: &LocalStore, id: &str) { + let tags_path = store.tags_json(id); + let old = fs::read_to_string(&tags_path) + .unwrap() + .replace("\"schema_version\": 2", "\"schema_version\": 1"); + fs::write(&tags_path, old).unwrap(); + assert!( + Tags::load(&tags_path).is_err(), + "旧スキーマは通常読み込みでは弾かれる前提" + ); + } + #[test] fn registers_non_std_library_with_files_dummy_and_hash() { let local = TempDir::new().unwrap(); @@ -590,23 +625,33 @@ mod tests { let store = store_in(&local); let source = source_with(&[("vec.hpp", "struct Vec {};")]); add(&store, "mylib", source.path()).unwrap(); + downgrade_schema(&store, "mylib"); + + update(&store, Some("mylib"), None).unwrap(); - let tags_path = store.tags_json("mylib"); - let old_json = fs::read_to_string(&tags_path) - .unwrap() - .replace("\"schema_version\": 2", "\"schema_version\": 1"); - fs::write(&tags_path, old_json).unwrap(); assert!( - Tags::load(&tags_path).is_err(), - "旧スキーマは通常読み込みでは弾かれる前提" + Tags::load(&store.tags_json("mylib")).is_ok(), + "update 後は現行スキーマで読めるべき" ); + } - update(&store, Some("mylib"), None).unwrap(); + #[test] + fn auto_migrate_regenerates_outdated_libraries_and_leaves_current_ones() { + let local = TempDir::new().unwrap(); + let store = store_in(&local); + let source = source_with(&[("vec.hpp", "struct Vec {};")]); + add(&store, "mylib", source.path()).unwrap(); + downgrade_schema(&store, "mylib"); + auto_migrate(&store).unwrap(); assert!( - Tags::load(&tags_path).is_ok(), - "update 後は現行スキーマで読めるべき" + Tags::load(&store.tags_json("mylib")).is_ok(), + "古い登録は移行されるべき" ); + + // 既に現行スキーマなら再度呼んでも成功し、読める状態を保つ。 + auto_migrate(&store).unwrap(); + assert!(Tags::load(&store.tags_json("mylib")).is_ok()); } #[test] @@ -617,16 +662,11 @@ mod tests { return; // g++ が無い環境ではスキップ }; add_std(&store, Some(&g)).unwrap(); - - let tags_path = store.tags_json("std"); - let old_json = fs::read_to_string(&tags_path) - .unwrap() - .replace("\"schema_version\": 2", "\"schema_version\": 1"); - fs::write(&tags_path, old_json).unwrap(); + downgrade_schema(&store, "std"); // スキーマの合わない登録があっても、add-std は集合を引き継いで作り直せる。 add_std(&store, Some(&g)).unwrap(); - assert!(Tags::load(&tags_path).is_ok()); + assert!(Tags::load(&store.tags_json("std")).is_ok()); } #[test] From a31471048f451aeeed61c69604b21b2e9504d1a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:44:50 +0900 Subject: [PATCH 11/18] feat(library): tolerate outdated schema in list and show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list と show は読み取りコマンドなので、スキーマ不一致でもエラーにせず、 状態も書き換えない (バンドル時の自動移行に任せる)。list は全バージョン 共通の ID・種別・パスだけを MigrationSource から読む。show は詳細を 読める場合はそのまま出し、読めない場合は基本情報と update 案内を出す。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/commands/library.rs | 55 +++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/src/commands/library.rs b/src/commands/library.rs index 48f3fe7..813a8ab 100644 --- a/src/commands/library.rs +++ b/src/commands/library.rs @@ -353,18 +353,21 @@ fn list(store: &LocalStore) -> Result<()> { println!("no libraries are registered"); return Ok(()); } + // ID・種別・パスしか出さず、いずれも全スキーマバージョンに共通のため、スキーマ検証をしない + // MigrationSource で読む。一覧はアップグレード直後でもエラーにせず動くべき (バンドル時に自動移行)。 // 種別を足しつつタブ区切りを保ち、grep/awk などでのパイプ処理を妨げない。 for id in ids { - let tags = Tags::load(&store.tags_json(&id))?; - println!("{id}\t{}\t{}", kind_label(&tags.kind), tags.path.display()); + let source = MigrationSource::load(&store.tags_json(&id))?; + println!("{id}\t{}\t{}", kind_label(&source), source.path.display()); } Ok(()) } -fn kind_label(kind: &TagsKind) -> &'static str { - match kind { - TagsKind::Std { .. } => "std", - TagsKind::Library { .. } => "library", +fn kind_label(source: &MigrationSource) -> &'static str { + if source.compilers.is_some() { + "std" + } else { + "library" } } @@ -376,7 +379,28 @@ fn show_field(label: &str, value: &str) { fn show(store: &LocalStore, id: &str, verbose: bool) -> Result<()> { validate_id(id)?; ensure_registered(store, id)?; - let tags = Tags::load(&store.tags_json(id))?; + match Tags::load(&store.tags_json(id)) { + Ok(tags) => show_tags(id, &tags, verbose), + // 詳細 (定義識別子・ハッシュ) は現行スキーマでないと読めない。読み取りコマンドが状態を + // 書き換えるのは避けたいので、自動移行はせず、読める基本情報だけ出して update を案内する。 + Err(err) => match err.downcast_ref::() { + Some(mismatch) => show_outdated(store, id, &mismatch.to_string()), + None => Err(err), + }, + } +} + +/// スキーマが古く詳細を読めない登録について、`MigrationSource` から読める基本情報だけ表示する。 +fn show_outdated(store: &LocalStore, id: &str, reason: &str) -> Result<()> { + let source = MigrationSource::load(&store.tags_json(id))?; + show_field("ID", id); + show_field("Path", &source.path.display().to_string()); + show_field("Kind", kind_label(&source)); + show_field("Details", &format!("unavailable ({reason})")); + Ok(()) +} + +fn show_tags(id: &str, tags: &Tags, verbose: bool) -> Result<()> { show_field("ID", id); show_field("Path", &tags.path.display().to_string()); match &tags.kind { @@ -654,6 +678,23 @@ mod tests { assert!(Tags::load(&store.tags_json("mylib")).is_ok()); } + #[test] + fn list_and_show_tolerate_outdated_schema() { + let local = TempDir::new().unwrap(); + let store = store_in(&local); + let source = source_with(&[("vec.hpp", "struct Vec {};")]); + add(&store, "mylib", source.path()).unwrap(); + downgrade_schema(&store, "mylib"); + + // 読み取りコマンドはスキーマ不一致でもエラーにせず、移行もしない (状態は古いまま)。 + list(&store).unwrap(); + show(&store, "mylib", true).unwrap(); + assert!( + Tags::load(&store.tags_json("mylib")).is_err(), + "読み取りコマンドは登録を書き換えない" + ); + } + #[test] fn add_std_keeps_the_compiler_set_from_an_older_schema() { let local = TempDir::new().unwrap(); From eedc0c5be5a6d83834876d7e4d99e01ea022f007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:46:22 +0900 Subject: [PATCH 12/18] docs(spec): describe automatic tags.json schema migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schema_version 不一致時の各コマンドの挙動 (バンドル時の自動移行、 update/add-std の回復、list/show の寛容読み) を tags.json 節に追記。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- docs/spec.ja.md | 2 +- docs/spec.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/spec.ja.md b/docs/spec.ja.md index 526e041..9027b68 100644 --- a/docs/spec.ja.md +++ b/docs/spec.ja.md @@ -105,7 +105,7 @@ tree-shaking がうまくいかないときの一時的なフォールバック } ``` -- `schema_version`: スキーマの互換性チェック用の整数。未知の値の場合は再生成を促すエラーを出す。 +- `schema_version`: スキーマの互換性チェック用の整数。現行と異なる値の登録は、バンドル時にライブラリ実体から自動で再生成する (キャッシュの形式差は risundle 側の都合であり、ユーザー操作を要求しない)。`update`・`add-std` も同じく登録パス (`std` ならコンパイラ集合) だけを読んで作り直すため、スキーマが合わなくても回復できる。`list`・`show` は読み取りに徹し、再生成せず読める情報だけを出す (`show` は詳細の代わりに再生成の案内を出す)。 - `path`: インクルードパス (絶対パス)。`std` では検出したシステム include パスのうち代表 (最初のコンパイラの C++ 標準ライブラリ dir) を表示用に記録する。 - `compilers`: `std` のみ。std を認識させたコンパイラの集合 (実体の絶対パスへ正規化済み)。 - `hash`: `path` 以下の全ファイルの相対パスと内容から計算した集約ハッシュ。`std` では持たない。 diff --git a/docs/spec.md b/docs/spec.md index bb22e0e..d1c2954 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -105,7 +105,7 @@ When `` is `std`: } ``` -- `schema_version`: An integer for schema compatibility checks. On an unknown value, it raises an error prompting regeneration. +- `schema_version`: An integer for schema compatibility checks. Registrations whose value differs from the current one are automatically regenerated from the library sources at bundle time (a cache format difference is risundle's own concern and requires no user action). `update` and `add-std` likewise read only the registered path (or the compiler set for `std`) and rebuild, so they recover even from a mismatched schema. `list` and `show` stay read-only and never regenerate, printing what they can read (`show` prints regeneration guidance in place of the details). - `path`: The include path (absolute). For `std`, it records a representative one among the detected system include paths (the C++ standard library dir of the first compiler) for display. - `compilers`: `std` only. The set of compilers that `std` was registered with (normalized to the absolute paths of the actual binaries). - `hash`: An aggregate hash computed from the relative paths and contents of all files under `path`. Not present for `std`. From a73343003574338c21a2464550800bb468311e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:50:47 +0900 Subject: [PATCH 13/18] refactor(library): clarify migration paths and cover error propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MigrationSource の doc を実際の利用箇所 (update/add-std/自動移行/list/show) に合わせて更新 (旧: library update のみと記載) - auto_migrate の分岐を「現行/旧スキーマ/破損」の 3 分岐へ整理しコメント付与 - auto_migrate が schema 不一致以外のエラーを伝播することのテストを追加 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/commands/library.rs | 18 +++++++++++++++--- src/library/tags.rs | 7 ++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/commands/library.rs b/src/commands/library.rs index 813a8ab..2883503 100644 --- a/src/commands/library.rs +++ b/src/commands/library.rs @@ -310,9 +310,9 @@ fn update_one(store: &LocalStore, id: &str, path: Option<&Path>) -> Result<()> { pub fn auto_migrate(store: &LocalStore) -> Result<()> { for id in store.library_ids()? { match Tags::load(&store.tags_json(&id)) { - Ok(_) => continue, - Err(err) if err.downcast_ref::().is_none() => return Err(err), - Err(_) => {} + Ok(_) => continue, // 現行スキーマ: 触らない + Err(err) if err.downcast_ref::().is_some() => {} // 旧スキーマ: 下で作り直す + Err(err) => return Err(err), // 破損など: 呼び出し側へ委ねる } eprintln!("migrating library `{id}` to the current tags format..."); reregister(store, &id, None)?; @@ -678,6 +678,18 @@ mod tests { assert!(Tags::load(&store.tags_json("mylib")).is_ok()); } + #[test] + fn auto_migrate_propagates_non_schema_errors() { + // スキーマ不一致 (回復可) と、破損など回復不能なエラーは区別する。後者は握り潰さず伝播する。 + let local = TempDir::new().unwrap(); + let store = store_in(&local); + let source = source_with(&[("vec.hpp", "struct Vec {};")]); + add(&store, "mylib", source.path()).unwrap(); + std::fs::write(store.tags_json("mylib"), "{ not valid json").unwrap(); + + assert!(auto_migrate(&store).is_err()); + } + #[test] fn list_and_show_tolerate_outdated_schema() { let local = TempDir::new().unwrap(); diff --git a/src/library/tags.rs b/src/library/tags.rs index 7176c22..d2388fb 100644 --- a/src/library/tags.rs +++ b/src/library/tags.rs @@ -82,10 +82,11 @@ impl Tags { } } -/// 旧スキーマの `tags.json` から、再登録に必要な情報だけを取り出したもの。 +/// `tags.json` から、登録の作り直しに必要な情報だけをスキーマ検証せず取り出したもの。 /// -/// `library update` がスキーマ不一致から自己復旧するために使う。`path` と (std なら) `compilers` は -/// 全スキーマバージョンに存在するため、それ以外のフィールドは検証せず読み飛ばす。 +/// `path` と (std なら) `compilers` は全スキーマバージョンに存在するため、それ以外のフィールドは +/// 読み飛ばす。これにより、登録を作り直す・種別とパスだけ読む処理 (`update`・`add-std`・バンドル時の +/// 自動移行・`list`・`show` の寛容表示) が、スキーマ不一致の登録からでも動ける。 pub struct MigrationSource { pub path: PathBuf, /// `std` の登録なら `Some` (認識コンパイラ集合)、通常ライブラリなら `None`。 From ebf6ba0329441188d538108621e4bd80c999f03f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:05:53 +0900 Subject: [PATCH 14/18] refactor(library): promote MigrationSource to Registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit スキーマ耐性の方針 (移行/救出再登録/寛容読み) が各コマンドに散っていた 問題に対し、共通の土台概念を明確にする。MigrationSource を 「スキーマ非依存の登録の中核」を表す Registration へ改名し、種別判定 (compilers の有無) を Registration::kind_label に一元化。commands 側で 重複していた kind_label 自由関数を廃止する。動作は不変。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/commands/library.rs | 38 +++++++++++++++----------------------- src/library/tags.rs | 22 ++++++++++++++++------ 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/commands/library.rs b/src/commands/library.rs index 2883503..56e2a98 100644 --- a/src/commands/library.rs +++ b/src/commands/library.rs @@ -7,7 +7,7 @@ use crate::cli::LibraryCommand; use crate::commands::compiler::resolve as resolve_compiler; use crate::config::Config; use crate::library::local::LocalStore; -use crate::library::tags::{MigrationSource, SchemaMismatch, Tags, TagsKind}; +use crate::library::tags::{Registration, SchemaMismatch, Tags, TagsKind}; use crate::library::{dummy, hash, identifiers}; /// `std` として扱うライブラリ ID。識別子情報を持たず、更新検知の対象外とする。 @@ -111,7 +111,7 @@ fn existing_std_compilers(store: &LocalStore) -> Result> { } // add-std は登録を作り直すため、既存の tags.json からは認識コンパイラ集合しか使わない。 // update と同じく、スキーマの合わない登録からでも集合を引き継げるよう検証せずに読む。 - Ok(MigrationSource::load(&store.tags_json(STD_ID))? + Ok(Registration::load(&store.tags_json(STD_ID))? .compilers .unwrap_or_default()) } @@ -320,13 +320,13 @@ pub fn auto_migrate(store: &LocalStore) -> Result<()> { Ok(()) } -/// tags.json から登録パス (std ならコンパイラ集合) だけを読み出し、ライブラリ実体から登録を作り直す。 +/// 既存の登録の中核 (`Registration`) からライブラリ実体を再走査し、登録を作り直す。 /// -/// スキーマ検証をしないため、旧スキーマの登録からでも回復できる。作り直しに使うのは登録パスと -/// コンパイラ集合のみで、これらは全スキーマバージョンに共通して存在する。 +/// [`Registration`] はスキーマ検証をせず読むため、旧スキーマの登録からでも回復できる。std は +/// コンパイラ集合を、通常ライブラリは登録パス (または明示された `path`) を種にして作り直す。 fn reregister(store: &LocalStore, id: &str, path: Option<&Path>) -> Result<()> { - let source = MigrationSource::load(&store.tags_json(id))?; - match source.compilers { + let reg = Registration::load(&store.tags_json(id))?; + match reg.compilers { Some(compilers) => { if path.is_some() { bail!( @@ -339,7 +339,7 @@ fn reregister(store: &LocalStore, id: &str, path: Option<&Path>) -> Result<()> { None => { let source_root = match path { Some(path) => resolve_source_root(path)?, - None => source.path, + None => reg.path, }; register_library(store, id, &source_root)?; } @@ -354,23 +354,15 @@ fn list(store: &LocalStore) -> Result<()> { return Ok(()); } // ID・種別・パスしか出さず、いずれも全スキーマバージョンに共通のため、スキーマ検証をしない - // MigrationSource で読む。一覧はアップグレード直後でもエラーにせず動くべき (バンドル時に自動移行)。 + // Registration で読む。一覧はアップグレード直後でもエラーにせず動くべき (バンドル時に自動移行)。 // 種別を足しつつタブ区切りを保ち、grep/awk などでのパイプ処理を妨げない。 for id in ids { - let source = MigrationSource::load(&store.tags_json(&id))?; - println!("{id}\t{}\t{}", kind_label(&source), source.path.display()); + let reg = Registration::load(&store.tags_json(&id))?; + println!("{id}\t{}\t{}", reg.kind_label(), reg.path.display()); } Ok(()) } -fn kind_label(source: &MigrationSource) -> &'static str { - if source.compilers.is_some() { - "std" - } else { - "library" - } -} - /// `show` の 1 項目を、ラベル幅を揃えて出力する。最長ラベル `Compilers` に合わせる。 fn show_field(label: &str, value: &str) { println!("{label:<9} {value}"); @@ -390,12 +382,12 @@ fn show(store: &LocalStore, id: &str, verbose: bool) -> Result<()> { } } -/// スキーマが古く詳細を読めない登録について、`MigrationSource` から読める基本情報だけ表示する。 +/// スキーマが古く詳細を読めない登録について、`Registration` から読める基本情報だけ表示する。 fn show_outdated(store: &LocalStore, id: &str, reason: &str) -> Result<()> { - let source = MigrationSource::load(&store.tags_json(id))?; + let reg = Registration::load(&store.tags_json(id))?; show_field("ID", id); - show_field("Path", &source.path.display().to_string()); - show_field("Kind", kind_label(&source)); + show_field("Path", ®.path.display().to_string()); + show_field("Kind", reg.kind_label()); show_field("Details", &format!("unavailable ({reason})")); Ok(()) } diff --git a/src/library/tags.rs b/src/library/tags.rs index d2388fb..0b44abe 100644 --- a/src/library/tags.rs +++ b/src/library/tags.rs @@ -82,18 +82,19 @@ impl Tags { } } -/// `tags.json` から、登録の作り直しに必要な情報だけをスキーマ検証せず取り出したもの。 +/// スキーマに依存しない登録の中核。`path` と (std なら) `compilers` だけを、スキーマ検証をせずに +/// 読み出す。 /// -/// `path` と (std なら) `compilers` は全スキーマバージョンに存在するため、それ以外のフィールドは -/// 読み飛ばす。これにより、登録を作り直す・種別とパスだけ読む処理 (`update`・`add-std`・バンドル時の -/// 自動移行・`list`・`show` の寛容表示) が、スキーマ不一致の登録からでも動ける。 -pub struct MigrationSource { +/// これらは全スキーマバージョンに共通して存在するため、[`Tags`] が読めない (スキーマ不一致の) 登録 +/// からでも取り出せる。登録の作り直し・種別とパスの表示 (`update`・`add-std`・バンドル時の自動移行・ +/// `list`・`show` の寛容表示) は、この中核だけで足りる。 +pub struct Registration { pub path: PathBuf, /// `std` の登録なら `Some` (認識コンパイラ集合)、通常ライブラリなら `None`。 pub compilers: Option>, } -impl MigrationSource { +impl Registration { pub fn load(path: &Path) -> Result { #[derive(Deserialize)] struct Probe { @@ -109,6 +110,15 @@ impl MigrationSource { compilers: probe.compilers, }) } + + /// 種別ラベル。`compilers` の有無が `std` / 通常ライブラリを一意に決める。 + pub fn kind_label(&self) -> &'static str { + if self.compilers.is_some() { + "std" + } else { + "library" + } + } } /// `tags.json` の生のシリアライズ表現。`std` は `compiler` のみ、通常ライブラリは `hash`・`files` を From e476c6ff37c1884c68f2425aa332fc212b0b5205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:05:53 +0900 Subject: [PATCH 15/18] refactor(inventory): return a FileTags record instead of a 3-tuple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tags_entry が (files, implements, key) の 3-tuple を返し #[allow(clippy::type_complexity)] を要していた点を、per-file の FileTags { defines, implements } を返す file_tags に置き換える。 定義/実装先を持たないファイルは空スライスになり (従来の None と同義)、 defined_identifiers/implement_targets の 2 ヘルパを畳んで呼び出しを直截にする。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/bundle/inventory.rs | 55 +++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/src/bundle/inventory.rs b/src/bundle/inventory.rs index df090e7..9ece3ef 100644 --- a/src/bundle/inventory.rs +++ b/src/bundle/inventory.rs @@ -5,7 +5,7 @@ //! 「維持指定された (tree-shaking 対象外の) ライブラリと `std` は識別子情報を使わない」という仕様の //! 区別を、各メソッドで一貫して適用する。 -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; @@ -28,6 +28,12 @@ struct Library { kind: TagsKind, } +/// 1 ファイルの `tags.json` レコード。定義識別子と実装先の型名を対で保持する。 +struct FileTags<'a> { + defines: &'a [String], + implements: &'a [String], +} + pub struct Inventory { libraries: Vec, } @@ -117,8 +123,8 @@ impl Inventory { present .iter() .filter(|path| { - self.defined_identifiers(path) - .is_some_and(|names| names.iter().any(|name| used.contains(name))) + self.file_tags(path) + .is_some_and(|tags| tags.defines.iter().any(|name| used.contains(name))) }) .cloned() .collect() @@ -136,44 +142,24 @@ impl Inventory { ) -> BTreeSet { let needed_names: BTreeSet<&String> = needed .iter() - .filter_map(|path| self.defined_identifiers(path)) - .flatten() + .filter_map(|path| self.file_tags(path)) + .flat_map(|tags| tags.defines) .collect(); present .iter() .filter(|path| { - self.implement_targets(path) - .is_some_and(|targets| targets.iter().any(|t| needed_names.contains(t))) + self.file_tags(path) + .is_some_and(|tags| tags.implements.iter().any(|t| needed_names.contains(t))) }) .cloned() .collect() } - /// realpath 済みパスが属する維持指定外ライブラリを特定し、そのファイルが `tags.json` で定義する - /// 識別子一覧を返す。定義を持たないファイルや対象外パスは `None`。 - fn defined_identifiers(&self, canonical: &Path) -> Option<&[String]> { - let (files, _, key) = self.tags_entry(canonical)?; - files.get(&key).map(Vec::as_slice) - } - - /// realpath 済みパスが属する維持指定外ライブラリを特定し、そのファイルの実装先の型名一覧を返す。 - fn implement_targets(&self, canonical: &Path) -> Option<&[String]> { - let (_, implements, key) = self.tags_entry(canonical)?; - implements.get(&key).map(Vec::as_slice) - } - - /// realpath 済みパスが属する維持指定外ライブラリの `files`・`implements` と、そのライブラリ内での - /// 相対キーを返す。linemarker の絶対パスを相対キーへ (`/` 区切り・`path` prefix 除去で) 対応づける - /// 処理がここに集約される。 - #[allow(clippy::type_complexity)] - fn tags_entry( - &self, - canonical: &Path, - ) -> Option<( - &BTreeMap>, - &BTreeMap>, - String, - )> { + /// realpath 済みパスが維持指定外ライブラリ配下なら、そのファイルの `tags.json` レコードを返す。 + /// 対象外パスは `None`。ライブラリ配下だが定義や実装先を持たないファイルは、対応するスライスが + /// 空になる。linemarker の絶対パスを相対キーへ (`/` 区切り・`path` prefix 除去で) 対応づける処理が + /// ここに集約される。 + fn file_tags(&self, canonical: &Path) -> Option> { for lib in &self.libraries { if lib.keep { continue; @@ -186,7 +172,10 @@ impl Inventory { }; if let Ok(relative) = canonical.strip_prefix(&lib.path) { let key = relpath::to_slash(relative).ok()?; - return Some((files, implements, key)); + return Some(FileTags { + defines: files.get(&key).map(Vec::as_slice).unwrap_or_default(), + implements: implements.get(&key).map(Vec::as_slice).unwrap_or_default(), + }); } } None From db279e10b4a7861e8a285e23d50bbb5d56d12458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:20:25 +0900 Subject: [PATCH 16/18] docs(architecture): record implements retention and schema auto-migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コードから読み取りにくい設計判断 (なぜ) を 2 節追記: - 実装ファイルを「実装先の型名」で引く理由と、素朴な代替案を退けた経緯 - tags.json のスキーマ変化を黙って吸収する非対称の原則 (ユーザーの内容変更は確認、risundle の形式変更は自動移行) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- docs/architecture.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 07fce59..ffc5c98 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,3 +61,15 @@ CLI ツール (約 2000 行) では、抽象を足すほど薄いディレクト C++ の厳密な依存解析は難しいので、識別子名の照合で近似する。余分に拾う分には安全 (取りこぼしだけが依存漏れ = コンパイルエラーになる) なので、メンバ名やマクロ名まで拾っても気にしない。 例外は namespace 名だけ。`atcoder` のような名前はほぼ全ファイルに現れ、定義として登録すると逆引きで大半のヘッダーが依存扱いになって tree-shaking が効かなくなる。過剰検出が裏目に出る唯一のケースなので、namespace 名は集めない (中のメンバは個別に拾う)。詳細は identifiers.rs の `NAME_NODES` を参照。 + +## 実装ファイルは「実装先の型名」で引く + +宣言と実装が別ファイルに分かれたライブラリでは、識別子名の照合だけでは依存を辿れない依存が生じる。典型は演算子オーバーロードで、利用側は `f * g` と書くだけで `operator*=` というトークンは現れず、実装ファイル (`operator*=` の本体だけを置いたファイル) が逆引きに引っかからない。過剰検出側に倒す方針でも、これは「取りこぼし」なので放置できない。 + +素朴な対策 (必要ファイルを include するファイルを全部残す / 識別子検出を残存コード全体へ広げる) はいずれも巻き込みが連鎖して tree-shaking をほぼ無効化する (Nyaan's Library で実測)。決め手は、クラス外実装が `void FormalPowerSeries::set_fft()` のように**実装先の型名を必ず自分で書いている**という観察。そこで登録時にこの型名 (クラス外修飾定義の修飾側・明示的特殊化の主テンプレート名) を `implements` として別に記録し、バンドル時に「必要ファイルが定義する型名を実装先に持つファイルも必要」と引く。識別子の逆引きと同型の引き方をもう 1 種足し、増えなくなるまで反復する。これは「余分に残すのは安全」の一貫であり、実装先を静的に特定できない依存 (自由関数の演算子だけのファイル等) は依然取りこぼすが、その場合も提出時のコンパイル/リンクエラーとして顕在化する。 + +## tags.json のスキーマ変化は黙って吸収する + +tags.json はライブラリ実体から決定的に再生成できるキャッシュであり、可搬性を持たない (`path` は絶対パス)。だから schema_version がバイナリの現行と食い違うのは「ユーザーの落ち度」ではなく「risundle の都合」であり、ユーザー操作を要求すべきでない。バンドル時に食い違う登録をライブラリ実体から黙って作り直す (`std` の初回自動登録と同じ発想)。 + +この非対称が肝: **ユーザーの内容が変わった (ハッシュ不一致) は確認させ、risundle の形式が変わった (スキーマ不一致) は黙って直す**。前者は意味的な事象なので `update` か `--no-check` の判断をユーザーに委ね、後者はキャッシュの世代交代なので自動で吸収する。`update`・`add-std` は登録を作り直すコマンドなので、スキーマ非依存の中核 (`Registration` = `path` + 任意の `compilers`) だけを検証せず読んで回復する。`list`・`show` は読み取りに徹し、再生成せず読める情報だけ出す。 From bc9d4c9a8e31b4070dd8c06e1edcad69045ae930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:26:40 +0900 Subject: [PATCH 17/18] style: drop redundant test comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit テスト名やモジュール doc・直後の assert から自明な説明コメントを削除する (実装コードの rationale コメントは維持)。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- src/bundle/inventory.rs | 1 - src/commands/library.rs | 1 - src/library/identifiers.rs | 3 --- 3 files changed, 5 deletions(-) diff --git a/src/bundle/inventory.rs b/src/bundle/inventory.rs index 9ece3ef..a95dad9 100644 --- a/src/bundle/inventory.rs +++ b/src/bundle/inventory.rs @@ -378,7 +378,6 @@ mod tests { let inventory = Inventory::load(&store, &keep_set(&["std"])).unwrap(); // present に無い (= include されていない) ファイルは、実装先が一致しても補わない。 - // バンドル入力に現れないファイルの注入は本メソッドの責務外。 assert!( inventory .implementation_files(&BTreeSet::from([fps.clone()]), &BTreeSet::from([fps])) diff --git a/src/commands/library.rs b/src/commands/library.rs index 56e2a98..b88b4fe 100644 --- a/src/commands/library.rs +++ b/src/commands/library.rs @@ -709,7 +709,6 @@ mod tests { add_std(&store, Some(&g)).unwrap(); downgrade_schema(&store, "std"); - // スキーマの合わない登録があっても、add-std は集合を引き継いで作り直せる。 add_std(&store, Some(&g)).unwrap(); assert!(Tags::load(&store.tags_json("std")).is_ok()); } diff --git a/src/library/identifiers.rs b/src/library/identifiers.rs index ca7b116..e211f61 100644 --- a/src/library/identifiers.rs +++ b/src/library/identifiers.rs @@ -396,8 +396,6 @@ mod tests { #[test] fn nested_qualifiers_record_each_scope() { - // `a::b::f` は各段の修飾側を拾う。namespace 名が混ざっても、namespace 名は定義識別子に - // 登録されないため逆引きで一致せず無害 (モジュールコメント参照)。 let source = "void outer::Inner::f() { }"; assert_eq!( implements_in(source), @@ -407,7 +405,6 @@ mod tests { #[test] fn in_class_definitions_have_no_implement_target() { - // クラス内で完結する定義は実装先を持たない (自分自身の実装は逆引き不要)。 let source = "struct V { V operator+(V o) { return o; } void f() { } };"; assert_eq!(implements_in(source), Vec::::new()); } From 95a451bb01177736d78a249f1c9c4f1857d2562c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8A=E3=81=99=E3=82=8A=E3=81=99/TwoSquirrels?= <47352965+TwoSquirrels@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:03:04 +0900 Subject: [PATCH 18/18] docs(spec): use a fictional library for the tags.json example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実在しない AC Library もどきのファイル構成 (atcoder/modint_impl.hpp 等。 ACL はヘッダーオンリーで implements が生じない) を、宣言と実装が分かれた 架空の行列ライブラリに差し替え、implements が発生する構造を正しく示す。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TwiopnwyFnRbSUyNoFLWAZ --- docs/spec.ja.md | 8 ++++---- docs/spec.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/spec.ja.md b/docs/spec.ja.md index 9027b68..027ce21 100644 --- a/docs/spec.ja.md +++ b/docs/spec.ja.md @@ -83,14 +83,14 @@ tree-shaking がうまくいかないときの一時的なフォールバック ```json { "schema_version": 2, - "path": "/usr/local/include", + "path": "/home/user/cp-library", "hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "files": { - "atcoder/modint.hpp": ["modint", "modint_base", "modint_common"], - "atcoder/segtree.hpp": ["segtree"] + "matrix/matrix.hpp": ["Matrix", "identity"], + "matrix/matrix-mul.hpp": ["pow"] }, "implements": { - "atcoder/modint_impl.hpp": ["modint"] + "matrix/matrix-mul.hpp": ["Matrix"] } } ``` diff --git a/docs/spec.md b/docs/spec.md index d1c2954..248fa8a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -83,14 +83,14 @@ When `` is not `std`: ```json { "schema_version": 2, - "path": "/usr/local/include", + "path": "/home/user/cp-library", "hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "files": { - "atcoder/modint.hpp": ["modint", "modint_base", "modint_common"], - "atcoder/segtree.hpp": ["segtree"] + "matrix/matrix.hpp": ["Matrix", "identity"], + "matrix/matrix-mul.hpp": ["pow"] }, "implements": { - "atcoder/modint_impl.hpp": ["modint"] + "matrix/matrix-mul.hpp": ["Matrix"] } } ```