Skip to content

Commit b20fda4

Browse files
committed
test(sync): guard docs against code drift with sync tests
Add two compile-enforced anti-rot tests: - `supported_languages_doc_lists_each_primary_extension` (in `src/lang.rs`) fails if a language's primary extension disappears from `docs/supported-languages.md`. - `ffi_markers_are_documented` (in `src/ffi/sync_tests.rs`) fails if an FFI marker or name prefix in the `SPECS` registry is absent from `docs/ffi-support-matrix.md`. Both tests require the doc to be updated in the same PR as the code, making the "hand-maintained" caveat enforced rather than advisory. To support the first test, expose `Language::ALL` (exhaustive slice of every variant, guarded by `all_is_exhaustive`) and `Language::extensions` (per-variant primary + alternate extensions). `from_extension` is re-derived from `extensions()` to keep the mapping single-sourced. Update `docs/ffi-support-matrix.md` and `CONTRIBUTING.md` to reflect that the docs are now test-backed rather than purely hand-maintained.
1 parent ffd90c6 commit b20fda4

5 files changed

Lines changed: 166 additions & 33 deletions

File tree

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Every public-facing file starts with `// SPDX-License-Identifier: Apache-2.0`.
9090

9191
This is the most common contribution and it's mechanical once a grammar exists. The resolver is language-agnostic, so **cross-file edges work for free** once extraction emits correct facts.
9292

93-
> **Before you start:** check [`docs/supported-languages.md`](docs/supported-languages.md) for what's already covered (plus the candidate, not-feasible, and out-of-scope lists), and [`docs/ffi-support-matrix.md`](docs/ffi-support-matrix.md) for cross-language FFI boundaries. Both are hand-maintained against the code (`src/lang.rs` is the canonical language set) — update them in the same PR when you add a language or an FFI boundary.
93+
> **Before you start:** check [`docs/supported-languages.md`](docs/supported-languages.md) for what's already covered (plus the candidate, not-feasible, and out-of-scope lists), and [`docs/ffi-support-matrix.md`](docs/ffi-support-matrix.md) for cross-language FFI boundaries. Both are guarded by sync tests — `supported-languages.md` against the `Language` enum in `src/lang.rs`, and `ffi-support-matrix.md` against the per-ABI `SPECS` registry in `src/ffi/` — so adding a language or an FFI boundary without updating the matching doc in the same PR fails the test.
9494
9595
> **First: is there a usable grammar?** code2graph pins `tree-sitter` to `>=0.24, <0.27`. A grammar crate must be compatible with that range. If no compatible grammar exists, read [When a language has no usable grammar](#when-a-language-has-no-usable-grammar) before writing any code.
9696

docs/ffi-support-matrix.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
Which cross-language (cross-runtime) call boundaries code2graph bridges today — honestly, with what
44
is **not yet** covered.
55

6-
> The canonical source is `FfiAbi` + `FfiAbi::consumers()` in [`src/graph/types.rs`](../src/graph/types.rs)
7-
> and the export markers in the per-language extractors. This page is hand-maintained; if it
8-
> disagrees with the code, the code wins.
6+
> The canonical source is the per-ABI registry in [`src/ffi/`](../src/ffi/) — one `AbiSpec` per ABI
7+
> (the `SPECS` table), carrying its consumer languages and export markers — plus the `FfiAbi` enum in
8+
> [`src/graph/types.rs`](../src/graph/types.rs). A sync test (`ffi_markers_are_documented`) fails if a
9+
> marker on this page drifts from `SPECS`; if the page still disagrees with the code, the code wins.
910
1011
## What an FFI bridge is
1112

@@ -16,7 +17,8 @@ one language to a definition in another, **deterministically**:
1617
- The **export** side is grounded in a real syntactic marker (e.g. Rust `#[no_mangle]`), recorded as
1718
a neutral `FfiExport { symbol, abi, export_name }`.
1819
- The **consumer** side is matched by the exported ABI name, and only in a language that actually
19-
consumes that ABI (`FfiAbi::consumers()`), so a C call never binds to a Python-only export.
20+
consumes that ABI (its `consumers` in the [`src/ffi/`](../src/ffi/) registry), so a C call never
21+
binds to a Python-only export.
2022
- Same-language use is _not_ a bridge (that's an ordinary call).
2123
- Every bridge edge carries `Provenance::FfiBridge` and an honest `Confidence`: `Scoped` when the
2224
export is unique, `NameOnly` when several share the name. Never dressed up as precise.
@@ -97,8 +99,10 @@ the edges of the map; ranked roughly by how common the boundary is.
9799
(`lib.foo()`, `C.foo()`), a different call shape than the bare-name match used today.
98100
- **WebAssembly component model / WIT imports** (beyond `wasm-bindgen` exports).
99101

100-
The architecture extends cleanly: each new boundary is one `FfiAbi` variant + a marker recognised in
101-
the relevant extractor + an entry in `consumers()`. The resolver itself is generic.
102+
The architecture extends cleanly: a new boundary is one `FfiAbi` variant + one `src/ffi/<abi>.rs` spec
103+
file (its consumer languages + export markers) + one line in the `SPECS` registry. The producer
104+
extractor keeps its syntactic walk and calls into `ffi::` to classify the marker; the resolver is
105+
generic — no growing match and no inline per-ABI block to extend.
102106

103107
## See also
104108

src/ffi/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,7 @@ mod python;
1010
mod spec;
1111
mod wasm;
1212

13+
#[cfg(test)]
14+
mod sync_tests;
15+
1316
pub(crate) use spec::{c_name_export_abi, consumers, rust_exports};

src/ffi/sync_tests.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
//! Anti-rot: every FFI marker/prefix in `SPECS` must be named in the matrix doc.
3+
use super::spec::SPECS;
4+
5+
fn read_doc(rel: &str) -> String {
6+
let path = format!("{}/{}", env!("CARGO_MANIFEST_DIR"), rel);
7+
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {path}: {e}"))
8+
}
9+
10+
#[test]
11+
fn ffi_markers_are_documented() {
12+
let doc = read_doc("docs/ffi-support-matrix.md");
13+
for spec in SPECS {
14+
for marker in spec.rust_attr_markers {
15+
assert!(
16+
doc.contains(marker),
17+
"FFI marker `{marker}` (abi {:?}) is missing from \
18+
docs/ffi-support-matrix.md — document it there",
19+
spec.abi,
20+
);
21+
}
22+
if let Some(prefix) = spec.name_prefix {
23+
assert!(
24+
doc.contains(prefix),
25+
"FFI name prefix `{prefix}` (abi {:?}) is missing from \
26+
docs/ffi-support-matrix.md — document the `{prefix}*` rule there",
27+
spec.abi,
28+
);
29+
}
30+
}
31+
}

src/lang.rs

Lines changed: 121 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,64 @@ pub enum Language {
3434
}
3535

3636
impl Language {
37+
/// Every `Language` variant. Kept exhaustive by the `all_is_exhaustive` test.
38+
pub const ALL: &[Language] = &[
39+
Language::Rust,
40+
Language::TypeScript,
41+
Language::JavaScript,
42+
Language::Python,
43+
Language::Go,
44+
Language::Shell,
45+
Language::C,
46+
Language::Cpp,
47+
Language::Java,
48+
Language::Ruby,
49+
Language::Php,
50+
Language::Swift,
51+
Language::Kotlin,
52+
Language::Solidity,
53+
Language::Sql,
54+
Language::Hcl,
55+
Language::CSharp,
56+
Language::Scala,
57+
Language::Dart,
58+
Language::Lua,
59+
Language::Luau,
60+
Language::Pascal,
61+
Language::Svelte,
62+
];
63+
64+
/// This variant's file extensions (without the leading dot); the first entry
65+
/// is the primary/canonical extension. Single source of truth for
66+
/// `from_extension` — keep in sync with the extensions accepted there.
67+
pub fn extensions(self) -> &'static [&'static str] {
68+
match self {
69+
Language::Rust => &["rs"],
70+
Language::TypeScript => &["ts", "tsx"],
71+
Language::JavaScript => &["js", "jsx", "mjs", "cjs"],
72+
Language::Python => &["py", "pyi"],
73+
Language::Go => &["go"],
74+
Language::Shell => &["sh", "bash", "zsh"],
75+
Language::C => &["c", "h"],
76+
Language::Cpp => &["cc", "cpp", "cxx", "hh", "hpp", "hxx"],
77+
Language::Java => &["java"],
78+
Language::Ruby => &["rb"],
79+
Language::Php => &["php"],
80+
Language::Swift => &["swift"],
81+
Language::Kotlin => &["kt", "kts"],
82+
Language::Solidity => &["sol"],
83+
Language::Sql => &["sql"],
84+
Language::Hcl => &["tf", "hcl", "tfvars"],
85+
Language::CSharp => &["cs"],
86+
Language::Scala => &["scala", "sc"],
87+
Language::Dart => &["dart"],
88+
Language::Lua => &["lua"],
89+
Language::Luau => &["luau"],
90+
Language::Pascal => &["pas", "dpr", "dpk", "lpr"],
91+
Language::Svelte => &["svelte"],
92+
}
93+
}
94+
3795
/// Canonical lowercase language tag (stable; used in `SymbolKey.lang`).
3896
pub fn as_str(self) -> &'static str {
3997
match self {
@@ -64,33 +122,12 @@ impl Language {
64122
}
65123

66124
/// Map a lowercase file extension to a `Language`, or `None` if unknown.
125+
/// Derives from `extensions()` so the mapping has a single source of truth.
67126
pub fn from_extension(ext: &str) -> Option<Self> {
68-
match ext {
69-
"rs" => Some(Self::Rust),
70-
"ts" | "tsx" => Some(Self::TypeScript),
71-
"js" | "jsx" | "mjs" | "cjs" => Some(Self::JavaScript),
72-
"py" | "pyi" => Some(Self::Python),
73-
"go" => Some(Self::Go),
74-
"sh" | "bash" | "zsh" => Some(Self::Shell),
75-
"c" | "h" => Some(Self::C),
76-
"cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => Some(Self::Cpp),
77-
"java" => Some(Self::Java),
78-
"rb" => Some(Self::Ruby),
79-
"php" => Some(Self::Php),
80-
"swift" => Some(Self::Swift),
81-
"kt" | "kts" => Some(Self::Kotlin),
82-
"sol" => Some(Self::Solidity),
83-
"sql" => Some(Self::Sql),
84-
"tf" | "hcl" | "tfvars" => Some(Self::Hcl),
85-
"cs" => Some(Self::CSharp),
86-
"scala" | "sc" => Some(Self::Scala),
87-
"dart" => Some(Self::Dart),
88-
"lua" => Some(Self::Lua),
89-
"luau" => Some(Self::Luau),
90-
"pas" | "dpr" | "dpk" | "lpr" => Some(Self::Pascal),
91-
"svelte" => Some(Self::Svelte),
92-
_ => None,
93-
}
127+
Language::ALL
128+
.iter()
129+
.copied()
130+
.find(|l| l.extensions().contains(&ext))
94131
}
95132

96133
/// Map a file path to a `Language` via its extension.
@@ -116,4 +153,62 @@ mod tests {
116153
fn tag_is_stable_lowercase() {
117154
assert_eq!(Language::TypeScript.as_str(), "typescript");
118155
}
156+
157+
/// Compile-time guard: a new `Language` variant forces a new arm here (no
158+
/// wildcard), where the contributor is reminded to also add it to `Language::ALL`.
159+
fn assert_variant_in_all(l: Language) -> bool {
160+
let listed = match l {
161+
// EVERY variant must be listed here AND in `Language::ALL`.
162+
Language::Rust
163+
| Language::TypeScript
164+
| Language::JavaScript
165+
| Language::Python
166+
| Language::Go
167+
| Language::Shell
168+
| Language::C
169+
| Language::Cpp
170+
| Language::Java
171+
| Language::Ruby
172+
| Language::Php
173+
| Language::Swift
174+
| Language::Kotlin
175+
| Language::Solidity
176+
| Language::Sql
177+
| Language::Hcl
178+
| Language::CSharp
179+
| Language::Scala
180+
| Language::Dart
181+
| Language::Lua
182+
| Language::Luau
183+
| Language::Pascal
184+
| Language::Svelte => true,
185+
};
186+
listed && Language::ALL.contains(&l)
187+
}
188+
189+
#[test]
190+
fn all_is_exhaustive() {
191+
for &l in Language::ALL {
192+
assert!(
193+
assert_variant_in_all(l),
194+
"variant missing from Language::ALL"
195+
);
196+
}
197+
}
198+
199+
#[test]
200+
fn supported_languages_doc_lists_each_primary_extension() {
201+
let path = format!("{}/docs/supported-languages.md", env!("CARGO_MANIFEST_DIR"));
202+
let doc =
203+
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {path}: {e}"));
204+
for &lang in Language::ALL {
205+
let primary = lang.extensions()[0];
206+
let token = format!("`.{primary}`"); // doc cells are backticked: `.rs`
207+
assert!(
208+
doc.contains(&token),
209+
"language {lang:?} (primary ext .{primary}) is not listed in \
210+
docs/supported-languages.md — add its row and update the doc",
211+
);
212+
}
213+
}
119214
}

0 commit comments

Comments
 (0)