From 73d6b545c6ce42f452e71296bbbd410ef7d2e8d9 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 09:09:45 +0200 Subject: [PATCH 01/18] rfc-0000: resolve the three open design questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounds each in the current compiler (0.5.12) instead of leaving them open: Lang::RFunction carries no default values or `...` handling today (so arity checking is exactly parameters.len(), no fallback needed), the untyped-call result stays Any rather than Foreign, and R { ... } blocks are explicitly out of scope since they already type-check as Type::Empty, not Type::Any, with different unification rules. Opens the RFC for the review this repo's rfcs/README.md describes — it previously landed on develop without going through that process. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- rfcs/0000-calling-untyped-r-functions.md | 61 ++++++++++++++++-------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/rfcs/0000-calling-untyped-r-functions.md b/rfcs/0000-calling-untyped-r-functions.md index c164185..9562a90 100644 --- a/rfcs/0000-calling-untyped-r-functions.md +++ b/rfcs/0000-calling-untyped-r-functions.md @@ -108,8 +108,10 @@ already carries `parameters`. 1. `Lang::RFunction` with parameters `p₁…pₙ` is typed as a function `(Any, …, Any) -> Any` with *n* parameters, instead of the current `Type::UnknownFunction` with none. -2. Applying it checks arity only. Argument types are not checked and not - propagated. +2. Applying it checks arity only, as the literal count of parsed parameters + (`parameters.len()`) — `Lang::RFunction` carries no default values and no + `...` today (see "Resolved during review" below), so there is no case to + special-case yet. Argument types are not checked and not propagated. 3. The result type is `Any`. 4. Preloaded untyped builtins keep `Type::UnknownFunction`, since `functions_R.txt` gives no arity, but `UnknownFunction` becomes **variadic**: @@ -161,10 +163,9 @@ you write when you want the value back inside the type system. no untyped call passes the type checker at all. This deliberately opens a hole and calls it a feature. - **Arity is checked, which may surprise R users.** R's own arity rules are - looser (partial matching, `...`, missing arguments with defaults). An R - function with defaults, `function(a, b = 2)`, would be called `f(1)` in R and - rejected here unless defaults are read from the parsed parameter list. That - detail must be settled before implementation — see below. + looser (partial matching, `...`, missing arguments with defaults). See + "Defaults and `...`" below for why this drawback does not currently bite: the + syntax that would trigger it isn't parseable yet. ## Rationale and alternatives @@ -205,19 +206,41 @@ site's example checks precisely because it does not compile. RFC: it already gives a way to call untyped R with a *declared* signature. The question is whether the undeclared case deserves an answer too. -## Unresolved questions - -- **Defaults and `...`.** How is `function(a, b = 2)` counted, and what happens - to `function(...)` with R's dots? Arity checking is only worth having if it is - right; if reading defaults out of `Lang::RFunction` is awkward, the fallback is - Option 2 (variadic) for those cases specifically. -- **Should the result be `Any` or `Foreign`?** `Foreign` is the existing - idiom for values that came from R and need an accessor. Using it would make - untyped results consistent with `@extern` returns; using `Any` keeps `as!` as - the single exit. -- **Does the same reasoning extend to `R { ... }` blocks?** They already produce - a value; if that value is `Any`, this RFC's `as!` story covers them too and - should say so. +## Resolved during review + +These three questions were open when this RFC was drafted. Checked against the +compiler (0.5.12): + +- **Defaults and `...`.** Neither exists in `Lang::RFunction` today. Its + `parameters: Vec` is built by the parser's generic `variable` combinator + (`processes/parsing/elements.rs`), which accepts a bare name plus an optional + `: Type` annotation — no `= expr` default syntax, no special-casing of `...`. + `function(a, b = 2)` and `function(...)` are not parseable as an `RFunction` + today; there is nothing for arity checking to get wrong, because the grammar + gives it nothing but a plain count of bare names. So point 2 of the + Reference-level explanation is exactly `parameters.len()`, no fallback needed. + Adding default-parameter or dots syntax to the escape hatch is a separate, + larger change (it touches the syntax manifest) and its own RFC if ever + proposed; this one does not depend on it. +- **`Any`, not `Foreign`.** `Foreign` is for a value TypR acknowledges + is opaque *and* gives an accessor story for. An untyped call's result could be + anything a plain R function returns — vector, list, S3 object — and forcing it + through `Foreign` would imply an accessor contract this RFC does not define. + `Any` is the honest choice, and it composes with the rest of this proposal: + a future package-registry design that degrades low-trust or generated + signatures to "callable, unchecked" (see `Type::UnknownFunction` above) needs + exactly this same fallback shape, so settling on `Any` here keeps that door + open instead of introducing a second opaque-value convention to reconcile + later. +- **`R { ... }` blocks: out of scope, and for a concrete reason.** They are + *not* already `Any` — the type checker assigns them `Type::Empty` + (`processes/type_checking/mod.rs`), a distinct placeholder from + `Type::UnknownFunction`/`Type::Any` with its own, more permissive unification + rule (`Type::Empty` unifies with anything; `Type::Any` only satisfies a + target that is itself `Any`). Folding `R { ... }` into this RFC's `Any`/`as!` + story would be a behavior change to existing code, not a documentation + footnote, since it would make `R { ... }` results stop unifying freely with + concrete types. Left for a separate RFC if ever proposed. ## Future possibilities From 5aadbb71b89faf8d404af221d8db00e4d585223e Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 09:15:26 +0200 Subject: [PATCH 02/18] rfc-0028: accept and rename to PR number Per rfcs/README.md: on acceptance the file is renamed to the PR's own number and the header filled in. Tracking issue we-data-ch/typr#29 opened for the implementation (J0 of registry.md's plan). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- ...d-r-functions.md => 0028-calling-untyped-r-functions.md} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename rfcs/{0000-calling-untyped-r-functions.md => 0028-calling-untyped-r-functions.md} (99%) diff --git a/rfcs/0000-calling-untyped-r-functions.md b/rfcs/0028-calling-untyped-r-functions.md similarity index 99% rename from rfcs/0000-calling-untyped-r-functions.md rename to rfcs/0028-calling-untyped-r-functions.md index 9562a90..8b7f0ec 100644 --- a/rfcs/0000-calling-untyped-r-functions.md +++ b/rfcs/0028-calling-untyped-r-functions.md @@ -1,6 +1,6 @@ -- **Status:** draft -- **RFC PR:** we-data-ch/typr#0000 -- **Tracking issue:** — +- **Status:** accepted +- **RFC PR:** we-data-ch/typr#28 +- **Tracking issue:** we-data-ch/typr#29 - **Implemented in:** not yet - **Start date:** 2026-09-09 From d85f8d9498f9f4642be336a63f59d8ffad728cce Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 09:40:41 +0200 Subject: [PATCH 03/18] Implement RFC 0028: make untyped R functions callable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Lang::RFunction` (`function(a, b) { ... }`) now types as a real `(Any, ..., Any) -> Any` function with n parameters, instead of the 0-ary `Type::UnknownFunction` placeholder that made any call with arguments a type error. `Type::UnknownFunction` (used for preloaded untyped base-R builtins like `Position`, `t`, `Reduce`) becomes variadic for the same reason — fixing only `RFunction` would leave the bug reachable through every untyped base-R name. - `processes/type_checking/mod.rs`: `Lang::RFunction`'s typing rule builds a `Type::Function` with n `Any` params -> `Any`. - `components/type/mod.rs` and `components/type/function_type.rs`: `Type::UnknownFunction`'s conversion to `FunctionType` is now a single variadic `Any` parameter -> `Any`. - `components/error_message/type_error.rs`: new `TypeError:: UntypedFunctionArity` (T044) replaces the confusing "no signature matches () -> UnknownFunction" message with the RFC's proposed wording when an untyped function is called with the wrong arity. - `processes/type_checking/function_application.rs`: wires the new error in, using `FunctionType::is_r_function()` to detect the untyped-function shape. - `cases/0062-0064`: regression cases for the callable case, the arity error message, and the preloaded-builtin case. Bumps the `guard_any_type_call_count_is_tracked` baseline 19 -> 21: two new `any_type()` calls in the `RFunction` typing arm, which is the RFC's whole point, not a silent degradation. Tracking issue: we-data-ch/typr#29 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- .../case.toml | 6 +++ .../expect.md | 28 ++++++++++++++ .../expect.toml | 15 ++++++++ .../repro/TypR/main.ty | 13 +++++++ .../case.toml | 6 +++ .../expect.md | 37 +++++++++++++++++++ .../expect.toml | 15 ++++++++ .../repro/TypR/main.ty | 9 +++++ .../case.toml | 6 +++ .../expect.md | 29 +++++++++++++++ .../expect.toml | 11 ++++++ .../repro/TypR/main.ty | 6 +++ .../components/error_message/type_error.rs | 27 ++++++++++++++ .../src/components/type/function_type.rs | 17 ++++++++- crates/typr-core/src/components/type/mod.rs | 7 +++- .../type_checking/function_application.rs | 13 +++++++ .../src/processes/type_checking/mod.rs | 28 ++++++++++++-- rfcs/0028-calling-untyped-r-functions.md | 8 ++-- 18 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 cases/0062-untyped-r-function-callable/case.toml create mode 100644 cases/0062-untyped-r-function-callable/expect.md create mode 100644 cases/0062-untyped-r-function-callable/expect.toml create mode 100644 cases/0062-untyped-r-function-callable/repro/TypR/main.ty create mode 100644 cases/0063-untyped-r-function-arity-error/case.toml create mode 100644 cases/0063-untyped-r-function-arity-error/expect.md create mode 100644 cases/0063-untyped-r-function-arity-error/expect.toml create mode 100644 cases/0063-untyped-r-function-arity-error/repro/TypR/main.ty create mode 100644 cases/0064-untyped-preloaded-builtin-variadic/case.toml create mode 100644 cases/0064-untyped-preloaded-builtin-variadic/expect.md create mode 100644 cases/0064-untyped-preloaded-builtin-variadic/expect.toml create mode 100644 cases/0064-untyped-preloaded-builtin-variadic/repro/TypR/main.ty diff --git a/cases/0062-untyped-r-function-callable/case.toml b/cases/0062-untyped-r-function-callable/case.toml new file mode 100644 index 0000000..974bf80 --- /dev/null +++ b/cases/0062-untyped-r-function-callable/case.toml @@ -0,0 +1,6 @@ +title = "untyped R function (`function(a, b)`) is callable, arity-checked, result `Any` (RFC 0028)" +cmd = "build" +layer = "type" +status = "fixed" +created = "2026-09-12" +origin = "perso" diff --git a/cases/0062-untyped-r-function-callable/expect.md b/cases/0062-untyped-r-function-callable/expect.md new file mode 100644 index 0000000..f1ed659 --- /dev/null +++ b/cases/0062-untyped-r-function-callable/expect.md @@ -0,0 +1,28 @@ +# untyped-r-function-callable + +Source: `rfcs/0028-calling-untyped-r-functions.md`, accepted 2026-09-12 +(we-data-ch/typr#28, tracking we-data-ch/typr#29). + +## Ce qui devrait se passer + +`function(a, b) { ... }` parses to `Lang::RFunction` and used to type as the placeholder +`Type::UnknownFunction`, which carries no parameter list — so any call with at least one +argument failed with `No signature of function 'my_addition' matches this call ... () -> +UnknownFunction`, even though the emitted R was already correct. Per the RFC, `Lang::RFunction` +with `n` parameters now types as `(Any, ..., Any) -> Any`: arity is checked (exactly `n` +arguments), argument types are not, and the result must be brought back into the type system +explicitly with `as!` — the same shape as the rest of TypR's opaque values. + +This is the exact `docs/philosophy/intro.md` example ("weak on safety, strong on freedom"), +which the RFC's Motivation section shows failing to compile on 0.5.10. + +## Vérification + +Implemented alongside this case: `Lang::RFunction`'s typing rule +(`processes/type_checking/mod.rs`) now builds a real `Type::Function` instead of +`Type::UnknownFunction`. The emitted R is unchanged — `my_addition(num1, num2)` — since only the +type checker ever rejected this call. + +## Statut + +Kept as a regression net for the RFC's core guarantee: an untyped R function must stay callable. diff --git a/cases/0062-untyped-r-function-callable/expect.toml b/cases/0062-untyped-r-function-callable/expect.toml new file mode 100644 index 0000000..5081f64 --- /dev/null +++ b/cases/0062-untyped-r-function-callable/expect.toml @@ -0,0 +1,15 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +[[rule]] +file = "@run" +must_not_contain = "doesn't match type" + +[[rule]] +file = "R/main.R" +must_contain = "my_addition(num1, num2)" diff --git a/cases/0062-untyped-r-function-callable/repro/TypR/main.ty b/cases/0062-untyped-r-function-callable/repro/TypR/main.ty new file mode 100644 index 0000000..5b012eb --- /dev/null +++ b/cases/0062-untyped-r-function-callable/repro/TypR/main.ty @@ -0,0 +1,13 @@ +#@case untyped-r-function-callable: RFC 0028 (0028-calling-untyped-r-functions) +# makes an untyped R function (`function(a, b) {...}`) callable: arity is +# checked, argument types are not, and the result is typed Any. This is the +# exact motivating example from the RFC and from docs/philosophy/intro.md. +let num1 <- 3; +let num2 <- 7; + +let my_addition <- function(a, b) { + a + b +}; + +let total: int <- my_addition(num1, num2) as! int; +print(total); diff --git a/cases/0063-untyped-r-function-arity-error/case.toml b/cases/0063-untyped-r-function-arity-error/case.toml new file mode 100644 index 0000000..bb73c68 --- /dev/null +++ b/cases/0063-untyped-r-function-arity-error/case.toml @@ -0,0 +1,6 @@ +title = "wrong arity on an untyped R function reports a dedicated message, not `UnknownFunction`" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-12" +origin = "perso" diff --git a/cases/0063-untyped-r-function-arity-error/expect.md b/cases/0063-untyped-r-function-arity-error/expect.md new file mode 100644 index 0000000..591606f --- /dev/null +++ b/cases/0063-untyped-r-function-arity-error/expect.md @@ -0,0 +1,37 @@ +# untyped-r-function-arity-error + +Source: `rfcs/0028-calling-untyped-r-functions.md`, accepted 2026-09-12 +(we-data-ch/typr#28, tracking we-data-ch/typr#29), Reference-level explanation, "Error messages". + +## Ce qui devrait se passer + +Before the RFC, any call to an untyped R function was rejected by the *arity* check that +`Type::UnknownFunction` (0-ary) implicitly enforced, and the message printed the internal +placeholder back at the user: + +``` +Type error: No signature of function 'my_addition' matches this call. + help: 'my_addition' exists but none of its signature(s) accepts these arguments: + () -> UnknownFunction +``` + +Now that the function is callable, the only thing left to check is arity, and the RFC specifies +a dedicated message for it instead of reusing the generic no-matching-signature wording: + +``` +Type error: 'my_addition' is an untyped R function taking 2 argument(s), called with 3. + help: its body is not type-checked; only the number of arguments is. +``` + +## Vérification + +Implemented alongside case `untyped-r-function-callable`: `TypeError::UntypedFunctionArity` +(`components/error_message/type_error.rs`, code `T044`), raised in +`processes/type_checking/function_application.rs` when a call's argument count doesn't match a +signature that is exactly one, non-variadic, all-`Any` parameters returning `Any` — the shape +`Lang::RFunction`'s typing rule now produces (`FunctionType::is_r_function`). + +## Statut + +Kept as a regression net: the internal `UnknownFunction` placeholder must never resurface in a +user-facing error message again. diff --git a/cases/0063-untyped-r-function-arity-error/expect.toml b/cases/0063-untyped-r-function-arity-error/expect.toml new file mode 100644 index 0000000..0499d91 --- /dev/null +++ b/cases/0063-untyped-r-function-arity-error/expect.toml @@ -0,0 +1,15 @@ +[[rule]] +file = "@run" +must_contain = "Type errors found" + +[[rule]] +file = "@run" +must_contain = "is an untyped R function taking 2 argument(s)" + +[[rule]] +file = "@run" +must_contain = "only the number of arguments is" + +[[rule]] +file = "@run" +must_not_contain = "UnknownFunction" diff --git a/cases/0063-untyped-r-function-arity-error/repro/TypR/main.ty b/cases/0063-untyped-r-function-arity-error/repro/TypR/main.ty new file mode 100644 index 0000000..7f732a2 --- /dev/null +++ b/cases/0063-untyped-r-function-arity-error/repro/TypR/main.ty @@ -0,0 +1,9 @@ +#@case untyped-r-function-arity-error: RFC 0028 - calling an untyped R +# function with the wrong number of arguments must report a specific arity +# error, not the generic "no signature matches" message that exposed the +# internal UnknownFunction placeholder. +let my_addition <- function(a, b) { + a + b +}; + +my_addition(1, 2, 3) diff --git a/cases/0064-untyped-preloaded-builtin-variadic/case.toml b/cases/0064-untyped-preloaded-builtin-variadic/case.toml new file mode 100644 index 0000000..3cc9fc8 --- /dev/null +++ b/cases/0064-untyped-preloaded-builtin-variadic/case.toml @@ -0,0 +1,6 @@ +title = "preloaded untyped base-R builtins (`Position`, `t`, `Reduce`, …) become variadic (RFC 0028)" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-12" +origin = "perso" diff --git a/cases/0064-untyped-preloaded-builtin-variadic/expect.md b/cases/0064-untyped-preloaded-builtin-variadic/expect.md new file mode 100644 index 0000000..90e834b --- /dev/null +++ b/cases/0064-untyped-preloaded-builtin-variadic/expect.md @@ -0,0 +1,29 @@ +# untyped-preloaded-builtin-variadic + +Source: `rfcs/0028-calling-untyped-r-functions.md`, accepted 2026-09-12 +(we-data-ch/typr#28, tracking we-data-ch/typr#29), Motivation and point 4 of the Reference-level +explanation ("Point 4 is the load-bearing half"). + +## Ce qui devrait se passer + +The untyped names from `functions_R.txt` (`Position`, `t`, `Reduce`, …) are preloaded as +`(Any, UnknownFunction)` so that referring to them is not an "undefined variable" error. +Before the RFC, `Type::UnknownFunction` was effectively 0-ary (`FunctionType::new(VecType::Empty, +vec![], ...)`), so `Position()` type-checked but `Position(1, 2)` failed the same way a +user-written `function(...)` did. Fixing only `Lang::RFunction` (case +`untyped-r-function-callable`) without also fixing `UnknownFunction` would leave the same bug +reachable through every untyped base-R name — this is the "confusing version of the bug rather +than a fix" the RFC warns about. `UnknownFunction` must be variadic: any number of arguments, +result `Any`. + +## Vérification + +Implemented alongside case `untyped-r-function-callable`: `Type::UnknownFunction`'s conversion to +`FunctionType` (`components/type/mod.rs`'s `to_function_type`, and the matching +`TryFrom for FunctionType` arm in `components/type/function_type.rs`) now produces a single +variadic `Any` parameter returning `Any`, instead of zero parameters. + +## Statut + +Kept as a regression net: every untyped base-R builtin must stay callable with arguments, not +just referenceable by name. diff --git a/cases/0064-untyped-preloaded-builtin-variadic/expect.toml b/cases/0064-untyped-preloaded-builtin-variadic/expect.toml new file mode 100644 index 0000000..e29aeef --- /dev/null +++ b/cases/0064-untyped-preloaded-builtin-variadic/expect.toml @@ -0,0 +1,11 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +[[rule]] +file = "@run" +must_not_contain = "UnknownFunction" diff --git a/cases/0064-untyped-preloaded-builtin-variadic/repro/TypR/main.ty b/cases/0064-untyped-preloaded-builtin-variadic/repro/TypR/main.ty new file mode 100644 index 0000000..0c92f13 --- /dev/null +++ b/cases/0064-untyped-preloaded-builtin-variadic/repro/TypR/main.ty @@ -0,0 +1,6 @@ +#@case untyped-preloaded-builtin-variadic: RFC 0028 - preloaded untyped +# base-R builtins (functions_R.txt: Position, t, Reduce, ...) are preloaded +# as `(Any, UnknownFunction)`; `UnknownFunction` must be variadic so calling +# them with arguments type-checks, not just referencing the bare name. +let x: int <- Position(1, 2) as! int; +print(x); diff --git a/crates/typr-core/src/components/error_message/type_error.rs b/crates/typr-core/src/components/error_message/type_error.rs index d691c5e..9ff7703 100644 --- a/crates/typr-core/src/components/error_message/type_error.rs +++ b/crates/typr-core/src/components/error_message/type_error.rs @@ -152,6 +152,13 @@ pub enum TypeError { /// `name.Suffix`" error in the generated R — /// `(function_name, forced_type, available_types, position)`. NoDispatchImplementation(String, Type, Vec, HelpData), + /// A call to an untyped R function (`Lang::RFunction`, RFC 0028) supplied + /// the wrong number of arguments. Distinguished from `NoMatchingSignature` + /// because the callee has exactly one signature and no argument types to + /// report — only arity was ever checked, and saying so avoids exposing + /// the internal `UnknownFunction` placeholder to someone who wrote plain + /// R — `(function_name, expected_arity, got_arity, position)`. + UntypedFunctionArity(String, usize, usize, HelpData), } impl TypeError { @@ -201,6 +208,7 @@ impl TypeError { TypeError::DataFrameColumnLengthMismatch(_, _, _, _, h) => Some(h.clone()), TypeError::NoMatchingSignature(_, _, _, h) => Some(h.clone()), TypeError::NoDispatchImplementation(_, _, _, h) => Some(h.clone()), + TypeError::UntypedFunctionArity(_, _, _, h) => Some(h.clone()), } } @@ -252,6 +260,7 @@ impl TypeError { TypeError::UnknownUnionVariant(..) => "T041", TypeError::NoMatchingSignature(..) => "T042", TypeError::NoDispatchImplementation(..) => "T043", + TypeError::UntypedFunctionArity(..) => "T044", } } @@ -475,6 +484,12 @@ impl TypeError { available.iter().map(|t| t.pretty()).collect::>().join(", ") ) } + TypeError::UntypedFunctionArity(name, expected, got, _) => { + format!( + "'{}' is an untyped R function taking {} argument(s), called with {}.", + name, expected, got + ) + } } } } @@ -1065,6 +1080,18 @@ impl ErrorMsg for TypeError { )) .build() } + TypeError::UntypedFunctionArity(name, expected, got, help_data) => { + let (file_data, pos) = safe_file_pos(&help_data, name.len()); + SingleBuilder::new(file_data.0, file_data.1) + .pos(pos) + .text(format!( + "'{}' is an untyped R function taking {} argument(s), called with {}.", + name, expected, got + )) + .pos_text(format!("Called with {} argument(s) here", got)) + .help("Its body is not type-checked; only the number of arguments is.") + .build() + } TypeError::NoDispatchImplementation(name, forced_type, available, help_data) => { let (file_data, pos) = safe_file_pos(&help_data, name.len()); let available_pretty = available.iter().map(|t| t.pretty()).collect::>().join(", "); diff --git a/crates/typr-core/src/components/type/function_type.rs b/crates/typr-core/src/components/type/function_type.rs index 2eed93d..7e0fe31 100644 --- a/crates/typr-core/src/components/type/function_type.rs +++ b/crates/typr-core/src/components/type/function_type.rs @@ -246,8 +246,15 @@ impl FunctionType { self.help_data.clone() } + /// RFC 0028: an untyped R function's signature is exactly n `Any` + /// parameters (n possibly 0), non-variadic, returning `Any` — the shape + /// `Lang::RFunction`'s typing rule now produces. Used to pick the + /// arity-specific error message over the generic no-matching-signature + /// one when a call's argument count doesn't match. pub fn is_r_function(&self) -> bool { - (self.arguments == vec![]) && (self.return_type == builder::unknown_function_type()) + !self.is_variadic + && self.return_type == builder::any_type() + && self.arguments.iter().all(|t| *t == builder::any_type()) } pub fn get_first_param(&self) -> Option { @@ -284,7 +291,13 @@ impl TryFrom for FunctionType { fn try_from(value: Type) -> Result { match value { Type::Function(args, ret, h) => Ok(FunctionType::new(VecType::Empty, args, *ret, h)), - Type::UnknownFunction(h) => Ok(FunctionType::default().set_help_data(h.clone())), + // RFC 0028: same variadic-Any shape as `Type::to_function_type()`. + Type::UnknownFunction(h) => Ok(FunctionType::new( + VecType::Empty, + vec![ArgumentType::new("...", &builder::any_type()).set_variadic(true)], + builder::any_type(), + h.clone(), + )), _ => Err(format!("{} is a type not convertible to FunctionType", value)), } } diff --git a/crates/typr-core/src/components/type/mod.rs b/crates/typr-core/src/components/type/mod.rs index e6014e4..f232b4c 100644 --- a/crates/typr-core/src/components/type/mod.rs +++ b/crates/typr-core/src/components/type/mod.rs @@ -688,10 +688,13 @@ impl Type { (**ret_ty).clone(), h.clone(), )), + // RFC 0028: preloaded untyped builtins (`Position`, `t`, `Reduce`, + // …) carry no declared arity, so they accept any number of + // arguments — a single variadic `Any` parameter, returning `Any`. Type::UnknownFunction(h) => Some(FunctionType::new( VecType::Empty, - vec![], - builder::unknown_function_type(), + vec![ArgumentType::new("...", &builder::any_type()).set_variadic(true)], + builder::any_type(), h.clone(), )), _ => None, diff --git a/crates/typr-core/src/processes/type_checking/function_application.rs b/crates/typr-core/src/processes/type_checking/function_application.rs index 2e87e14..8cffcc6 100644 --- a/crates/typr-core/src/processes/type_checking/function_application.rs +++ b/crates/typr-core/src/processes/type_checking/function_application.rs @@ -1320,6 +1320,19 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h h.clone(), ))); } + // RFC 0028: a name bound to exactly one signature shaped like an + // untyped R function (n `Any` params, `Any` return, non-variadic — + // `Lang::RFunction`'s typing rule) gets its own arity message instead + // of `NoMatchingSignature`, which would otherwise print the callee's + // own `Any` signature back at the caller as if it were informative. + None if all_signatures.len() == 1 && all_signatures[0].is_r_function() => { + errors.push(TypRError::Type(TypeError::UntypedFunctionArity( + var.get_name(), + all_signatures[0].get_param_types().len(), + types.len(), + h.clone(), + ))); + } None if !all_signatures.is_empty() => { // The name IS bound to function signature(s) — the call just // doesn't match any of them (wrong arity or argument types). diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index 251985e..8878259 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -1741,8 +1741,22 @@ pub fn typing(context: &Context, expr: &Lang) -> TypeContext { } Lang::VecBlock { help_data: h, .. } => TypeContext::new(Type::Empty(h.clone()), expr.clone(), context.clone()), Lang::RBlock { help_data: h, .. } => TypeContext::new(Type::Empty(h.clone()), expr.clone(), context.clone()), - Lang::RFunction { help_data: h, .. } => { - TypeContext::new(Type::UnknownFunction(h.clone()), expr.clone(), context.clone()) + Lang::RFunction { + parameters, + help_data: h, + .. + } => { + // RFC 0028: an untyped R function is callable, checked on arity + // only. `parameters.len()` is the literal count of parsed bare + // names — `Lang::RFunction` has no default values and no `...` + // today, so there is nothing else to special-case here. + let params: Vec = parameters + .iter() + .enumerate() + .map(|(i, _)| ArgumentType::new(&format!("_{}", i), &builder::any_type())) + .collect(); + let func_type = Type::Function(params, Box::new(builder::any_type()), h.clone()); + TypeContext::new(func_type, expr.clone(), context.clone()) } Lang::ExternBlock { parameters: params, @@ -4742,7 +4756,15 @@ p"#; // paired with a pushed `TypeError` (AliasNotFound / // TagFieldConstructorNotSupported / UnknownUnionVariant) — never a // silent fallback, always an error-carrying result type. - const BASELINE: usize = 19; + // + // 19 -> 21 (RFC 0028): `Lang::RFunction` is now typed as a function + // of `n` `Any` parameters returning `Any`, instead of the old + // `Type::UnknownFunction` placeholder — this is the RFC's whole + // point (an untyped R function becomes callable, unchecked), not a + // silent degradation: the call site still gets a real `Type::Function` + // with the right arity, and the `Any` boundary is the documented, + // intentional cost of the escape hatch (see rfcs/0028). + const BASELINE: usize = 21; let count = production_source().matches("any_type()").count(); assert!( count <= BASELINE, diff --git a/rfcs/0028-calling-untyped-r-functions.md b/rfcs/0028-calling-untyped-r-functions.md index 8b7f0ec..e3ee1c3 100644 --- a/rfcs/0028-calling-untyped-r-functions.md +++ b/rfcs/0028-calling-untyped-r-functions.md @@ -250,9 +250,11 @@ of scope here. ## Implementation checklist -- [ ] `cases/` entry: untyped `function(a, b)` defined, called, and cast -- [ ] `cases/` entry: arity error message -- [ ] `cases/` entry: preloaded builtin called with arguments (`Position(1, 2)`) +- [x] `cases/` entry: untyped `function(a, b)` defined, called, and cast + (`cases/0062-untyped-r-function-callable`) +- [x] `cases/` entry: arity error message (`cases/0063-untyped-r-function-arity-error`) +- [x] `cases/` entry: preloaded builtin called with arguments (`Position(1, 2)`) + (`cases/0064-untyped-preloaded-builtin-variadic`) - [ ] `syntaxe.md` §12 updated in both copies - [ ] `docs/philosophy/intro.md` and `docs/reference/escape-hatches.md` updated on `typr.github.io`; the `noplayground` marker on the philosophy block From 6164464bc7c69e539552f4e095dbed5664ea1d5e Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 14:54:45 +0200 Subject: [PATCH 04/18] rfc-0000: propose external type definitions (J2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns registry.md §5 (definition format), §7 (resolution/locking) and §8.3 (conflict order) into a committed contract: typr-def.toml manifests, since/until on FunctionMeta, external .ty loading gated by a project-configured trust threshold that degrades to Any (never a hard error), typr.lock, and typr types add/update/list/vendor. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- rfcs/0000-external-type-definitions.md | 504 +++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 rfcs/0000-external-type-definitions.md diff --git a/rfcs/0000-external-type-definitions.md b/rfcs/0000-external-type-definitions.md new file mode 100644 index 0000000..98d2da0 --- /dev/null +++ b/rfcs/0000-external-type-definitions.md @@ -0,0 +1,504 @@ +- **Status:** draft +- **RFC PR:** we-data-ch/typr#0000 +- **Tracking issue:** — +- **Implemented in:** not yet +- **Start date:** 2026-09-12 + +# External type definitions + + + +## Summary + +A TypR project can declare a `.ty` file written by someone else as the type +description of an R package it imports, pin it to an exact commit and content +digest in a new `typr.lock`, and load it into the type-checking context at a +configurable trust threshold (`T1`/`T2`/`T3`). An entry below that threshold is +never rejected and never errors: it is loaded as a variadic function returning +`Any`, so an untrustworthy or wrong external definition can only make TypR +check *less*, never make a correct program stop compiling. `typr gen-types` +(already shipped) becomes the bootstrap path for this: its output is a valid +`T3` external definition, consumable the same way as a hand-written one. + +This is the second milestone (J2) of the type-registry design in +`typR/registry.md`, whose §5 (definition format), §7 (resolution and locking) +and §8.3 (conflict order) this RFC turns into a committed contract. It +deliberately excludes the registry service itself (a hosted index of +definitions, `typr search`, `typr add` auto-discovery) — that is J3 and later, +and needs no language or CLI contract change beyond what is specified here: +until it exists, `typr types add ` is the only way to point at a +definition, which is sufficient to use everything below. + +## Motivation + +TypR programs already import untyped R packages today, and the escape hatches +to describe them exist and work: `@extern`, `@importFrom` + a hand-written +`@name: (T) -> R;`, `Foreign` for opaque values, `Option` for +`from_nullable()`. What is missing is not expressive power — it is a *place to +put* those declarations that isn't the consuming project's own source, and a +way to load them safely. + +Concretely, two things are true at once and neither has an answer today: + +1. **Nobody will hand-type `shiny`.** It exports on the order of 400 functions. + `typR/typr/crates/typr-cli/src/gen_types.rs` (shipped, J1) already produces + a `.ty` file for any installed package via `Rscript`-based introspection — + but that file has nowhere to live except inside the consuming project, and + nothing loads it automatically. Two projects using `shiny` today duplicate + the same generated file, or don't bother and leave every `shiny::*` call + untyped. + +2. **A hand-written or generated definition can be wrong**, and R's ABI makes + "wrong" dangerous, not just imprecise. `typR/ai_context/external_packages_incompatibilities.md` + documents that a TypR value does not cross into a base-R function for free: + `int` is `structure(x, class = c("Integer", "integer", "Any", "Generic"))`, + `[N, T]` is an S3 list (`typed_vec`), not an atomic vector. A definition that + only states `(int) -> int` for a function that actually strips those + attributes on return produces code that type-checks and returns the wrong + runtime type — silently. There is currently no mechanism that limits the + blast radius of that mistake to "less checking" instead of "a corrupted + type", because there is no mechanism for loading a third-party `.ty` at all. + +Today, describing `dplyr::filter` requires either editing the consuming +project's own source with hand-rolled `@importFrom`/`@extern` declarations (not +shareable, not versioned against the package, redone by every project), or +nothing, leaving the call fully untyped with no hover, no signature help, no +argument names. This RFC is what turns "someone wrote this once" into +"everyone using `dplyr` in TypR benefits from it, without trusting it more than +the project asks to." + +## Guide-level explanation + +Say you are writing a Shiny app in TypR and want typed access to +`fluidPage`/`titlePanel`, and someone has already published a definition for +it. + +**Pointing at a definition.** `typr.toml` gets a new `[types]` table. It is +*not* a dependency list — `DESCRIPTION`'s `Imports:` remains the one place R +dependencies are declared, exactly as today (`typr add shiny` still runs +`usethis::use_package('shiny')` under the hood). `[types]` only says which type +description to use for a package already imported, and how much to trust +descriptions you did not pin explicitly: + +```toml +# typr.toml +[types] +trust = "T2" # T1 = paranoid, T2 = default, T3 = trust generated code too +shiny = "github:alice/typr-shiny" # explicit pin — wins over everything else +``` + +```bash +typr types add github:alice/typr-shiny +``` + +resolves `alice/typr-shiny` on GitHub, fetches its `HEAD`, checks the manifest +(below), and records exactly what was fetched in a new `typr.lock`: + +```toml +# typr.lock — generated, commit this file +[[definition]] +package = "shiny" +repository = "github:alice/typr-shiny" +version = "0.3.0" +rev = "a1b2c3d4e5f6…" +digest = "sha256:…" +tier = "T2" +r_version_seen = "1.11.1" +``` + +From then on, `typr check`/`build`/`run` load `alice/typr-shiny`'s `.ty` files +the same way they load `std.ty` today, and: + +```typr +@importFrom shiny fluidPage; +fluidPage("hello") # hover shows the real signature and doc, not "Any" +``` + +type-checks against the real declared signature, with completion and hover in +the LSP and MCP showing where the definition came from and at what tier. + +**No definition, or one you don't trust enough.** Nothing breaks. If `shiny` +had no pinned definition at all, `fluidPage` is exactly as untyped as any other +R name today — callable, arity-checked when known, returning `Any` +(`rfcs/0028-calling-untyped-r-functions.md`, already shipped). If a definition +*is* pinned but its declared tier is below the project's `trust`, the effect is +identical: every one of its entries loads as `(Any, …) -> Any` instead of its +declared signature. The project never has to remove or fight a bad definition +to keep building — lowering `trust`, or unpinning it, is enough, and doing +neither is also safe. + +**Bootstrapping your own.** If nobody has published one yet: + +```bash +typr gen-types shiny --out ./ty/ +``` + +produces a `T3` definition from the package installed locally (arity and +argument names from `formals()`, everything else `Any`) — this already ships. +This RFC adds `typr types vendor`, which copies whatever is currently resolved +(generated or fetched) into the project's own tree, so the build stops +depending on the network or on the upstream repository's continued existence. + +**How you'd explain it to an R user who knows TypR's basics but nothing about +this feature:** "The `#! tier` you've seen on standard-library entries isn't +just internal bookkeeping — you can attach the same trust levels to +descriptions of *any* R package, written by anyone, and TypR will never let a +description it doesn't fully trust turn your correct code into a type error." + +## Reference-level explanation + +### Definition repository layout and manifest + +A definition repository (`typr-shiny/` in the example above) has this shape: + +```text +typr-shiny/ +├── README.md +├── typr-def.toml # manifest +├── ty/ +│ ├── core.ty +│ └── ui.ty +├── R/ # optional — see Capabilities and R shims below +│ └── shims.R +└── tests/ + └── smoke.ty # a program that must compile against this definition +``` + +`typr-def.toml`, not `typr.toml`, on purpose: the two files answer different +questions (the definition's own metadata vs. a consuming project's +configuration) and living in different repositories does not stop someone from +copy-pasting one into the other by habit if the name is shared. + +```toml +format_version = 1 # REQUIRED — an unknown format_version is refused, not guessed at + +[package] +name = "shiny" +since = "1.11.0" # a floor, not a closed range — see Version compatibility below +# until = "2.0.0" # only when a break is *known*, never speculative + +[definition] +version = "0.3.0" # semver of the definition itself, independent of the package's +tier = "T2" # default tier for entries with no `#! tier:` of their own + +[provider] +type = "community" # official | community | generated | local +repository = "github:alice/typr-shiny" + +[capabilities] +r_shims = false # ships executable R alongside the declarations? +extern_raw = false # uses `extern: (...) -> T r#"...R..."#` verbatim blocks? +``` + +`format_version` is what keeps this survivable across N repositories the +project does not control: when the definition format changes, the compiler +reads old manifests it recognizes or refuses the ones it doesn't — it never +silently misparses one. + +### `.ty` files and the two new annotations + +Definitions are ordinary `.ty` files, parsed and type-checked the same way as +`std.ty`, with the same `#!` annotation block already implemented in +`crates/typr-core/src/processes/spg/stdlib_meta.rs` (`pkg`, `tier`, `param`, +`ret`, `coercion`/`note`, `example`, `seealso`). That parser currently drops +any key it does not recognize (`_ => {} // ignore silently`), which is exactly +how it stays forward-compatible — this RFC uses that door to add two new keys +rather than changing the parser's shape: + +```typr +#! pkg: shiny +#! tier: T2 +#! since: 1.11.0 +#! ret: UI object, opaque on the TypR side +#! example: fluidPage(titlePanel("hello")) +@importFrom shiny fluidPage; +@fluidPage: (Any) -> Foreign; + +#! pkg: dplyr +#! tier: T3 +#! since: 1.1.0 +#! until: 2.0.0 +#! param .data: input table +@importFrom dplyr filter; +@filter: (Any, Any) -> Any; +``` + +`FunctionMeta` gains `since: Option` and `until: Option`, +parsed identically to `ret`/`coercion` (a single value, `strip_leading_colon`). +Nothing about `@extern`/`@importFrom`/`Foreign` changes: choosing between +them *is* the declaration of how the boundary is crossed +(`typR/ai_context/tuto_external_packages.md`), and that choice is exactly what +these files make. + +### Loading external `.ty` into the context + +`crates/typr-cli/src/standard_library.rs` builds the checking context from +`R_T1_SOURCES` (plus `R_DOC_ONLY_SOURCES`) — a fixed, embedded list of +`(filename, source)` pairs. This RFC adds a second list built at project-load +time from `typr.lock`: for each resolved definition, read its `.ty` files from +the on-disk cache (below), parse `#! tier` per entry (falling back to the +manifest's `[definition] tier` when absent), and merge into the same table +`build_typed_vartype` already builds from `R_T1_SOURCES` — with one difference +from today's uniform T1 preload: **each entry's tier is compared against the +project's configured `trust`.** + +- entry tier ≥ project `trust`: loaded with its declared signature, exactly as + `R_T1_SOURCES` entries are today. +- entry tier < project `trust`: loaded as `Type::UnknownFunction` (variadic, + returns `Any`) instead of its declared signature — the same representation + `rfcs/0028-calling-untyped-r-functions.md` already gives every untyped R + name. No error, no warning bubbled up to a build failure; the LSP and MCP + still surface the declared signature and doc (`§11` below), only the + type-checker itself degrades. +- package with no resolved definition at all: unchanged from today — every + name from it is `UnknownFunction` unless the project's own source declares + it. + +This is the mechanism that makes D2 (`typR/registry.md` §0) real: nothing +downstream of this merge step needs to know *why* an entry became `Any` — a +missing definition, a low tier, and an out-of-range version (next section) all +collapse to the same representation. + +### Version compatibility: a floor, not a range + +R has no npm/cargo-style version resolution — CRAN publishes one current +version, and a user has "whatever is installed". Declaring `supports = +["1.11.x"]` would make compatibility depend on the resolving machine (breaking +`typr.lock`'s point), produce false "incompatible" verdicts the moment a +package ships a patch release (R rarely breaks its own API), and require +upkeep nobody will do. + +Instead: `since` is a floor. At load time, the introspected installed version +(already recovered by `introspect_pkg.R`'s `P` line, used today by +`r_name_cache`) is compared against it: + +- installed ≥ `since` (and ≤ `until`, when present): used without warning. +- installed < `since`, or > `until` when declared: TypR **warns and degrades to + `Any`** for that package's entries — never refuses to build. The version + actually observed is written into `typr.lock` as `r_version_seen`, which is + what lets any future CI on a definition repository notice drift between what + it declares and what real installs report. + +### Resolution, `typr.lock`, cache, vendoring + +```text +typr add shiny + → DESCRIPTION: Imports += shiny (usethis — unchanged) + → typr.toml [types]: explicit pin for `shiny`? + yes → use it, done + no → no definition resolved (until J3's registry exists to search) + → fetch resolved repository at HEAD, or the pinned rev + → verify content digest + → ~/.cache/typr/types/// + → typr.lock updated + → available to the compiler, the LSP and the MCP +``` + +New CLI surface (`crates/typr-cli/src/cli.rs`, alongside the existing `Add`, +`Check`, `Cache` subcommands): + +| Command | Role | +|---|---| +| `typr add ` | unchanged (`usethis::use_package`) — this RFC adds nothing here until J3 lets it also look up a definition automatically | +| `typr types add ` | pin a definition (`github:owner/repo[@rev]`) — fetch, verify, write into `typr.toml`/`typr.lock` | +| `typr types update [pkg]` | re-fetch and re-pin `typr.lock` for one or all resolved definitions | +| `typr types list` | what's resolved, with tier and provenance | +| `typr types vendor` | copy resolved `.ty` files into the project tree, so the build no longer depends on the network or on the upstream repository still existing | + +`typr.toml`'s `[types]` and `typr.lock` are new files with no analog in the +project today; `DESCRIPTION` is untouched and remains authoritative for R +dependencies (`§7.1` — one source of truth per question). + +### Capabilities and R shims + +A `.ty` file is inert; `extern: (…) -> T r#"…R…"#` blocks and any `R/` shim +directory are not — they execute in the consuming project's process. The +threat model is `npm postinstall`'s, not `@types/*`'s package.json's. This RFC +adopts the manifest's `[capabilities]` gate as a hard rule, not a convention: + +- a definition whose manifest leaves `r_shims`/`extern_raw` at their default + (`false`) but ships either anyway is **rejected at fetch time**, before a + single byte of it is loaded — not just flagged in CI on the definition's own + repository. +- a definition that sets either to `true` shows an explicit warning on the + first `typr types add`/`typr add` that resolves it, and requires + confirmation (or `--allow-r`). +- nothing is executed during discovery or resolution: fetching a definition is + a source download, full stop; only actually building/running the consuming + project can execute a shim, exactly as it would execute any other R in the + project. + +### Failure modes are never hard errors + +Every one of the following degrades to the *some entries are `Any`* case +above; none of them fails a build: + +- the pinned repository is unreachable (network, deleted, rewritten history — + digest mismatch on the cached copy). +- the resolved manifest has no `format_version`, or one this compiler build + doesn't recognize (the *definition* is refused; the package's names fall + back to fully untyped, same as having none). +- `since`/`until` exclude the installed R package version. +- the project's `trust` excludes the definition's declared tier. + +## Gradual typing + +This proposal is gradual typing's boundary case taken to its logical end: a +package with **zero** resolved definitions behaves exactly as today (every +name `UnknownFunction`, callable, arity-checked when knowable, `Any`-typed +result — `rfcs/0028`). Resolving a `T3` (generated) definition changes *only* +argument names and documentation surfaced to a human or to the LSP/MCP; the +type-checker's view is unchanged, because `T3` entries load exactly like +`UnknownFunction` today. Type constraints appear at `T2`/`T1`, and only for the +entries that actually declare them, only when the project's own `trust` +accepts that tier. There is no annotation density this proposal forces: +a project that never touches `typr.toml [types]` sees no behavior change at +all. + +## Drawbacks + +- **Two new project files** (`typr.toml [types]`, `typr.lock`) for a project + type (R packages) that already has one dependency manifest (`DESCRIPTION`). + The mitigation is the explicit division of labor in §7.1 of + `typR/registry.md`, but it is still a second file to explain to newcomers. +- **The trust/degradation model is invisible by default.** A project that sets + `trust = "T2"` and pins a `T3` definition gets *no* type checking for that + package and no error telling it so — by design (D2), but it means a mistake + here reads as "TypR isn't catching this" rather than as a loud failure. + `typr types list` showing tier per package is the mitigation; it needs to be + something people actually run. +- **Fetching arbitrary GitHub repositories as part of a build-adjacent command** + (`typr types add`, `typr types update`) is new network/trust surface for a + compiler CLI that has not had it before, even with the capability gate in + place. The digest-pinning in `typr.lock` bounds this to "you get what you + first approved," not "you get whatever is at HEAD right now" — but it is a + new class of thing this CLI does. +- **`since`/`until` add two more `#!` keys to a metadata format that already + has seven**; each addition is small, but the format is not designed against + a fixed budget, and every key is a piece every generator and consumer has to + handle for good, per `format_version`'s forward-compatibility promise. + +## Rationale and alternatives + +The central choice is D2 (`typR/registry.md` §0): **degrade to `Any`, never +error.** The alternative — treat a definition below trust, or a version +mismatch, as a hard type error — was considered and rejected, because a +third-party definition is exactly the kind of input a project does not +control, and "a package you don't maintain published something" becoming +"your correct code stops compiling" is the single failure mode a community +type registry cannot survive. This is only possible because +`rfcs/0028-calling-untyped-r-functions.md` already made `UnknownFunction` +variadic and callable — before that RFC, "degrade to `Any`" had nowhere safe to +land. + +The second choice is pinning by content digest in a lockfile rather than a +semver range (`registry.md` §7.2, §D4/§D5): R has no ecosystem-wide version +resolver, so a range like `supports = ["1.11.x"]` would depend on the +resolving machine and rot without anyone noticing. A digest is the only thing +that is reproducible without inventing R version-range semantics R itself +doesn't have. + +The cost of doing nothing is not small: it is the status quo described in +Motivation — every project hand-rolling or duplicating its own `@importFrom` +declarations for the same handful of popular packages, with no sharing +mechanism at all. `typr gen-types` (J1, already shipped) is only half-useful +without this RFC, because its output currently has nowhere to be shared or +loaded except by hand-copying files between projects. + +## Prior art + +- **R itself**: no package in CRAN or Bioconductor ships a machine-readable + type description of its own API; `Rd`/roxygen comments are prose. There is + no existing convention to be compatible with, which is why this proposal + invents one rather than adapting one. +- **DefinitelyTyped** (`@types/*` for TypeScript) is the closest analog: + community-maintained type descriptions decoupled from the packages they + describe, in a monorepo. Its main lesson, already reflected in + `registry.md` §8.2 (not this RFC's scope, since J2 has no registry yet): a + single monorepo makes format migrations tractable across a long tail of + small packages, at the cost of requiring PRs into a repo the community + doesn't fully own. Its second lesson, which *is* this RFC's concern: + `@types` packages can and do drift from the real library's behavior, and + TypeScript has no equivalent of D2 — a wrong `@types` package produces + compile errors on correct code, which is the exact failure this RFC's + degradation rule is designed to avoid. +- **Rust's `cargo vendor`** is the direct model for `typr types vendor`: + making a build reproducible and independent of an upstream repository's + continued existence, distinct from the cache used for day-to-day iteration. +- **npm's `postinstall` scripts** are the threat model for the capabilities + gate in §"Capabilities and R shims" — the industry's cautionary tale for + "a dependency description can execute arbitrary code," which is why this + RFC treats `r_shims`/`extern_raw` as a hard gate rather than a documented + convention. + +## Unresolved questions + +Carried over from `registry.md` §14, to the extent they bear on J2 rather than +the registry service itself: + +- **Q1** — is `typr-def.toml` the right filename, or should it be folded into + something reused elsewhere? This RFC takes the name as settled for the + purpose of shipping J2; revisiting it later is a rename, not a redesign. +- **Q2** — do third-party definitions get to ship R at all (shims, + `extern r#"…"#`)? This RFC allows it behind the `[capabilities]` gate, but + whether entire categories of package (those needing S3/S4 shims) are + reachable *without* shims is open, and may push some of that need into the + standard library instead of third-party definitions. +- **Q4** — a definition's own `type X <- Foreign;` declarations leak into + the consuming project's namespace; this RFC does not specify a namespacing + rule for that, and two definitions declaring the same type name is + unresolved. +- **Q5** — can a definition be type-checked (in the definition repository's + own CI, or when a consuming project resolves it) when its package is not + installed locally? This determines whether definition repositories, and + projects that pin them, need R + the package installed just to run `typr + check`. Left open for J2; matters more once J4 (registry CI) exists. + +## Future possibilities + +Explicitly out of scope here, deferred to `registry.md`'s later milestones: + +- **J3 — the registry itself**: a hosted index (`typr-lang/registry`) that + lets `typr add`/`typr types update` *discover* a definition instead of + requiring an explicit `typr types add `, plus `typr search`. Nothing + in this RFC needs to change for that to land — J3 only adds a lookup step + before the resolution flow specified here, and the conflict order in + `registry.md` §8.3 (explicit pin, then official, then community by tier, + then locally generated, then nothing) already anticipates it. +- **J4 — registry CI**: mechanical validation (exports exist, arity matches + `formals()`, no unconstrained `...` at `T1`) and drift re-detection over + time, run centrally instead of per-consumer. +- **J6 — the Store**: a web UI over the registry. Explicitly optional per + `registry.md` D6, and needs nothing from this RFC beyond the manifest and + lockfile shapes already specified. +- Typing S4/R6/S7 by anything more precise than `Foreign` is its own RFC, + out of scope here as it is in `registry.md` §15. + +## Implementation checklist + + + +- [ ] `typr-def.toml` manifest parsing + `format_version` gate +- [ ] `since`/`until` added to `FunctionMeta` (`stdlib_meta.rs`) and to the + manifest +- [ ] external `.ty` loading merged into `standard_library.rs`'s context build, + keyed by resolved `typr.lock` entries +- [ ] `trust` threshold + degrade-to-`UnknownFunction` at merge time +- [ ] `typr types add|update|list|vendor`, `typr.lock` read/write, digest + verification, `~/.cache/typr/types///` +- [ ] `cases/`: missing definition, tier below `trust`, version below `since`, + version above `until`, unreachable repository, digest mismatch — each + asserting *no hard error*, only degradation +- [ ] `[capabilities]` gate enforced at fetch time (reject undeclared R; + confirm-or-`--allow-r` for declared) +- [ ] `syntaxe.md` — no lexeme changes expected, but confirm before merge +- [ ] Documentation PR on `we-data-ch/typr.github.io` (a How-to page for + consuming an external definition; a Reference page for the manifest and + `#!` keys), landing in the same release +- [ ] `Implemented in:` filled in above From c57588e05fe3f3ee5c01d7b26f97924d31678a01 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 14:55:07 +0200 Subject: [PATCH 05/18] rfc-0000: fill in the PR number now that #31 is open Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- rfcs/0000-external-type-definitions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/0000-external-type-definitions.md b/rfcs/0000-external-type-definitions.md index 98d2da0..9ee5a5d 100644 --- a/rfcs/0000-external-type-definitions.md +++ b/rfcs/0000-external-type-definitions.md @@ -1,5 +1,5 @@ - **Status:** draft -- **RFC PR:** we-data-ch/typr#0000 +- **RFC PR:** we-data-ch/typr#31 - **Tracking issue:** — - **Implemented in:** not yet - **Start date:** 2026-09-12 From f36a5bf8ec7135c1e8ea8865485981eef31c8f6a Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 15:05:25 +0200 Subject: [PATCH 06/18] update --- cases/0065-reassign-widen-literal/case.toml | 7 + cases/0065-reassign-widen-literal/expect.md | 36 ++ cases/0065-reassign-widen-literal/expect.toml | 11 + .../0065-reassign-widen-literal/observed.txt | 41 +++ .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 10 + crates/typr-cli/configs/src/introspect_pkg.R | 18 + crates/typr-cli/src/cases.rs | 10 +- crates/typr-cli/src/cli.rs | 13 + crates/typr-cli/src/gen_types.rs | 327 ++++++++++++++++++ crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 1 + crates/typr-cli/src/r_name_cache.rs | 5 +- .../src/processes/type_checking/mod.rs | 24 +- rfcs/0028-calling-untyped-r-functions.md | 11 +- 17 files changed, 510 insertions(+), 11 deletions(-) create mode 100644 cases/0065-reassign-widen-literal/case.toml create mode 100644 cases/0065-reassign-widen-literal/expect.md create mode 100644 cases/0065-reassign-widen-literal/expect.toml create mode 100644 cases/0065-reassign-widen-literal/observed.txt create mode 100644 cases/0065-reassign-widen-literal/repro/DESCRIPTION create mode 100644 cases/0065-reassign-widen-literal/repro/NAMESPACE create mode 100644 cases/0065-reassign-widen-literal/repro/R/.gitkeep create mode 100644 cases/0065-reassign-widen-literal/repro/TypR/main.ty create mode 100644 crates/typr-cli/src/gen_types.rs diff --git a/cases/0065-reassign-widen-literal/case.toml b/cases/0065-reassign-widen-literal/case.toml new file mode 100644 index 0000000..2e382bd --- /dev/null +++ b/cases/0065-reassign-widen-literal/case.toml @@ -0,0 +1,7 @@ +title = "reassignment narrows a mutable variable to the RHS literal's singleton type" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-12" +origin = "perso" +checked = false diff --git a/cases/0065-reassign-widen-literal/expect.md b/cases/0065-reassign-widen-literal/expect.md new file mode 100644 index 0000000..dc2ac7f --- /dev/null +++ b/cases/0065-reassign-widen-literal/expect.md @@ -0,0 +1,36 @@ +# reassign-widen-literal + +Source: found while updating `docs/reference/bindings-mutation.md` on `typr.github.io` — its +"Reassignment & mutation" example (`let x <- 0; x <- 10; x <- x + 1; x |> f() |> g()!;`) no +longer type-checks. + +## Ce qui devrait se passer + +Reassigning an already-bound `let`-declared variable (`x <- 10;`, no `let`) to a new value of the +same base kind should keep working across any number of reassignments — that's the entire point +of `docs/reference/bindings-mutation.md`'s "Reassignment & mutation" section and of the `expr!;` +implicit-mutation sugar (`ai_context/in_place.md`), which desugars to exactly this form +(`lhs <- expr`). + +## Anomalies + +`observed.txt` shows three failures for one linear sequence of reassignments to a single `int` +variable: + +1. `let x <- 0;` (no annotation) infers `x` as the *singleton* literal type `Integer(Val(0))` — + confirmed deliberate elsewhere (`test_let_expression0`, + `crates/typr-core/src/processes/type_checking/let_expression.rs`: `let a <- 5;` types `a` as + `integer_type(5)`). Reassigning to a *different* literal (`x <- 10;`) then fails the subtype + check outright: "type 0 doesn't match type 10". +2. Even past that (e.g. with an explicit `let x: int <- 0;` instead), the *first* successful + assignment rebinds `x`'s type in context to the literal RHS type (`Integer(Val(10))`) rather + than keeping it widened — so the *next* reassignment fails just the same: + "type 10 doesn't match type int" — `x`'s type keeps narrowing one literal at a time instead + of staying at its base kind. +3. The `!;` implicit-mutation sugar (`x |> f() |> g()!;`) desugars to the same `Assign` node + (`ai_context/in_place.md` §5.2), so it inherits the same failure once `x` has narrowed. + +Root cause: `Lang::Assign`'s typing arm (`processes/type_checking/mod.rs`) stores the RHS's raw +type as the variable's new type on every successful assignment, instead of widening a literal +singleton to its base kind the way `Type::generalize()` already does elsewhere (e.g. +`cases/0017-char-if-widening`). diff --git a/cases/0065-reassign-widen-literal/expect.toml b/cases/0065-reassign-widen-literal/expect.toml new file mode 100644 index 0000000..aedb1f8 --- /dev/null +++ b/cases/0065-reassign-widen-literal/expect.toml @@ -0,0 +1,11 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +[[rule]] +file = "@run" +must_not_contain = "doesn't match type" diff --git a/cases/0065-reassign-widen-literal/observed.txt b/cases/0065-reassign-widen-literal/observed.txt new file mode 100644 index 0000000..9052711 --- /dev/null +++ b/cases/0065-reassign-widen-literal/observed.txt @@ -0,0 +1,41 @@ +# exit=1 + +## stdout/stderr + + Parsing... done (7 ms) + Type checking... failed +Type errors found: + × Type error: type 0 doesn't match type 10 + ╭─[TypR/main.ty:5:2] + 4 │ + 5 │ x <- 10; + · ▲ ┬ + · │ ╰── Received 10 + · ╰── Expected 0 + 6 │ x <- x + 1; + ╰──── + + × Type error: type 0 doesn't match type int + ╭─[TypR/main.ty:6:2] + 5 │ x <- 10; + 6 │ x <- x + 1; + · ▲ ┬ + · │ ╰── Received int + · ╰── Expected 0 + 7 │ + ╰──── + + × Type error: type 0 doesn't match type int + ╭─[TypR/main.ty:8:14] + 7 │ + 8 │ x |> f() |> g()!; + · ┬ ▲ + · │ ╰── Expected 0 + · ╰── Received int + 9 │ + ╰──── + + + +## R généré + diff --git a/cases/0065-reassign-widen-literal/repro/DESCRIPTION b/cases/0065-reassign-widen-literal/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0065-reassign-widen-literal/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0065-reassign-widen-literal/repro/NAMESPACE b/cases/0065-reassign-widen-literal/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0065-reassign-widen-literal/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0065-reassign-widen-literal/repro/R/.gitkeep b/cases/0065-reassign-widen-literal/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0065-reassign-widen-literal/repro/TypR/main.ty b/cases/0065-reassign-widen-literal/repro/TypR/main.ty new file mode 100644 index 0000000..47111a6 --- /dev/null +++ b/cases/0065-reassign-widen-literal/repro/TypR/main.ty @@ -0,0 +1,10 @@ +let x <- 0; +let f <- fn(a: int): int { a + 1 }; +let g <- fn(a: int): int { a * 2 }; + +x <- 10; +x <- x + 1; + +x |> f() |> g()!; + +print(x); diff --git a/crates/typr-cli/configs/src/introspect_pkg.R b/crates/typr-cli/configs/src/introspect_pkg.R index a7f795f..ab1ecce 100644 --- a/crates/typr-cli/configs/src/introspect_pkg.R +++ b/crates/typr-cli/configs/src/introspect_pkg.R @@ -18,7 +18,9 @@ # machine during a build and must not require any package to be installed. # # Vr_version +# Ppkgversion (package version, if determinable) # Nnamepkgs3_generics4_generichas_default +# Fnamehas_dotsparam1param2... (formals(), for `typr gen-types`) # Cs4_class_name # Epkgreason (package could not be loaded) @@ -60,6 +62,9 @@ for (pkg in args) { next } + ver <- tryCatch(as.character(utils::packageVersion(pkg)), error = function(e) NA_character_) + if (!is.na(ver) && tsv_safe(ver)) emit("P", pkg, ver) + exports <- tryCatch(getNamespaceExports(ns), error = function(e) character(0)) # S4 generics visible now that the namespace is loaded. `getGenerics()` is @@ -89,6 +94,19 @@ for (pkg in args) { if (n %in% s4_names) "1" else "0", if (has_default) "1" else "0" ) + + # formals() — arity and parameter names, for `typr gen-types` (registry.md + # §6). Primitives report `formals()` as NULL; `args(val)` recovers theirs. + formals_val <- tryCatch(formals(val), error = function(e) NULL) + if (is.null(formals_val)) { + formals_val <- tryCatch(formals(args(val)), error = function(e) NULL) + } + if (!is.null(formals_val)) { + param_names <- names(formals_val) + has_dots <- "..." %in% param_names + params <- Filter(tsv_safe, param_names[param_names != "..."]) + emit("F", n, if (has_dots) "1" else "0", params) + } } # NOTE: `getClasses()` reads its caller's environment when `where` is left to diff --git a/crates/typr-cli/src/cases.rs b/crates/typr-cli/src/cases.rs index d9730d0..c2eeb34 100644 --- a/crates/typr-cli/src/cases.rs +++ b/crates/typr-cli/src/cases.rs @@ -453,7 +453,15 @@ pub fn add(slug: &str, from: Option, cmd: &str, layer: &str) { if !cases.exists() { let _ = std::fs::create_dir_all(&cases); } - let n = case_dirs().len() + 1; + // Next id from the highest existing numeric prefix, not the directory + // count: cases/ has gaps (deleted/renumbered entries), so `len() + 1` + // collides with an already-used id the moment count < max + 1. + let n = case_dirs() + .iter() + .filter_map(|p| basename(p).split('-').next().and_then(|s| s.parse::().ok())) + .max() + .unwrap_or(0) + + 1; let id = format!("{:04}", n); let dir = cases.join(format!("{id}-{slug}")); if let Err(e) = std::fs::create_dir_all(&dir) { diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index f949759..3ac26e6 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -173,6 +173,17 @@ enum Commands { #[arg(long, short, value_name = "FILE")] output: Option, }, + /// Generate a `.ty` type definition for an *installed* R package by + /// introspecting its exports (arity, argument names, presence of `...`), + /// entirely at `#! tier: T3` — see `typR/registry.md` §6. Never fails a + /// build: a generated definition types every parameter and return value + /// as `Any`. + GenTypes { + package: String, + /// Directory to write `.generated.ty` into (default: `ty/`). + #[arg(long, short, value_name = "DIR")] + out: Option, + }, } #[derive(Subcommand, Debug)] @@ -304,6 +315,7 @@ fn skips_r_deps_check(command: &Option) -> bool { | Some(Commands::Std { .. }) | Some(Commands::Cache { .. }) | Some(Commands::Syntax { .. }) + | Some(Commands::GenTypes { .. }) ) } @@ -430,6 +442,7 @@ pub fn start() { check, }) => run_syntax_command(json, target, output, write, check), Some(Commands::Spg { output }) => generate_spg(output), + Some(Commands::GenTypes { package, out }) => crate::gen_types::run(&package, out), _ => { println!("Please specify a subcommand or file to execute"); std::process::exit(1); diff --git a/crates/typr-cli/src/gen_types.rs b/crates/typr-cli/src/gen_types.rs new file mode 100644 index 0000000..f4304d5 --- /dev/null +++ b/crates/typr-cli/src/gen_types.rs @@ -0,0 +1,327 @@ +//! `typr gen-types ` — J1 of the type registry proposal (see +//! `typR/registry.md` §6, "Génération automatique — le chemin principal"). +//! +//! Nobody will hand-write signatures for a 400-function package like `shiny`. +//! This command introspects an *installed* R package (arity, argument names, +//! presence of `...`, version) and emits a `.ty` file where every entry is +//! `#! tier: T3`: every parameter and the return type are `Any`, so the +//! generated definition cannot make the type-checker reject correct code +//! (registry.md D2) — it is immediately useful to the LSP and MCP (argument +//! names, doc scaffolding) while being a no-op for soundness. Promotion to +//! T2/T1 is a manual, per-entry follow-up (registry.md §6, "Promotion"). +//! +//! Reuses `r_name_cache`'s `introspect_pkg.R`: that script's `F`/`P` lines +//! (formals, package version) exist for this command; its `N`/`V`/`C`/`E` +//! lines are what the R-name cache itself consumes. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::r_name_cache::INTROSPECT_R; + +/// One exported function, as recovered from `formals()`. +pub struct GeneratedFn { + pub name: String, + pub has_dots: bool, + pub params: Vec, +} + +pub struct Introspection { + pub r_version: String, + pub pkg_version: Option, + pub functions: Vec, + /// Human-readable notes about packages that failed to load. + pub errors: Vec, +} + +/// Whether a name can appear in `@importFrom pkg ;` and as a plain +/// (optionally backtick-quoted) `@name: ...;` signature — i.e. it is not an +/// operator or other non-identifier export (`%>%`, `[.foo`, `+.money`, ...), +/// which `gen-types` has no safe syntax to emit and simply skips. +fn is_generatable_name(name: &str) -> bool { + !name.is_empty() + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_') + && name.chars().any(|c| c.is_ascii_alphabetic()) +} + +pub fn rscript_available() -> bool { + Command::new("Rscript") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Spawn `Rscript introspect_pkg.R ` and return its stdout, exactly like +/// `r_name_cache::run_introspection` but for a single package at a time. +fn run_introspection(pkg: &str) -> Result { + let script = std::env::temp_dir().join(format!("typr_gen_types_{}.R", std::process::id())); + fs::write(&script, INTROSPECT_R).map_err(|e| format!("cannot write introspection script: {e}"))?; + let result = Command::new("Rscript").arg(&script).arg(pkg).output(); + let _ = fs::remove_file(&script); + let output = result.map_err(|e| format!("Rscript could not be run ({e})"))?; + if !output.status.success() { + return Err(format!( + "Rscript exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Introspect `pkg` (must be installed locally) and collect its exported +/// functions' arity and parameter names. +pub fn introspect(pkg: &str) -> Result { + let raw = run_introspection(pkg)?; + + let mut r_version = String::new(); + let mut pkg_version = None; + let mut functions = Vec::new(); + let mut errors = Vec::new(); + + for line in raw.lines() { + let mut fields = line.split('\t'); + match fields.next() { + Some("V") => r_version = fields.next().unwrap_or_default().to_string(), + Some("P") => { + let _pkg = fields.next(); + pkg_version = fields.next().map(str::to_string); + } + Some("F") => { + let (Some(name), Some(has_dots)) = (fields.next(), fields.next()) else { + continue; + }; + if !is_generatable_name(name) { + continue; + } + functions.push(GeneratedFn { + name: name.to_string(), + has_dots: has_dots == "1", + params: fields.map(str::to_string).collect(), + }); + } + Some("E") => { + if let Some(pkg) = fields.next() { + let reason = fields.next().unwrap_or("unknown reason"); + errors.push(format!("{pkg}: {reason}")); + } + } + _ => {} + } + } + + Ok(Introspection { + r_version, + pkg_version, + functions, + errors, + }) +} + +/// Render an `@name: (...) -> Any;` signature name, backtick-quoting it when +/// it contains a `.` — the convention already used throughout +/// `configs/std/*.ty` for names like `` `is.numeric` `` (a bare `.` inside a +/// TypR identifier would otherwise parse as field access). +fn signature_name(name: &str) -> String { + if name.contains('.') { + format!("`{name}`") + } else { + name.to_string() + } +} + +/// Emit the `.ty` source for every generatable function of `pkg`, all at +/// `#! tier: T3` (registry.md §5.4 / §6). +pub fn emit_ty(pkg: &str, info: &Introspection) -> String { + let mut out = String::new(); + let version = info.pkg_version.as_deref().unwrap_or("unknown"); + let generated_from = format!("{pkg} {version}, R {}", info.r_version); + + if info.functions.is_empty() { + return out; + } + + let names: Vec<&str> = info.functions.iter().map(|f| f.name.as_str()).collect(); + out.push_str(&format!("@importFrom {pkg} {};\n\n", names.join(" "))); + + for f in &info.functions { + out.push_str("#! pkg: "); + out.push_str(pkg); + out.push('\n'); + out.push_str("#! tier: T3\n"); + out.push_str(&format!("#! generated-from: {generated_from}\n")); + for p in &f.params { + out.push_str(&format!("#! param {p}:\n")); + } + if f.has_dots { + out.push_str("#! param ...:\n"); + } + + let mut arg_types: Vec<&str> = vec!["Any"; f.params.len()]; + if f.has_dots { + arg_types.push("...Any"); + } + out.push_str(&format!( + "@{}: ({}) -> Any;\n\n", + signature_name(&f.name), + arg_types.join(", ") + )); + } + + out +} + +/// `typr gen-types [--out DIR]` — see registry.md §6. +pub fn run(pkg: &str, out_dir: Option) { + if !rscript_available() { + eprintln!("error: `Rscript` is not on PATH — `typr gen-types` needs R to introspect `{pkg}`."); + std::process::exit(1); + } + + let info = match introspect(pkg) { + Ok(info) => info, + Err(e) => { + eprintln!("error: could not introspect `{pkg}`: {e}"); + std::process::exit(1); + } + }; + + for note in &info.errors { + eprintln!("warning: {note}"); + } + if info.functions.is_empty() { + eprintln!( + "error: `{pkg}` introspected but exports nothing gen-types can generate a signature for \ + (not installed, or every export is an operator/non-identifier form)." + ); + std::process::exit(1); + } + + let content = emit_ty(pkg, &info); + + let dir: PathBuf = out_dir.unwrap_or_else(|| Path::new("ty").to_path_buf()); + if let Err(e) = fs::create_dir_all(&dir) { + eprintln!("error: could not create `{}`: {e}", dir.display()); + std::process::exit(1); + } + let path = dir.join(format!("{pkg}.generated.ty")); + if let Err(e) = fs::write(&path, &content) { + eprintln!("error: could not write `{}`: {e}", path.display()); + std::process::exit(1); + } + + println!( + "{} — {} function(s), all T3 (generated, unverified) → {}", + pkg, + info.functions.len(), + path.display() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Introspection { + Introspection { + r_version: "4.5.2".to_string(), + pkg_version: Some("1.1.4".to_string()), + functions: vec![ + GeneratedFn { + name: "filter".to_string(), + has_dots: true, + params: vec![".data".to_string()], + }, + GeneratedFn { + name: "is.numeric".to_string(), + has_dots: false, + params: vec!["x".to_string()], + }, + ], + errors: vec![], + } + } + + #[test] + fn skips_operator_and_non_identifier_exports() { + assert!(is_generatable_name("filter")); + assert!(is_generatable_name("is.numeric")); + assert!(!is_generatable_name("%>%")); + assert!(!is_generatable_name("[.foo")); + assert!(!is_generatable_name("+.money")); + assert!(!is_generatable_name("")); + assert!(!is_generatable_name("...")); + } + + #[test] + fn quotes_dotted_signature_names_only() { + assert_eq!(signature_name("filter"), "filter"); + assert_eq!(signature_name("is.numeric"), "`is.numeric`"); + } + + #[test] + fn emits_import_from_and_variadic_signature() { + let ty = emit_ty("dplyr", &sample()); + + assert!(ty.starts_with("@importFrom dplyr filter is.numeric;\n")); + assert!(ty.contains("#! tier: T3\n")); + assert!(ty.contains("#! generated-from: dplyr 1.1.4, R 4.5.2\n")); + assert!(ty.contains("#! param .data:\n")); + assert!(ty.contains("#! param ...:\n")); + assert!(ty.contains("@filter: (Any, ...Any) -> Any;\n")); + assert!(ty.contains("@`is.numeric`: (Any) -> Any;\n")); + } + + #[test] + fn empty_introspection_emits_nothing() { + let info = Introspection { + r_version: "4.5.2".to_string(), + pkg_version: None, + functions: vec![], + errors: vec![], + }; + assert!(emit_ty("empty", &info).is_empty()); + } + + /// Real end-to-end check against packages actually installed on this + /// machine (registry.md J1: "tests sur 3 packages contrastés"). Fails + /// open — like every other Rscript-dependent test in this crate — since + /// CI's `test` job runs with no R and none of these packages installed. + #[test] + fn generated_ty_type_checks_for_contrasting_packages() { + if !rscript_available() { + eprintln!("skipping: Rscript not on PATH"); + return; + } + for pkg in ["jsonlite", "httr2", "dplyr"] { + let info = match introspect(pkg) { + Ok(info) if !info.functions.is_empty() => info, + _ => { + eprintln!("skipping {pkg}: not installed on this machine"); + continue; + } + }; + let ty = emit_ty(pkg, &info); + assert!(!ty.is_empty(), "{pkg}: generated nothing"); + + let dir = std::env::temp_dir().join(format!("typr_gen_types_test_{pkg}_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + crate::engine::write_std_for_type_checking(&dir); + let file = dir.join(format!("{pkg}.generated.ty")); + fs::write(&file, &ty).unwrap(); + + let context = typr_core::components::context::Context::default() + .set_environment(typr_core::components::context::config::Environment::Project); + let (lang, syntax_errors) = crate::engine::parse_code(&file, context.get_environment()); + assert!(syntax_errors.is_empty(), "{pkg}: syntax errors: {syntax_errors:?}"); + let type_checker = + typr_core::processes::type_checking::type_checker::TypeChecker::new(context).typing_no_panic(&lang); + assert!(!type_checker.has_errors(), "{pkg}: generated .ty failed to type-check"); + + let _ = fs::remove_dir_all(&dir); + } + } +} diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index 009c363..9d9536e 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -44,6 +44,7 @@ pub mod cases; pub mod cli; pub mod engine; pub mod fuzz; +pub mod gen_types; pub mod io; pub mod md_renderer; pub mod metaprogramming; diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index 9442ff8..36b5485 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -7,6 +7,7 @@ mod cases; mod cli; mod engine; mod fuzz; +mod gen_types; mod io; mod md_renderer; mod metaprogramming; diff --git a/crates/typr-cli/src/r_name_cache.rs b/crates/typr-cli/src/r_name_cache.rs index 85fb65a..c34d928 100644 --- a/crates/typr-cli/src/r_name_cache.rs +++ b/crates/typr-cli/src/r_name_cache.rs @@ -39,7 +39,10 @@ use crate::cache::CACHE_DIR; /// The committed base-R table, used to seed a fresh cache. const SEED_JSON: &str = include_str!("../configs/src/r_name_db.json"); /// The introspection script run against packages the seed does not cover. -const INTROSPECT_R: &str = include_str!("../configs/src/introspect_pkg.R"); +/// Also the backbone of `typr gen-types` (see `crate::gen_types`), which reads +/// this same script's `F`/`P` lines (formals, package version) alongside the +/// `N`/`V`/`C`/`E` lines this cache consumes. +pub(crate) const INTROSPECT_R: &str = include_str!("../configs/src/introspect_pkg.R"); pub const CACHE_FILE: &str = "r_names.json"; diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index 8878259..2daebaa 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -354,15 +354,29 @@ pub fn eval(context: &Context, expr: &Lang) -> TypeContext { let reduced_left_type = reduce_type(context, &left_type); let reduced_right_type = reduce_type(context, &right_type); - if reduced_right_type.is_subtype(&reduced_left_type, context).0 { - let Some(var) = Var::from_language((**left_expr).clone()).map(|v| v.set_type(right_type.clone())) + // A reassignment must not pin the variable to the exact literal just + // assigned (`x <- 10;` after unannotated `let x <- 0;` infers `x` as + // the singleton `0`), and must not keep narrowing it one literal at a + // time on every later assignment (`x <- 10;` then `x <- x + 1;`, + // cases/0065-reassign-widen-literal). Widen a literal singleton to its + // base kind before both the compatibility check and the type stored + // back into context — `Type::generalize()`, the same widening + // cases/0017-char-if-widening uses for `if` branches — leaving + // non-literal types (aliases, records, functions, unions...) + // untouched, so occurrence narrowing onto those still works. + let widened_left_type = reduced_left_type.clone().generalize(); + let widened_right_type = right_type.clone().generalize(); + + if reduced_right_type.is_subtype(&widened_left_type, context).0 { + let Some(var) = + Var::from_language((**left_expr).clone()).map(|v| v.set_type(widened_right_type.clone())) else { - return TypeContext::new(right_type, expr.clone(), context.clone()).with_errors(errors); + return TypeContext::new(widened_right_type, expr.clone(), context.clone()).with_errors(errors); }; TypeContext::new( - right_type.clone(), + widened_right_type.clone(), expr.clone(), - context.clone().push_var_type(var, right_type, context), + context.clone().push_var_type(var, widened_right_type, context), ) .with_errors(errors) } else { diff --git a/rfcs/0028-calling-untyped-r-functions.md b/rfcs/0028-calling-untyped-r-functions.md index e3ee1c3..3a06b6d 100644 --- a/rfcs/0028-calling-untyped-r-functions.md +++ b/rfcs/0028-calling-untyped-r-functions.md @@ -255,8 +255,11 @@ of scope here. - [x] `cases/` entry: arity error message (`cases/0063-untyped-r-function-arity-error`) - [x] `cases/` entry: preloaded builtin called with arguments (`Position(1, 2)`) (`cases/0064-untyped-preloaded-builtin-variadic`) -- [ ] `syntaxe.md` §12 updated in both copies -- [ ] `docs/philosophy/intro.md` and `docs/reference/escape-hatches.md` updated +- [x] `syntaxe.md` §12 updated (`typr.github.io/syntaxe.md`; no second copy + exists under `typr/` yet) +- [x] `docs/philosophy/intro.md` and `docs/reference/escape-hatches.md` updated on `typr.github.io`; the `noplayground` marker on the philosophy block - removed (see `doc_correction.md` §C in that repository) -- [ ] `Implemented in:` filled in above + removed — both blocks verified with `typr check` (0.5.12+d85f8d9) and + `npm run check:examples` +- [ ] `Implemented in:` filled in above — pending a release past 0.5.12; the + implementing commit (`d85f8d9`) is on `develop`, not yet tagged From 260d5aa55f9699b5d7b70beb1857ee67fb5d7911 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 15:18:57 +0200 Subject: [PATCH 07/18] rfc-0031: accept External type definitions (J2) Status: draft -> accepted, file renamed to its assigned PR number (0031) per rfcs/README.md's process. Tracking issue to follow in a separate commit once opened. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- ...al-type-definitions.md => 0031-external-type-definitions.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename rfcs/{0000-external-type-definitions.md => 0031-external-type-definitions.md} (99%) diff --git a/rfcs/0000-external-type-definitions.md b/rfcs/0031-external-type-definitions.md similarity index 99% rename from rfcs/0000-external-type-definitions.md rename to rfcs/0031-external-type-definitions.md index 9ee5a5d..98e699c 100644 --- a/rfcs/0000-external-type-definitions.md +++ b/rfcs/0031-external-type-definitions.md @@ -1,4 +1,4 @@ -- **Status:** draft +- **Status:** accepted - **RFC PR:** we-data-ch/typr#31 - **Tracking issue:** — - **Implemented in:** not yet From 60f84af8d7bf9b83058aa0874e0246afe59c6e94 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 15:19:34 +0200 Subject: [PATCH 08/18] rfc-0031: fill in tracking issue #32 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- rfcs/0031-external-type-definitions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/0031-external-type-definitions.md b/rfcs/0031-external-type-definitions.md index 98e699c..5a86288 100644 --- a/rfcs/0031-external-type-definitions.md +++ b/rfcs/0031-external-type-definitions.md @@ -1,6 +1,6 @@ - **Status:** accepted - **RFC PR:** we-data-ch/typr#31 -- **Tracking issue:** — +- **Tracking issue:** we-data-ch/typr#32 - **Implemented in:** not yet - **Start date:** 2026-09-12 From 61cadd36d839fa50dc38d01e71163dd5dba5502f Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 15:27:00 +0200 Subject: [PATCH 09/18] rfc-0031: freeze the typr-def.toml manifest format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First checklist item of rfcs/0031-external-type-definitions.md (J2, we-data-ch/typr#32): fix the manifest's shape as a real type instead of a TOML example in prose, and enforce the format_version gate the RFC requires ("the compiler reads old manifests it recognizes or refuses the ones it doesn't — it never silently misparses one"). - crates/typr-cli/src/type_definition.rs: DefinitionManifest and its sections (package/definition/provider/capabilities), parse_manifest() checking format_version against a raw toml::Value before attempting the typed parse, so an unsupported version is reported as exactly that rather than as a confusing missing-field error. - since/until added to FunctionMeta (typr-core's stdlib_meta.rs) and StdlibMeta (spg/model.rs), parsed identically to `ret`/`coercion` via the existing #! annotation parser — the per-entry override the RFC's reference section describes, kept a lossless mapping between the two structs. Nothing here fetches a repository, resolves typr.lock, or loads a definition into the type checker yet — those remain in #32. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/md_renderer.rs | 2 + crates/typr-cli/src/type_definition.rs | 224 ++++++++++++++++++ crates/typr-core/src/processes/spg/model.rs | 12 + .../src/processes/spg/stdlib_meta.rs | 47 ++++ 5 files changed, 286 insertions(+) create mode 100644 crates/typr-cli/src/type_definition.rs diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index 9d9536e..e1d53dc 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -57,6 +57,7 @@ pub mod rd_renderer; pub mod repl; pub mod standard_library; pub mod syntax; +pub mod type_definition; pub mod vignette_renderer; // Re-export commonly used items diff --git a/crates/typr-cli/src/md_renderer.rs b/crates/typr-cli/src/md_renderer.rs index 59ff4a2..2399cc0 100644 --- a/crates/typr-cli/src/md_renderer.rs +++ b/crates/typr-cli/src/md_renderer.rs @@ -216,6 +216,8 @@ mod tests { examples: vec!["abs(-5) # -> 5".to_string()], seealso: vec!["sign".to_string()], pkg: Some(pkg.to_string()), + since: None, + until: None, }); node } diff --git a/crates/typr-cli/src/type_definition.rs b/crates/typr-cli/src/type_definition.rs new file mode 100644 index 0000000..156c70b --- /dev/null +++ b/crates/typr-cli/src/type_definition.rs @@ -0,0 +1,224 @@ +//! `typr-def.toml` — the manifest of an external Type Definition repository. +//! +//! This is the "spec du format figée" item of `typR/registry.md` §13 J2: it +//! fixes the manifest's shape and enforces the `format_version` gate, turning +//! `typr/rfcs/0031-external-type-definitions.md`'s "Definition repository +//! layout and manifest" section into a real type instead of a TOML example in +//! prose. Nothing here fetches a repository, resolves `typr.lock`, or loads a +//! definition into the type checker — those are the RFC's remaining +//! checklist items (registry.md §13 J2: loading into `standard_library.rs`, +//! the `trust` threshold, `typr types add|update|list|vendor`, `cases/`). +//! +//! Example manifest this module parses (RFC §"Definition repository layout +//! and manifest"): +//! +//! ```toml +//! format_version = 1 +//! +//! [package] +//! name = "shiny" +//! since = "1.11.0" +//! # until = "2.0.0" +//! +//! [definition] +//! version = "0.3.0" +//! tier = "T2" +//! +//! [provider] +//! type = "community" +//! repository = "github:alice/typr-shiny" +//! +//! [capabilities] +//! r_shims = false +//! extern_raw = false +//! ``` + +#![allow(dead_code)] + +use serde::Deserialize; + +/// The only `format_version` this build of typr understands. A manifest +/// declaring anything else is refused outright rather than guessed at — see +/// rfcs/0031: "`format_version` is what keeps this survivable across N +/// repositories the project does not control: when the definition format +/// changes, the compiler reads old manifests it recognizes or refuses the +/// ones it doesn't — it never silently misparses one." +pub const CURRENT_FORMAT_VERSION: i64 = 1; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DefinitionManifest { + pub format_version: i64, + pub package: PackageSection, + pub definition: DefinitionSection, + pub provider: ProviderSection, + #[serde(default)] + pub capabilities: CapabilitiesSection, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct PackageSection { + pub name: String, + /// Minimum R package version this definition was written against — a + /// floor, never a closed range (`typR/registry.md` §7.2: `supports = + /// ["1.11.x"]` would depend on the resolving machine and rot unnoticed). + pub since: String, + /// Only set when a break is *known*, never speculative. + #[serde(default)] + pub until: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DefinitionSection { + /// semver of the definition itself, independent of the R package's own + /// version. + pub version: String, + /// Default tier (`T1`/`T2`/`T3`) for entries with no `#! tier:` of their + /// own. Kept as a free-form string rather than an enum, matching + /// `FunctionMeta::tier` — an unrecognized future tier degrades instead of + /// failing the whole manifest to parse. + pub tier: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ProviderType { + Official, + Community, + Generated, + Local, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ProviderSection { + #[serde(rename = "type")] + pub kind: ProviderType, + pub repository: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)] +pub struct CapabilitiesSection { + /// Ships executable R alongside the declarations (an `R/` shim + /// directory)? + #[serde(default)] + pub r_shims: bool, + /// Uses an `extern: (...) -> T r#"...R..."#` verbatim block anywhere? + #[serde(default)] + pub extern_raw: bool, +} + +/// Parse a `typr-def.toml` manifest. +/// +/// Checks `format_version` against a raw TOML value first, before attempting +/// to interpret the rest of the document against today's schema — so an +/// unsupported version is reported as exactly that, not as a confusing +/// "missing field" error from a future schema this build does not know about. +pub fn parse_manifest(source: &str) -> Result { + let raw: toml::Value = source.parse().map_err(|e| format!("not valid TOML: {e}"))?; + let found = raw + .get("format_version") + .ok_or_else(|| "missing required field `format_version`".to_string())? + .as_integer() + .ok_or_else(|| "`format_version` must be an integer".to_string())?; + if found != CURRENT_FORMAT_VERSION { + return Err(format!( + "unsupported format_version = {found} (this build of typr understands \ + format_version = {CURRENT_FORMAT_VERSION}); update typr, or ask this \ + definition's provider to publish one this build supports" + )); + } + toml::from_str(source) + .map_err(|e| format!("manifest does not match format_version {CURRENT_FORMAT_VERSION}: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHINY_MANIFEST: &str = r#" +format_version = 1 + +[package] +name = "shiny" +since = "1.11.0" + +[definition] +version = "0.3.0" +tier = "T2" + +[provider] +type = "community" +repository = "github:alice/typr-shiny" + +[capabilities] +r_shims = false +extern_raw = false +"#; + + #[test] + fn parses_the_rfc_example_manifest() { + let manifest = parse_manifest(SHINY_MANIFEST).unwrap(); + assert_eq!(manifest.format_version, 1); + assert_eq!(manifest.package.name, "shiny"); + assert_eq!(manifest.package.since, "1.11.0"); + assert_eq!(manifest.package.until, None); + assert_eq!(manifest.definition.version, "0.3.0"); + assert_eq!(manifest.definition.tier, "T2"); + assert_eq!(manifest.provider.kind, ProviderType::Community); + assert_eq!(manifest.provider.repository, "github:alice/typr-shiny"); + assert!(!manifest.capabilities.r_shims); + assert!(!manifest.capabilities.extern_raw); + } + + #[test] + fn until_is_optional() { + let manifest = parse_manifest(SHINY_MANIFEST).unwrap(); + assert!(manifest.package.until.is_none()); + + let with_until = SHINY_MANIFEST.replacen( + "since = \"1.11.0\"", + "since = \"1.11.0\"\nuntil = \"2.0.0\"", + 1, + ); + let manifest = parse_manifest(&with_until).unwrap(); + assert_eq!(manifest.package.until.as_deref(), Some("2.0.0")); + } + + #[test] + fn capabilities_default_to_false_when_section_is_absent() { + let without_capabilities = SHINY_MANIFEST + .lines() + .filter(|l| !l.contains("[capabilities]") && !l.contains("r_shims") && !l.contains("extern_raw")) + .collect::>() + .join("\n"); + let manifest = parse_manifest(&without_capabilities).unwrap(); + assert!(!manifest.capabilities.r_shims); + assert!(!manifest.capabilities.extern_raw); + } + + #[test] + fn missing_format_version_is_refused() { + let source = SHINY_MANIFEST.replacen("format_version = 1\n", "", 1); + let err = parse_manifest(&source).unwrap_err(); + assert!(err.contains("format_version"), "unexpected error: {err}"); + } + + #[test] + fn unknown_format_version_is_refused_not_misparsed() { + let source = SHINY_MANIFEST.replacen("format_version = 1", "format_version = 2", 1); + let err = parse_manifest(&source).unwrap_err(); + assert!(err.contains("unsupported format_version = 2"), "unexpected error: {err}"); + assert!(err.contains("format_version = 1"), "unexpected error: {err}"); + } + + #[test] + fn invalid_toml_is_refused() { + let err = parse_manifest("this is not { toml").unwrap_err(); + assert!(err.contains("not valid TOML"), "unexpected error: {err}"); + } + + #[test] + fn invalid_provider_type_is_refused() { + let source = SHINY_MANIFEST.replacen("type = \"community\"", "type = \"unofficial\"", 1); + assert!(parse_manifest(&source).is_err()); + } +} diff --git a/crates/typr-core/src/processes/spg/model.rs b/crates/typr-core/src/processes/spg/model.rs index caff81a..dc0b5bc 100644 --- a/crates/typr-core/src/processes/spg/model.rs +++ b/crates/typr-core/src/processes/spg/model.rs @@ -115,6 +115,14 @@ pub struct StdlibMeta { pub seealso: Vec, /// The R package of origin (e.g. "base", "stats"). pub pkg: Option, + /// Minimum R package version this entry was declared against (a floor, + /// never a closed range — `typR/registry.md` §7.2). External type + /// definitions only (rfcs/0031-external-type-definitions.md); unset for + /// the standard library. + pub since: Option, + /// Only set when a break is *known*, never speculative. Same scope as + /// `since`. + pub until: Option, } impl StdlibMeta { @@ -127,6 +135,8 @@ impl StdlibMeta { examples: Vec::new(), seealso: Vec::new(), pkg: None, + since: None, + until: None, } } @@ -139,6 +149,8 @@ impl StdlibMeta { || !self.examples.is_empty() || !self.seealso.is_empty() || self.pkg.is_some() + || self.since.is_some() + || self.until.is_some() } } diff --git a/crates/typr-core/src/processes/spg/stdlib_meta.rs b/crates/typr-core/src/processes/spg/stdlib_meta.rs index e6d23c9..017cb2f 100644 --- a/crates/typr-core/src/processes/spg/stdlib_meta.rs +++ b/crates/typr-core/src/processes/spg/stdlib_meta.rs @@ -14,6 +14,14 @@ pub struct FunctionMeta { pub coercion_notes: Option, pub examples: Vec, pub seealso: Vec, + /// Minimum R package version this entry was declared against — a floor, + /// never a closed range (`typR/registry.md` §7.2). Meaningful only in an + /// external type definition (`rfcs/0031-external-type-definitions.md`); + /// absent from the standard library. + pub since: Option, + /// Only set when a break is *known*, never speculative. Same scope as + /// `since`. + pub until: Option, } impl FunctionMeta { @@ -27,6 +35,8 @@ impl FunctionMeta { examples: self.examples, seealso: self.seealso, pkg: self.pkg, + since: self.since, + until: self.until, } } @@ -39,6 +49,8 @@ impl FunctionMeta { || !self.examples.is_empty() || !self.seealso.is_empty() || self.pkg.is_some() + || self.since.is_some() + || self.until.is_some() } } @@ -99,6 +111,14 @@ pub fn parse_meta_from_source(source: &str) -> HashMap { let target = pending_meta.get_or_insert_with(FunctionMeta::default); target.ret_doc = Some(strip_leading_colon(value)); } + "since" => { + let target = pending_meta.get_or_insert_with(FunctionMeta::default); + target.since = Some(strip_leading_colon(value)); + } + "until" => { + let target = pending_meta.get_or_insert_with(FunctionMeta::default); + target.until = Some(strip_leading_colon(value)); + } "coercion" | "note" => { let target = pending_meta.get_or_insert_with(FunctionMeta::default); target.coercion_notes = Some(strip_leading_colon(value)); @@ -282,6 +302,33 @@ let x: int <- 5;"; assert!(!map.contains_key("stats::rnorm")); } + #[test] + fn since_and_until_are_parsed() { + let src = "\ +#! pkg: dplyr +#! tier: T3 +#! since: 1.1.0 +#! until: 2.0.0 +@filter: (Any, Any) -> Any;"; + + let map = parse_meta_from_source(src); + let meta = map.get("filter").unwrap(); + assert_eq!(meta.since.as_deref(), Some("1.1.0")); + assert_eq!(meta.until.as_deref(), Some("2.0.0")); + } + + #[test] + fn since_without_until_leaves_until_none() { + let src = "\ +#! since: 1.11.0 +@fluidPage: (Any) -> Any;"; + + let map = parse_meta_from_source(src); + let meta = map.get("fluidPage").unwrap(); + assert_eq!(meta.since.as_deref(), Some("1.11.0")); + assert!(meta.until.is_none()); + } + #[test] fn empty_tier_is_none() { let src = "\ From 02782cb65423a339051c223e66757dcc46a77442 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sun, 13 Sep 2026 22:05:11 +0200 Subject: [PATCH 10/18] update --- Cargo.lock | 74 +- Cargo.toml | 1 + crates/typr-cli/Cargo.toml | 1 + crates/typr-cli/src/cli.rs | 100 ++ crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 2 + crates/typr-cli/src/r_deps.rs | 2 +- crates/typr-cli/src/standard_library.rs | 296 +++++- crates/typr-cli/src/type_registry.rs | 935 ++++++++++++++++++ .../src/components/context/vartype.rs | 95 ++ rfcs/0031-external-type-definitions.md | 9 +- 11 files changed, 1500 insertions(+), 16 deletions(-) create mode 100644 crates/typr-cli/src/type_registry.rs diff --git a/Cargo.lock b/Cargo.lock index 85b8ea5..e5fae38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,6 +151,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -204,7 +213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core", ] @@ -301,6 +310,15 @@ dependencies = [ "num-traits 0.1.43", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -334,6 +352,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.24.1" @@ -391,6 +419,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -576,6 +614,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1387,6 +1435,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1835,6 +1894,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "typr" version = "0.5.12" @@ -1859,6 +1924,7 @@ dependencies = [ "rustyline", "serde", "serde_json", + "sha2", "syntect", "tap", "thiserror", @@ -2001,6 +2067,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index f064cc2..03e089d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ rand = "0.10.0" anyhow = "1.0.100" countmap = "0.2" toml = "0.8" +sha2 = "0.10" clap = { version = "4.5", features = ["derive"] } tokio = { version = "1", features = ["full"] } diff --git a/crates/typr-cli/Cargo.toml b/crates/typr-cli/Cargo.toml index 8ad1741..7e2bb0c 100644 --- a/crates/typr-cli/Cargo.toml +++ b/crates/typr-cli/Cargo.toml @@ -44,3 +44,4 @@ typr-mcp.workspace = true rustyline.workspace = true syntect.workspace = true bincode.workspace = true +sha2.workspace = true diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index 3ac26e6..0f44e68 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -184,6 +184,37 @@ enum Commands { #[arg(long, short, value_name = "DIR")] out: Option, }, + /// Resolve, cache, and vendor external Type Definitions for R packages — + /// see `typR/registry.md` §7 and `rfcs/0031-external-type-definitions.md`. + Types { + #[command(subcommand)] + types_command: TypesCommands, + }, +} + +#[derive(Subcommand, Debug)] +enum TypesCommands { + /// Pin a definition for `` (fetch, verify its manifest and + /// capabilities, cache it by content digest, and record it in + /// `typr.lock`). + Add { + /// The package this definition describes (e.g. `shiny`). + package: String, + /// `github:owner/repo[@rev]`. + repo: String, + }, + /// Re-fetch and re-pin `typr.lock` for one package (or, with none given, + /// every resolved definition). + Update { package: Option }, + /// What's resolved in `typr.lock`, with tier and provenance. + List, + /// Copy every resolved definition's `.ty` files into the project tree + /// (default `ty/vendor//`), so the build stops depending on the + /// network or the upstream repository's continued existence. + Vendor { + #[arg(long, short, value_name = "DIR")] + out: Option, + }, } #[derive(Subcommand, Debug)] @@ -316,6 +347,7 @@ fn skips_r_deps_check(command: &Option) -> bool { | Some(Commands::Cache { .. }) | Some(Commands::Syntax { .. }) | Some(Commands::GenTypes { .. }) + | Some(Commands::Types { .. }) ) } @@ -443,6 +475,7 @@ pub fn start() { }) => run_syntax_command(json, target, output, write, check), Some(Commands::Spg { output }) => generate_spg(output), Some(Commands::GenTypes { package, out }) => crate::gen_types::run(&package, out), + Some(Commands::Types { types_command }) => run_types_command(types_command), _ => { println!("Please specify a subcommand or file to execute"); std::process::exit(1); @@ -553,3 +586,70 @@ fn run_cache_command(command: CacheCommands) { } } } + +/// `typr types ` — see `typR/registry.md` §7 and +/// `rfcs/0031-external-type-definitions.md`. +fn run_types_command(command: TypesCommands) { + use crate::type_registry; + + let root = std::path::Path::new("."); + + match command { + TypesCommands::Add { package, repo } => match type_registry::add(root, &package, &repo) { + Ok(locked) => println!( + "{} — {} {} (tier {}, rev {}) → typr.lock", + locked.package, + locked.repository, + locked.version, + locked.tier, + &locked.rev[..locked.rev.len().min(12)] + ), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }, + TypesCommands::Update { package } => match type_registry::update(root, package.as_deref()) { + Ok(updated) if updated.is_empty() => println!("nothing to update — typr.lock is empty."), + Ok(updated) => { + for locked in updated { + println!( + "{} — {} {} (tier {}, rev {})", + locked.package, + locked.repository, + locked.version, + locked.tier, + &locked.rev[..locked.rev.len().min(12)] + ); + } + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }, + TypesCommands::List => { + let definitions = type_registry::list(root); + if definitions.is_empty() { + println!("nothing resolved — see `typr types add`."); + } + for def in definitions { + println!( + "{:<12} {:<32} {:<10} tier {:<3} rev {}", + def.package, + def.repository, + def.version, + def.tier, + &def.rev[..def.rev.len().min(12)] + ); + } + } + TypesCommands::Vendor { out } => match type_registry::vendor(root, out.as_deref()) { + Ok(written) => println!("vendored {} file(s).", written.len()), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }, + } +} diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index e1d53dc..91c7f89 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -58,6 +58,7 @@ pub mod repl; pub mod standard_library; pub mod syntax; pub mod type_definition; +pub mod type_registry; pub mod vignette_renderer; // Re-export commonly used items diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index 36b5485..8086ce7 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -20,6 +20,8 @@ mod rd_renderer; mod repl; mod standard_library; mod syntax; +mod type_definition; +mod type_registry; mod vignette_renderer; fn main() { diff --git a/crates/typr-cli/src/r_deps.rs b/crates/typr-cli/src/r_deps.rs index b25c377..eb5eac9 100644 --- a/crates/typr-cli/src/r_deps.rs +++ b/crates/typr-cli/src/r_deps.rs @@ -88,7 +88,7 @@ fn now_secs() -> u64 { .unwrap_or(0) } -fn cache_home() -> Option { +pub(crate) fn cache_home() -> Option { for var in ["XDG_CACHE_HOME", "LOCALAPPDATA"] { if let Ok(dir) = std::env::var(var) { if !dir.is_empty() { diff --git a/crates/typr-cli/src/standard_library.rs b/crates/typr-cli/src/standard_library.rs index adcfd4f..9be9f33 100644 --- a/crates/typr-cli/src/standard_library.rs +++ b/crates/typr-cli/src/standard_library.rs @@ -4,6 +4,7 @@ //! and prints the content of the standard library. use std::collections::HashMap; +use std::collections::HashSet; use std::path::PathBuf; use typr_core::components::context::vartype::VarType; use typr_core::components::context::Context; @@ -436,20 +437,26 @@ fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { } } -/// Build a VarType from typed standard library .ty source files. +/// Parse and type-check a sequence of `.ty` sources, threading a *starting* +/// context through them so that later files can reference types from earlier +/// ones (and from whatever `base_context` already carries). Signature lines +/// (`@`) are preprocessed to strip named parameters. /// -/// Parses and type-checks each .ty source file sequentially, threading the -/// context through so that later files can reference types from earlier ones. -/// Signature lines (`@`) are preprocessed to strip named parameters. +/// This is the shared loop behind both `build_typed_vartype` (bundled +/// stdlib, always starts from `Context::empty()`) and +/// `load_external_ty_definitions` (a third-party definition repository, +/// starts from the caller's own context so it can see the stdlib's types). /// -/// Returns the built `VarType` plus the list of `(filename, panic message)` -/// for every source file that was skipped because parsing/type-checking it -/// panicked. A skipped file's signatures are silently absent from the -/// resulting `VarType` — the caller MUST surface this loudly (see -/// `standard_library()`), never let it pass as a quiet informational line, -/// since it means real stdlib entries silently vanished from the compiler. -fn build_typed_vartype(ty_sources: &[(&str, &str)]) -> (VarType, Vec<(String, String)>) { - let mut context = Context::empty(); +/// Returns the resulting `Context` plus the list of `(filename, panic +/// message)` for every source file that was skipped because parsing/ +/// type-checking it panicked. A skipped file's signatures are silently +/// absent from the resulting context — the caller MUST surface this loudly, +/// never let it pass as a quiet informational line. +fn extend_context_with_ty_sources( + base_context: Context, + ty_sources: &[(&str, &str)], +) -> (Context, Vec<(String, String)>) { + let mut context = base_context; let mut skipped: Vec<(String, String)> = Vec::new(); // Silence the default panic hook while probing these files: a skip is an @@ -486,9 +493,131 @@ fn build_typed_vartype(ty_sources: &[(&str, &str)]) -> (VarType, Vec<(String, St std::panic::set_hook(previous_hook); + (context, skipped) +} + +/// Build a VarType from typed standard library .ty source files. +/// +/// Parses and type-checks each .ty source file sequentially, threading the +/// context through so that later files can reference types from earlier ones. +/// +/// Returns the built `VarType` plus the list of `(filename, panic message)` +/// for every source file that was skipped because parsing/type-checking it +/// panicked. A skipped file's signatures are silently absent from the +/// resulting `VarType` — the caller MUST surface this loudly (see +/// `standard_library()`), never let it pass as a quiet informational line, +/// since it means real stdlib entries silently vanished from the compiler. +fn build_typed_vartype(ty_sources: &[(&str, &str)]) -> (VarType, Vec<(String, String)>) { + let (context, skipped) = extend_context_with_ty_sources(Context::empty(), ty_sources); (context.get_vartype(), skipped) } +/// Ranking of the three tiers by trustworthiness, most trusted first (`T1` +/// = 3, `T2` = 2, `T3` = 1). `None` for anything else — including a tier +/// string a manifest or `#! tier:` annotation declares that this build +/// doesn't recognize, per `type_definition.rs::DefinitionSection::tier`'s +/// "an unrecognized future tier degrades instead of failing the whole +/// manifest to parse". +fn tier_rank(tier: &str) -> Option { + match tier { + "T1" => Some(3), + "T2" => Some(2), + "T3" => Some(1), + _ => None, + } +} + +/// Does `entry_tier` meet the project's `trust` threshold? +/// +/// `rfcs/0031-external-type-definitions.md`, "Loading external `.ty` into +/// the context": "entry tier ≥ project trust: loaded with its declared +/// signature […]; entry tier < project trust: loaded as +/// `Type::UnknownFunction`". An entry tier or a project `trust` this build +/// doesn't recognize never meets the threshold — D2 (`typR/registry.md` +/// §0/§5.4) requires an unreliable or unreadable trust signal to widen +/// towards `Any`, never to be silently treated as trusted. +fn meets_trust(entry_tier: &str, trust: &str) -> bool { + match (tier_rank(entry_tier), tier_rank(trust)) { + (Some(entry), Some(required)) => entry >= required, + _ => false, + } +} + +/// Every function name declared across `ty_sources` whose *effective* tier — +/// its own `#! tier:` annotation, falling back to `default_tier` (the +/// manifest's `[definition] tier`, RFC-0031) when absent — falls below +/// `trust`. These are exactly the names `load_external_ty_definitions` +/// degrades to `(Any, UnknownFunction)` after type-checking. +fn names_below_trust(ty_sources: &[(&str, &str)], default_tier: &str, trust: &str) -> HashSet { + let mut below = HashSet::new(); + for (_filename, source) in ty_sources { + let meta_map = parse_meta_from_source(source); + for line in source.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('@') { + continue; + } + let Some(raw_name) = extract_raw_signature_name(trimmed) else { + continue; + }; + let tier = meta_map + .get(&raw_name) + .and_then(|m| m.tier.as_deref()) + .unwrap_or(default_tier); + if !meets_trust(tier, trust) { + below.insert(unwrap_backtick_name(&raw_name)); + } + } + } + below +} + +/// Load externally-provided `.ty` definitions on top of an existing typing +/// context, using the exact same parse/type-check loop that builds the +/// bundled standard library from `R_T1_SOURCES` — so an external definition +/// can reference the stdlib's own types (`Foreign`, etc.) exactly the way +/// `std.ty` itself does — and then degrading every entry whose effective +/// tier falls below `trust` to `Type::UnknownFunction`. +/// +/// This is the "chargement d'un `.ty` externe dans le contexte" + +/// "seuil `trust` + règle de dégradation vers `Any`" items of +/// `typR/registry.md` §13 J2 (`rfcs/0031-external-type-definitions.md`, +/// "Loading external `.ty` into the context"). `ty_sources` is expected to +/// come from a single resolved definition repository, so a single +/// `default_tier` (its manifest's `[definition] tier`) applies to every +/// entry with no `#! tier:` of its own; `trust` is the consuming project's +/// own threshold (`typr.toml [types] trust`, not yet read from disk — the +/// machinery that resolves a `typr.lock` entry into these arguments is the +/// next checklist item). +/// +/// A degraded entry is never dropped or rejected: it is loaded exactly like +/// any other untyped R name (`(Any, UnknownFunction)`), keeping it callable +/// with arity/type checking simply skipped — this is D2 made real +/// (`typR/registry.md` §0/§5.4): a definition the project doesn't trust +/// enough can only make TypR check *less*, never break a build. +/// +/// `base_context` is typically `Context::default()` (or a project's own +/// context built on top of it) — starting from it, rather than +/// `Context::empty()`, is what lets a third-party `.ty` see the bundled +/// stdlib while it is being type-checked. +/// +/// Not yet called from the CLI: nothing resolves a `typr.lock` entry into +/// `(filename, source)` pairs plus a manifest's tier/trust yet (`typr types +/// add`/`typr.lock`, the next checklist item), so this has no caller outside +/// its own tests until then. +#[allow(dead_code)] +pub fn load_external_ty_definitions( + base_context: Context, + ty_sources: &[(&str, &str)], + default_tier: &str, + trust: &str, +) -> (Context, Vec<(String, String)>) { + let (mut context, skipped) = extend_context_with_ty_sources(base_context, ty_sources); + let degraded_names = names_below_trust(ty_sources, default_tier, trust); + context.typing_context = context.typing_context.clone().degrade_to_any(°raded_names); + (context, skipped) +} + /// Build a documentation graph over a set of `.ty` sources. /// /// Shared by `build_stdlib_docs` (production) and the tests, so the @@ -774,6 +903,149 @@ mod tests { assert!(skipped.is_empty()); } + /// `load_external_ty_definitions` is the registry.md §13 J2 "chargement + /// d'un `.ty` externe dans le contexte" mechanism: it must merge new + /// signatures on top of an existing context, exactly like an + /// `R_T1_SOURCES` file merges on top of the ones processed before it. + #[test] + fn load_external_ty_definitions_merges_new_signatures_into_base_context() { + let (base_context, base_skipped) = + extend_context_with_ty_sources(Context::empty(), &[("base.ty", "@base_fn: (int) -> int;")]); + assert!(base_skipped.is_empty()); + + let (context, skipped) = + load_external_ty_definitions(base_context, &[("shiny.generated.ty", "@fluidPage: (Any) -> Any;")], "T2", "T2"); + + assert!(skipped.is_empty()); + let names: Vec = context.get_vartype().variables.iter().map(|(v, _)| v.get_name()).collect(); + assert!( + names.contains(&"base_fn".to_string()), + "base context signature must survive the merge" + ); + assert!( + names.contains(&"fluidPage".to_string()), + "external signature must be loaded" + ); + } + + /// An external definition can reference a type the bundled stdlib itself + /// declares (`Foreign`, `foreign.ty`) — proving the context is + /// threaded through from `base_context`, not type-checked in isolation. + /// This is what lets a third-party `.ty` use `Foreign` the way + /// `std.ty` itself does (RFC-0031, "Loading external `.ty` into the + /// context"). + #[test] + fn load_external_ty_definitions_sees_types_declared_in_base_context() { + let (base_context, base_skipped) = extend_context_with_ty_sources(Context::empty(), &[("foreign.ty", FOREIGN_TY)]); + assert!(base_skipped.is_empty()); + + let (_context, skipped) = load_external_ty_definitions( + base_context, + &[( + "shiny.generated.ty", + "type UiObject <- Foreign;\n@fluidPage: (Any) -> UiObject;", + )], + "T2", + "T2", + ); + + assert!( + skipped.is_empty(), + "external definition referencing a base-context type must type-check: {:?}", + skipped + ); + } + + /// A broken external definition is reported as skipped — same contract as + /// a broken bundled stdlib file — and never corrupts the base context: + /// signatures already resolved before it stay intact. This is the D2 + /// "an unreliable definition widens to `Any`, it never fails the build" + /// principle at its narrowest: at minimum, a bad external file must not + /// take the rest of the project's own types down with it. + #[test] + fn load_external_ty_definitions_skips_a_broken_source_without_losing_the_base_context() { + let (base_context, base_skipped) = + extend_context_with_ty_sources(Context::empty(), &[("base.ty", "@base_fn: (int) -> int;")]); + assert!(base_skipped.is_empty()); + + let (context, skipped) = + load_external_ty_definitions(base_context, &[("broken.ty", "let f <- fn(x) { x };")], "T2", "T2"); + + assert_eq!(skipped.len(), 1); + assert_eq!(skipped[0].0, "broken.ty"); + let names: Vec = context.get_vartype().variables.iter().map(|(v, _)| v.get_name()).collect(); + assert!( + names.contains(&"base_fn".to_string()), + "base context must survive a skipped external source" + ); + } + + /// registry.md §13 J2 "seuil `trust` + règle de dégradation vers `Any`": + /// an entry whose own `#! tier:` is below the project's `trust` loads as + /// `(Any, UnknownFunction)` instead of its declared signature, while an + /// entry at or above `trust` keeps it — same source, same call, only the + /// tier differs. + #[test] + fn entries_below_trust_degrade_to_any_entries_at_or_above_keep_their_signature() { + let source = "\ +#! tier: T3 +@untrusted_fn: (int) -> int; + +#! tier: T1 +@trusted_fn: (int) -> int;"; + + let (context, skipped) = + load_external_ty_definitions(Context::default(), &[("mixed.ty", source)], "T2", "T2"); + assert!(skipped.is_empty()); + + let untrusted_type = context + .get_type_from_variable(&Var::from_name("untrusted_fn")) + .expect("degraded entry must still be present, just untyped"); + assert!( + untrusted_type.is_unknown_function(), + "T3 entry under a T2 trust threshold must degrade to UnknownFunction, got {:?}", + untrusted_type + ); + + let trusted_type = context + .get_type_from_variable(&Var::from_name("trusted_fn")) + .expect("trusted entry must be present"); + assert!( + !trusted_type.is_unknown_function(), + "T1 entry under a T2 trust threshold must keep its declared signature, got {:?}", + trusted_type + ); + } + + /// An entry with no `#! tier:` of its own falls back to the manifest's + /// `[definition] tier` (`default_tier`) — a whole low-tier definition + /// with no per-entry annotations must degrade uniformly. + #[test] + fn entry_with_no_own_tier_falls_back_to_the_manifest_default_tier() { + let source = "@generated_fn: (int) -> int;"; + + let (context, skipped) = + load_external_ty_definitions(Context::default(), &[("generated.ty", source)], "T3", "T2"); + assert!(skipped.is_empty()); + + let typ = context + .get_type_from_variable(&Var::from_name("generated_fn")) + .expect("entry must still be present"); + assert!( + typ.is_unknown_function(), + "an entry with no #! tier must inherit the manifest's T3 default and degrade under T2 trust" + ); + } + + /// An unrecognized tier string — on the entry or on the project's own + /// `trust` setting — must never be silently treated as trusted (D2, + /// `typR/registry.md` §0/§5.4): it always degrades. + #[test] + fn unrecognized_tier_or_trust_never_meets_the_threshold() { + assert!(!meets_trust("T1", "not-a-tier")); + assert!(!meets_trust("not-a-tier", "T3")); + } + /// Phase 1: build_stdlib_docs parses #! annotations from .ty files and /// attaches them as `meta` on function nodes in the SPG. #[test] diff --git a/crates/typr-cli/src/type_registry.rs b/crates/typr-cli/src/type_registry.rs new file mode 100644 index 0000000..04cfd49 --- /dev/null +++ b/crates/typr-cli/src/type_registry.rs @@ -0,0 +1,935 @@ +//! Resolution, `typr.lock`, on-disk cache, and vendoring for external Type +//! Definitions — the `typr types add|update|list|vendor` item of +//! `typR/registry.md` §13 J2 +//! (`rfcs/0031-external-type-definitions.md`, "Resolution, `typr.lock`, +//! cache, vendoring"). +//! +//! What this module does NOT do: load a resolved definition into the +//! type-checking context. That is `standard_library::load_external_ty_definitions`, +//! already implemented and tested — this module is purely the plumbing that +//! turns a `github:owner/repo[@rev]` string into `(filename, source)` pairs on +//! disk plus a `typr.lock` entry recording exactly what was fetched. Wiring +//! the two together (reading `typr.lock` at `check`/`build`/`run` time) is a +//! follow-up, not part of this checklist item. + +use crate::type_definition::{parse_manifest, DefinitionManifest}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub const LOCKFILE_NAME: &str = "typr.lock"; +pub const PROJECT_CONFIG_NAME: &str = "typr.toml"; +const MANIFEST_NAME: &str = "typr-def.toml"; + +// --------------------------------------------------------------------- +// Repository spec +// --------------------------------------------------------------------- + +/// `github:owner/repo[@rev]` — the only repository scheme this build +/// understands (registry.md §6/§7: GitHub is the primary host; a monorepo +/// long tail is J3, not this). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoSpec { + pub owner: String, + pub repo: String, + /// A pinned commit/branch/tag, when the user gave one after `@`. `None` + /// means "resolve `HEAD`". + pub rev: Option, +} + +impl RepoSpec { + pub fn parse(spec: &str) -> Result { + let rest = spec.strip_prefix("github:").ok_or_else(|| { + format!("unsupported repository scheme in `{spec}` — only `github:owner/repo[@rev]` is understood") + })?; + let (path, rev) = match rest.split_once('@') { + Some((path, rev)) => (path, Some(rev.to_string())), + None => (rest, None), + }; + let (owner, repo) = path + .split_once('/') + .ok_or_else(|| format!("`{spec}` is not `github:owner/repo[@rev]` — missing `owner/repo`"))?; + if owner.is_empty() || repo.is_empty() { + return Err(format!("`{spec}` is not `github:owner/repo[@rev]` — empty owner or repo")); + } + Ok(RepoSpec { + owner: owner.to_string(), + repo: repo.to_string(), + rev, + }) + } + + pub fn clone_url(&self) -> String { + format!("https://github.com/{}/{}.git", self.owner, self.repo) + } + + /// The `repository` field as it is written into `typr.toml`/`typr.lock` — + /// always without the resolved rev, since that lives in `typr.lock`'s own + /// `rev` field (registry.md §7.1: one source of truth per question). + pub fn display(&self) -> String { + format!("github:{}/{}", self.owner, self.repo) + } +} + +// --------------------------------------------------------------------- +// typr.lock +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LockedDefinition { + pub package: String, + pub repository: String, + pub version: String, + pub rev: String, + pub digest: String, + pub tier: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r_version_seen: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Lockfile { + #[serde(default, rename = "definition")] + pub definitions: Vec, +} + +impl Lockfile { + /// A missing or unparsable lockfile is simply "nothing resolved yet" — + /// never a hard error, matching the fail-open convention the rest of the + /// CLI's caches use (`r_name_cache`, `cache::BuildManifest`). + pub fn read(path: &Path) -> Self { + fs::read_to_string(path) + .ok() + .and_then(|s| toml::from_str(&s).ok()) + .unwrap_or_default() + } + + pub fn write(&self, path: &Path) -> Result<(), String> { + let rendered = + toml::to_string_pretty(self).map_err(|e| format!("could not serialize {}: {e}", path.display()))?; + let header = "# typr.lock — generated by `typr types`, commit this file.\n\ + # See typR/registry.md §7.1 and rfcs/0031-external-type-definitions.md.\n\n"; + fs::write(path, format!("{header}{rendered}")).map_err(|e| format!("could not write {}: {e}", path.display())) + } + + pub fn find(&self, package: &str) -> Option<&LockedDefinition> { + self.definitions.iter().find(|d| d.package == package) + } + + /// Replace any existing entry for the same package (a package resolves to + /// exactly one definition, registry.md §8.3) and insert the new one. + pub fn upsert(&mut self, def: LockedDefinition) { + self.definitions.retain(|d| d.package != def.package); + self.definitions.push(def); + } +} + +// --------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------- + +/// `~/.cache/typr/types/` (or `$XDG_CACHE_HOME`/`$LOCALAPPDATA`), mirroring +/// `r_deps::cache_path`'s resolution rules. +pub fn cache_root() -> Option { + crate::r_deps::cache_home().map(|dir| dir.join("typr").join("types")) +} + +/// `~/.cache/typr/types///` — the RFC's +/// `~/.cache/typr/types///` (registry.md §7.4). +pub fn cache_dir_for(pkg: &str, digest: &str) -> Option { + cache_root().map(|root| root.join(pkg).join(digest_dirname(digest))) +} + +fn digest_dirname(digest: &str) -> &str { + digest.strip_prefix("sha256:").unwrap_or(digest) +} + +// --------------------------------------------------------------------- +// Fetch +// --------------------------------------------------------------------- + +/// A definition repository fetched to a local directory, before it is +/// admitted into the cache. Kept separate from `LockedDefinition` because +/// capability-gating (below) happens on these raw files, before anything is +/// written to `typr.lock`. +pub struct FetchedDefinition { + pub manifest: DefinitionManifest, + pub rev: String, + pub digest: String, + /// Directory holding the fetched repository's tracked files (`.git` + /// excluded). + pub dir: PathBuf, +} + +fn git_available() -> bool { + Command::new("git").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) +} + +/// Clone `url` (at `rev` when given, else the default branch's HEAD) into a +/// fresh temp directory and return it plus the resolved commit hash. +/// +/// Shells out to the `git` binary — the same pattern `gen_types.rs` uses for +/// `Rscript` — rather than adding a git-in-process dependency for a command +/// that already needs network access. +fn clone_repo(url: &str, rev: Option<&str>) -> Result<(PathBuf, String), String> { + let dir = std::env::temp_dir().join(format!( + "typr_types_fetch_{}_{}", + std::process::id(), + now_millis() + )); + fs::create_dir_all(&dir).map_err(|e| format!("could not create temp dir: {e}"))?; + + let clone_status = Command::new("git") + .args(["clone", "--quiet"]) + .args(if rev.is_none() { vec!["--depth", "1"] } else { vec![] }) + .arg(url) + .arg(&dir) + .output() + .map_err(|e| format!("could not run `git clone`: {e}"))?; + if !clone_status.status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(format!( + "`git clone {url}` failed: {}", + String::from_utf8_lossy(&clone_status.stderr).trim() + )); + } + + if let Some(rev) = rev { + let checkout = Command::new("git") + .args(["-C"]) + .arg(&dir) + .args(["checkout", "--quiet", rev]) + .output() + .map_err(|e| format!("could not run `git checkout`: {e}"))?; + if !checkout.status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(format!( + "`git checkout {rev}` failed: {}", + String::from_utf8_lossy(&checkout.stderr).trim() + )); + } + } + + let head = Command::new("git") + .args(["-C"]) + .arg(&dir) + .args(["rev-parse", "HEAD"]) + .output() + .map_err(|e| format!("could not run `git rev-parse HEAD`: {e}"))?; + if !head.status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(format!( + "`git rev-parse HEAD` failed: {}", + String::from_utf8_lossy(&head.stderr).trim() + )); + } + let rev = String::from_utf8_lossy(&head.stdout).trim().to_string(); + + let git_dir = dir.join(".git"); + let _ = fs::remove_dir_all(&git_dir); + + Ok((dir, rev)) +} + +fn now_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// Every file under `dir` (relative paths, `/`-separated, sorted), skipping +/// `.git` — the exact set of bytes the content digest and the cache/vendor +/// copies are built from. +fn tracked_files(dir: &Path) -> Result, String> { + let mut out = BTreeSet::new(); + collect_files(dir, dir, &mut out)?; + Ok(out.into_iter().collect()) +} + +fn collect_files(root: &Path, current: &Path, out: &mut BTreeSet) -> Result<(), String> { + let entries = fs::read_dir(current).map_err(|e| format!("could not read {}: {e}", current.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("could not read entry in {}: {e}", current.display()))?; + let path = entry.path(); + if path.file_name().and_then(|n| n.to_str()) == Some(".git") { + continue; + } + let file_type = entry.file_type().map_err(|e| format!("could not stat {}: {e}", path.display()))?; + if file_type.is_dir() { + collect_files(root, &path, out)?; + } else if file_type.is_file() { + let rel = path + .strip_prefix(root) + .map_err(|e| format!("could not relativize {}: {e}", path.display()))?; + out.insert(rel.to_path_buf()); + } + } + Ok(()) +} + +/// A deterministic content digest over every tracked file's path and bytes — +/// what `typr.lock`'s `digest` field pins, and what `typr check`/`build`/`run` +/// will re-verify a cached copy against before trusting it (registry.md +/// principle 5). +fn compute_digest(dir: &Path) -> Result { + let files = tracked_files(dir)?; + let mut hasher = Sha256::new(); + for rel in &files { + let content = fs::read(dir.join(rel)).map_err(|e| format!("could not read {}: {e}", rel.display()))?; + hasher.update(rel.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0u8]); + hasher.update((content.len() as u64).to_le_bytes()); + hasher.update(&content); + } + Ok(format!("sha256:{:x}", hasher.finalize())) +} + +/// Does `dir` ship an `R/` shim directory with at least one file in it? +fn has_r_shims(dir: &Path) -> bool { + let r_dir = dir.join("R"); + r_dir.is_dir() + && fs::read_dir(&r_dir) + .map(|mut entries| entries.any(|e| e.map(|e| e.path().is_file()).unwrap_or(false))) + .unwrap_or(false) +} + +/// Does any `.ty` file under `dir` use a raw `extern: (...) -> T r#"...R..."#` +/// verbatim block? Detected by the literal `r#"` token, the only place that +/// sequence appears in TypR source (`typR/ai_context/tuto_external_packages.md` +/// §"Niveau 3"). +fn has_extern_raw(dir: &Path) -> Result { + for rel in tracked_files(dir)? { + if rel.extension().and_then(|e| e.to_str()) != Some("ty") { + continue; + } + let content = fs::read_to_string(dir.join(&rel)).unwrap_or_default(); + if content.contains("r#\"") { + return Ok(true); + } + } + Ok(false) +} + +/// Enforce the manifest's `[capabilities]` gate against what was actually +/// fetched (rfcs/0031-external-type-definitions.md, "Capabilities and R +/// shims"): a definition that leaves `r_shims`/`extern_raw` at their default +/// `false` but ships either anyway is rejected outright, before a single +/// `.ty` file is loaded anywhere. A definition that truthfully declares +/// `true` is let through with a warning message for the caller to print. +fn check_capabilities(manifest: &DefinitionManifest, dir: &Path) -> Result, String> { + let mut warnings = Vec::new(); + let ships_r_shims = has_r_shims(dir); + let ships_extern_raw = has_extern_raw(dir)?; + + if ships_r_shims && !manifest.capabilities.r_shims { + return Err( + "rejected: this definition ships an `R/` shim directory but its manifest declares \ + `capabilities.r_shims = false` — undeclared executable R is refused at fetch time \ + (typR/registry.md §5.5)" + .to_string(), + ); + } + if ships_extern_raw && !manifest.capabilities.extern_raw { + return Err( + "rejected: this definition uses a raw `extern: (...) -> T r#\"...R...\"#` block but its \ + manifest declares `capabilities.extern_raw = false` — undeclared executable R is \ + refused at fetch time (typR/registry.md §5.5)" + .to_string(), + ); + } + if manifest.capabilities.r_shims { + warnings.push( + "this definition declares `capabilities.r_shims = true` — it ships executable R that \ + will run in this project's process (typR/registry.md §5.5)." + .to_string(), + ); + } + if manifest.capabilities.extern_raw { + warnings.push( + "this definition declares `capabilities.extern_raw = true` — it uses raw R blocks that \ + will run in this project's process (typR/registry.md §5.5)." + .to_string(), + ); + } + Ok(warnings) +} + +/// Fetch `spec`, validate its manifest and capabilities, and compute its +/// content digest — the shared core of `add` and `update`. Leaves the +/// fetched files at `FetchedDefinition::dir` for the caller to admit into the +/// cache (or discard, on any failure). +pub fn fetch(spec: &RepoSpec) -> Result<(FetchedDefinition, Vec), String> { + if !git_available() { + return Err("`git` is not on PATH — `typr types add`/`update` need it to fetch a definition repository".to_string()); + } + let (dir, rev) = clone_repo(&spec.clone_url(), spec.rev.as_deref())?; + + let result = (|| { + let manifest_path = dir.join(MANIFEST_NAME); + let manifest_source = fs::read_to_string(&manifest_path) + .map_err(|_| format!("no `{MANIFEST_NAME}` at the root of {}", spec.display()))?; + let manifest = parse_manifest(&manifest_source)?; + let warnings = check_capabilities(&manifest, &dir)?; + let digest = compute_digest(&dir)?; + Ok(( + FetchedDefinition { + manifest, + rev, + digest, + dir: dir.clone(), + }, + warnings, + )) + })(); + + if result.is_err() { + let _ = fs::remove_dir_all(&dir); + } + result +} + +/// Copy every tracked file of a fetched definition into the on-disk cache at +/// `~/.cache/typr/types///`, replacing whatever was there before +/// (the digest already identifies the content, so an existing directory with +/// the same digest is assumed identical — a corrupted one is exactly what +/// re-running `typr types add`/`update` is for). +fn admit_to_cache(fetched: &FetchedDefinition, pkg: &str) -> Result { + let cache_dir = cache_dir_for(pkg, &fetched.digest) + .ok_or_else(|| "could not determine a cache directory (no $HOME/$XDG_CACHE_HOME)".to_string())?; + let _ = fs::remove_dir_all(&cache_dir); + fs::create_dir_all(&cache_dir).map_err(|e| format!("could not create {}: {e}", cache_dir.display()))?; + for rel in tracked_files(&fetched.dir)? { + let from = fetched.dir.join(&rel); + let to = cache_dir.join(&rel); + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + fs::copy(&from, &to).map_err(|e| format!("could not copy {} to {}: {e}", from.display(), to.display()))?; + } + Ok(cache_dir) +} + +// --------------------------------------------------------------------- +// typr.toml [types] +// --------------------------------------------------------------------- + +/// The subset of a project's `typr.toml` this module reads: the `[types]` +/// table (registry.md §7.1 — never a dependency list, only trust + +/// package→definition pins). +/// +/// `trust` is read and kept here but not yet consumed anywhere: passing it +/// into `standard_library::load_external_ty_definitions` at `check`/`build`/ +/// `run` time — reading `typr.lock` into the type-checking context — is the +/// next integration step, not part of this `typr types add/update/list/ +/// vendor` checklist item (`typR/registry.md` §13 J2). +#[derive(Debug, Clone, Default)] +pub struct TypesConfig { + #[allow(dead_code)] + pub trust: Option, + /// package name → explicit `github:owner/repo[@rev]` pin. + pub pins: std::collections::BTreeMap, +} + +impl TypesConfig { + pub fn read(project_root: &Path) -> Self { + let Ok(source) = fs::read_to_string(project_root.join(PROJECT_CONFIG_NAME)) else { + return Self::default(); + }; + let Ok(value) = source.parse::() else { + return Self::default(); + }; + let Some(types) = value.get("types").and_then(|t| t.as_table()) else { + return Self::default(); + }; + let trust = types.get("trust").and_then(|v| v.as_str()).map(str::to_string); + let pins = types + .iter() + .filter(|(k, _)| k.as_str() != "trust") + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect(); + TypesConfig { trust, pins } + } +} + +// --------------------------------------------------------------------- +// Commands: add / update / list / vendor +// --------------------------------------------------------------------- + +/// `typr types add ` — resolve `spec` for `package`, fetch it, verify +/// it, cache it, and record it in `typr.lock`. +pub fn add(project_root: &Path, package: &str, spec_str: &str) -> Result { + let spec = RepoSpec::parse(spec_str)?; + let (fetched, warnings) = fetch(&spec)?; + for w in &warnings { + eprintln!("warning: {w}"); + } + admit_to_cache(&fetched, package)?; + + let locked = LockedDefinition { + package: package.to_string(), + repository: spec.display(), + version: fetched.manifest.definition.version.clone(), + rev: fetched.rev.clone(), + digest: fetched.digest.clone(), + tier: fetched.manifest.definition.tier.clone(), + r_version_seen: None, + }; + + let lock_path = project_root.join(LOCKFILE_NAME); + let mut lockfile = Lockfile::read(&lock_path); + lockfile.upsert(locked.clone()); + lockfile.write(&lock_path)?; + + let _ = fs::remove_dir_all(&fetched.dir); + Ok(locked) +} + +/// `typr types update [pkg]` — re-fetch and re-pin one (or, with `package == +/// None`, every) resolved definition. The repository spec comes from the +/// existing `typr.lock` entry (or `typr.toml [types]`'s pin, for a package +/// not yet locked but explicitly pinned). +pub fn update(project_root: &Path, package: Option<&str>) -> Result, String> { + let lock_path = project_root.join(LOCKFILE_NAME); + let lockfile = Lockfile::read(&lock_path); + let config = TypesConfig::read(project_root); + + let targets: Vec<(String, String)> = match package { + Some(pkg) => { + let spec = lockfile + .find(pkg) + .map(|d| d.repository.clone()) + .or_else(|| config.pins.get(pkg).cloned()) + .ok_or_else(|| format!("no resolved or pinned definition for `{pkg}` — use `typr types add` first"))?; + vec![(pkg.to_string(), spec)] + } + None => lockfile + .definitions + .iter() + .map(|d| (d.package.clone(), d.repository.clone())) + .collect(), + }; + + if targets.is_empty() { + return Ok(Vec::new()); + } + + let mut updated = Vec::new(); + for (pkg, spec) in targets { + updated.push(add(project_root, &pkg, &spec)?); + } + Ok(updated) +} + +/// `typr types list` — everything `typr.lock` currently resolves. +pub fn list(project_root: &Path) -> Vec { + Lockfile::read(&project_root.join(LOCKFILE_NAME)).definitions +} + +/// `typr types vendor [--out DIR]` — copy every resolved definition's cached +/// `.ty` files into the project tree (default `ty/vendor//`), so the +/// build stops depending on the network or the upstream repository's +/// continued existence (registry.md §7.4, rfcs/0031 "vendor"). +pub fn vendor(project_root: &Path, out_dir: Option<&Path>) -> Result, String> { + let lockfile = Lockfile::read(&project_root.join(LOCKFILE_NAME)); + if lockfile.definitions.is_empty() { + return Err("nothing resolved yet — `typr types add` a definition before vendoring".to_string()); + } + let base = out_dir + .map(|p| project_root.join(p)) + .unwrap_or_else(|| project_root.join("ty").join("vendor")); + + let mut written = Vec::new(); + for def in &lockfile.definitions { + let cache_dir = cache_dir_for(&def.package, &def.digest) + .ok_or_else(|| "could not determine a cache directory (no $HOME/$XDG_CACHE_HOME)".to_string())?; + if !cache_dir.is_dir() { + return Err(format!( + "`{}` is not in the local cache at {} — run `typr types update {}` first", + def.package, + cache_dir.display(), + def.package + )); + } + let actual_digest = compute_digest(&cache_dir)?; + if actual_digest != def.digest { + return Err(format!( + "digest mismatch for `{}`: typr.lock says {} but the cached copy hashes to {} — \ + the cache is corrupted; run `typr types update {}` to re-fetch", + def.package, def.digest, actual_digest, def.package + )); + } + + let dest = base.join(&def.package); + let _ = fs::remove_dir_all(&dest); + for rel in tracked_files(&cache_dir)? { + let from = cache_dir.join(&rel); + let to = dest.join(&rel); + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + fs::copy(&from, &to).map_err(|e| format!("could not copy {} to {}: {e}", from.display(), to.display()))?; + written.push(to); + } + } + Ok(written) +} + +#[cfg(test)] +mod tests { + use super::*; + + // -- RepoSpec ---------------------------------------------------- + + #[test] + fn parses_owner_repo_without_rev() { + let spec = RepoSpec::parse("github:alice/typr-shiny").unwrap(); + assert_eq!(spec.owner, "alice"); + assert_eq!(spec.repo, "typr-shiny"); + assert_eq!(spec.rev, None); + assert_eq!(spec.clone_url(), "https://github.com/alice/typr-shiny.git"); + assert_eq!(spec.display(), "github:alice/typr-shiny"); + } + + #[test] + fn parses_owner_repo_with_rev() { + let spec = RepoSpec::parse("github:alice/typr-shiny@a1b2c3d").unwrap(); + assert_eq!(spec.rev.as_deref(), Some("a1b2c3d")); + // the rev never leaks into the persisted `repository` string — + // typr.lock's own `rev` field is the one source of truth for it. + assert_eq!(spec.display(), "github:alice/typr-shiny"); + } + + #[test] + fn rejects_unsupported_scheme() { + let err = RepoSpec::parse("gitlab:alice/typr-shiny").unwrap_err(); + assert!(err.contains("github:"), "unexpected error: {err}"); + } + + #[test] + fn rejects_missing_owner_or_repo() { + assert!(RepoSpec::parse("github:typr-shiny").is_err()); + assert!(RepoSpec::parse("github:/typr-shiny").is_err()); + assert!(RepoSpec::parse("github:alice/").is_err()); + } + + // -- Lockfile ------------------------------------------------------ + + fn sample_locked(package: &str) -> LockedDefinition { + LockedDefinition { + package: package.to_string(), + repository: "github:alice/typr-shiny".to_string(), + version: "0.3.0".to_string(), + rev: "a1b2c3d4e5f6".to_string(), + digest: "sha256:deadbeef".to_string(), + tier: "T2".to_string(), + r_version_seen: Some("1.11.1".to_string()), + } + } + + #[test] + fn lockfile_round_trips_through_toml() { + let mut lockfile = Lockfile::default(); + lockfile.upsert(sample_locked("shiny")); + lockfile.upsert(sample_locked("dplyr")); + + let dir = std::env::temp_dir().join(format!("typr_lockfile_test_{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join(LOCKFILE_NAME); + lockfile.write(&path).unwrap(); + + let read_back = Lockfile::read(&path); + assert_eq!(read_back.definitions.len(), 2); + assert_eq!(read_back.find("shiny"), lockfile.find("shiny")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_replaces_existing_entry_for_same_package() { + let mut lockfile = Lockfile::default(); + lockfile.upsert(sample_locked("shiny")); + let mut updated = sample_locked("shiny"); + updated.digest = "sha256:newdigest".to_string(); + lockfile.upsert(updated); + + assert_eq!(lockfile.definitions.len(), 1); + assert_eq!(lockfile.find("shiny").unwrap().digest, "sha256:newdigest"); + } + + #[test] + fn missing_lockfile_reads_as_empty() { + let path = std::env::temp_dir().join("typr_lockfile_definitely_missing.lock"); + let _ = fs::remove_file(&path); + assert!(Lockfile::read(&path).definitions.is_empty()); + } + + // -- TypesConfig ----------------------------------------------------- + + #[test] + fn reads_trust_and_pins_from_typr_toml() { + let dir = std::env::temp_dir().join(format!("typr_types_config_test_{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(PROJECT_CONFIG_NAME), + "[types]\ntrust = \"T2\"\nshiny = \"github:alice/typr-shiny\"\n", + ) + .unwrap(); + + let config = TypesConfig::read(&dir); + assert_eq!(config.trust.as_deref(), Some("T2")); + assert_eq!(config.pins.get("shiny").map(String::as_str), Some("github:alice/typr-shiny")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn missing_typr_toml_reads_as_default() { + let dir = std::env::temp_dir().join("typr_types_config_definitely_missing"); + let _ = fs::remove_dir_all(&dir); + let config = TypesConfig::read(&dir); + assert!(config.trust.is_none()); + assert!(config.pins.is_empty()); + } + + // -- digest / capability gate ----------------------------------------- + + fn write_file(root: &Path, rel: &str, content: &str) { + let path = root.join(rel); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, content).unwrap(); + } + + #[test] + fn digest_is_deterministic_and_order_independent() { + let dir_a = std::env::temp_dir().join(format!("typr_digest_a_{}", std::process::id())); + let dir_b = std::env::temp_dir().join(format!("typr_digest_b_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir_a); + let _ = fs::remove_dir_all(&dir_b); + + write_file(&dir_a, "ty/core.ty", "@f: (int) -> int;"); + write_file(&dir_a, "typr-def.toml", "format_version = 1"); + // same content, written in the opposite order + write_file(&dir_b, "typr-def.toml", "format_version = 1"); + write_file(&dir_b, "ty/core.ty", "@f: (int) -> int;"); + + assert_eq!(compute_digest(&dir_a).unwrap(), compute_digest(&dir_b).unwrap()); + + write_file(&dir_b, "ty/core.ty", "@f: (int) -> char;"); + assert_ne!(compute_digest(&dir_a).unwrap(), compute_digest(&dir_b).unwrap()); + + let _ = fs::remove_dir_all(&dir_a); + let _ = fs::remove_dir_all(&dir_b); + } + + #[test] + fn digest_ignores_git_directory() { + let dir = std::env::temp_dir().join(format!("typr_digest_git_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "ty/core.ty", "@f: (int) -> int;"); + let before = compute_digest(&dir).unwrap(); + write_file(&dir, ".git/HEAD", "ref: refs/heads/main"); + let after = compute_digest(&dir).unwrap(); + assert_eq!(before, after); + let _ = fs::remove_dir_all(&dir); + } + + fn manifest_with(r_shims: bool, extern_raw: bool) -> DefinitionManifest { + let toml = format!( + "format_version = 1\n[package]\nname = \"shiny\"\nsince = \"1.11.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T2\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:alice/typr-shiny\"\n\ + [capabilities]\nr_shims = {r_shims}\nextern_raw = {extern_raw}\n" + ); + parse_manifest(&toml).unwrap() + } + + #[test] + fn undeclared_r_shims_are_rejected() { + let dir = std::env::temp_dir().join(format!("typr_caps_shims_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "R/shim.R", "f <- function(x) x"); + let err = check_capabilities(&manifest_with(false, false), &dir).unwrap_err(); + assert!(err.contains("r_shims"), "unexpected error: {err}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn declared_r_shims_pass_with_a_warning() { + let dir = std::env::temp_dir().join(format!("typr_caps_shims_ok_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "R/shim.R", "f <- function(x) x"); + let warnings = check_capabilities(&manifest_with(true, false), &dir).unwrap(); + assert!(warnings.iter().any(|w| w.contains("r_shims"))); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn undeclared_extern_raw_is_rejected() { + let dir = std::env::temp_dir().join(format!("typr_caps_extern_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "ty/core.ty", "let f <- extern: (x: int) -> int r#\"\nfunction(x) x\n\"#;"); + let err = check_capabilities(&manifest_with(false, false), &dir).unwrap_err(); + assert!(err.contains("extern_raw"), "unexpected error: {err}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn clean_definition_has_no_warnings() { + let dir = std::env::temp_dir().join(format!("typr_caps_clean_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "ty/core.ty", "@f: (int) -> int;"); + let warnings = check_capabilities(&manifest_with(false, false), &dir).unwrap(); + assert!(warnings.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + // -- End-to-end add/update/list/vendor against a local `file://` repo -- + + /// Build a minimal, valid definition repository under `dir` and commit it + /// with `git`, so `fetch`/`add` can clone it over a `file://` URL with no + /// network access — the same offline-friendly pattern + /// `gen_types.rs`/`r_name_cache.rs` use for their Rscript-dependent tests + /// (fail open when the external tool is missing). + fn make_definition_repo(dir: &Path) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + write_file( + dir, + MANIFEST_NAME, + "format_version = 1\n\ + [package]\nname = \"shiny\"\nsince = \"1.11.0\"\n\ + [definition]\nversion = \"0.3.0\"\ntier = \"T2\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:alice/typr-shiny\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n", + ); + write_file( + dir, + "ty/core.ty", + "#! pkg: shiny\n#! tier: T2\n@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n", + ); + + let run = |args: &[&str]| -> Result<(), String> { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).into_owned()); + } + Ok(()) + }; + run(&["init", "--quiet"])?; + run(&["config", "user.email", "test@example.com"])?; + run(&["config", "user.name", "test"])?; + run(&["add", "."])?; + run(&["commit", "--quiet", "-m", "initial"])?; + Ok(()) + } + + fn file_url(dir: &Path) -> String { + format!("file://{}", dir.display()) + } + + #[test] + fn add_update_list_vendor_round_trip_against_a_local_repo() { + if !git_available() { + eprintln!("skipping: git not on PATH"); + return; + } + let repo_dir = std::env::temp_dir().join(format!("typr_types_repo_{}", std::process::id())); + let project_dir = std::env::temp_dir().join(format!("typr_types_project_{}", std::process::id())); + let _ = fs::remove_dir_all(&repo_dir); + let _ = fs::remove_dir_all(&project_dir); + fs::create_dir_all(&project_dir).unwrap(); + + if let Err(e) = make_definition_repo(&repo_dir) { + eprintln!("skipping: could not set up local git fixture: {e}"); + let _ = fs::remove_dir_all(&repo_dir); + let _ = fs::remove_dir_all(&project_dir); + return; + } + + // `fetch`/`add` only understand `github:owner/repo` — reach the local + // fixture through the real clone path by cloning the `file://` URL + // directly and driving the cache/lock plumbing at that level, since + // `RepoSpec` cannot express a local path (by design: only GitHub is a + // supported host today). + let (dir, rev) = clone_repo(&file_url(&repo_dir), None).expect("clone should succeed"); + let manifest = parse_manifest(&fs::read_to_string(dir.join(MANIFEST_NAME)).unwrap()).unwrap(); + let warnings = check_capabilities(&manifest, &dir).unwrap(); + assert!(warnings.is_empty()); + let digest = compute_digest(&dir).unwrap(); + let fetched = FetchedDefinition { + manifest, + rev, + digest, + dir, + }; + + admit_to_cache(&fetched, "shiny").unwrap(); + let locked = LockedDefinition { + package: "shiny".to_string(), + repository: "github:alice/typr-shiny".to_string(), + version: fetched.manifest.definition.version.clone(), + rev: fetched.rev.clone(), + digest: fetched.digest.clone(), + tier: fetched.manifest.definition.tier.clone(), + r_version_seen: None, + }; + let mut lockfile = Lockfile::default(); + lockfile.upsert(locked); + lockfile.write(&project_dir.join(LOCKFILE_NAME)).unwrap(); + + // list() + let listed = list(&project_dir); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].package, "shiny"); + assert_eq!(listed[0].tier, "T2"); + + // vendor() + let written = vendor(&project_dir, None).unwrap(); + assert!(!written.is_empty()); + let vendored_ty = project_dir.join("ty").join("vendor").join("shiny").join("ty").join("core.ty"); + assert!(vendored_ty.is_file(), "expected {}", vendored_ty.display()); + let content = fs::read_to_string(&vendored_ty).unwrap(); + assert!(content.contains("fluidPage")); + + let _ = fs::remove_dir_all(&repo_dir); + let _ = fs::remove_dir_all(&project_dir); + let _ = fs::remove_dir_all(&fetched.dir); + if let Some(cache_dir) = cache_dir_for("shiny", &fetched.digest) { + let _ = fs::remove_dir_all(&cache_dir); + } + } + + #[test] + fn vendor_detects_cache_digest_mismatch() { + let project_dir = std::env::temp_dir().join(format!("typr_vendor_mismatch_{}", std::process::id())); + let _ = fs::remove_dir_all(&project_dir); + fs::create_dir_all(&project_dir).unwrap(); + + let mut lockfile = Lockfile::default(); + lockfile.upsert(LockedDefinition { + package: "ghost".to_string(), + repository: "github:alice/typr-ghost".to_string(), + version: "0.1.0".to_string(), + rev: "deadbeef".to_string(), + digest: "sha256:doesnotexistanywhereincache".to_string(), + tier: "T3".to_string(), + r_version_seen: None, + }); + lockfile.write(&project_dir.join(LOCKFILE_NAME)).unwrap(); + + let err = vendor(&project_dir, None).unwrap_err(); + assert!(err.contains("ghost"), "unexpected error: {err}"); + + let _ = fs::remove_dir_all(&project_dir); + } +} diff --git a/crates/typr-core/src/components/context/vartype.rs b/crates/typr-core/src/components/context/vartype.rs index e74b621..868a2a7 100644 --- a/crates/typr-core/src/components/context/vartype.rs +++ b/crates/typr-core/src/components/context/vartype.rs @@ -698,6 +698,55 @@ impl VarType { vars.iter().fold(self, |acc, x| acc.remove_var(x)) } + /// Replace every entry named in `names` with the canonical `(Any, + /// UnknownFunction)` pair the compiler already uses everywhere for "a + /// real name, callable with any arguments, but not type-checked" (see + /// the `(Any, UnknownFunction)` preload for untyped R/JS builtins in + /// `build_function_list_vartype`, and the `UseSelector::Wildcard` comment + /// in `type_checking/mod.rs`). Used to degrade an external type + /// definition's entries that fall below the project's configured trust + /// threshold (`typR/registry.md` §5.4/D2, + /// `rfcs/0031-external-type-definitions.md` "Loading external `.ty` into + /// the context": entry tier < project trust -> load as + /// `Type::UnknownFunction` instead of its declared signature). + /// + /// A name absent from `self` is a no-op — degrading only ever removes + /// type information that would otherwise have been added, never invents + /// a new name. + pub fn degrade_to_any(self, names: &std::collections::HashSet) -> Self { + if names.is_empty() { + return self; + } + let VarType { + variables, + aliases, + std, + alias_counter, + .. + } = self; + let mut new_variables = Arc::unwrap_or_clone(variables); + let mut degraded: std::collections::HashSet = std::collections::HashSet::new(); + new_variables.retain(|(var, _)| { + let name = var.get_name(); + if names.contains(&name) { + degraded.insert(name); + false + } else { + true + } + }); + for name in degraded { + new_variables.insert((Var::from_name(&name).set_type(builder::any_type()), builder::unknown_function_type())); + } + Self { + variables: Arc::new(new_variables), + aliases, + std, + alias_counter, + name_index: Arc::new(OnceLock::new()), + } + } + pub fn remove_var(self, var: &Var) -> Self { let VarType { variables, @@ -903,6 +952,52 @@ mod tests { s.parse::().unwrap() } + #[test] + fn degrade_to_any_replaces_named_entries_with_any_unknown_function() { + let vt = VarType::new().push_var_type(&[ + (Var::from_name("keep_me").set_type(builder::integer_type_default()), builder::integer_type_default()), + ( + Var::from_name("degrade_me").set_type(builder::integer_type_default()), + builder::integer_type_default(), + ), + ]); + + let mut names = std::collections::HashSet::new(); + names.insert("degrade_me".to_string()); + let degraded = vt.degrade_to_any(&names); + + let (kept_var, kept_type) = degraded + .entries_named("keep_me") + .into_iter() + .next() + .expect("untouched entry must survive"); + assert_eq!(kept_type, builder::integer_type_default()); + assert_eq!(kept_var.get_type(), builder::integer_type_default()); + + let (degraded_var, degraded_type) = degraded + .entries_named("degrade_me") + .into_iter() + .next() + .expect("degraded entry must still be present"); + assert!(degraded_type.is_unknown_function()); + assert!(degraded_var.get_type().is_any()); + } + + #[test] + fn degrade_to_any_is_a_no_op_for_a_name_not_present() { + let vt = VarType::new().push_var_type(&[( + Var::from_name("keep_me").set_type(builder::integer_type_default()), + builder::integer_type_default(), + )]); + + let mut names = std::collections::HashSet::new(); + names.insert("never_declared".to_string()); + let degraded = vt.degrade_to_any(&names); + + assert_eq!(degraded.entries_named("keep_me").len(), 1); + assert!(degraded.entries_named("never_declared").is_empty()); + } + #[test] fn atomic_array_elem_primitive_and_vec_are_atomic() { let ctx = FluentParser::new().get_context(); diff --git a/rfcs/0031-external-type-definitions.md b/rfcs/0031-external-type-definitions.md index 5a86288..17db0d4 100644 --- a/rfcs/0031-external-type-definitions.md +++ b/rfcs/0031-external-type-definitions.md @@ -489,9 +489,14 @@ Explicitly out of scope here, deferred to `registry.md`'s later milestones: manifest - [ ] external `.ty` loading merged into `standard_library.rs`'s context build, keyed by resolved `typr.lock` entries -- [ ] `trust` threshold + degrade-to-`UnknownFunction` at merge time -- [ ] `typr types add|update|list|vendor`, `typr.lock` read/write, digest +- [x] `trust` threshold + degrade-to-`UnknownFunction` at merge time + (`crates/typr-cli/src/standard_library.rs::load_external_ty_definitions`, + `VarType::degrade_to_any`; see `typR/registry.md` §13 J2 for detail) +- [x] `typr types add|update|list|vendor`, `typr.lock` read/write, digest verification, `~/.cache/typr/types///` + (`crates/typr-cli/src/type_registry.rs`; see `typR/registry.md` §13 J2 + for detail — the `typr.lock` → `load_external_ty_definitions` wiring + itself is still open, tracked by the next unchecked item below) - [ ] `cases/`: missing definition, tier below `trust`, version below `since`, version above `until`, unreachable repository, digest mismatch — each asserting *no hard error*, only degradation From 8840af9fe55d92442de7940d2d377d7d28a34a1b Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sun, 13 Sep 2026 23:52:51 +0200 Subject: [PATCH 11/18] update --- crates/typr-cli/src/project.rs | 33 ++- crates/typr-cli/src/standard_library.rs | 264 ++++++++++++++++++++++- crates/typr-cli/src/type_registry.rs | 270 ++++++++++++++++++++++-- rfcs/0031-external-type-definitions.md | 34 ++- 4 files changed, 564 insertions(+), 37 deletions(-) diff --git a/crates/typr-cli/src/project.rs b/crates/typr-cli/src/project.rs index 423b9aa..422528c 100644 --- a/crates/typr-cli/src/project.rs +++ b/crates/typr-cli/src/project.rs @@ -718,7 +718,10 @@ pub fn new(name: &str, renv: bool) { } pub fn check_project() { - let context = Context::default().set_environment(Environment::Project); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default().set_environment(Environment::Project), + ); let step = Step::new("Parsing"); let (lang, syntax_errors) = parse_code(&PathBuf::from("TypR/main.ty"), context.get_environment()); @@ -741,7 +744,10 @@ pub fn check_project() { } pub fn check_file(path: &PathBuf) { - let context = Context::default().set_environment(Environment::Project); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default().set_environment(Environment::Project), + ); let dir = PathBuf::from("."); write_std_for_type_checking(&dir); @@ -975,10 +981,13 @@ fn build_project_impl( } } - let context = Context::default() - .set_environment(Environment::Project) - .set_test_mode(test_mode) - .set_checked_mode(checked_mode); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default() + .set_environment(Environment::Project) + .set_test_mode(test_mode) + .set_checked_mode(checked_mode), + ); let step = Step::new("Parsing"); let (lang, mut expansion_info) = parse_code_with_info(&PathBuf::from("TypR/main.ty"), context.get_environment()); @@ -1085,9 +1094,10 @@ pub fn build_file(path: &Path, test_mode: bool, checked_mode: bool, strict_mode: } step.done(); - let context = Context::default() - .set_test_mode(test_mode) - .set_checked_mode(checked_mode); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default().set_test_mode(test_mode).set_checked_mode(checked_mode), + ); let step = Step::new("Type checking"); let type_checker = TypeChecker::new(context.clone()).typing_no_panic(&lang); @@ -1247,7 +1257,10 @@ fn run_file_impl(path: &Path, keep_files: bool, profile: bool, checked_mode: boo let work_dir = get_working_directory(keep_files); let guard = TempDirGuard::new(if keep_files { None } else { Some(work_dir.clone()) }); write_std_for_type_checking(&work_dir); - let context = Context::default().set_checked_mode(checked_mode); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default().set_checked_mode(checked_mode), + ); let type_checker = TypeChecker::new(context.clone()).typing_no_panic(&lang); if type_checker.has_errors() { step.fail(); diff --git a/crates/typr-cli/src/standard_library.rs b/crates/typr-cli/src/standard_library.rs index 9be9f33..a5fba1d 100644 --- a/crates/typr-cli/src/standard_library.rs +++ b/crates/typr-cli/src/standard_library.rs @@ -601,11 +601,9 @@ fn names_below_trust(ty_sources: &[(&str, &str)], default_tier: &str, trust: &st /// `Context::empty()`, is what lets a third-party `.ty` see the bundled /// stdlib while it is being type-checked. /// -/// Not yet called from the CLI: nothing resolves a `typr.lock` entry into -/// `(filename, source)` pairs plus a manifest's tier/trust yet (`typr types -/// add`/`typr.lock`, the next checklist item), so this has no caller outside -/// its own tests until then. -#[allow(dead_code)] +/// Called from `load_project_type_definitions`, below, which resolves a +/// project's `typr.lock` into exactly the `(ty_sources, default_tier, +/// trust)` triples this function expects. pub fn load_external_ty_definitions( base_context: Context, ty_sources: &[(&str, &str)], @@ -618,6 +616,165 @@ pub fn load_external_ty_definitions( (context, skipped) } +/// Every function name declared across `ty_sources`, regardless of tier — +/// what `degrade_if_version_out_of_range` widens to `Any` when the whole +/// definition is out of its declared version range. Unlike +/// `names_below_trust`, tier plays no role here: a version mismatch is a +/// property of the *definition*, not of any one entry's declared +/// trustworthiness. +fn all_declared_names(ty_sources: &[(&str, &str)]) -> HashSet { + let mut names = HashSet::new(); + for (_filename, source) in ty_sources { + for line in source.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('@') { + continue; + } + if let Some(raw_name) = extract_raw_signature_name(trimmed) { + names.insert(unwrap_backtick_name(&raw_name)); + } + } + } + names +} + +/// Parse a dotted version string into numeric components, ignoring any +/// non-digit suffix on a component (`"1.11.0-beta"` -> `[1, 11, 0]`) and +/// treating an unparsable component as `0` — good enough for the floor/ +/// ceiling comparison below, never a reason to fail a build over a +/// malformed version string. +fn parse_version(v: &str) -> Vec { + v.split(['.', '-', '+']) + .map(|part| { + let digits: String = part.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse::().unwrap_or(0) + }) + .collect() +} + +/// Is `a` strictly less than `b`, comparing dotted version strings +/// component-wise (`"1.9"` < `"1.10"`, not string order)? Both are padded to +/// the same length first so `"1.2"` and `"1.2.0"` compare equal rather than +/// the shorter one spuriously losing. +fn version_less_than(a: &str, b: &str) -> bool { + let mut pa = parse_version(a); + let mut pb = parse_version(b); + while pa.len() < pb.len() { + pa.push(0); + } + while pb.len() < pa.len() { + pb.push(0); + } + pa < pb +} + +/// registry.md §7.2 "Compatibilité de versions : borne minimale, pas plage +/// fermée": when the R package version actually observed at resolution time +/// (`typr.lock`'s `r_version_seen`, populated by `typr types add`/`update`) +/// falls below the definition's declared `since` floor, or above its +/// optional `until` ceiling, every name the definition declares degrades to +/// `Any` — same D2 degrade-never-fail contract as the trust threshold +/// (§0/§5.4), just gated on a different signal, and applied on top of it +/// rather than instead of it. +/// +/// No comparison is made, and nothing degrades, when `r_version_seen` is +/// `None`: a version that was never observed (offline resolution, or R +/// unavailable when the definition was added/updated) is not the same as an +/// incompatible one, and D2 forbids treating an unreadable signal as +/// grounds for anything other than staying exactly as trusting as the tier +/// check already decided. +fn degrade_if_version_out_of_range( + context: Context, + ty_sources: &[(&str, &str)], + since: &str, + until: Option<&str>, + r_version_seen: Option<&str>, +) -> (Context, Option) { + let Some(observed) = r_version_seen else { + return (context, None); + }; + let below_floor = version_less_than(observed, since); + let above_ceiling = until.map(|u| version_less_than(u, observed)).unwrap_or(false); + if !below_floor && !above_ceiling { + return (context, None); + } + + let reason = if below_floor { + format!( + "observed R package version {observed} is older than this definition's declared floor (since = \"{since}\")" + ) + } else { + format!( + "observed R package version {observed} is newer than this definition's declared ceiling (until = \"{}\")", + until.unwrap_or_default() + ) + }; + + let names = all_declared_names(ty_sources); + let mut context = context; + context.typing_context = context.typing_context.clone().degrade_to_any(&names); + (context, Some(reason)) +} + +/// Load every package's resolved external Type Definition on top of +/// `base_context` — the "reading `typr.lock` at `check`/`build`/`run` time" +/// wiring that `type_registry.rs`'s module doc and `load_external_ty_definitions` +/// name as the last missing piece of `typR/registry.md` §13 J2. Called from +/// every `check`/`build`/`run` entry point in `project.rs`. +/// +/// A project with no `typr.lock` is unaffected (`resolve_locked_definitions` +/// returns nothing to load). A locked package whose cache is missing, stale, +/// or out of its declared version range degrades or is skipped with a +/// `warning:` line — never a hard error: this function cannot make a build +/// that passed before fail now (D2, registry.md §0/§5.4). +/// +/// `project_root` is the directory holding `typr.toml`/`typr.lock` — every +/// call site in `project.rs` passes `Path::new(".")`, since CLI commands +/// already run with the project root as the current directory (same +/// convention as `PathBuf::from("TypR/main.ty")` elsewhere in that module). +/// Taking it as a parameter, rather than hard-coding `"."` in here, is what +/// lets tests point it at a temporary project without touching the process's +/// current directory. +pub fn load_project_type_definitions(project_root: &std::path::Path, base_context: Context) -> Context { + let trust = crate::type_registry::TypesConfig::read(project_root) + .trust + .unwrap_or_else(|| "T2".to_string()); + let (resolved, warnings) = crate::type_registry::resolve_locked_definitions(project_root); + for w in &warnings { + eprintln!("warning: {w}"); + } + + let mut context = base_context; + for def in &resolved { + let sources: Vec<(&str, &str)> = def.ty_sources.iter().map(|(f, s)| (f.as_str(), s.as_str())).collect(); + + let (next_context, skipped) = load_external_ty_definitions(context, &sources, &def.default_tier, &trust); + for (filename, message) in &skipped { + eprintln!( + "warning: `{}` — {} could not be loaded ({message}); its declared names stay untyped", + def.package, filename + ); + } + + let (next_context, version_warning) = degrade_if_version_out_of_range( + next_context, + &sources, + &def.since, + def.until.as_deref(), + def.r_version_seen.as_deref(), + ); + if let Some(reason) = version_warning { + eprintln!( + "warning: `{}` — {reason}; its declared types are degraded to Any for this run (registry.md §7.2)", + def.package + ); + } + + context = next_context; + } + context +} + /// Build a documentation graph over a set of `.ty` sources. /// /// Shared by `build_stdlib_docs` (production) and the tests, so the @@ -1037,6 +1194,103 @@ mod tests { ); } + // -- version_less_than / degrade_if_version_out_of_range (registry.md §7.2) -- + + /// The whole reason `version_less_than` exists instead of a plain string + /// comparison: `"1.9" < "1.10"` numerically, but `"1.10" < "1.9"` + /// lexicographically. + #[test] + fn version_less_than_compares_components_numerically() { + assert!(version_less_than("1.9", "1.10")); + assert!(!version_less_than("1.10", "1.9")); + assert!(version_less_than("1.11.0", "2.0.0")); + assert!(!version_less_than("2.0.0", "1.11.0")); + } + + /// `"1.2"` and `"1.2.0"` must compare equal (neither less than the + /// other) rather than the shorter string spuriously losing to padding. + #[test] + fn version_less_than_treats_missing_trailing_components_as_zero() { + assert!(!version_less_than("1.2", "1.2.0")); + assert!(!version_less_than("1.2.0", "1.2")); + } + + /// An observed version below the definition's `since` floor degrades + /// every declared name to `Any` and names the floor in the reason. + #[test] + fn degrade_if_version_out_of_range_degrades_below_the_since_floor() { + let source = "@f: (int) -> int;"; + let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); + assert!(skipped.is_empty()); + + let (context, reason) = + degrade_if_version_out_of_range(context, &[("pkg.ty", source)], "1.11.0", None, Some("1.9.0")); + + let reason = reason.expect("an observed version below `since` must degrade"); + assert!(reason.contains("older") && reason.contains("1.11.0"), "unexpected reason: {reason}"); + let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); + assert!(typ.is_unknown_function()); + } + + /// An observed version above the definition's `until` ceiling degrades + /// every declared name to `Any` and names the ceiling in the reason. + #[test] + fn degrade_if_version_out_of_range_degrades_above_the_until_ceiling() { + let source = "@f: (int) -> int;"; + let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); + assert!(skipped.is_empty()); + + let (context, reason) = degrade_if_version_out_of_range( + context, + &[("pkg.ty", source)], + "1.0.0", + Some("1.5.0"), + Some("2.0.0"), + ); + + let reason = reason.expect("an observed version above `until` must degrade"); + assert!(reason.contains("newer") && reason.contains("1.5.0"), "unexpected reason: {reason}"); + let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); + assert!(typ.is_unknown_function()); + } + + /// An observed version inside `[since, until]` is a no-op: the declared + /// signature survives untouched. + #[test] + fn degrade_if_version_out_of_range_is_a_no_op_within_range() { + let source = "@f: (int) -> int;"; + let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); + assert!(skipped.is_empty()); + + let (context, reason) = degrade_if_version_out_of_range( + context, + &[("pkg.ty", source)], + "1.0.0", + Some("2.0.0"), + Some("1.5.0"), + ); + + assert!(reason.is_none()); + let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); + assert!(!typ.is_unknown_function()); + } + + /// D2: a version that was never observed (`r_version_seen == None`) must + /// never be treated as out of range — only an actually-observed + /// incompatible version may trigger the degradation. + #[test] + fn degrade_if_version_out_of_range_is_a_no_op_when_version_was_never_observed() { + let source = "@f: (int) -> int;"; + let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); + assert!(skipped.is_empty()); + + let (context, reason) = degrade_if_version_out_of_range(context, &[("pkg.ty", source)], "1.11.0", None, None); + + assert!(reason.is_none()); + let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); + assert!(!typ.is_unknown_function()); + } + /// An unrecognized tier string — on the entry or on the project's own /// `trust` setting — must never be silently treated as trusted (D2, /// `typR/registry.md` §0/§5.4): it always degrades. diff --git a/crates/typr-cli/src/type_registry.rs b/crates/typr-cli/src/type_registry.rs index 04cfd49..b8c956f 100644 --- a/crates/typr-cli/src/type_registry.rs +++ b/crates/typr-cli/src/type_registry.rs @@ -4,13 +4,12 @@ //! (`rfcs/0031-external-type-definitions.md`, "Resolution, `typr.lock`, //! cache, vendoring"). //! -//! What this module does NOT do: load a resolved definition into the -//! type-checking context. That is `standard_library::load_external_ty_definitions`, -//! already implemented and tested — this module is purely the plumbing that -//! turns a `github:owner/repo[@rev]` string into `(filename, source)` pairs on -//! disk plus a `typr.lock` entry recording exactly what was fetched. Wiring -//! the two together (reading `typr.lock` at `check`/`build`/`run` time) is a -//! follow-up, not part of this checklist item. +//! `resolve_locked_definitions`, near the bottom of this file, is the +//! "reading `typr.lock` at `check`/`build`/`run` time" wiring step: it turns +//! a `typr.lock` entry back into `(filename, source)` `.ty` pairs from the +//! on-disk cache, re-verifying the digest first. Loading those into the +//! type-checking context is `standard_library::load_project_type_definitions`, +//! which calls this function and then `load_external_ty_definitions`. use crate::type_definition::{parse_manifest, DefinitionManifest}; use serde::{Deserialize, Serialize}; @@ -392,6 +391,22 @@ pub fn fetch(spec: &RepoSpec) -> Result<(FetchedDefinition, Vec), String result } +/// Best-effort: the version of `package` actually installed on this +/// machine, via the same `Rscript`-based introspection `typr gen-types` +/// already uses (`gen_types::introspect`, which itself parses the `P` line +/// of `introspect_pkg.R`'s output). This is the "version réellement +/// observée" registry.md §7.2 says `typr.lock`'s `r_version_seen` records. +/// +/// Fail-open (`None`) when `Rscript` is not on PATH or the package is not +/// installed locally: `typr types add`/`update` must still succeed without R +/// present (same contract as the rest of this module's git-only fetch path) +/// — it just leaves nothing for the version-floor check at `check`/`build`/ +/// `run` time to compare against later, per `degrade_if_version_out_of_range`'s +/// own "no comparison is made... when `r_version_seen` is `None`" rule. +fn observed_r_package_version(package: &str) -> Option { + crate::gen_types::introspect(package).ok().and_then(|info| info.pkg_version) +} + /// Copy every tracked file of a fetched definition into the on-disk cache at /// `~/.cache/typr/types///`, replacing whatever was there before /// (the digest already identifies the content, so an existing directory with @@ -421,14 +436,13 @@ fn admit_to_cache(fetched: &FetchedDefinition, pkg: &str) -> Result, /// package name → explicit `github:owner/repo[@rev]` pin. pub pins: std::collections::BTreeMap, @@ -476,7 +490,7 @@ pub fn add(project_root: &Path, package: &str, spec_str: &str) -> Result) -> Result, + /// The manifest's `[definition] tier` — the tier an entry with no + /// `#! tier:` of its own falls back to. + pub default_tier: String, + pub since: String, + pub until: Option, + /// `typr.lock`'s own `r_version_seen`, frozen at the last `typr types + /// add`/`update` — `None` when it was never observed (R unavailable at + /// resolution time). + pub r_version_seen: Option, +} + +/// Read `typr.lock` and, for every locked definition whose cached copy is +/// present and still matches its pinned digest, collect its `.ty` sources — +/// the "reading `typr.lock` at `check`/`build`/`run` time" step this +/// module's doc comment names. A locked package whose cache is missing, +/// stale, or corrupted is skipped, with a message for the caller to print as +/// a warning, rather than failing the build: the same fail-open contract +/// `vendor()` already applies, and D2 ("an unreliable signal only ever +/// removes checking, it never breaks a build", registry.md §0/§5.4) taken to +/// its logical end — a package `typr.lock` cannot currently resolve simply +/// falls back to being untyped R, exactly as if it had never been added. +pub fn resolve_locked_definitions(project_root: &Path) -> (Vec, Vec) { + let lockfile = Lockfile::read(&project_root.join(LOCKFILE_NAME)); + let mut resolved = Vec::new(); + let mut warnings = Vec::new(); + for def in &lockfile.definitions { + match resolve_one_locked_definition(def) { + Ok(r) => resolved.push(r), + Err(w) => warnings.push(w), + } + } + (resolved, warnings) +} + +fn resolve_one_locked_definition(def: &LockedDefinition) -> Result { + let cache_dir = cache_dir_for(&def.package, &def.digest).ok_or_else(|| { + format!( + "`{}`: no cache directory available (no $HOME/$XDG_CACHE_HOME) — its declared types are unavailable this run, names stay untyped", + def.package + ) + })?; + if !cache_dir.is_dir() { + return Err(format!( + "`{}`: not in the local cache at {} — run `typr types update {}`; its declared types are unavailable this run, names stay untyped", + def.package, + cache_dir.display(), + def.package + )); + } + let actual_digest = compute_digest(&cache_dir).map_err(|e| format!("`{}`: {e}", def.package))?; + if actual_digest != def.digest { + return Err(format!( + "`{}`: cached copy no longer matches typr.lock (expected {}, found {}) — run `typr types update {}`; \ + its declared types are unavailable this run, names stay untyped", + def.package, def.digest, actual_digest, def.package + )); + } + + let manifest_source = fs::read_to_string(cache_dir.join(MANIFEST_NAME)) + .map_err(|e| format!("`{}`: could not read {MANIFEST_NAME} from its cache: {e}", def.package))?; + let manifest = parse_manifest(&manifest_source).map_err(|e| format!("`{}`: {e}", def.package))?; + + let mut ty_sources = Vec::new(); + for rel in tracked_files(&cache_dir).map_err(|e| format!("`{}`: {e}", def.package))? { + if rel.extension().and_then(|e| e.to_str()) != Some("ty") { + continue; + } + let content = fs::read_to_string(cache_dir.join(&rel)) + .map_err(|e| format!("`{}`: could not read {}: {e}", def.package, rel.display()))?; + ty_sources.push((rel.to_string_lossy().replace('\\', "/"), content)); + } + + Ok(ResolvedDefinition { + package: def.package.clone(), + ty_sources, + default_tier: manifest.definition.tier, + since: manifest.package.since, + until: manifest.package.until, + r_version_seen: def.r_version_seen.clone(), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -932,4 +1044,134 @@ mod tests { let _ = fs::remove_dir_all(&project_dir); } + + // -- End-to-end: typr.lock -> real cache -> type-checking context ------ + // + // These exercise `standard_library::load_project_type_definitions`, the + // "reading typr.lock at check/build/run time" wiring named as the last + // missing piece of registry.md §13 J2's `typr types add/update/list/ + // vendor` item. They admit a definition straight into the real on-disk + // cache (no git fixture, unlike `add_update_list_vendor_round_trip_ + // against_a_local_repo` above) since only the cache/lock -> context path + // is under test here, not fetching. + + fn lock_definition_in_real_cache( + project_dir: &Path, + package: &str, + manifest_toml: &str, + ty_source: &str, + r_version_seen: Option<&str>, + ) -> LockedDefinition { + let src_dir = std::env::temp_dir().join(format!("typr_lock_src_{package}_{}", std::process::id())); + let _ = fs::remove_dir_all(&src_dir); + write_file(&src_dir, MANIFEST_NAME, manifest_toml); + write_file(&src_dir, "ty/core.ty", ty_source); + + let manifest = parse_manifest(manifest_toml).unwrap(); + let digest = compute_digest(&src_dir).unwrap(); + let fetched = FetchedDefinition { + manifest, + rev: "0000000000000000000000000000000000000000".to_string(), + digest, + dir: src_dir.clone(), + }; + admit_to_cache(&fetched, package).unwrap(); + + let locked = LockedDefinition { + package: package.to_string(), + repository: format!("github:test/{package}"), + version: fetched.manifest.definition.version.clone(), + rev: fetched.rev.clone(), + digest: fetched.digest.clone(), + tier: fetched.manifest.definition.tier.clone(), + r_version_seen: r_version_seen.map(str::to_string), + }; + + let lock_path = project_dir.join(LOCKFILE_NAME); + let mut lockfile = Lockfile::read(&lock_path); + lockfile.upsert(locked.clone()); + lockfile.write(&lock_path).unwrap(); + + let _ = fs::remove_dir_all(&src_dir); + locked + } + + /// registry.md §5.4: a `T3` entry, locked and cached for real, degrades + /// to `Any` under the project's default `T2` trust once loaded through + /// the real `check`/`build`/`run` entry point + /// (`standard_library::load_project_type_definitions`) — not just + /// through the lower-level `load_external_ty_definitions` unit tests in + /// `standard_library.rs`. + #[test] + fn load_project_type_definitions_degrades_a_low_tier_locked_definition() { + let project_dir = std::env::temp_dir().join(format!("typr_wiring_tier_{}", std::process::id())); + let _ = fs::remove_dir_all(&project_dir); + fs::create_dir_all(&project_dir).unwrap(); + + let manifest_toml = "format_version = 1\n\ + [package]\nname = \"widget\"\nsince = \"1.0.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T3\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:test/typr-widget\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n"; + let locked = + lock_definition_in_real_cache(&project_dir, "widget", manifest_toml, "@do_widget_thing: (int) -> int;", None); + + let context = crate::standard_library::load_project_type_definitions( + &project_dir, + typr_core::components::context::Context::default(), + ); + let typ = context + .get_type_from_variable(&typr_core::components::language::var::Var::from_name("do_widget_thing")) + .expect("locked definition's entry must be loaded into the context"); + assert!( + typ.is_unknown_function(), + "a T3 entry under the default T2 project trust must degrade to Any" + ); + + let _ = fs::remove_dir_all(&project_dir); + if let Some(cache_dir) = cache_dir_for("widget", &locked.digest) { + let _ = fs::remove_dir_all(&cache_dir); + } + } + + /// registry.md §7.2: a `typr.lock` entry whose recorded `r_version_seen` + /// is below the manifest's `since` floor degrades to `Any` once loaded + /// through the real `check`/`build`/`run` entry point, even though its + /// own tier (`T1`) is trusted outright. + #[test] + fn load_project_type_definitions_degrades_when_locked_version_is_below_since() { + let project_dir = std::env::temp_dir().join(format!("typr_wiring_version_{}", std::process::id())); + let _ = fs::remove_dir_all(&project_dir); + fs::create_dir_all(&project_dir).unwrap(); + + let manifest_toml = "format_version = 1\n\ + [package]\nname = \"widget2\"\nsince = \"2.0.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T1\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:test/typr-widget2\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n"; + let locked = lock_definition_in_real_cache( + &project_dir, + "widget2", + manifest_toml, + "@do_widget2_thing: (int) -> int;", + Some("1.0.0"), + ); + + let context = crate::standard_library::load_project_type_definitions( + &project_dir, + typr_core::components::context::Context::default(), + ); + let typ = context + .get_type_from_variable(&typr_core::components::language::var::Var::from_name("do_widget2_thing")) + .expect("locked definition's entry must be loaded into the context"); + assert!( + typ.is_unknown_function(), + "a T1 entry whose observed version is below `since` must still degrade to Any (registry.md §7.2)" + ); + + let _ = fs::remove_dir_all(&project_dir); + if let Some(cache_dir) = cache_dir_for("widget2", &locked.digest) { + let _ = fs::remove_dir_all(&cache_dir); + } + } } diff --git a/rfcs/0031-external-type-definitions.md b/rfcs/0031-external-type-definitions.md index 17db0d4..9774465 100644 --- a/rfcs/0031-external-type-definitions.md +++ b/rfcs/0031-external-type-definitions.md @@ -484,25 +484,43 @@ Explicitly out of scope here, deferred to `registry.md`'s later milestones: -- [ ] `typr-def.toml` manifest parsing + `format_version` gate -- [ ] `since`/`until` added to `FunctionMeta` (`stdlib_meta.rs`) and to the +- [x] `typr-def.toml` manifest parsing + `format_version` gate + (`crates/typr-cli/src/type_definition.rs::parse_manifest`) +- [x] `since`/`until` added to `FunctionMeta` (`stdlib_meta.rs`) and to the manifest -- [ ] external `.ty` loading merged into `standard_library.rs`'s context build, +- [x] external `.ty` loading merged into `standard_library.rs`'s context build, keyed by resolved `typr.lock` entries + (`crates/typr-cli/src/standard_library.rs::load_project_type_definitions`, + called from every `check`/`build`/`run` entry point in `project.rs`; + also applies the `since`/`until` version-floor degradation via + `degrade_if_version_out_of_range`; see `typR/registry.md` §13 J2 for + detail) - [x] `trust` threshold + degrade-to-`UnknownFunction` at merge time (`crates/typr-cli/src/standard_library.rs::load_external_ty_definitions`, `VarType::degrade_to_any`; see `typR/registry.md` §13 J2 for detail) - [x] `typr types add|update|list|vendor`, `typr.lock` read/write, digest verification, `~/.cache/typr/types///` (`crates/typr-cli/src/type_registry.rs`; see `typR/registry.md` §13 J2 - for detail — the `typr.lock` → `load_external_ty_definitions` wiring - itself is still open, tracked by the next unchecked item below) + for detail) - [ ] `cases/`: missing definition, tier below `trust`, version below `since`, version above `until`, unreachable repository, digest mismatch — each - asserting *no hard error*, only degradation -- [ ] `[capabilities]` gate enforced at fetch time (reject undeclared R; + asserting *no hard error*, only degradation. Covered so far only by + Rust unit/integration tests + (`standard_library.rs::tests`, `type_registry.rs::tests`) and a manual + end-to-end smoke test through the real `typr` binary — not yet by a + `cases/NNNN-…` entry replayed through `typr case run`, since that + would need a portable way to pre-populate `~/.cache/typr/types/` for a + sandboxed `repro/` (the cache lives outside the project directory by + design, §7.4). See `typR/registry.md` §13 J2 for detail. +- [x] `[capabilities]` gate enforced at fetch time (reject undeclared R; confirm-or-`--allow-r` for declared) -- [ ] `syntaxe.md` — no lexeme changes expected, but confirm before merge + (`crates/typr-cli/src/type_registry.rs::check_capabilities`, enforced + inside `fetch()` before anything is admitted to the cache; the + `--allow-r` confirmation prompt itself is not implemented — today a + declared `r_shims`/`extern_raw` definition passes with a printed + warning, never a blocking confirmation) +- [x] `syntaxe.md` — no lexeme changes were made throughout this RFC's + implementation - [ ] Documentation PR on `we-data-ch/typr.github.io` (a How-to page for consuming an external definition; a Reference page for the manifest and `#!` keys), landing in the same release From d1f2b03d0e5775a97dbf7ba5ee157d70052b4f12 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Mon, 14 Sep 2026 07:34:06 +0200 Subject: [PATCH 12/18] update Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014brvdoSu1AUJnY3mFzSc8C --- .../typr-def.toml | 13 + .../case.toml | 6 + .../expect.md | 44 + .../expect.toml | 20 + .../repro/TypR/main.ty | 9 + .../repro/typr.lock | 10 + .../typr-def.toml | 13 + .../case.toml | 6 + .../expect.md | 34 + .../expect.toml | 18 + .../repro/TypR/main.ty | 9 + .../repro/typr.lock | 11 + cases/README.md | 31 + crates/typr-cli/src/cases.rs | 18 + crates/typr-cli/src/cli.rs | 153 +++- crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 1 + crates/typr-cli/src/registry_validate.rs | 785 ++++++++++++++++++ crates/typr-cli/src/type_registry.rs | 511 +++++++++++- 19 files changed, 1669 insertions(+), 24 deletions(-) create mode 100644 cases/0066-external-type-definition-low-tier-degrades-to-any/cache/typr/types/gizmo/35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88/typr-def.toml create mode 100644 cases/0066-external-type-definition-low-tier-degrades-to-any/case.toml create mode 100644 cases/0066-external-type-definition-low-tier-degrades-to-any/expect.md create mode 100644 cases/0066-external-type-definition-low-tier-degrades-to-any/expect.toml create mode 100644 cases/0066-external-type-definition-low-tier-degrades-to-any/repro/TypR/main.ty create mode 100644 cases/0066-external-type-definition-low-tier-degrades-to-any/repro/typr.lock create mode 100644 cases/0067-external-type-definition-old-version-degrades-to-any/cache/typr/types/sprocket/1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7/typr-def.toml create mode 100644 cases/0067-external-type-definition-old-version-degrades-to-any/case.toml create mode 100644 cases/0067-external-type-definition-old-version-degrades-to-any/expect.md create mode 100644 cases/0067-external-type-definition-old-version-degrades-to-any/expect.toml create mode 100644 cases/0067-external-type-definition-old-version-degrades-to-any/repro/TypR/main.ty create mode 100644 cases/0067-external-type-definition-old-version-degrades-to-any/repro/typr.lock create mode 100644 crates/typr-cli/src/registry_validate.rs diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/cache/typr/types/gizmo/35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88/typr-def.toml b/cases/0066-external-type-definition-low-tier-degrades-to-any/cache/typr/types/gizmo/35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88/typr-def.toml new file mode 100644 index 0000000..02e3ab4 --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/cache/typr/types/gizmo/35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88/typr-def.toml @@ -0,0 +1,13 @@ +format_version = 1 +[package] +name = "gizmo" +since = "1.0.0" +[definition] +version = "0.1.0" +tier = "T3" +[provider] +type = "community" +repository = "github:test/typr-gizmo" +[capabilities] +r_shims = false +extern_raw = false diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/case.toml b/cases/0066-external-type-definition-low-tier-degrades-to-any/case.toml new file mode 100644 index 0000000..fd0a5ec --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/case.toml @@ -0,0 +1,6 @@ +title = "a locked external Type Definition below the project's `trust` threshold degrades to Any, never a hard error" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-13" +origin = "perso" diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.md b/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.md new file mode 100644 index 0000000..ecd1ff2 --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.md @@ -0,0 +1,44 @@ +# external-type-definition-low-tier-degrades-to-any + +Source: `typR/registry.md` §5.4 (D2 — "an unreliable definition widens to `Any`, it never fails +a build") and §13 J2, last open checklist item ("`cases/`: définition absente / tier bas / +version trop ancienne → jamais d'erreur dure"). + +## Ce qui devrait se passer + +`gizmo` is locked in `typr.lock` at tier `T3` and cached under +`cache/typr/types/gizmo//` (this case bundles that cache directory itself, mirrored at +`$XDG_CACHE_HOME/typr/types/…`, since the real cache lives outside any project directory — +`cases.rs::build_sandbox` now copies a case's `cache/` folder into the sandbox and points +`$XDG_CACHE_HOME` at it before invoking `typr`). Its one declared symbol, +`@spin_gizmo: (int) -> int;`, is a plain declaration with no real R binding behind it. + +The project has no `typr.toml`, so `standard_library::load_project_type_definitions` applies the +default `trust = "T2"`. `T3 < T2`, so `names_below_trust` must widen `spin_gizmo` to +`VarType::degrade_to_any` — the `(Any, UnknownFunction)` pair, callable with any argument type, +arity-checked against the declared parameter count. + +`main.ty` calls `spin_gizmo("not-an-int")` — the wrong argument *type* for the declared +`(int) -> int` signature, but the right *arity* (one argument). This is a 3-way discriminator: + +- degradation broken and the definition never loads at all → `spin_gizmo` is an unknown symbol → + hard type error ("unknown function"/"Type errors found"); +- degradation broken but the definition loads with its literal declared signature → the `string` + argument doesn't match `int` → hard type error; +- degradation working as designed → arity matches, argument type isn't checked → `typr check` + succeeds. + +## Vérification + +Implemented already (`typR/registry.md` §13 J2, "seuil `trust` + règle de dégradation" and +"brancher `typr.lock` sur `check`/`build`/`run`"): `crates/typr-cli/src/standard_library.rs` +(`load_external_ty_definitions`, `names_below_trust`, `load_project_type_definitions`) and +`crates/typr-cli/src/type_registry.rs` (`resolve_locked_definitions`). This case is the +end-to-end regression net through the real CLI that those checklist items were missing — +previously only exercised by `type_registry.rs`'s `#[cfg(test)]` module, not by `typr case run`. + +## Statut + +Kept as a regression net: any future change that stops loading a locked, cached, low-tier +definition — or that starts enforcing its declared types instead of degrading them — breaks +this case. diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.toml b/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.toml new file mode 100644 index 0000000..0cacae2 --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.toml @@ -0,0 +1,20 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +# If cache resolution were broken (e.g. `typr.lock`'s digest not matching +# the cache, or `$XDG_CACHE_HOME` not reaching the sandboxed invocation), +# `resolve_one_locked_definition` skips the package with one of these +# messages and `spin_gizmo` falls back to a genuinely unknown symbol — which +# *does* raise a hard type error (unlike a degraded-but-loaded definition). +[[rule]] +file = "@run" +must_not_contain = "not in the local cache" + +[[rule]] +file = "@run" +must_not_contain = "no longer matches typr.lock" diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/TypR/main.ty b/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/TypR/main.ty new file mode 100644 index 0000000..351f368 --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/TypR/main.ty @@ -0,0 +1,9 @@ +#@case external-type-definition-low-tier-degrades-to-any: registry.md §5.4 (D2) — +# a locked external Type Definition entry below the project's `trust` threshold +# must degrade to a variadic `Any` function, never a hard type error. `gizmo` +# is declared `@spin_gizmo: (int) -> int;` at tier `T3` (typr.lock, cache/) but +# the project has no `typr.toml`, so the default `trust = "T2"` applies: +# `spin_gizmo` must load as callable-with-anything, not as its strict +# signature and not as a wholly unknown symbol. +let result <- spin_gizmo("not-an-int"); +print(result); diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/typr.lock b/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/typr.lock new file mode 100644 index 0000000..8af6f6f --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/typr.lock @@ -0,0 +1,10 @@ +# typr.lock — generated by `typr types`, commit this file. +# See typR/registry.md §7.1 and rfcs/0031-external-type-definitions.md. + +[[definition]] +package = "gizmo" +repository = "github:test/typr-gizmo" +version = "0.1.0" +rev = "0000000000000000000000000000000000000000" +digest = "sha256:35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88" +tier = "T3" diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/cache/typr/types/sprocket/1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7/typr-def.toml b/cases/0067-external-type-definition-old-version-degrades-to-any/cache/typr/types/sprocket/1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7/typr-def.toml new file mode 100644 index 0000000..e90d0a3 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/cache/typr/types/sprocket/1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7/typr-def.toml @@ -0,0 +1,13 @@ +format_version = 1 +[package] +name = "sprocket" +since = "2.0.0" +[definition] +version = "0.1.0" +tier = "T1" +[provider] +type = "community" +repository = "github:test/typr-sprocket" +[capabilities] +r_shims = false +extern_raw = false diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/case.toml b/cases/0067-external-type-definition-old-version-degrades-to-any/case.toml new file mode 100644 index 0000000..aeecdc1 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/case.toml @@ -0,0 +1,6 @@ +title = "a locked external Type Definition observed below its manifest's `since` floor degrades to Any even at tier T1" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-13" +origin = "perso" diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/expect.md b/cases/0067-external-type-definition-old-version-degrades-to-any/expect.md new file mode 100644 index 0000000..bf0b358 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/expect.md @@ -0,0 +1,34 @@ +# external-type-definition-old-version-degrades-to-any + +Source: `typR/registry.md` §7.2 ("Compatibilité de versions : borne minimale, pas plage +fermée") and §13 J2, last open checklist item. + +## Ce qui devrait se passer + +`sprocket` is locked at tier `T1` — normally trusted outright, regardless of the project's +`trust` threshold — but `typr.lock` records `r_version_seen = "1.0.0"`, below the manifest's +`since = "2.0.0"` floor. Per §7.2, a version below `since` must **warn and degrade to `Any`**, +never refuse the build, and the degradation applies to *every* declared name in the definition, +independent of that name's own tier (`standard_library::degrade_if_version_out_of_range` widens +`all_declared_names`, not just the ones under `trust`). + +`main.ty` calls `turn_sprocket("not-an-int")` — right arity, wrong argument type for the +declared `(int) -> int` — the same 3-way discriminator as case +`0066-external-type-definition-low-tier-degrades-to-any`: unknown symbol or an enforced strict +signature would both raise a hard type error; a correctly degraded `Any` function does not. + +## Vérification + +Implemented already: `crates/typr-cli/src/standard_library.rs` +(`degrade_if_version_out_of_range`, `version_less_than`) and +`crates/typr-cli/src/type_registry.rs` (`resolve_locked_definitions`, +`observed_r_package_version`). Previously only exercised by unit tests in those two files' +`#[cfg(test)]` modules (`load_project_type_definitions_degrades_when_locked_version_is_below_since`) +— this case is the same scenario replayed through the real `typr` binary via `typr case run`, +using a case-bundled `cache/` directory (`cases.rs::build_sandbox`, `$XDG_CACHE_HOME`) since the +real cache lives outside any project directory (§7.4). + +## Statut + +Kept as a regression net for the version-floor half of D2/§7.2, alongside case +`0066-external-type-definition-low-tier-degrades-to-any` for the tier half. diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/expect.toml b/cases/0067-external-type-definition-old-version-degrades-to-any/expect.toml new file mode 100644 index 0000000..5a2f588 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/expect.toml @@ -0,0 +1,18 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +# Same 3-way discriminator as case 0066: if cache/lock resolution were +# broken, `turn_sprocket` would fall back to a genuinely unknown symbol, +# which raises a hard type error instead of degrading. +[[rule]] +file = "@run" +must_not_contain = "not in the local cache" + +[[rule]] +file = "@run" +must_not_contain = "no longer matches typr.lock" diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/repro/TypR/main.ty b/cases/0067-external-type-definition-old-version-degrades-to-any/repro/TypR/main.ty new file mode 100644 index 0000000..49416a9 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/repro/TypR/main.ty @@ -0,0 +1,9 @@ +#@case external-type-definition-old-version-degrades-to-any: registry.md §7.2 — +# a locked external Type Definition whose observed R package version +# (`typr.lock`'s `r_version_seen`) is below the manifest's `since` floor must +# degrade to Any for every one of its names, even at tier `T1` (normally +# trusted outright). `sprocket` declares `@turn_sprocket: (int) -> int;` at +# tier `T1`, `since = "2.0.0"`, but was locked against R package `1.0.0` — +# below the floor. +let result <- turn_sprocket("not-an-int"); +print(result); diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/repro/typr.lock b/cases/0067-external-type-definition-old-version-degrades-to-any/repro/typr.lock new file mode 100644 index 0000000..42c93e5 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/repro/typr.lock @@ -0,0 +1,11 @@ +# typr.lock — generated by `typr types`, commit this file. +# See typR/registry.md §7.1 and rfcs/0031-external-type-definitions.md. + +[[definition]] +package = "sprocket" +repository = "github:test/typr-sprocket" +version = "0.1.0" +rev = "0000000000000000000000000000000000000000" +digest = "sha256:1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7" +tier = "T1" +r_version_seen = "1.0.0" diff --git a/cases/README.md b/cases/README.md index c876294..d0a02f0 100644 --- a/cases/README.md +++ b/cases/README.md @@ -15,6 +15,7 @@ cases/0001-pub-typed-fn-in-module/ expect.md # ce qui DEVRAIT se passer (prose), + localisation dans le code observed.txt # l'erreur / le R fautif au moment du report golden/ # (cas fixed) copie des R/*.R ciblés → diff de non-régression (jamais "@run") + cache/ # optionnel : voir "Bundler un cache" ci-dessous ``` `case.toml` : @@ -66,6 +67,36 @@ particulier à gérer. `@run` n'est **jamais** diffé en golden (le texte captur timings de la barre de progression CLI, qui varient à chaque run) — reste du grep pur, golden/ continue de ne cibler que des fichiers `R/*.R` déterministes. +## Bundler un cache (`cache/`) + +Certaines fonctionnalités lisent un cache **hors** du répertoire du projet par construction — +par exemple `~/.cache/typr/types///` pour une Type Definition externe verrouillée +dans `typr.lock` (`typR/registry.md` §7.4). Un `repro/` copié dans un sandbox temporaire ne peut +donc pas transporter ce cache avec lui, et le cas échouerait toujours en pratique (paquet +"absent du cache") plutôt que d'exercer le vrai comportement. + +Un sous-dossier `cache/` optionnel, sibling de `repro/`, résout ça : `cases.rs::build_sandbox` +le copie dans le sandbox et pointe `$XDG_CACHE_HOME` dessus pour la durée de l'invocation. Il +doit donc être disposé exactement comme `$XDG_CACHE_HOME` le serait, par ex. : + +``` +cases/00NN-mon-cas/ + cache/ + typr/types/// + typr-def.toml + ty/core.ty + repro/ + typr.lock # digest ci-dessus, même package + TypR/main.ty +``` + +Le digest doit correspondre exactement à ce que `typr.lock` déclare — c'est une vérification +mécanique (`type_registry::compute_digest`, ré-exécutée à chaque `check`/`build`/`run`), pas une +convention : un `cache/` désynchronisé du `typr.lock` du cas se traduit par un cas qui échoue +avec « no longer matches typr.lock », pas par un chargement silencieusement raté. Voir +`cases/0066-external-type-definition-low-tier-degrades-to-any/` pour un exemple complet. Sans +`cache/`, le comportement de `build_sandbox` est inchangé. + ## Capturer un cas depuis un projet en cours de dev (`snapshot`) Quand tu rencontres un bug en développant un package TypR ailleurs, **depuis la racine de ce diff --git a/crates/typr-cli/src/cases.rs b/crates/typr-cli/src/cases.rs index c2eeb34..ed21449 100644 --- a/crates/typr-cli/src/cases.rs +++ b/crates/typr-cli/src/cases.rs @@ -210,6 +210,15 @@ pub fn unique_tmp() -> PathBuf { } /// Copy `repro/` into a temp sandbox and run `typr ` (this same binary) inside it. +/// +/// If the case bundle also ships a `cache/` folder (sibling of `repro/`), it is copied into +/// the sandbox too and exposed as `$XDG_CACHE_HOME` for the invocation — the fix for +/// `registry.md` §13 J2's "cache portability" gap: `typr.lock` resolution reads from +/// `~/.cache/typr/types///` (`type_registry::cache_root`), which lives outside +/// `repro/` by construction (§7.4) and so was invisible to a case's sandboxed copy. Bundling a +/// `cache/` directory laid out exactly like `$XDG_CACHE_HOME` (i.e. `cache/typr/types/…`) lets +/// a case exercise real `typr.lock` resolution — tier/version degradation — without touching +/// the developer's real cache or the network. Cases with no `cache/` folder are unaffected. fn build_sandbox(case: &Path) -> Sandbox { let meta = read_meta(case); let repro = case.join("repro"); @@ -225,6 +234,15 @@ fn build_sandbox(case: &Path) -> Sandbox { .arg(&meta.cmd) .current_dir(&work) .env(crate::r_deps::SKIP_ENV_VAR, "1"); + let bundled_cache = case.join("cache"); + if bundled_cache.is_dir() { + let cache_work = tmp.join("cache"); + if let Err(e) = copy_dir(&bundled_cache, &cache_work) { + eprintln!("{RED}Impossible de copier le cache de {}: {e}{RESET}", case.display()); + std::process::exit(1); + } + command.env("XDG_CACHE_HOME", &cache_work); + } if meta.checked && (meta.cmd == "build" || meta.cmd == "run") { command.arg("--checked"); } diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index 0f44e68..f3c1ee8 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -190,6 +190,14 @@ enum Commands { #[command(subcommand)] types_command: TypesCommands, }, + /// List every Type Definition the `we-data-ch/registry` index has for a + /// package, ranked exactly as `typr types add`/`typr use` would pick + /// among them (registry.md §8.3, §13 J3) — a survey of the alternatives, + /// not a selection. Never fails a build: an unreachable registry or an + /// unlisted package are reported, not treated as errors. + Search { + package: String, + }, } #[derive(Subcommand, Debug)] @@ -200,8 +208,10 @@ enum TypesCommands { Add { /// The package this definition describes (e.g. `shiny`). package: String, - /// `github:owner/repo[@rev]`. - repo: String, + /// `github:owner/repo[@rev]`. Omit to resolve one automatically: an + /// explicit `typr.toml [types]` pin first, then a lookup in the + /// `we-data-ch/registry` (registry.md §13 J3). + repo: Option, }, /// Re-fetch and re-pin `typr.lock` for one package (or, with none given, /// every resolved definition). @@ -215,6 +225,19 @@ enum TypesCommands { #[arg(long, short, value_name = "DIR")] out: Option, }, + /// Run the mechanical checks of `typR/registry.md` §9 against a Type + /// Definition repository: manifest/capabilities, `.ty` parsing/type- + /// checking, `tests/smoke.ty`, exports and arity vs. `formals()` on the + /// locally installed package, and the T1 "no unconstrained `...`" + /// promotion gate. Exits 1 if any check fails (never on a `Skipped` one — + /// registry.md D2/D5). + Validate { + /// The package this definition describes (e.g. `shiny`). + package: String, + /// `github:owner/repo[@rev]`. Omit to resolve one automatically, the + /// same way `typr types add` does. + repo: Option, + }, } #[derive(Subcommand, Debug)] @@ -348,6 +371,7 @@ fn skips_r_deps_check(command: &Option) -> bool { | Some(Commands::Syntax { .. }) | Some(Commands::GenTypes { .. }) | Some(Commands::Types { .. }) + | Some(Commands::Search { .. }) ) } @@ -434,7 +458,10 @@ pub fn start() { }, Some(Commands::Document) => document(), Some(Commands::Pkgdown) => pkgdown(), - Some(Commands::Use { package_name }) => use_package(&package_name), + Some(Commands::Use { package_name }) => { + use_package(&package_name); + try_auto_resolve_type_definition(std::path::Path::new("."), &package_name); + } Some(Commands::Load) => load(), Some(Commands::Cran) => cran(), Some(Commands::Std { std_command }) => match std_command { @@ -476,6 +503,7 @@ pub fn start() { Some(Commands::Spg { output }) => generate_spg(output), Some(Commands::GenTypes { package, out }) => crate::gen_types::run(&package, out), Some(Commands::Types { types_command }) => run_types_command(types_command), + Some(Commands::Search { package }) => run_search_command(&package), _ => { println!("Please specify a subcommand or file to execute"); std::process::exit(1); @@ -595,20 +623,22 @@ fn run_types_command(command: TypesCommands) { let root = std::path::Path::new("."); match command { - TypesCommands::Add { package, repo } => match type_registry::add(root, &package, &repo) { - Ok(locked) => println!( - "{} — {} {} (tier {}, rev {}) → typr.lock", - locked.package, - locked.repository, - locked.version, - locked.tier, - &locked.rev[..locked.rev.len().min(12)] - ), - Err(e) => { - eprintln!("error: {e}"); - std::process::exit(1); - } - }, + TypesCommands::Add { package, repo } => { + let spec = match repo { + Some(spec) => spec, + None => match type_registry::resolve_spec_for_package(root, &package) { + Some(spec) => spec, + None => { + eprintln!( + "error: no definition found for `{package}` — no `typr.toml [types]` pin \ + and nothing in the registry; pass an explicit `github:owner/repo[@rev]`" + ); + std::process::exit(1); + } + }, + }; + run_types_add(root, &package, &spec); + } TypesCommands::Update { package } => match type_registry::update(root, package.as_deref()) { Ok(updated) if updated.is_empty() => println!("nothing to update — typr.lock is empty."), Ok(updated) => { @@ -651,5 +681,94 @@ fn run_types_command(command: TypesCommands) { std::process::exit(1); } }, + TypesCommands::Validate { package, repo } => { + let spec = match repo { + Some(spec) => spec, + None => match type_registry::resolve_spec_for_package(root, &package) { + Some(spec) => spec, + None => { + eprintln!( + "error: no definition found for `{package}` — no `typr.toml [types]` pin \ + and nothing in the registry; pass an explicit `github:owner/repo[@rev]`" + ); + std::process::exit(1); + } + }, + }; + let report = crate::registry_validate::validate(&package, &spec); + print!("{}", report.render()); + if !report.ok() { + std::process::exit(1); + } + } + } +} + +/// `typr search ` — survey what `we-data-ch/registry` has for `pkg`, +/// ranked exactly as `typr types add`/`typr use` would pick among them +/// (registry.md §8.3, §13 J3's `typr search` item). +fn run_search_command(package: &str) { + use crate::type_registry; + + match type_registry::search(package) { + Ok(entries) if entries.is_empty() => { + println!("no definitions found for `{package}` in the registry."); + } + Ok(entries) => { + for entry in entries { + let repo = match entry.rev.as_deref() { + Some(rev) if !rev.is_empty() => format!("github:{}@{}", entry.repository, rev), + _ => format!("github:{}", entry.repository), + }; + println!("{repo:<48} tier {:<3} {}", entry.tier, entry.source); + } + } + Err(e) => { + eprintln!("error: could not reach the registry: {e}"); + std::process::exit(1); + } + } +} + +/// Shared by `run_types_command`'s `Add` arm and `try_auto_resolve_type_definition` +/// — fetch, cache, and lock a resolved spec for `package`, printing the same +/// confirmation line either way. +fn run_types_add(root: &std::path::Path, package: &str, spec: &str) { + use crate::type_registry; + match type_registry::add(root, package, spec) { + Ok(locked) => println!( + "{} — {} {} (tier {}, rev {}) → typr.lock", + locked.package, + locked.repository, + locked.version, + locked.tier, + &locked.rev[..locked.rev.len().min(12)] + ), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } +} + +/// After `typr use ` adds the R dependency, best-effort resolve and pin +/// a type definition too — the remaining steps of registry.md §7.3's flow, +/// now that J3 gives them something to search (`we-data-ch/registry`). +/// Unlike `run_types_add`, this never exits the process: the R dependency was +/// just added successfully, and a definition lookup finding nothing (or +/// failing to fetch) must not turn that into a command failure — D2 again, +/// one level up: a missing or unusable type signal only ever leaves the +/// package untyped, it never blocks anything else `typr use` already did. +fn try_auto_resolve_type_definition(root: &std::path::Path, package: &str) { + use crate::type_registry; + let Some(spec) = type_registry::resolve_spec_for_package(root, package) else { + return; + }; + match type_registry::add(root, package, &spec) { + Ok(locked) => println!( + "found a type definition for `{}`: {} {} (tier {}) → typr.lock", + locked.package, locked.repository, locked.version, locked.tier + ), + Err(e) => eprintln!("warning: found a type definition for `{package}` but could not resolve it ({e})"), } } diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index 91c7f89..7595c57 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -54,6 +54,7 @@ pub mod r_deps; pub mod r_name_cache; pub mod r_name_lint; pub mod rd_renderer; +pub mod registry_validate; pub mod repl; pub mod standard_library; pub mod syntax; diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index 8086ce7..1f80a3d 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -17,6 +17,7 @@ mod r_deps; mod r_name_cache; mod r_name_lint; mod rd_renderer; +mod registry_validate; mod repl; mod standard_library; mod syntax; diff --git a/crates/typr-cli/src/registry_validate.rs b/crates/typr-cli/src/registry_validate.rs new file mode 100644 index 0000000..568ecb8 --- /dev/null +++ b/crates/typr-cli/src/registry_validate.rs @@ -0,0 +1,785 @@ +//! `typr types validate [--repo SPEC]` — the mechanically-verifiable +//! checks of `typR/registry.md` §9, run against a single Type Definition +//! repository (registry.md §13 J4, "contrôles mécaniques de §9, dont le diff +//! `formals()` contre le package installé"). +//! +//! Two checks named in §9 are deliberately out of scope here and stay open in +//! the J4 checklist: "schéma JSON du registre valide" is a property of +//! `we-data-ch/registry`'s own `packages/*.json` files, not of one definition +//! repository (it already gets a mechanical check for free every time +//! `type_registry::lookup_in_registry_dir`/`search_in_registry_dir` parses one +//! into `RegistryPackageFile` — a malformed file simply resolves to no +//! candidates, per D2 — but there is no *dedicated* validator yet that flags +//! *which* file is malformed); "revalidation périodique" (drift re-detection +//! over time) needs a place to run centrally (cron/CI on the registry itself), +//! which is the next J4 item, not this one. +//! +//! Every other §9 line is a [`CheckResult`] here: +//! +//! - `format_version` known, repository accessible, rev pinned, digest — +//! mostly free: `type_registry::fetch` already enforces/computes these +//! before this module sees anything. +//! - capabilities coherent with the real content — same, via `fetch`'s +//! returned warnings (`type_registry::check_capabilities`). +//! - the `.ty` files parse and type-check, and `tests/smoke.ty` compiles — +//! reuses `standard_library::load_external_ty_definitions`, the exact loop +//! a consuming project runs. +//! - every declared name is really exported by the installed package, and its +//! arity/`...`-ness matches `formals()` — the new piece: diffs +//! `parse_declared_entries`'s view of the `.ty` sources against +//! `gen_types::introspect`'s view of the installed R package. +//! - no `T1` entry with an unconstrained `...` — the RFC-STDLIB-0001 §7 +//! promotion gate (registry.md §6, "Promotion T3 → T2 → T1"), applied here +//! for the first time to *external* definitions rather than the bundled +//! stdlib. +//! - the package exists on CRAN — shelled out to `Rscript`'s own +//! `available.packages()` against the public CRAN mirror, the same +//! shell-out-rather-than-add-an-HTTP-client choice `gen_types.rs`/ +//! `type_registry.rs` already made for R/`git`. +//! +//! Every introspection-dependent check (exports, arity, CRAN) fails *open* +//! when `Rscript` is unavailable or the package isn't installed locally — +//! `Skipped`, never `Fail` — the same contract every other Rscript-dependent +//! path in this crate already follows (`gen_types.rs`, `r_name_cache.rs`). +//! Only a check that could actually run and found something wrong reports +//! `Fail`; `typr types validate`'s exit code is 1 only in that case. + +use crate::gen_types; +use crate::standard_library; +use crate::type_registry::{self, FetchedDefinition, RepoSpec}; +use std::collections::HashMap; +use std::fs; +use std::process::Command; +use typr_core::components::context::Context; +use typr_core::processes::spg::stdlib_meta::parse_meta_from_source; + +// --------------------------------------------------------------------- +// Report shape +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckStatus { + Pass, + Warn, + Fail, + /// Could not run (missing `Rscript`/`git`, package not installed + /// locally, network unreachable, no `tests/smoke.ty` present, …) — never + /// a reason to fail the build (registry.md D2). + Skipped, +} + +#[derive(Debug, Clone)] +pub struct CheckResult { + pub name: &'static str, + pub status: CheckStatus, + pub detail: String, +} + +impl CheckResult { + fn pass(name: &'static str, detail: impl Into) -> Self { + CheckResult { name, status: CheckStatus::Pass, detail: detail.into() } + } + fn warn(name: &'static str, detail: impl Into) -> Self { + CheckResult { name, status: CheckStatus::Warn, detail: detail.into() } + } + fn fail(name: &'static str, detail: impl Into) -> Self { + CheckResult { name, status: CheckStatus::Fail, detail: detail.into() } + } + fn skipped(name: &'static str, detail: impl Into) -> Self { + CheckResult { name, status: CheckStatus::Skipped, detail: detail.into() } + } +} + +#[derive(Debug, Clone)] +pub struct ValidationReport { + pub package: String, + pub repository: String, + pub definition_version: String, + pub checks: Vec, +} + +impl ValidationReport { + /// `false` when any check is `Fail` — everything else (`Warn`, + /// `Skipped`) is, per D5, information to display, never a build-blocking + /// signal on its own. + pub fn ok(&self) -> bool { + !self.checks.iter().any(|c| c.status == CheckStatus::Fail) + } + + /// The nominative report of registry.md §9: what was verified, named, + /// never a single green badge. + pub fn render(&self) -> String { + let mut out = format!("{} — {} (definition v{})\n", self.package, self.repository, self.definition_version); + for c in &self.checks { + let word = match c.status { + CheckStatus::Pass => "ok", + CheckStatus::Warn => "warning", + CheckStatus::Fail => "FAILED", + CheckStatus::Skipped => "not checked", + }; + out.push_str(&format!(" {:<26} {:<12} {}\n", c.name, word, c.detail)); + } + out + } +} + +// --------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------- + +/// Fetch `spec_str` (`github:owner/repo[@rev]`) and run every check below +/// against it. Always cleans up the temporary clone, success or failure. +pub fn validate(package: &str, spec_str: &str) -> ValidationReport { + let spec = match RepoSpec::parse(spec_str) { + Ok(s) => s, + Err(e) => { + return ValidationReport { + package: package.to_string(), + repository: spec_str.to_string(), + definition_version: "unknown".to_string(), + checks: vec![CheckResult::fail("repository spec", e)], + } + } + }; + + let (fetched, warnings) = match type_registry::fetch(&spec) { + Ok(ok) => ok, + Err(e) => { + return ValidationReport { + package: package.to_string(), + repository: spec.display(), + definition_version: "unknown".to_string(), + checks: vec![CheckResult::fail("fetch", e)], + } + } + }; + + let report = validate_fetched(package, &spec.display(), spec.rev.is_some(), &fetched, &warnings); + let _ = fs::remove_dir_all(&fetched.dir); + report +} + +// --------------------------------------------------------------------- +// The checks themselves — pure given an already-fetched definition, so +// this is directly testable against a hand-built temp directory with no +// `git`/network involved (see `tests` below). +// --------------------------------------------------------------------- + +fn validate_fetched( + package: &str, + repository: &str, + rev_pinned: bool, + fetched: &FetchedDefinition, + capability_warnings: &[String], +) -> ValidationReport { + let mut checks = Vec::new(); + + checks.push(CheckResult::pass("format_version", fetched.manifest.format_version.to_string())); + + checks.push(CheckResult::pass("repository accessible", format!("cloned @ {}", short_rev(&fetched.rev)))); + checks.push(if rev_pinned { + CheckResult::pass("rev pinned", &fetched.rev) + } else { + CheckResult::warn( + "rev pinned", + "resolved from the default branch's HEAD — pass @ to pin reproducibly (registry.md D4)", + ) + }); + checks.push(CheckResult::pass("digest", fetched.digest.clone())); + + if capability_warnings.is_empty() { + checks.push(CheckResult::pass("capabilities", "no undeclared R; r_shims/extern_raw not declared")); + } else { + checks.push(CheckResult::warn("capabilities", capability_warnings.join(" "))); + } + + let all_files = type_registry::tracked_files(&fetched.dir).unwrap_or_default(); + // Declarations first, `tests/` last — a plain lexicographic walk would + // interleave them by accident ("tests/…" sorts before "ty/…") and + // type-check a smoke test against a context that doesn't have its own + // definitions loaded yet. + let mut declaration_files: Vec<&std::path::PathBuf> = Vec::new(); + let mut test_files: Vec<&std::path::PathBuf> = Vec::new(); + for rel in &all_files { + if rel.extension().and_then(|e| e.to_str()) != Some("ty") { + continue; + } + if rel.starts_with("tests") { + test_files.push(rel); + } else { + declaration_files.push(rel); + } + } + let mut ty_sources: Vec<(String, String)> = Vec::new(); + for rel in declaration_files.into_iter().chain(test_files) { + let content = fs::read_to_string(fetched.dir.join(rel)).unwrap_or_default(); + ty_sources.push((rel.to_string_lossy().replace('\\', "/"), content)); + } + + let sources_ref: Vec<(&str, &str)> = ty_sources.iter().map(|(f, s)| (f.as_str(), s.as_str())).collect(); + // trust = "T1" here has no bearing on this check: `load_external_ty_definitions` + // only *degrades* entries below trust after they've already + // parsed/type-checked, and `skipped` records failures from before that + // point — this just avoids implying a trust decision that isn't ours to + // make in a validator. + let (_context, skipped) = + standard_library::load_external_ty_definitions(Context::default(), &sources_ref, &fetched.manifest.definition.tier, "T1"); + + if ty_sources.is_empty() { + checks.push(CheckResult::warn(".ty parse/type-check", "no .ty file found in this repository")); + } else if skipped.is_empty() { + checks.push(CheckResult::pass(".ty parse/type-check", format!("{} file(s) OK", ty_sources.len()))); + } else { + let names: Vec<&str> = skipped.iter().map(|(f, _)| f.as_str()).collect(); + checks.push(CheckResult::fail( + ".ty parse/type-check", + format!("{}/{} file(s) failed: {}", skipped.len(), ty_sources.len(), names.join(", ")), + )); + } + + const SMOKE_PATH: &str = "tests/smoke.ty"; + match ty_sources.iter().any(|(f, _)| f == SMOKE_PATH) { + true => match skipped.iter().find(|(f, _)| f == SMOKE_PATH) { + Some((_, message)) => checks.push(CheckResult::fail("tests/smoke.ty", message.clone())), + None => checks.push(CheckResult::pass("tests/smoke.ty", "compiles")), + }, + false => checks.push(CheckResult::skipped("tests/smoke.ty", "not present in this repository")), + } + + // Declared names/arity/tier — deliberately excludes `tests/`, which + // exercises the API rather than declaring it (registry.md §5.1). + let declared_sources: Vec<(String, String)> = + ty_sources.iter().filter(|(f, _)| !f.starts_with("tests/")).cloned().collect(); + let declared = parse_declared_entries(&declared_sources, &fetched.manifest.definition.tier); + + let t1_entries: Vec<&DeclaredEntry> = declared.iter().filter(|e| e.tier.as_deref() == Some("T1")).collect(); + let t1_violations: Vec<&str> = t1_entries + .iter() + .filter(|e| e.params.iter().any(|p| p.is_variadic && p.type_text.trim() == "Any")) + .map(|e| e.name.as_str()) + .collect(); + if t1_violations.is_empty() { + checks.push(CheckResult::pass( + "T1 promotion gate", + format!("{} T1 entrie(s), none with an unconstrained `...`", t1_entries.len()), + )); + } else { + checks.push(CheckResult::fail("T1 promotion gate", format!("unconstrained `...` at T1: {}", t1_violations.join(", ")))); + } + + if !gen_types::rscript_available() { + checks.push(CheckResult::skipped("exports vs formals()", "Rscript not on PATH")); + checks.push(CheckResult::skipped("arity vs formals()", "Rscript not on PATH")); + checks.push(CheckResult::skipped("package on CRAN", "Rscript not on PATH")); + } else { + match gen_types::introspect(package) { + Ok(info) if !info.functions.is_empty() => { + push_formals_diff(&mut checks, &declared, &info.functions); + } + _ => { + checks.push(CheckResult::skipped("exports vs formals()", format!("`{package}` not installed locally"))); + checks.push(CheckResult::skipped("arity vs formals()", format!("`{package}` not installed locally"))); + } + } + + match check_cran_availability(package) { + Some(true) => checks.push(CheckResult::pass("package on CRAN", "found on cloud.r-project.org")), + Some(false) => { + checks.push(CheckResult::warn("package on CRAN", "not found on CRAN — may be R-universe/Bioconductor/GitHub-only")) + } + None => checks.push(CheckResult::skipped("package on CRAN", "could not reach the CRAN mirror")), + } + } + + ValidationReport { + package: package.to_string(), + repository: repository.to_string(), + definition_version: fetched.manifest.definition.version.clone(), + checks, + } +} + +fn short_rev(rev: &str) -> &str { + &rev[..rev.len().min(12)] +} + +/// Diff `declared` (what the `.ty` sources say) against `installed` (what +/// `formals()` says on the real, locally installed package) — the "diff +/// `formals()` contre le package installé" registry.md §13 J4 names +/// explicitly. Pushes both the "exports vs formals()" and "arity vs +/// formals()" checks. +fn push_formals_diff(checks: &mut Vec, declared: &[DeclaredEntry], installed_fns: &[gen_types::GeneratedFn]) { + let installed: HashMap<&str, &gen_types::GeneratedFn> = installed_fns.iter().map(|f| (f.name.as_str(), f)).collect(); + + let mut missing = Vec::new(); + let mut mismatches = Vec::new(); + for entry in declared { + match installed.get(entry.name.as_str()) { + None => missing.push(entry.name.clone()), + Some(f) => { + let declared_fixed = entry.params.iter().filter(|p| !p.is_variadic).count(); + let declared_variadic = entry.params.iter().any(|p| p.is_variadic); + if declared_fixed != f.params.len() || declared_variadic != f.has_dots { + mismatches.push(format!( + "{} (declared {} arg(s){}, formals() has {} arg(s){})", + entry.name, + declared_fixed, + if declared_variadic { "+..." } else { "" }, + f.params.len(), + if f.has_dots { "+..." } else { "" } + )); + } + } + } + } + + let total = declared.len(); + let found = total - missing.len(); + if missing.is_empty() { + checks.push(CheckResult::pass("exports vs formals()", format!("{found}/{total} found"))); + } else { + checks.push(CheckResult::fail("exports vs formals()", format!("{found}/{total} found — missing: {}", missing.join(", ")))); + } + if mismatches.is_empty() { + checks.push(CheckResult::pass("arity vs formals()", format!("{total}/{total} match"))); + } else { + checks.push(CheckResult::fail( + "arity vs formals()", + format!("{} mismatch(es): {}", mismatches.len(), mismatches.join("; ")), + )); + } +} + +/// Best-effort: is `pkg` on CRAN, via `Rscript`'s own +/// `available.packages()` against the public mirror — the same +/// shell-out-to-`Rscript` choice `gen_types.rs` already made, rather than +/// adding an HTTP client dependency for one query. `None` (never a `Fail`) +/// when `Rscript` errors, the mirror is unreachable, or `pkg` isn't a plain +/// package-name-shaped string (never interpolated into R source otherwise — +/// same guard as `gen_types::is_generatable_name`). +fn check_cran_availability(pkg: &str) -> Option { + if pkg.is_empty() || !pkg.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_') { + return None; + } + let expr = format!( + "ap <- tryCatch(available.packages(repos = \"https://cloud.r-project.org\"), error = function(e) NULL); \ + if (is.null(ap)) cat(\"NETERR\") else if (\"{pkg}\" %in% rownames(ap)) cat(\"YES\") else cat(\"NO\")" + ); + let output = Command::new("Rscript").arg("-e").arg(&expr).output().ok()?; + if !output.status.success() { + return None; + } + match String::from_utf8_lossy(&output.stdout).trim() { + "YES" => Some(true), + "NO" => Some(false), + _ => None, + } +} + +// --------------------------------------------------------------------- +// Declared signature parsing — arity, parameter names and tier straight +// from the `.ty` source text, independent of the type-checker's own +// preprocessing (which strips parameter names before parsing, since the +// type grammar doesn't accept them — `standard_library::preprocess_ty_source`). +// --------------------------------------------------------------------- + +struct DeclaredParam { + is_variadic: bool, + type_text: String, +} + +struct DeclaredEntry { + /// Display name, backticks stripped. + name: String, + /// Effective tier: the entry's own `#! tier:`, or the manifest's + /// `[definition] tier` when absent. + tier: Option, + params: Vec, +} + +fn parse_declared_entries(ty_sources: &[(String, String)], default_tier: &str) -> Vec { + let mut out = Vec::new(); + for (_file, source) in ty_sources { + let meta_map = parse_meta_from_source(source); + for line in source.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('@') { + continue; + } + let Some(sig) = parse_signature_line(trimmed) else { continue }; + let tier = meta_map + .get(&sig.raw_name) + .and_then(|m| m.tier.clone()) + .or_else(|| Some(default_tier.to_string())); + out.push(DeclaredEntry { name: unwrap_backtick(&sig.raw_name), tier, params: sig.params }); + } + } + out +} + +struct ParsedSignature { + /// Exactly what `#! tier:`-map lookups need to match against — keeps + /// surrounding backticks, since `parse_meta_from_source` keys its map the + /// same way. + raw_name: String, + params: Vec, +} + +fn unwrap_backtick(name: &str) -> String { + name.trim_matches('`').to_string() +} + +/// Parse one `@name: (...) -> Ret;` (or `@extern pkg::name: ...;`) line into +/// its raw name and parameter list. `None` for a non-function declaration +/// (`type X <- Foreign;`, a bare `@x: int;` constant, …) — those don't +/// carry an arity to diff against `formals()`. +fn parse_signature_line(line: &str) -> Option { + let rest = line.strip_prefix('@')?; + let sep = find_unqualified_colon(rest)?; + let head = &rest[..sep]; + let raw_name = head + .strip_prefix("extern ") + .map(|n| n.rsplit("::").next().unwrap_or(n)) + .unwrap_or(head) + .trim(); + if raw_name.is_empty() { + return None; + } + let sig = rest[sep + 1..].trim(); + let sig = sig.strip_suffix(';').unwrap_or(sig).trim(); + if !sig.starts_with('(') { + return None; + } + let params = parse_param_list(sig)?; + Some(ParsedSignature { raw_name: raw_name.to_string(), params }) +} + +/// The first `:` in `text` that is not part of a `::` (which appears in +/// `@extern pkg::name`) — same rule stdlib_meta.rs's private +/// `extract_signature_name` uses, reimplemented here since that one isn't +/// exported across the crate boundary. +fn find_unqualified_colon(text: &str) -> Option { + let bytes = text.as_bytes(); + for (i, &b) in bytes.iter().enumerate() { + if b == b':' { + let prev = if i > 0 { bytes[i - 1] } else { 0 }; + let next = if i + 1 < bytes.len() { bytes[i + 1] } else { 0 }; + if prev != b':' && next != b':' { + return Some(i); + } + } + } + None +} + +/// `sig` starts with `(` — find its matching `)` (bracket-depth aware, so a +/// nested function-type or generic parameter doesn't confuse the split) and +/// parse the top-level comma-separated parameter list inside. +fn parse_param_list(sig: &str) -> Option> { + let chars: Vec = sig.chars().collect(); + let mut depth = 0i32; + let mut close_idx = None; + for (i, &c) in chars.iter().enumerate() { + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => { + depth -= 1; + if depth == 0 && c == ')' { + close_idx = Some(i); + break; + } + } + _ => {} + } + } + let close_idx = close_idx?; + let inner: String = chars[1..close_idx].iter().collect(); + Some(split_top_level(&inner).into_iter().map(|p| parse_one_param(&p)).collect()) +} + +fn split_top_level(inner: &str) -> Vec { + let trimmed = inner.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + let mut parts = Vec::new(); + let mut depth = 0i32; + let mut current = String::new(); + for c in trimmed.chars() { + match c { + '(' | '[' | '{' => { + depth += 1; + current.push(c); + } + ')' | ']' | '}' => { + depth -= 1; + current.push(c); + } + ',' if depth == 0 => { + parts.push(current.trim().to_string()); + current.clear(); + } + _ => current.push(c), + } + } + if !current.trim().is_empty() { + parts.push(current.trim().to_string()); + } + parts +} + +fn parse_one_param(text: &str) -> DeclaredParam { + let (is_variadic, rest) = match text.strip_prefix("...") { + Some(r) => (true, r.trim()), + None => (false, text.trim()), + }; + if rest.is_empty() { + return DeclaredParam { is_variadic, type_text: "Any".to_string() }; + } + match find_unqualified_colon(rest) { + Some(colon_idx) => DeclaredParam { is_variadic, type_text: rest[colon_idx + 1..].trim().to_string() }, + None => DeclaredParam { is_variadic, type_text: rest.to_string() }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::type_definition::{parse_manifest, DefinitionManifest}; + use std::path::{Path, PathBuf}; + + // -- Signature parsing -------------------------------------------- + + #[test] + fn parses_unnamed_generated_style_signature() { + let sig = parse_signature_line("@filter: (Any, ...Any) -> Any;").unwrap(); + assert_eq!(sig.raw_name, "filter"); + assert_eq!(sig.params.len(), 2); + assert!(!sig.params[0].is_variadic); + assert_eq!(sig.params[0].type_text, "Any"); + assert!(sig.params[1].is_variadic); + assert_eq!(sig.params[1].type_text, "Any"); + } + + #[test] + fn parses_named_hand_authored_signature() { + let sig = parse_signature_line("@cat: (...values: Any) -> Empty;").unwrap(); + assert_eq!(sig.raw_name, "cat"); + assert_eq!(sig.params.len(), 1); + assert!(sig.params[0].is_variadic); + assert_eq!(sig.params[0].type_text, "Any"); + } + + #[test] + fn parses_extern_pkg_double_colon_signature() { + let sig = parse_signature_line("@extern jsonlite::toJSON: (Any) -> char;").unwrap(); + assert_eq!(sig.raw_name, "toJSON"); + assert_eq!(sig.params.len(), 1); + } + + #[test] + fn parses_backtick_quoted_name() { + let sig = parse_signature_line("@`is.numeric`: (Any) -> bool;").unwrap(); + assert_eq!(sig.raw_name, "`is.numeric`"); + } + + #[test] + fn nested_higher_order_param_does_not_confuse_arity() { + let sig = parse_signature_line("@apply_fn: (f: (int) -> int, x: int) -> int;").unwrap(); + assert_eq!(sig.params.len(), 2); + assert_eq!(sig.params[0].type_text, "(int) -> int"); + assert_eq!(sig.params[1].type_text, "int"); + } + + #[test] + fn non_function_declaration_is_not_a_signature() { + assert!(parse_signature_line("type DataFrame <- Foreign;").is_none()); + } + + // -- Declared entries + tier fallback ------------------------------- + + #[test] + fn own_tier_annotation_wins_over_manifest_default() { + let src = "#! tier: T1\n@a: (Any) -> Any;\n\n@b: (Any) -> Any;\n"; + let declared = parse_declared_entries(&[("core.ty".to_string(), src.to_string())], "T3"); + let a = declared.iter().find(|e| e.name == "a").unwrap(); + let b = declared.iter().find(|e| e.name == "b").unwrap(); + assert_eq!(a.tier.as_deref(), Some("T1")); + assert_eq!(b.tier.as_deref(), Some("T3")); + } + + // -- T1 promotion gate + formals() diff, via `validate_fetched` ----- + + fn manifest(tier: &str) -> DefinitionManifest { + parse_manifest(&format!( + "format_version = 1\n\ + [package]\nname = \"shiny\"\nsince = \"1.11.0\"\n\ + [definition]\nversion = \"0.3.0\"\ntier = \"{tier}\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:alice/typr-shiny\"\n" + )) + .unwrap() + } + + fn write_fetched(subdir: &str, files: &[(&str, &str)]) -> FetchedDefinition { + let dir = std::env::temp_dir().join(format!("typr_registry_validate_{subdir}_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + for (rel, content) in files { + let path = dir.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, content).unwrap(); + } + FetchedDefinition { manifest: manifest("T2"), rev: "deadbeefcafef00d".to_string(), digest: "sha256:test".to_string(), dir } + } + + fn cleanup(fetched: &FetchedDefinition) { + let _ = fs::remove_dir_all(&fetched.dir); + } + + #[test] + fn clean_definition_passes_the_non_introspection_checks() { + let fetched = write_fetched( + "clean", + &[("ty/core.ty", "#! tier: T2\n@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n")], + ); + let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + + let by_name = |n: &str| report.checks.iter().find(|c| c.name == n).unwrap(); + assert_eq!(by_name("format_version").status, CheckStatus::Pass); + assert_eq!(by_name("rev pinned").status, CheckStatus::Pass); + assert_eq!(by_name("capabilities").status, CheckStatus::Pass); + assert_eq!(by_name(".ty parse/type-check").status, CheckStatus::Pass); + assert_eq!(by_name("tests/smoke.ty").status, CheckStatus::Skipped); + assert_eq!(by_name("T1 promotion gate").status, CheckStatus::Pass); + } + + #[test] + fn unpinned_rev_warns() { + let fetched = write_fetched("unpinned", &[("ty/core.ty", "@f: (Any) -> Any;\n")]); + let report = validate_fetched("shiny", "github:alice/typr-shiny", false, &fetched, &[]); + cleanup(&fetched); + let rev_check = report.checks.iter().find(|c| c.name == "rev pinned").unwrap(); + assert_eq!(rev_check.status, CheckStatus::Warn); + } + + /// A package name no real registry/CRAN package will ever have — + /// guarantees `gen_types::introspect` finds nothing installed, so tests + /// that assert on `report.ok()` as a whole aren't at the mercy of what + /// happens to be installed on the machine running the suite (unlike + /// `formals_diff_flags_a_missing_export_and_an_arity_mismatch`, below, + /// which deliberately wants a real installed package). + const FAKE_PACKAGE: &str = "typr_registry_validate_fixture_zzz"; + + #[test] + fn declared_capability_warning_is_surfaced_not_failed() { + let fetched = write_fetched("capwarn", &[("ty/core.ty", "@f: (Any) -> Any;\n")]); + let report = + validate_fetched(FAKE_PACKAGE, "github:alice/typr-shiny", true, &fetched, &["ships R shims".to_string()]); + cleanup(&fetched); + let cap = report.checks.iter().find(|c| c.name == "capabilities").unwrap(); + assert_eq!(cap.status, CheckStatus::Warn); + assert!(report.ok(), "a declared capability must not fail validation"); + } + + #[test] + fn broken_ty_source_fails_the_parse_check() { + // `fn(x)` with no parameter type hits the dedicated + // `SyntaxError::FunctionWithoutType` panic in `parsing/elements.rs` + // (see `standard_library.rs::tests::broken_ty_source_is_reported_as_skipped_with_a_real_message`). + let fetched = write_fetched("broken", &[("ty/core.ty", "let f <- fn(x) { x };\n")]); + let report = validate_fetched(FAKE_PACKAGE, "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + let parse_check = report.checks.iter().find(|c| c.name == ".ty parse/type-check").unwrap(); + assert_eq!(parse_check.status, CheckStatus::Fail); + assert!(!report.ok()); + } + + #[test] + fn present_smoke_test_is_checked_and_passes_when_it_typechecks() { + let fetched = write_fetched( + "smoke", + &[ + ("ty/core.ty", "@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n"), + ("tests/smoke.ty", "let x <- fluidPage(1);\n"), + ], + ); + let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + let smoke = report.checks.iter().find(|c| c.name == "tests/smoke.ty").unwrap(); + assert_eq!(smoke.status, CheckStatus::Pass); + } + + #[test] + fn t1_entry_with_unconstrained_variadic_fails_the_promotion_gate() { + let dir = std::env::temp_dir().join(format!("typr_registry_validate_t1gate_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let rel = Path::new("ty/core.ty"); + let path: PathBuf = dir.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "#! tier: T1\n@risky: (...values: Any) -> Any;\n").unwrap(); + let fetched = + FetchedDefinition { manifest: manifest("T2"), rev: "abc123".to_string(), digest: "sha256:test".to_string(), dir }; + + let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + + let gate = report.checks.iter().find(|c| c.name == "T1 promotion gate").unwrap(); + assert_eq!(gate.status, CheckStatus::Fail); + assert!(gate.detail.contains("risky"), "unexpected detail: {}", gate.detail); + assert!(!report.ok()); + } + + #[test] + fn t1_entry_with_constrained_variadic_passes_the_gate() { + let fetched = + write_fetched("t1ok", &[("ty/core.ty", "#! tier: T1\n@safe: (...values: int) -> Any;\n")]); + let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + let gate = report.checks.iter().find(|c| c.name == "T1 promotion gate").unwrap(); + assert_eq!(gate.status, CheckStatus::Pass); + } + + // -- formals() diff, against a real installed package (fail-open like + // every other Rscript-dependent test in this crate: skipped, not + // failed, when R/the package isn't available on this machine — see + // `gen_types.rs::generated_ty_type_checks_for_contrasting_packages`) -- + + #[test] + fn formals_diff_flags_a_missing_export_and_an_arity_mismatch() { + if !gen_types::rscript_available() { + eprintln!("skipping: Rscript not on PATH"); + return; + } + let info = match gen_types::introspect("jsonlite") { + Ok(info) if !info.functions.is_empty() => info, + _ => { + eprintln!("skipping: jsonlite not installed on this machine"); + return; + } + }; + let real = info.functions.iter().find(|f| f.name == "toJSON"); + let Some(real) = real else { + eprintln!("skipping: jsonlite::toJSON not found by introspection"); + return; + }; + // `toJSON` declared with one extra fixed argument beyond its real + // `formals()` — a real, existing export with the wrong arity — plus + // a name that plain doesn't exist in the package at all. + let wrong_arity_args = std::iter::repeat("Any").take(real.params.len() + 1).collect::>().join(", "); + let src = format!( + "@extern jsonlite::toJSON: ({wrong_arity_args}) -> char;\n\ + @extern jsonlite::doesNotExist: (Any) -> char;\n" + ); + let fetched = write_fetched("formals", &[("ty/core.ty", &src)]); + let report = validate_fetched("jsonlite", "github:alice/typr-jsonlite", true, &fetched, &[]); + cleanup(&fetched); + + let exports = report.checks.iter().find(|c| c.name == "exports vs formals()").unwrap(); + assert_eq!(exports.status, CheckStatus::Fail); + assert!(exports.detail.contains("doesNotExist"), "unexpected detail: {}", exports.detail); + + let arity = report.checks.iter().find(|c| c.name == "arity vs formals()").unwrap(); + assert_eq!(arity.status, CheckStatus::Fail); + assert!(arity.detail.contains("toJSON"), "unexpected detail: {}", arity.detail); + } +} diff --git a/crates/typr-cli/src/type_registry.rs b/crates/typr-cli/src/type_registry.rs index b8c956f..9efbd45 100644 --- a/crates/typr-cli/src/type_registry.rs +++ b/crates/typr-cli/src/type_registry.rs @@ -242,8 +242,10 @@ fn now_millis() -> u128 { /// Every file under `dir` (relative paths, `/`-separated, sorted), skipping /// `.git` — the exact set of bytes the content digest and the cache/vendor -/// copies are built from. -fn tracked_files(dir: &Path) -> Result, String> { +/// copies are built from. `pub(crate)` so `registry_validate.rs` can walk a +/// freshly fetched definition the same way `resolve_one_locked_definition` +/// walks a cached one, rather than re-implementing directory traversal. +pub(crate) fn tracked_files(dir: &Path) -> Result, String> { let mut out = BTreeSet::new(); collect_files(dir, dir, &mut out)?; Ok(out.into_iter().collect()) @@ -469,6 +471,256 @@ impl TypesConfig { } } +// --------------------------------------------------------------------- +// Registry lookup (registry.md §13 J3 — "résolution par le registre dans +// `typr add`, `typr types update`") +// --------------------------------------------------------------------- + +/// The one community registry this build knows how to query +/// (`we-data-ch/registry`, registry.md §8.1) — same one-host restriction as +/// `RepoSpec` only understanding `github:`. +const REGISTRY_REPO_URL: &str = "https://github.com/we-data-ch/registry.git"; + +/// Deserializes one entry of a `packages/.json` `definitions` array +/// (registry.md §8.1). Only the fields the selection logic below needs; +/// anything else in the file (e.g. `capabilities`) is not read here — the +/// real capability gate is enforced later, on the fetched repository itself, +/// by `check_capabilities`. +#[derive(Debug, Clone, Deserialize)] +struct RegistryDefinitionEntry { + /// `owner/repo`, no `github:` scheme (registry.md §8.1's example). + repository: String, + #[serde(default)] + rev: Option, + /// `official` | `community` | `generated` | `local`. + #[serde(default)] + source: String, + /// `T1` | `T2` | `T3`. + #[serde(default)] + tier: String, +} + +#[derive(Debug, Clone, Deserialize, Default)] +struct RegistryPackageFile { + #[serde(default)] + definitions: Vec, +} + +/// registry.md §8.3's conflict order, restricted to what a registry file can +/// express — an explicit user pin and a locally generated definition are +/// resolved before this is ever consulted (`resolve_spec_for_package`, +/// below): official first, then community, then anything else. An +/// unrecognized `source` string ranks last rather than erroring — same D2 +/// instinct as `standard_library::tier_rank` for an unreadable tier: a signal +/// this build cannot read must never be trusted more than one it can. +fn provider_rank(source: &str) -> u8 { + match source { + "official" => 3, + "community" => 2, + "generated" => 1, + _ => 0, + } +} + +/// Same ranking as `standard_library::tier_rank`, duplicated locally rather +/// than shared: that function ranks a *loaded* entry's trust for the +/// degrade-to-`Any` decision, this one ranks *candidates* before anything is +/// fetched — different callers, same T1 > T2 > T3 intuition. +fn tier_rank_for_selection(tier: &str) -> u8 { + match tier { + "T1" => 3, + "T2" => 2, + "T3" => 1, + _ => 0, + } +} + +/// Highest-ranked entry in `file.definitions`, ties broken by whichever comes +/// first in the file (no editorial ordering beyond tier/provenance — Q3, +/// registry.md §14, is still open). `None` when the file lists nothing. +fn pick_best_entry(file: &RegistryPackageFile) -> Option<&RegistryDefinitionEntry> { + let mut best: Option<&RegistryDefinitionEntry> = None; + let mut best_rank = (0u8, 0u8); + for entry in &file.definitions { + let rank = (provider_rank(&entry.source), tier_rank_for_selection(&entry.tier)); + if best.is_none() || rank > best_rank { + best = Some(entry); + best_rank = rank; + } + } + best +} + +/// `~/.cache/typr/registry-index/` — a local mirror of `we-data-ch/registry`, +/// refreshed in place rather than re-cloned on every lookup. +fn registry_index_dir() -> Option { + crate::r_deps::cache_home().map(|dir| dir.join("typr").join("registry-index")) +} + +/// Clone (first use) or fast-forward `git pull` (subsequent uses) the local +/// registry mirror, returning its path. +fn sync_registry_index() -> Result { + let dir = registry_index_dir() + .ok_or_else(|| "could not determine a cache directory (no $HOME/$XDG_CACHE_HOME)".to_string())?; + + if dir.join(".git").is_dir() { + let pull = Command::new("git") + .arg("-C") + .arg(&dir) + .args(["pull", "--quiet", "--ff-only"]) + .output() + .map_err(|e| format!("could not run `git pull`: {e}"))?; + if !pull.status.success() { + return Err(format!( + "`git pull` in {} failed: {}", + dir.display(), + String::from_utf8_lossy(&pull.stderr).trim() + )); + } + return Ok(dir); + } + + let _ = fs::remove_dir_all(&dir); + if let Some(parent) = dir.parent() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + let clone = Command::new("git") + .args(["clone", "--quiet", "--depth", "1"]) + .arg(REGISTRY_REPO_URL) + .arg(&dir) + .output() + .map_err(|e| format!("could not run `git clone`: {e}"))?; + if !clone.status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(format!( + "`git clone {REGISTRY_REPO_URL}` failed: {}", + String::from_utf8_lossy(&clone.stderr).trim() + )); + } + Ok(dir) +} + +/// Look up `packages/.json` in `we-data-ch/registry` and, when it +/// lists at least one usable entry, return a `github:owner/repo[@rev]` spec +/// string for the best one (`pick_best_entry`) — ready to hand straight to +/// `add`/`fetch`, exactly like a spec the user typed by hand. +/// +/// Fails open at every step: no `git`, an unreachable registry, no entry for +/// `package`, or an entry whose `repository` field is not `owner/repo` all +/// resolve to `None`, never an error the caller must special-case — "the +/// registry has nothing to say" is exactly as safe as "no definition at all" +/// (D2, registry.md §0/§5.4: an absent or unusable signal never blocks +/// anything, it just leaves the package untyped). +pub fn lookup_in_registry(package: &str) -> Option { + if !git_available() { + return None; + } + let dir = match sync_registry_index() { + Ok(dir) => dir, + Err(e) => { + eprintln!("warning: could not reach the registry ({e}) — skipping automatic type-definition lookup for `{package}`"); + return None; + } + }; + lookup_in_registry_dir(&dir, package) +} + +/// The JSON-reading and entry-selection half of `lookup_in_registry`, kept +/// separate from syncing the index so it can be unit-tested against a +/// hand-written `packages/` directory instead of a real clone of +/// `we-data-ch/registry`. +fn lookup_in_registry_dir(dir: &Path, package: &str) -> Option { + let path = dir.join("packages").join(format!("{package}.json")); + let source = fs::read_to_string(&path).ok()?; + let file: RegistryPackageFile = serde_json::from_str(&source).ok()?; + let entry = pick_best_entry(&file)?; + if entry.repository.split('/').filter(|s| !s.is_empty()).count() != 2 { + return None; + } + Some(match entry.rev.as_deref() { + Some(rev) if !rev.is_empty() => format!("github:{}@{}", entry.repository, rev), + _ => format!("github:{}", entry.repository), + }) +} + +/// One entry of `packages/.json`'s `definitions` array, as `typr +/// search` reports it — every entry, not just the winner `pick_best_entry` +/// would choose, so the user can see what `typr types add` would pick among. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchEntry { + /// `owner/repo`, no `github:` scheme (matches the registry file's own field). + pub repository: String, + pub rev: Option, + /// `official` | `community` | `generated` | `local`, or anything else the + /// registry file happens to contain — not validated here. + pub source: String, + /// `T1` | `T2` | `T3`, same caveat. + pub tier: String, +} + +/// `typr search ` — list every definition the registry has for +/// `package`, ranked best (registry.md §8.3's provider-then-tier order) +/// first, ties broken by file order exactly like `pick_best_entry`. +/// +/// An empty result means "the registry has nothing for this package", which +/// is not an error (D2's instinct: an absent signal is safe, never fatal) — +/// `Ok(vec![])` covers a missing `packages/.json`, invalid JSON, and an +/// empty `definitions` array alike. `Err` is reserved for why the registry +/// itself could not be consulted at all (no `git`, clone/pull failure), so +/// the caller can show that reason instead of silently reporting zero hits. +pub fn search(package: &str) -> Result, String> { + if !git_available() { + return Err("`git` is not installed or not on PATH — cannot reach the registry".to_string()); + } + let dir = sync_registry_index()?; + Ok(search_in_registry_dir(&dir, package)) +} + +/// The JSON-reading half of `search`, kept separate so it can be unit-tested +/// against a hand-written `packages/` directory instead of a real clone — +/// same split as `lookup_in_registry`/`lookup_in_registry_dir`. +fn search_in_registry_dir(dir: &Path, package: &str) -> Vec { + let path = dir.join("packages").join(format!("{package}.json")); + let Ok(source) = fs::read_to_string(&path) else { + return Vec::new(); + }; + let Ok(file) = serde_json::from_str::(&source) else { + return Vec::new(); + }; + let mut entries: Vec = file + .definitions + .iter() + .map(|e| SearchEntry { + repository: e.repository.clone(), + rev: e.rev.clone(), + source: e.source.clone(), + tier: e.tier.clone(), + }) + .collect(); + entries.sort_by(|a, b| { + let rank_a = (provider_rank(&a.source), tier_rank_for_selection(&a.tier)); + let rank_b = (provider_rank(&b.source), tier_rank_for_selection(&b.tier)); + rank_b.cmp(&rank_a) + }); + entries +} + +/// Resolve a repository spec string for `package` when the caller has none +/// in hand yet — the two steps of registry.md §7.3's flow that precede a +/// fetch: an explicit `typr.toml [types]` pin always wins (§8.3 priority 1); +/// failing that, the registry (§8.3 priorities 2-3, as far as a registry +/// entry can distinguish them). `None` means neither had anything to say — +/// the caller decides what "nothing resolved" means for it (an error for an +/// explicit `typr types add` with no repo, a silent no-op for `typr add`'s +/// best-effort lookup). +pub fn resolve_spec_for_package(project_root: &Path, package: &str) -> Option { + let config = TypesConfig::read(project_root); + if let Some(pin) = config.pins.get(package) { + return Some(pin.clone()); + } + lookup_in_registry(package) +} + // --------------------------------------------------------------------- // Commands: add / update / list / vendor // --------------------------------------------------------------------- @@ -504,20 +756,25 @@ pub fn add(project_root: &Path, package: &str, spec_str: &str) -> Result) -> Result, String> { let lock_path = project_root.join(LOCKFILE_NAME); let lockfile = Lockfile::read(&lock_path); - let config = TypesConfig::read(project_root); let targets: Vec<(String, String)> = match package { Some(pkg) => { let spec = lockfile .find(pkg) .map(|d| d.repository.clone()) - .or_else(|| config.pins.get(pkg).cloned()) - .ok_or_else(|| format!("no resolved or pinned definition for `{pkg}` — use `typr types add` first"))?; + .or_else(|| resolve_spec_for_package(project_root, pkg)) + .ok_or_else(|| { + format!( + "no resolved, pinned, or registry-listed definition for `{pkg}` — use \ + `typr types add {pkg} github:owner/repo` with an explicit repository" + ) + })?; vec![(pkg.to_string(), spec)] } None => lockfile @@ -1174,4 +1431,244 @@ mod tests { let _ = fs::remove_dir_all(&cache_dir); } } + + // -- Registry lookup (registry.md §13 J3) ---------------------------- + + fn write_registry_package(dir: &Path, package: &str, json: &str) { + write_file(dir, &format!("packages/{package}.json"), json); + } + + #[test] + fn picks_official_over_community_regardless_of_tier() { + let dir = std::env::temp_dir().join(format!("typr_registry_official_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "shiny", + r#"{ + "name": "shiny", + "definitions": [ + {"repository": "alice/typr-shiny", "rev": "aaa", "source": "community", "tier": "T1"}, + {"repository": "rstudio/typr-shiny-official", "rev": "bbb", "source": "official", "tier": "T3"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "shiny").unwrap(); + assert_eq!(spec, "github:rstudio/typr-shiny-official@bbb"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn picks_highest_tier_among_same_provenance() { + let dir = std::env::temp_dir().join(format!("typr_registry_tier_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "dplyr", + r#"{ + "name": "dplyr", + "definitions": [ + {"repository": "alice/typr-dplyr", "rev": "aaa", "source": "community", "tier": "T3"}, + {"repository": "bob/typr-dplyr", "rev": "bbb", "source": "community", "tier": "T1"}, + {"repository": "carol/typr-dplyr", "rev": "ccc", "source": "community", "tier": "T2"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "dplyr").unwrap(); + assert_eq!(spec, "github:bob/typr-dplyr@bbb"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn ties_keep_the_files_own_order() { + let dir = std::env::temp_dir().join(format!("typr_registry_tie_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "ggplot2", + r#"{ + "name": "ggplot2", + "definitions": [ + {"repository": "first/typr-ggplot2", "rev": "aaa", "source": "community", "tier": "T2"}, + {"repository": "second/typr-ggplot2", "rev": "bbb", "source": "community", "tier": "T2"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "ggplot2").unwrap(); + assert_eq!(spec, "github:first/typr-ggplot2@aaa"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn no_entry_for_package_resolves_to_none() { + let dir = std::env::temp_dir().join(format!("typr_registry_missing_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + + assert!(lookup_in_registry_dir(&dir, "sf").is_none()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn empty_definitions_array_resolves_to_none() { + let dir = std::env::temp_dir().join(format!("typr_registry_empty_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package(&dir, "sf", r#"{"name": "sf", "definitions": []}"#); + + assert!(lookup_in_registry_dir(&dir, "sf").is_none()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn malformed_json_resolves_to_none_rather_than_erroring() { + let dir = std::env::temp_dir().join(format!("typr_registry_bad_json_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package(&dir, "sf", "this is not { json"); + + assert!(lookup_in_registry_dir(&dir, "sf").is_none()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn entry_without_rev_resolves_to_head() { + let dir = std::env::temp_dir().join(format!("typr_registry_norev_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "httr2", + r#"{"name": "httr2", "definitions": [{"repository": "alice/typr-httr2", "source": "community", "tier": "T2"}]}"#, + ); + + let spec = lookup_in_registry_dir(&dir, "httr2").unwrap(); + assert_eq!(spec, "github:alice/typr-httr2"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn unrecognized_source_and_tier_never_outrank_a_recognized_one() { + let dir = std::env::temp_dir().join(format!("typr_registry_unknown_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "jsonlite", + r#"{ + "name": "jsonlite", + "definitions": [ + {"repository": "sketchy/typr-jsonlite", "rev": "zzz", "source": "totally-trustworthy", "tier": "super-good"}, + {"repository": "alice/typr-jsonlite", "rev": "aaa", "source": "generated", "tier": "T3"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "jsonlite").unwrap(); + assert_eq!(spec, "github:alice/typr-jsonlite@aaa"); + + let _ = fs::remove_dir_all(&dir); + } + + // -- search: every entry, ranked, not just the winner ----------------- + + #[test] + fn search_lists_every_entry_ranked_best_first() { + let dir = std::env::temp_dir().join(format!("typr_search_ranked_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "shiny", + r#"{ + "name": "shiny", + "definitions": [ + {"repository": "alice/typr-shiny", "rev": "aaa", "source": "community", "tier": "T1"}, + {"repository": "rstudio/typr-shiny-official", "rev": "bbb", "source": "official", "tier": "T3"}, + {"repository": "carol/typr-shiny", "rev": "ccc", "source": "community", "tier": "T3"} + ] + }"#, + ); + + let entries = search_in_registry_dir(&dir, "shiny"); + let repos: Vec<&str> = entries.iter().map(|e| e.repository.as_str()).collect(); + // official (any tier) still outranks community, matching pick_best_entry. + assert_eq!( + repos, + vec!["rstudio/typr-shiny-official", "alice/typr-shiny", "carol/typr-shiny"] + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn search_ties_keep_the_files_own_order() { + let dir = std::env::temp_dir().join(format!("typr_search_tie_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "ggplot2", + r#"{ + "name": "ggplot2", + "definitions": [ + {"repository": "first/typr-ggplot2", "rev": "aaa", "source": "community", "tier": "T2"}, + {"repository": "second/typr-ggplot2", "rev": "bbb", "source": "community", "tier": "T2"} + ] + }"#, + ); + + let entries = search_in_registry_dir(&dir, "ggplot2"); + let repos: Vec<&str> = entries.iter().map(|e| e.repository.as_str()).collect(); + assert_eq!(repos, vec!["first/typr-ggplot2", "second/typr-ggplot2"]); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn search_no_entry_for_package_is_an_empty_list_not_an_error() { + let dir = std::env::temp_dir().join(format!("typr_search_missing_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + + assert!(search_in_registry_dir(&dir, "sf").is_empty()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn search_malformed_json_is_an_empty_list_not_an_error() { + let dir = std::env::temp_dir().join(format!("typr_search_bad_json_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package(&dir, "sf", "this is not { json"); + + assert!(search_in_registry_dir(&dir, "sf").is_empty()); + + let _ = fs::remove_dir_all(&dir); + } + + // -- resolve_spec_for_package: explicit pin beats the registry -------- + + #[test] + fn resolve_spec_for_package_prefers_explicit_pin_without_touching_the_registry() { + let dir = std::env::temp_dir().join(format!("typr_resolve_pin_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(PROJECT_CONFIG_NAME), + "[types]\nshiny = \"github:alice/typr-shiny@pinned\"\n", + ) + .unwrap(); + + // No registry index is reachable/needed here: the pin must win before + // `lookup_in_registry` is ever consulted. + let spec = resolve_spec_for_package(&dir, "shiny"); + assert_eq!(spec, Some("github:alice/typr-shiny@pinned".to_string())); + + let _ = fs::remove_dir_all(&dir); + } } From c56e52b98a69c0326dff93182fde4cc4f933c192 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Mon, 14 Sep 2026 07:46:41 +0200 Subject: [PATCH 13/18] update Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014brvdoSu1AUJnY3mFzSc8C --- crates/typr-cli/src/cli.rs | 39 +- crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 1 + crates/typr-cli/src/registry_revalidate.rs | 467 +++++++++++++++++++++ crates/typr-cli/src/registry_validate.rs | 223 +++++++--- crates/typr-cli/src/type_registry.rs | 207 ++++++++- 6 files changed, 869 insertions(+), 69 deletions(-) create mode 100644 crates/typr-cli/src/registry_revalidate.rs diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index f3c1ee8..cafa55c 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -238,6 +238,25 @@ enum TypesCommands { /// same way `typr types add` does. repo: Option, }, + /// Run `Validate`'s checks against *every* definition the + /// `we-data-ch/registry` index lists, and report drift since the last run + /// — registry.md §13 J4, "revalidation périodique des définitions déjà + /// indexées (détection de dérive)". Meant to run centrally (a scheduled + /// CI job in `we-data-ch/registry` itself), not per-project. Exits 1 only + /// when something that was fine last run just broke (`--out`'s previous + /// contents vs. now) — a long-standing, already-known failure doesn't + /// keep failing the job forever. + Revalidate { + /// A local checkout of `we-data-ch/registry` to validate as-is (e.g. + /// a CI job's own working tree). Omit to sync the same local mirror + /// `typr search`/`typr types add` already use. + #[arg(long, value_name = "PATH")] + dir: Option, + /// Where the previous run's snapshot is read from and the new one is + /// written to. Defaults to `/status/validation.json`. + #[arg(long, short, value_name = "FILE")] + out: Option, + }, } #[derive(Subcommand, Debug)] @@ -615,8 +634,8 @@ fn run_cache_command(command: CacheCommands) { } } -/// `typr types ` — see `typR/registry.md` §7 and -/// `rfcs/0031-external-type-definitions.md`. +/// `typr types ` — see +/// `typR/registry.md` §7, §9, §13 J4 and `rfcs/0031-external-type-definitions.md`. fn run_types_command(command: TypesCommands) { use crate::type_registry; @@ -701,6 +720,22 @@ fn run_types_command(command: TypesCommands) { std::process::exit(1); } } + TypesCommands::Revalidate { dir, out } => { + use crate::registry_revalidate; + match registry_revalidate::revalidate(dir.as_deref(), out.as_deref()) { + Ok((snapshots, drift, out_path)) => { + print!("{}", registry_revalidate::render(&snapshots, &drift)); + println!("\nwrote {}", out_path.display()); + if !drift.newly_failing.is_empty() { + std::process::exit(1); + } + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } + } } } diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index 7595c57..e4ac736 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -54,6 +54,7 @@ pub mod r_deps; pub mod r_name_cache; pub mod r_name_lint; pub mod rd_renderer; +pub mod registry_revalidate; pub mod registry_validate; pub mod repl; pub mod standard_library; diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index 1f80a3d..abc000d 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -17,6 +17,7 @@ mod r_deps; mod r_name_cache; mod r_name_lint; mod rd_renderer; +mod registry_revalidate; mod registry_validate; mod repl; mod standard_library; diff --git a/crates/typr-cli/src/registry_revalidate.rs b/crates/typr-cli/src/registry_revalidate.rs new file mode 100644 index 0000000..10b355a --- /dev/null +++ b/crates/typr-cli/src/registry_revalidate.rs @@ -0,0 +1,467 @@ +//! `typr types revalidate` — periodic revalidation of every Type Definition +//! indexed by `we-data-ch/registry`, and detection of drift since the last +//! run. `typR/registry.md` §13 J4, "revalidation périodique des définitions +//! déjà indexées (détection de dérive)" — the item `registry_validate.rs`'s +//! own doc comment named as still open when it landed the single-repository +//! checks of §9. +//! +//! `registry_validate::validate` answers "is this one definition healthy +//! *today*". This module answers two questions a single on-demand run +//! cannot: "is *everything* the registry lists healthy today" (by walking +//! `type_registry::list_registry_targets` instead of one repository), and +//! "did anything that was fine *stop* being fine" (by diffing against a +//! persisted snapshot of the previous run) — the actual failure mode this +//! guards against is a CRAN release silently breaking a definition nobody +//! touched (registry.md §9: "le mode de mort n° 1 d'un registre +//! communautaire"). +//! +//! This is meant to run centrally — a scheduled CI job in `we-data-ch/registry` +//! itself, not a command a consuming project would ever call — which is why +//! `revalidate` takes an optional `--dir` pointing at a *registry* checkout +//! (a CI job that has already checked one out) rather than a project root; +//! omitted, it falls back to the same synced mirror `typr search` already +//! uses (`type_registry::sync_registry_index`). + +use crate::registry_validate::{self, CheckStatus}; +use crate::type_registry; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// --------------------------------------------------------------------- +// Persisted snapshot shape +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SnapshotCheck { + pub name: String, + /// "ok" | "warning" | "FAILED" | "not checked" — `CheckStatus`'s own + /// display words (`ValidationReport::render`), kept as text rather than + /// re-deriving the enum so an older snapshot file with a status word this + /// build no longer emits still deserializes instead of breaking the diff. + pub status: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Snapshot { + pub package: String, + /// `github:owner/repo[@rev]` — doubles as the identity key alongside + /// `package` for diffing against the previous run (registry.md §8.3: one + /// package can resolve several definitions, so `package` alone isn't a + /// key). + pub repository: String, + pub definition_version: String, + /// `YYYY-MM-DD`, UTC — the "last verified" date registry.md §9's example + /// report shows next to each check. + pub checked_at: String, + pub ok: bool, + pub checks: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct SnapshotFile { + #[serde(default)] + definitions: Vec, +} + +// --------------------------------------------------------------------- +// Drift +// --------------------------------------------------------------------- + +/// What changed since the previous persisted run, keyed by `(package, +/// repository)`. Informational, not a build gate on its own (D2/D5) — a +/// caller (the CLI, a CI job) decides what to do with it; `typr types +/// revalidate` exits non-zero only when `newly_failing` is non-empty, so a +/// long-standing, already-known failure doesn't keep paging someone forever. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Drift { + /// `ok` last run (or never checked before) — `Fail` now. + pub newly_failing: Vec<(String, String)>, + /// `Fail` last run — `ok` now. + pub recovered: Vec<(String, String)>, + /// Checked last run, no longer listed by the registry at all (the + /// `packages/.json` entry, or the whole file, was removed). + pub removed: Vec<(String, String)>, +} + +impl Drift { + pub fn is_empty(&self) -> bool { + self.newly_failing.is_empty() && self.recovered.is_empty() && self.removed.is_empty() + } +} + +/// Compare `previous` against `current`, both keyed by `(package, +/// repository)`. An entry with no prior record counts as "newly failing" when +/// it fails now — first sight of a problem is still news — but never as +/// "recovered" or "removed", which need an actual prior record to compare +/// against. +fn diff(previous: &[Snapshot], current: &[Snapshot]) -> Drift { + let prev_by_key: HashMap<(&str, &str), &Snapshot> = previous + .iter() + .map(|s| ((s.package.as_str(), s.repository.as_str()), s)) + .collect(); + let curr_keys: HashSet<(&str, &str)> = current + .iter() + .map(|s| (s.package.as_str(), s.repository.as_str())) + .collect(); + + let mut drift = Drift::default(); + for snap in current { + let key = (snap.package.as_str(), snap.repository.as_str()); + match (prev_by_key.get(&key).map(|p| p.ok), snap.ok) { + (Some(true), false) | (None, false) => drift + .newly_failing + .push((snap.package.clone(), snap.repository.clone())), + (Some(false), true) => drift.recovered.push((snap.package.clone(), snap.repository.clone())), + _ => {} + } + } + for prev in previous { + let key = (prev.package.as_str(), prev.repository.as_str()); + if !curr_keys.contains(&key) { + drift.removed.push((prev.package.clone(), prev.repository.clone())); + } + } + drift.newly_failing.sort(); + drift.recovered.sort(); + drift.removed.sort(); + drift +} + +// --------------------------------------------------------------------- +// Running the checks +// --------------------------------------------------------------------- + +fn status_word(status: CheckStatus) -> &'static str { + match status { + CheckStatus::Pass => "ok", + CheckStatus::Warn => "warning", + CheckStatus::Fail => "FAILED", + CheckStatus::Skipped => "not checked", + } +} + +/// Run `registry_validate::validate` against every target `type_registry:: +/// list_registry_targets(registry_dir)` lists, stamping each result with +/// today's date. Real work per target (a `git clone`, a type-check, an +/// `Rscript` introspection) — this is the periodic/CI path, not an +/// interactive one, so it is expected to take as long as the registry is big. +fn run(registry_dir: &Path) -> Vec { + let checked_at = today(); + type_registry::list_registry_targets(registry_dir) + .into_iter() + .map(|target| { + let report = registry_validate::validate(&target.package, &target.spec); + Snapshot { + package: target.package, + repository: target.spec, + definition_version: report.definition_version.clone(), + checked_at: checked_at.clone(), + ok: report.ok(), + checks: report + .checks + .iter() + .map(|c| SnapshotCheck { + name: c.name.to_string(), + status: status_word(c.status).to_string(), + detail: c.detail.clone(), + }) + .collect(), + } + }) + .collect() +} + +/// A missing or unparsable snapshot file is simply "no previous run" — never +/// a hard error (D2), same contract as `type_registry::Lockfile::read`. +fn load_previous(path: &Path) -> Vec { + fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|f| f.definitions) + .unwrap_or_default() +} + +fn save(path: &Path, snapshots: &[Snapshot]) -> Result<(), String> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + } + let rendered = serde_json::to_string_pretty(&SnapshotFile { + definitions: snapshots.to_vec(), + }) + .map_err(|e| format!("could not serialize {}: {e}", path.display()))?; + fs::write(path, rendered).map_err(|e| format!("could not write {}: {e}", path.display())) +} + +// --------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------- + +/// `typr types revalidate [--dir PATH] [--out FILE]`. +/// +/// `registry_dir`: a local checkout of `we-data-ch/registry` to validate as-is +/// (e.g. a CI job's own working tree, so a PR can be checked before it merges) +/// — `None` syncs the same local mirror `typr search`/`typr types add` use. +/// +/// `out`: where the previous run's snapshot is read from and the new one is +/// written to. Defaults to `/status/validation.json`, so a CI +/// job that commits its checkout's working tree back naturally persists +/// history across runs without needing to know the path in advance. +/// +/// Fails only when the registry truly cannot be consulted at all (no `git`, +/// clone/pull failure, or a snapshot that cannot be written) — matches +/// `type_registry::search`'s Err semantics. A registry with nothing indexed +/// yet, or one where every check is `Skipped`/`Warn`, resolves fine (D2): an +/// absent or uncertain signal is never a reason to fail the job that +/// discovers it. +pub fn revalidate(registry_dir: Option<&Path>, out: Option<&Path>) -> Result<(Vec, Drift, PathBuf), String> { + let dir = match registry_dir { + Some(d) => d.to_path_buf(), + None => { + if !type_registry::git_available() { + return Err("`git` is not installed or not on PATH — cannot reach the registry".to_string()); + } + type_registry::sync_registry_index()? + } + }; + let out_path = out + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| dir.join("status").join("validation.json")); + + let previous = load_previous(&out_path); + let current = run(&dir); + let drift = diff(&previous, ¤t); + save(&out_path, ¤t)?; + + Ok((current, drift, out_path)) +} + +/// The nominative report of registry.md §9 — what was verified, named, with +/// the date it was last checked, never a single green badge — plus a "drift +/// since last run" section when `drift` has anything to say. +pub fn render(snapshots: &[Snapshot], drift: &Drift) -> String { + let mut out = String::new(); + if snapshots.is_empty() { + out.push_str("no definitions indexed in the registry — nothing to revalidate.\n"); + return out; + } + for s in snapshots { + out.push_str(&format!( + "{} — {} (definition v{}) — last verified {}\n", + s.package, s.repository, s.definition_version, s.checked_at + )); + for c in &s.checks { + out.push_str(&format!(" {:<26} {:<12} {}\n", c.name, c.status, c.detail)); + } + } + if !drift.is_empty() { + out.push_str("\ndrift since last run:\n"); + for (pkg, repo) in &drift.newly_failing { + out.push_str(&format!(" NEW FAILURE {pkg} — {repo}\n")); + } + for (pkg, repo) in &drift.recovered { + out.push_str(&format!(" recovered {pkg} — {repo}\n")); + } + for (pkg, repo) in &drift.removed { + out.push_str(&format!(" removed {pkg} — {repo}\n")); + } + } + out +} + +// --------------------------------------------------------------------- +// Dates — no `chrono` in this workspace (registry.md's own instinct +// elsewhere: shell out or hand-roll rather than add an HTTP/date dependency +// for one call site). Howard Hinnant's `civil_from_days` (public domain) is a +// small, well-known, dependency-free days-since-epoch → (y, m, d) conversion. +// --------------------------------------------------------------------- + +fn today() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let (y, m, d) = civil_from_days((secs / 86_400) as i64); + format!("{y:04}-{m:02}-{d:02}") +} + +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + // -- civil_from_days -------------------------------------------------- + + #[test] + fn civil_from_days_epoch_is_1970_01_01() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + } + + #[test] + fn civil_from_days_matches_a_known_date() { + // 2024-03-01 is 19783 days after the epoch (external reference: date -d @1709251200 -u). + assert_eq!(civil_from_days(19_783), (2024, 3, 1)); + } + + #[test] + fn civil_from_days_handles_a_leap_day() { + // 2024-02-29 is one day before the above. + assert_eq!(civil_from_days(19_782), (2024, 2, 29)); + } + + // -- diff --------------------------------------------------------------- + + fn snap(package: &str, repository: &str, ok: bool) -> Snapshot { + Snapshot { + package: package.to_string(), + repository: repository.to_string(), + definition_version: "0.1.0".to_string(), + checked_at: "2026-01-01".to_string(), + ok, + checks: Vec::new(), + } + } + + #[test] + fn ok_to_fail_is_newly_failing() { + let previous = vec![snap("shiny", "github:alice/typr-shiny", true)]; + let current = vec![snap("shiny", "github:alice/typr-shiny", false)]; + let drift = diff(&previous, ¤t); + assert_eq!( + drift.newly_failing, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + assert!(drift.recovered.is_empty()); + assert!(drift.removed.is_empty()); + } + + #[test] + fn fail_to_ok_is_recovered() { + let previous = vec![snap("shiny", "github:alice/typr-shiny", false)]; + let current = vec![snap("shiny", "github:alice/typr-shiny", true)]; + let drift = diff(&previous, ¤t); + assert_eq!( + drift.recovered, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + assert!(drift.newly_failing.is_empty()); + } + + #[test] + fn never_seen_before_and_failing_counts_as_newly_failing() { + let previous: Vec = Vec::new(); + let current = vec![snap("shiny", "github:alice/typr-shiny", false)]; + let drift = diff(&previous, ¤t); + assert_eq!( + drift.newly_failing, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + } + + #[test] + fn never_seen_before_and_ok_is_not_drift() { + let previous: Vec = Vec::new(); + let current = vec![snap("shiny", "github:alice/typr-shiny", true)]; + assert!(diff(&previous, ¤t).is_empty()); + } + + #[test] + fn still_failing_both_runs_is_not_drift() { + let previous = vec![snap("shiny", "github:alice/typr-shiny", false)]; + let current = vec![snap("shiny", "github:alice/typr-shiny", false)]; + assert!(diff(&previous, ¤t).is_empty()); + } + + #[test] + fn dropped_from_the_registry_is_removed() { + let previous = vec![snap("shiny", "github:alice/typr-shiny", true)]; + let current: Vec = Vec::new(); + let drift = diff(&previous, ¤t); + assert_eq!( + drift.removed, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + assert!(drift.newly_failing.is_empty()); + } + + #[test] + fn same_package_two_repositories_are_independent_keys() { + let previous = vec![ + snap("shiny", "github:alice/typr-shiny", true), + snap("shiny", "github:bob/typr-shiny", true), + ]; + let current = vec![ + snap("shiny", "github:alice/typr-shiny", false), + snap("shiny", "github:bob/typr-shiny", true), + ]; + let drift = diff(&previous, ¤t); + assert_eq!( + drift.newly_failing, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + } + + // -- load_previous / save round-trip ------------------------------------ + + #[test] + fn load_previous_missing_file_is_empty_not_an_error() { + let path = std::env::temp_dir().join(format!("typr_revalidate_missing_{}.json", std::process::id())); + let _ = fs::remove_file(&path); + assert!(load_previous(&path).is_empty()); + } + + #[test] + fn save_then_load_previous_round_trips() { + let path = std::env::temp_dir().join(format!("typr_revalidate_roundtrip_{}.json", std::process::id())); + let _ = fs::remove_file(&path); + let snapshots = vec![snap("shiny", "github:alice/typr-shiny", true)]; + + save(&path, &snapshots).unwrap(); + let loaded = load_previous(&path); + + let _ = fs::remove_file(&path); + assert_eq!(loaded, snapshots); + } + + // -- render --------------------------------------------------------------- + + #[test] + fn render_empty_registry_says_so() { + assert_eq!( + render(&[], &Drift::default()), + "no definitions indexed in the registry — nothing to revalidate.\n" + ); + } + + #[test] + fn render_includes_drift_section_only_when_non_empty() { + let clean = render(&[snap("shiny", "github:alice/typr-shiny", true)], &Drift::default()); + assert!(!clean.contains("drift since last run")); + + let mut drift = Drift::default(); + drift + .newly_failing + .push(("shiny".to_string(), "github:alice/typr-shiny".to_string())); + let dirty = render(&[snap("shiny", "github:alice/typr-shiny", false)], &drift); + assert!(dirty.contains("drift since last run")); + assert!(dirty.contains("NEW FAILURE shiny — github:alice/typr-shiny")); + } +} diff --git a/crates/typr-cli/src/registry_validate.rs b/crates/typr-cli/src/registry_validate.rs index 568ecb8..a2f08ed 100644 --- a/crates/typr-cli/src/registry_validate.rs +++ b/crates/typr-cli/src/registry_validate.rs @@ -3,16 +3,16 @@ //! repository (registry.md §13 J4, "contrôles mécaniques de §9, dont le diff //! `formals()` contre le package installé"). //! -//! Two checks named in §9 are deliberately out of scope here and stay open in +//! One check named in §9 is deliberately out of scope here and stays open in //! the J4 checklist: "schéma JSON du registre valide" is a property of //! `we-data-ch/registry`'s own `packages/*.json` files, not of one definition //! repository (it already gets a mechanical check for free every time //! `type_registry::lookup_in_registry_dir`/`search_in_registry_dir` parses one //! into `RegistryPackageFile` — a malformed file simply resolves to no //! candidates, per D2 — but there is no *dedicated* validator yet that flags -//! *which* file is malformed); "revalidation périodique" (drift re-detection -//! over time) needs a place to run centrally (cron/CI on the registry itself), -//! which is the next J4 item, not this one. +//! *which* file is malformed). "revalidation périodique" (drift re-detection +//! over time) — a place to run this validator centrally, on every registry +//! entry, on a schedule — is [`crate::registry_revalidate`], the next J4 item. //! //! Every other §9 line is a [`CheckResult`] here: //! @@ -77,16 +77,32 @@ pub struct CheckResult { impl CheckResult { fn pass(name: &'static str, detail: impl Into) -> Self { - CheckResult { name, status: CheckStatus::Pass, detail: detail.into() } + CheckResult { + name, + status: CheckStatus::Pass, + detail: detail.into(), + } } fn warn(name: &'static str, detail: impl Into) -> Self { - CheckResult { name, status: CheckStatus::Warn, detail: detail.into() } + CheckResult { + name, + status: CheckStatus::Warn, + detail: detail.into(), + } } fn fail(name: &'static str, detail: impl Into) -> Self { - CheckResult { name, status: CheckStatus::Fail, detail: detail.into() } + CheckResult { + name, + status: CheckStatus::Fail, + detail: detail.into(), + } } fn skipped(name: &'static str, detail: impl Into) -> Self { - CheckResult { name, status: CheckStatus::Skipped, detail: detail.into() } + CheckResult { + name, + status: CheckStatus::Skipped, + detail: detail.into(), + } } } @@ -109,7 +125,10 @@ impl ValidationReport { /// The nominative report of registry.md §9: what was verified, named, /// never a single green badge. pub fn render(&self) -> String { - let mut out = format!("{} — {} (definition v{})\n", self.package, self.repository, self.definition_version); + let mut out = format!( + "{} — {} (definition v{})\n", + self.package, self.repository, self.definition_version + ); for c in &self.checks { let word = match c.status { CheckStatus::Pass => "ok", @@ -174,9 +193,15 @@ fn validate_fetched( ) -> ValidationReport { let mut checks = Vec::new(); - checks.push(CheckResult::pass("format_version", fetched.manifest.format_version.to_string())); + checks.push(CheckResult::pass( + "format_version", + fetched.manifest.format_version.to_string(), + )); - checks.push(CheckResult::pass("repository accessible", format!("cloned @ {}", short_rev(&fetched.rev)))); + checks.push(CheckResult::pass( + "repository accessible", + format!("cloned @ {}", short_rev(&fetched.rev)), + )); checks.push(if rev_pinned { CheckResult::pass("rev pinned", &fetched.rev) } else { @@ -188,7 +213,10 @@ fn validate_fetched( checks.push(CheckResult::pass("digest", fetched.digest.clone())); if capability_warnings.is_empty() { - checks.push(CheckResult::pass("capabilities", "no undeclared R; r_shims/extern_raw not declared")); + checks.push(CheckResult::pass( + "capabilities", + "no undeclared R; r_shims/extern_raw not declared", + )); } else { checks.push(CheckResult::warn("capabilities", capability_warnings.join(" "))); } @@ -222,18 +250,33 @@ fn validate_fetched( // parsed/type-checked, and `skipped` records failures from before that // point — this just avoids implying a trust decision that isn't ours to // make in a validator. - let (_context, skipped) = - standard_library::load_external_ty_definitions(Context::default(), &sources_ref, &fetched.manifest.definition.tier, "T1"); + let (_context, skipped) = standard_library::load_external_ty_definitions( + Context::default(), + &sources_ref, + &fetched.manifest.definition.tier, + "T1", + ); if ty_sources.is_empty() { - checks.push(CheckResult::warn(".ty parse/type-check", "no .ty file found in this repository")); + checks.push(CheckResult::warn( + ".ty parse/type-check", + "no .ty file found in this repository", + )); } else if skipped.is_empty() { - checks.push(CheckResult::pass(".ty parse/type-check", format!("{} file(s) OK", ty_sources.len()))); + checks.push(CheckResult::pass( + ".ty parse/type-check", + format!("{} file(s) OK", ty_sources.len()), + )); } else { let names: Vec<&str> = skipped.iter().map(|(f, _)| f.as_str()).collect(); checks.push(CheckResult::fail( ".ty parse/type-check", - format!("{}/{} file(s) failed: {}", skipped.len(), ty_sources.len(), names.join(", ")), + format!( + "{}/{} file(s) failed: {}", + skipped.len(), + ty_sources.len(), + names.join(", ") + ), )); } @@ -248,8 +291,11 @@ fn validate_fetched( // Declared names/arity/tier — deliberately excludes `tests/`, which // exercises the API rather than declaring it (registry.md §5.1). - let declared_sources: Vec<(String, String)> = - ty_sources.iter().filter(|(f, _)| !f.starts_with("tests/")).cloned().collect(); + let declared_sources: Vec<(String, String)> = ty_sources + .iter() + .filter(|(f, _)| !f.starts_with("tests/")) + .cloned() + .collect(); let declared = parse_declared_entries(&declared_sources, &fetched.manifest.definition.tier); let t1_entries: Vec<&DeclaredEntry> = declared.iter().filter(|e| e.tier.as_deref() == Some("T1")).collect(); @@ -264,7 +310,10 @@ fn validate_fetched( format!("{} T1 entrie(s), none with an unconstrained `...`", t1_entries.len()), )); } else { - checks.push(CheckResult::fail("T1 promotion gate", format!("unconstrained `...` at T1: {}", t1_violations.join(", ")))); + checks.push(CheckResult::fail( + "T1 promotion gate", + format!("unconstrained `...` at T1: {}", t1_violations.join(", ")), + )); } if !gen_types::rscript_available() { @@ -277,17 +326,27 @@ fn validate_fetched( push_formals_diff(&mut checks, &declared, &info.functions); } _ => { - checks.push(CheckResult::skipped("exports vs formals()", format!("`{package}` not installed locally"))); - checks.push(CheckResult::skipped("arity vs formals()", format!("`{package}` not installed locally"))); + checks.push(CheckResult::skipped( + "exports vs formals()", + format!("`{package}` not installed locally"), + )); + checks.push(CheckResult::skipped( + "arity vs formals()", + format!("`{package}` not installed locally"), + )); } } match check_cran_availability(package) { Some(true) => checks.push(CheckResult::pass("package on CRAN", "found on cloud.r-project.org")), - Some(false) => { - checks.push(CheckResult::warn("package on CRAN", "not found on CRAN — may be R-universe/Bioconductor/GitHub-only")) - } - None => checks.push(CheckResult::skipped("package on CRAN", "could not reach the CRAN mirror")), + Some(false) => checks.push(CheckResult::warn( + "package on CRAN", + "not found on CRAN — may be R-universe/Bioconductor/GitHub-only", + )), + None => checks.push(CheckResult::skipped( + "package on CRAN", + "could not reach the CRAN mirror", + )), } } @@ -308,8 +367,13 @@ fn short_rev(rev: &str) -> &str { /// `formals()` contre le package installé" registry.md §13 J4 names /// explicitly. Pushes both the "exports vs formals()" and "arity vs /// formals()" checks. -fn push_formals_diff(checks: &mut Vec, declared: &[DeclaredEntry], installed_fns: &[gen_types::GeneratedFn]) { - let installed: HashMap<&str, &gen_types::GeneratedFn> = installed_fns.iter().map(|f| (f.name.as_str(), f)).collect(); +fn push_formals_diff( + checks: &mut Vec, + declared: &[DeclaredEntry], + installed_fns: &[gen_types::GeneratedFn], +) { + let installed: HashMap<&str, &gen_types::GeneratedFn> = + installed_fns.iter().map(|f| (f.name.as_str(), f)).collect(); let mut missing = Vec::new(); let mut mismatches = Vec::new(); @@ -336,12 +400,21 @@ fn push_formals_diff(checks: &mut Vec, declared: &[DeclaredEntry], let total = declared.len(); let found = total - missing.len(); if missing.is_empty() { - checks.push(CheckResult::pass("exports vs formals()", format!("{found}/{total} found"))); + checks.push(CheckResult::pass( + "exports vs formals()", + format!("{found}/{total} found"), + )); } else { - checks.push(CheckResult::fail("exports vs formals()", format!("{found}/{total} found — missing: {}", missing.join(", ")))); + checks.push(CheckResult::fail( + "exports vs formals()", + format!("{found}/{total} found — missing: {}", missing.join(", ")), + )); } if mismatches.is_empty() { - checks.push(CheckResult::pass("arity vs formals()", format!("{total}/{total} match"))); + checks.push(CheckResult::pass( + "arity vs formals()", + format!("{total}/{total} match"), + )); } else { checks.push(CheckResult::fail( "arity vs formals()", @@ -406,12 +479,18 @@ fn parse_declared_entries(ty_sources: &[(String, String)], default_tier: &str) - if !trimmed.starts_with('@') { continue; } - let Some(sig) = parse_signature_line(trimmed) else { continue }; + let Some(sig) = parse_signature_line(trimmed) else { + continue; + }; let tier = meta_map .get(&sig.raw_name) .and_then(|m| m.tier.clone()) .or_else(|| Some(default_tier.to_string())); - out.push(DeclaredEntry { name: unwrap_backtick(&sig.raw_name), tier, params: sig.params }); + out.push(DeclaredEntry { + name: unwrap_backtick(&sig.raw_name), + tier, + params: sig.params, + }); } } out @@ -451,7 +530,10 @@ fn parse_signature_line(line: &str) -> Option { return None; } let params = parse_param_list(sig)?; - Some(ParsedSignature { raw_name: raw_name.to_string(), params }) + Some(ParsedSignature { + raw_name: raw_name.to_string(), + params, + }) } /// The first `:` in `text` that is not part of a `::` (which appears in @@ -494,7 +576,12 @@ fn parse_param_list(sig: &str) -> Option> { } let close_idx = close_idx?; let inner: String = chars[1..close_idx].iter().collect(); - Some(split_top_level(&inner).into_iter().map(|p| parse_one_param(&p)).collect()) + Some( + split_top_level(&inner) + .into_iter() + .map(|p| parse_one_param(&p)) + .collect(), + ) } fn split_top_level(inner: &str) -> Vec { @@ -534,11 +621,20 @@ fn parse_one_param(text: &str) -> DeclaredParam { None => (false, text.trim()), }; if rest.is_empty() { - return DeclaredParam { is_variadic, type_text: "Any".to_string() }; + return DeclaredParam { + is_variadic, + type_text: "Any".to_string(), + }; } match find_unqualified_colon(rest) { - Some(colon_idx) => DeclaredParam { is_variadic, type_text: rest[colon_idx + 1..].trim().to_string() }, - None => DeclaredParam { is_variadic, type_text: rest.to_string() }, + Some(colon_idx) => DeclaredParam { + is_variadic, + type_text: rest[colon_idx + 1..].trim().to_string(), + }, + None => DeclaredParam { + is_variadic, + type_text: rest.to_string(), + }, } } @@ -628,7 +724,12 @@ mod tests { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, content).unwrap(); } - FetchedDefinition { manifest: manifest("T2"), rev: "deadbeefcafef00d".to_string(), digest: "sha256:test".to_string(), dir } + FetchedDefinition { + manifest: manifest("T2"), + rev: "deadbeefcafef00d".to_string(), + digest: "sha256:test".to_string(), + dir, + } } fn cleanup(fetched: &FetchedDefinition) { @@ -639,7 +740,10 @@ mod tests { fn clean_definition_passes_the_non_introspection_checks() { let fetched = write_fetched( "clean", - &[("ty/core.ty", "#! tier: T2\n@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n")], + &[( + "ty/core.ty", + "#! tier: T2\n@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n", + )], ); let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); cleanup(&fetched); @@ -673,8 +777,13 @@ mod tests { #[test] fn declared_capability_warning_is_surfaced_not_failed() { let fetched = write_fetched("capwarn", &[("ty/core.ty", "@f: (Any) -> Any;\n")]); - let report = - validate_fetched(FAKE_PACKAGE, "github:alice/typr-shiny", true, &fetched, &["ships R shims".to_string()]); + let report = validate_fetched( + FAKE_PACKAGE, + "github:alice/typr-shiny", + true, + &fetched, + &["ships R shims".to_string()], + ); cleanup(&fetched); let cap = report.checks.iter().find(|c| c.name == "capabilities").unwrap(); assert_eq!(cap.status, CheckStatus::Warn); @@ -699,7 +808,10 @@ mod tests { let fetched = write_fetched( "smoke", &[ - ("ty/core.ty", "@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n"), + ( + "ty/core.ty", + "@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n", + ), ("tests/smoke.ty", "let x <- fluidPage(1);\n"), ], ); @@ -717,8 +829,12 @@ mod tests { let path: PathBuf = dir.join(rel); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, "#! tier: T1\n@risky: (...values: Any) -> Any;\n").unwrap(); - let fetched = - FetchedDefinition { manifest: manifest("T2"), rev: "abc123".to_string(), digest: "sha256:test".to_string(), dir }; + let fetched = FetchedDefinition { + manifest: manifest("T2"), + rev: "abc123".to_string(), + digest: "sha256:test".to_string(), + dir, + }; let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); cleanup(&fetched); @@ -731,8 +847,10 @@ mod tests { #[test] fn t1_entry_with_constrained_variadic_passes_the_gate() { - let fetched = - write_fetched("t1ok", &[("ty/core.ty", "#! tier: T1\n@safe: (...values: int) -> Any;\n")]); + let fetched = write_fetched( + "t1ok", + &[("ty/core.ty", "#! tier: T1\n@safe: (...values: int) -> Any;\n")], + ); let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); cleanup(&fetched); let gate = report.checks.iter().find(|c| c.name == "T1 promotion gate").unwrap(); @@ -765,7 +883,10 @@ mod tests { // `toJSON` declared with one extra fixed argument beyond its real // `formals()` — a real, existing export with the wrong arity — plus // a name that plain doesn't exist in the package at all. - let wrong_arity_args = std::iter::repeat("Any").take(real.params.len() + 1).collect::>().join(", "); + let wrong_arity_args = std::iter::repeat("Any") + .take(real.params.len() + 1) + .collect::>() + .join(", "); let src = format!( "@extern jsonlite::toJSON: ({wrong_arity_args}) -> char;\n\ @extern jsonlite::doesNotExist: (Any) -> char;\n" @@ -776,7 +897,11 @@ mod tests { let exports = report.checks.iter().find(|c| c.name == "exports vs formals()").unwrap(); assert_eq!(exports.status, CheckStatus::Fail); - assert!(exports.detail.contains("doesNotExist"), "unexpected detail: {}", exports.detail); + assert!( + exports.detail.contains("doesNotExist"), + "unexpected detail: {}", + exports.detail + ); let arity = report.checks.iter().find(|c| c.name == "arity vs formals()").unwrap(); assert_eq!(arity.status, CheckStatus::Fail); diff --git a/crates/typr-cli/src/type_registry.rs b/crates/typr-cli/src/type_registry.rs index 9efbd45..bb1b922 100644 --- a/crates/typr-cli/src/type_registry.rs +++ b/crates/typr-cli/src/type_registry.rs @@ -52,7 +52,9 @@ impl RepoSpec { .split_once('/') .ok_or_else(|| format!("`{spec}` is not `github:owner/repo[@rev]` — missing `owner/repo`"))?; if owner.is_empty() || repo.is_empty() { - return Err(format!("`{spec}` is not `github:owner/repo[@rev]` — empty owner or repo")); + return Err(format!( + "`{spec}` is not `github:owner/repo[@rev]` — empty owner or repo" + )); } Ok(RepoSpec { owner: owner.to_string(), @@ -163,8 +165,14 @@ pub struct FetchedDefinition { pub dir: PathBuf, } -fn git_available() -> bool { - Command::new("git").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) +/// `pub(crate)`: also used by `registry_revalidate.rs` to fail open the same +/// way `lookup_in_registry`/`search` already do when `git` isn't on `PATH`. +pub(crate) fn git_available() -> bool { + Command::new("git") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) } /// Clone `url` (at `rev` when given, else the default branch's HEAD) into a @@ -174,11 +182,7 @@ fn git_available() -> bool { /// `Rscript` — rather than adding a git-in-process dependency for a command /// that already needs network access. fn clone_repo(url: &str, rev: Option<&str>) -> Result<(PathBuf, String), String> { - let dir = std::env::temp_dir().join(format!( - "typr_types_fetch_{}_{}", - std::process::id(), - now_millis() - )); + let dir = std::env::temp_dir().join(format!("typr_types_fetch_{}_{}", std::process::id(), now_millis())); fs::create_dir_all(&dir).map_err(|e| format!("could not create temp dir: {e}"))?; let clone_status = Command::new("git") @@ -259,7 +263,9 @@ fn collect_files(root: &Path, current: &Path, out: &mut BTreeSet) -> Re if path.file_name().and_then(|n| n.to_str()) == Some(".git") { continue; } - let file_type = entry.file_type().map_err(|e| format!("could not stat {}: {e}", path.display()))?; + let file_type = entry + .file_type() + .map_err(|e| format!("could not stat {}: {e}", path.display()))?; if file_type.is_dir() { collect_files(root, &path, out)?; } else if file_type.is_file() { @@ -365,7 +371,9 @@ fn check_capabilities(manifest: &DefinitionManifest, dir: &Path) -> Result Result<(FetchedDefinition, Vec), String> { if !git_available() { - return Err("`git` is not on PATH — `typr types add`/`update` need it to fetch a definition repository".to_string()); + return Err( + "`git` is not on PATH — `typr types add`/`update` need it to fetch a definition repository".to_string(), + ); } let (dir, rev) = clone_repo(&spec.clone_url(), spec.rev.as_deref())?; @@ -406,7 +414,9 @@ pub fn fetch(spec: &RepoSpec) -> Result<(FetchedDefinition, Vec), String /// `run` time to compare against later, per `degrade_if_version_out_of_range`'s /// own "no comparison is made... when `r_version_seen` is `None`" rule. fn observed_r_package_version(package: &str) -> Option { - crate::gen_types::introspect(package).ok().and_then(|info| info.pkg_version) + crate::gen_types::introspect(package) + .ok() + .and_then(|info| info.pkg_version) } /// Copy every tracked file of a fetched definition into the on-disk cache at @@ -559,7 +569,12 @@ fn registry_index_dir() -> Option { /// Clone (first use) or fast-forward `git pull` (subsequent uses) the local /// registry mirror, returning its path. -fn sync_registry_index() -> Result { +/// +/// `pub(crate)`: `registry_revalidate.rs` calls this directly when `typr +/// types revalidate` isn't given an explicit `--dir` (e.g. a CI job that has +/// already checked out `we-data-ch/registry` itself and wants to validate +/// that working tree instead of a fresh mirror). +pub(crate) fn sync_registry_index() -> Result { let dir = registry_index_dir() .ok_or_else(|| "could not determine a cache directory (no $HOME/$XDG_CACHE_HOME)".to_string())?; @@ -634,6 +649,14 @@ fn lookup_in_registry_dir(dir: &Path, package: &str) -> Option { let source = fs::read_to_string(&path).ok()?; let file: RegistryPackageFile = serde_json::from_str(&source).ok()?; let entry = pick_best_entry(&file)?; + entry_spec(entry) +} + +/// `entry.repository`/`entry.rev` as a `github:owner/repo[@rev]` spec string, +/// ready for `fetch`/`RepoSpec::parse` — `None` when `repository` isn't +/// shaped like `owner/repo`. Shared by `lookup_in_registry_dir` (the single +/// best entry) and `list_registry_targets` (every entry). +fn entry_spec(entry: &RegistryDefinitionEntry) -> Option { if entry.repository.split('/').filter(|s| !s.is_empty()).count() != 2 { return None; } @@ -643,6 +666,63 @@ fn lookup_in_registry_dir(dir: &Path, package: &str) -> Option { }) } +/// One definition entry from `packages/.json`, resolved to a spec string +/// — `typr types revalidate`'s unit of work (registry.md §13 J4, "revalidation +/// périodique des définitions déjà indexées"). Unlike `lookup_in_registry` +/// (the single winner `typr types add` would pick) or `search` (every entry, +/// but not resolved to a fetchable spec), this is *every* entry across *every* +/// package file, each already a `github:owner/repo[@rev]` string ready to feed +/// straight to `registry_validate::validate`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryTarget { + pub package: String, + pub spec: String, +} + +/// Walk `dir/packages/*.json` and resolve every listed definition entry to a +/// `RegistryTarget`. Sorted by filename then file order, so two runs over an +/// unchanged registry produce results in the same order — `typr types +/// revalidate` diffs successive runs, and a stable order keeps that diff +/// about content, not happenstance directory iteration. +/// +/// Fails open like everything else that reads registry files (D2): a missing +/// `packages/` directory, an unreadable file, invalid JSON, or a malformed +/// `repository` field all just drop that entry rather than aborting the walk. +pub fn list_registry_targets(dir: &Path) -> Vec { + let packages_dir = dir.join("packages"); + let Ok(read_dir) = fs::read_dir(&packages_dir) else { + return Vec::new(); + }; + let mut files: Vec = read_dir + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json")) + .collect(); + files.sort(); + + let mut out = Vec::new(); + for path in files { + let Some(package) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Ok(source) = fs::read_to_string(&path) else { + continue; + }; + let Ok(file) = serde_json::from_str::(&source) else { + continue; + }; + for entry in &file.definitions { + if let Some(spec) = entry_spec(entry) { + out.push(RegistryTarget { + package: package.to_string(), + spec, + }); + } + } + } + out +} + /// One entry of `packages/.json`'s `definitions` array, as `typr /// search` reports it — every entry, not just the winner `pick_best_entry` /// would choose, so the user can see what `typr types add` would pick among. @@ -1050,7 +1130,10 @@ mod tests { let config = TypesConfig::read(&dir); assert_eq!(config.trust.as_deref(), Some("T2")); - assert_eq!(config.pins.get("shiny").map(String::as_str), Some("github:alice/typr-shiny")); + assert_eq!( + config.pins.get("shiny").map(String::as_str), + Some("github:alice/typr-shiny") + ); let _ = fs::remove_dir_all(&dir); } @@ -1142,7 +1225,11 @@ mod tests { fn undeclared_extern_raw_is_rejected() { let dir = std::env::temp_dir().join(format!("typr_caps_extern_{}", std::process::id())); let _ = fs::remove_dir_all(&dir); - write_file(&dir, "ty/core.ty", "let f <- extern: (x: int) -> int r#\"\nfunction(x) x\n\"#;"); + write_file( + &dir, + "ty/core.ty", + "let f <- extern: (x: int) -> int r#\"\nfunction(x) x\n\"#;", + ); let err = check_capabilities(&manifest_with(false, false), &dir).unwrap_err(); assert!(err.contains("extern_raw"), "unexpected error: {err}"); let _ = fs::remove_dir_all(&dir); @@ -1265,7 +1352,12 @@ mod tests { // vendor() let written = vendor(&project_dir, None).unwrap(); assert!(!written.is_empty()); - let vendored_ty = project_dir.join("ty").join("vendor").join("shiny").join("ty").join("core.ty"); + let vendored_ty = project_dir + .join("ty") + .join("vendor") + .join("shiny") + .join("ty") + .join("core.ty"); assert!(vendored_ty.is_file(), "expected {}", vendored_ty.display()); let content = fs::read_to_string(&vendored_ty).unwrap(); assert!(content.contains("fluidPage")); @@ -1370,8 +1462,13 @@ mod tests { [definition]\nversion = \"0.1.0\"\ntier = \"T3\"\n\ [provider]\ntype = \"community\"\nrepository = \"github:test/typr-widget\"\n\ [capabilities]\nr_shims = false\nextern_raw = false\n"; - let locked = - lock_definition_in_real_cache(&project_dir, "widget", manifest_toml, "@do_widget_thing: (int) -> int;", None); + let locked = lock_definition_in_real_cache( + &project_dir, + "widget", + manifest_toml, + "@do_widget_thing: (int) -> int;", + None, + ); let context = crate::standard_library::load_project_type_definitions( &project_dir, @@ -1419,7 +1516,9 @@ mod tests { typr_core::components::context::Context::default(), ); let typ = context - .get_type_from_variable(&typr_core::components::language::var::Var::from_name("do_widget2_thing")) + .get_type_from_variable(&typr_core::components::language::var::Var::from_name( + "do_widget2_thing", + )) .expect("locked definition's entry must be loaded into the context"); assert!( typ.is_unknown_function(), @@ -1651,6 +1750,78 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + // -- list_registry_targets (typr types revalidate, registry.md §13 J4) -- + + #[test] + fn list_registry_targets_covers_every_entry_across_every_package_file() { + let dir = std::env::temp_dir().join(format!("typr_list_targets_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "dplyr", + r#"{"name": "dplyr", "definitions": [ + {"repository": "alice/typr-dplyr", "rev": "aaa", "source": "community", "tier": "T2"}, + {"repository": "bob/typr-dplyr", "source": "community", "tier": "T3"} + ]}"#, + ); + write_registry_package( + &dir, + "shiny", + r#"{"name": "shiny", "definitions": [ + {"repository": "carol/typr-shiny", "rev": "ccc", "source": "official", "tier": "T1"} + ]}"#, + ); + + let targets = list_registry_targets(&dir); + let pairs: Vec<(String, String)> = targets.into_iter().map(|t| (t.package, t.spec)).collect(); + assert_eq!( + pairs, + vec![ + ("dplyr".to_string(), "github:alice/typr-dplyr@aaa".to_string()), + ("dplyr".to_string(), "github:bob/typr-dplyr".to_string()), + ("shiny".to_string(), "github:carol/typr-shiny@ccc".to_string()), + ] + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn list_registry_targets_skips_a_malformed_entry_without_dropping_the_rest() { + let dir = std::env::temp_dir().join(format!("typr_list_targets_bad_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "sf", + r#"{"name": "sf", "definitions": [ + {"repository": "not-a-valid-repo-field", "source": "community", "tier": "T2"}, + {"repository": "alice/typr-sf", "source": "community", "tier": "T2"} + ]}"#, + ); + + let targets = list_registry_targets(&dir); + assert_eq!( + targets, + vec![RegistryTarget { + package: "sf".to_string(), + spec: "github:alice/typr-sf".to_string() + }] + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn list_registry_targets_empty_when_packages_dir_is_absent() { + let dir = std::env::temp_dir().join(format!("typr_list_targets_empty_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + assert!(list_registry_targets(&dir).is_empty()); + + let _ = fs::remove_dir_all(&dir); + } + // -- resolve_spec_for_package: explicit pin beats the registry -------- #[test] From a5944d3435bbe7cfcb2d29f8acf23b89e7dad3b7 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Mon, 14 Sep 2026 11:49:47 +0200 Subject: [PATCH 14/18] =?UTF-8?q?Support=20monorepo=20subdirectories=20in?= =?UTF-8?q?=20RepoSpec=20(registry.md=20=C2=A713=20J5=20prereq)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registry.md §8.2 and we-data-ch/registry's definitions/README.md already documented a `definitions//` monorepo path for long-tail Type Definitions, indexed via `"repository": "we-data-ch/registry"` — but RepoSpec/fetch only ever read `typr-def.toml` at a cloned repository's root, so two packages sharing that one repository could never both resolve (each `typr-def.toml` has exactly one `[package] name`). This extends `github:owner/repo[/subdir][@rev]` with an optional subdir, scopes fetch/digest/cache to that subdir when present, and widens the registry JSON schema's `repository` pattern and `entry_spec` parsing to match — unblocking J5's plan to land 30 generated definitions under one registry repo instead of 30 external ones. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014brvdoSu1AUJnY3mFzSc8C --- crates/typr-cli/src/registry_validate.rs | 10 +- crates/typr-cli/src/type_registry.rs | 258 ++++++++++++++++++++--- 2 files changed, 239 insertions(+), 29 deletions(-) diff --git a/crates/typr-cli/src/registry_validate.rs b/crates/typr-cli/src/registry_validate.rs index a2f08ed..bff6549 100644 --- a/crates/typr-cli/src/registry_validate.rs +++ b/crates/typr-cli/src/registry_validate.rs @@ -174,7 +174,7 @@ pub fn validate(package: &str, spec_str: &str) -> ValidationReport { }; let report = validate_fetched(package, &spec.display(), spec.rev.is_some(), &fetched, &warnings); - let _ = fs::remove_dir_all(&fetched.dir); + let _ = fs::remove_dir_all(&fetched.root); report } @@ -728,12 +728,13 @@ mod tests { manifest: manifest("T2"), rev: "deadbeefcafef00d".to_string(), digest: "sha256:test".to_string(), - dir, + dir: dir.clone(), + root: dir, } } fn cleanup(fetched: &FetchedDefinition) { - let _ = fs::remove_dir_all(&fetched.dir); + let _ = fs::remove_dir_all(&fetched.root); } #[test] @@ -833,7 +834,8 @@ mod tests { manifest: manifest("T2"), rev: "abc123".to_string(), digest: "sha256:test".to_string(), - dir, + dir: dir.clone(), + root: dir, }; let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); diff --git a/crates/typr-cli/src/type_registry.rs b/crates/typr-cli/src/type_registry.rs index bb1b922..0b5a7f3 100644 --- a/crates/typr-cli/src/type_registry.rs +++ b/crates/typr-cli/src/type_registry.rs @@ -27,13 +27,21 @@ const MANIFEST_NAME: &str = "typr-def.toml"; // Repository spec // --------------------------------------------------------------------- -/// `github:owner/repo[@rev]` — the only repository scheme this build -/// understands (registry.md §6/§7: GitHub is the primary host; a monorepo -/// long tail is J3, not this). +/// `github:owner/repo[/subdir...][@rev]` — the only repository scheme this +/// build understands (registry.md §6/§7: GitHub is the primary host). The +/// optional `subdir` is what lets a `packages/.json` entry point *into* +/// a monorepo like `we-data-ch/registry`'s own `definitions//` +/// (registry.md §8.2, `definitions/README.md`) instead of only at a +/// repository's root — without it, two packages indexed against the same +/// monorepo would collide on "the one `typr-def.toml` at the repo root". #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoSpec { pub owner: String, pub repo: String, + /// Path *within* the repository to the definition's own root (the + /// directory holding its `typr-def.toml`), when it isn't the repository + /// root itself. `/`-separated, no leading or trailing slash. + pub subdir: Option, /// A pinned commit/branch/tag, when the user gave one after `@`. `None` /// means "resolve `HEAD`". pub rev: Option, @@ -42,23 +50,34 @@ pub struct RepoSpec { impl RepoSpec { pub fn parse(spec: &str) -> Result { let rest = spec.strip_prefix("github:").ok_or_else(|| { - format!("unsupported repository scheme in `{spec}` — only `github:owner/repo[@rev]` is understood") + format!("unsupported repository scheme in `{spec}` — only `github:owner/repo[/subdir][@rev]` is understood") })?; let (path, rev) = match rest.split_once('@') { Some((path, rev)) => (path, Some(rev.to_string())), None => (rest, None), }; - let (owner, repo) = path - .split_once('/') - .ok_or_else(|| format!("`{spec}` is not `github:owner/repo[@rev]` — missing `owner/repo`"))?; + let mut segments = path.split('/'); + let owner = segments.next().unwrap_or(""); + let repo = segments.next().unwrap_or(""); if owner.is_empty() || repo.is_empty() { return Err(format!( - "`{spec}` is not `github:owner/repo[@rev]` — empty owner or repo" + "`{spec}` is not `github:owner/repo[/subdir][@rev]` — missing `owner/repo`" )); } + let rest_segments: Vec<&str> = segments.collect(); + let subdir = if rest_segments.is_empty() { + None + } else if rest_segments.iter().any(|s| s.is_empty()) { + return Err(format!( + "`{spec}` is not `github:owner/repo[/subdir][@rev]` — empty path segment in subdir" + )); + } else { + Some(rest_segments.join("/")) + }; Ok(RepoSpec { owner: owner.to_string(), repo: repo.to_string(), + subdir, rev, }) } @@ -71,7 +90,10 @@ impl RepoSpec { /// always without the resolved rev, since that lives in `typr.lock`'s own /// `rev` field (registry.md §7.1: one source of truth per question). pub fn display(&self) -> String { - format!("github:{}/{}", self.owner, self.repo) + match &self.subdir { + Some(sub) => format!("github:{}/{}/{}", self.owner, self.repo, sub), + None => format!("github:{}/{}", self.owner, self.repo), + } } } @@ -160,9 +182,16 @@ pub struct FetchedDefinition { pub manifest: DefinitionManifest, pub rev: String, pub digest: String, - /// Directory holding the fetched repository's tracked files (`.git` - /// excluded). + /// The definition's own root — where its `typr-def.toml` lives. Equal to + /// `root` unless `spec.subdir` was set, in which case it is `root` joined + /// with that subdir. This is what `tracked_files`/`compute_digest`/ + /// `admit_to_cache` walk, so a monorepo definition's digest and cache + /// copy only ever cover its own files, never its siblings. pub dir: PathBuf, + /// The full clone — what must be removed to clean up the temp checkout + /// (`dir` alone isn't enough when it's a nested subdirectory of a + /// monorepo clone). + pub root: PathBuf, } /// `pub(crate)`: also used by `registry_revalidate.rs` to fail open the same @@ -375,12 +404,18 @@ pub fn fetch(spec: &RepoSpec) -> Result<(FetchedDefinition, Vec), String "`git` is not on PATH — `typr types add`/`update` need it to fetch a definition repository".to_string(), ); } - let (dir, rev) = clone_repo(&spec.clone_url(), spec.rev.as_deref())?; + let (root, rev) = clone_repo(&spec.clone_url(), spec.rev.as_deref())?; let result = (|| { + let dir = match &spec.subdir { + Some(sub) => root.join(sub), + None => root.clone(), + }; let manifest_path = dir.join(MANIFEST_NAME); - let manifest_source = fs::read_to_string(&manifest_path) - .map_err(|_| format!("no `{MANIFEST_NAME}` at the root of {}", spec.display()))?; + let manifest_source = fs::read_to_string(&manifest_path).map_err(|_| match &spec.subdir { + Some(sub) => format!("no `{MANIFEST_NAME}` at `{sub}` in {}", spec.display()), + None => format!("no `{MANIFEST_NAME}` at the root of {}", spec.display()), + })?; let manifest = parse_manifest(&manifest_source)?; let warnings = check_capabilities(&manifest, &dir)?; let digest = compute_digest(&dir)?; @@ -389,14 +424,15 @@ pub fn fetch(spec: &RepoSpec) -> Result<(FetchedDefinition, Vec), String manifest, rev, digest, - dir: dir.clone(), + dir, + root: root.clone(), }, warnings, )) })(); if result.is_err() { - let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&root); } result } @@ -498,7 +534,9 @@ const REGISTRY_REPO_URL: &str = "https://github.com/we-data-ch/registry.git"; /// by `check_capabilities`. #[derive(Debug, Clone, Deserialize)] struct RegistryDefinitionEntry { - /// `owner/repo`, no `github:` scheme (registry.md §8.1's example). + /// `owner/repo` or `owner/repo/subdir...`, no `github:` scheme + /// (registry.md §8.1's example; the optional trailing segments are the + /// monorepo path of §8.2, e.g. `we-data-ch/registry/definitions/dplyr`). repository: String, #[serde(default)] rev: Option, @@ -652,12 +690,16 @@ fn lookup_in_registry_dir(dir: &Path, package: &str) -> Option { entry_spec(entry) } -/// `entry.repository`/`entry.rev` as a `github:owner/repo[@rev]` spec string, -/// ready for `fetch`/`RepoSpec::parse` — `None` when `repository` isn't -/// shaped like `owner/repo`. Shared by `lookup_in_registry_dir` (the single -/// best entry) and `list_registry_targets` (every entry). +/// `entry.repository`/`entry.rev` as a `github:owner/repo[/subdir][@rev]` +/// spec string, ready for `fetch`/`RepoSpec::parse` — `None` when +/// `repository` isn't at least `owner/repo` (extra `/`-separated segments +/// past the second are the monorepo subdir, registry.md §8.2 — e.g. +/// `we-data-ch/registry/definitions/dplyr`). Shared by +/// `lookup_in_registry_dir` (the single best entry) and +/// `list_registry_targets` (every entry). fn entry_spec(entry: &RegistryDefinitionEntry) -> Option { - if entry.repository.split('/').filter(|s| !s.is_empty()).count() != 2 { + let segment_count = entry.repository.split('/').filter(|s| !s.is_empty()).count(); + if segment_count < 2 || entry.repository.contains("//") { return None; } Some(match entry.rev.as_deref() { @@ -830,7 +872,7 @@ pub fn add(project_root: &Path, package: &str, spec_str: &str) -> Result LockedDefinition { @@ -1293,6 +1365,114 @@ mod tests { format!("file://{}", dir.display()) } + /// Build a minimal *monorepo*-style repository under `dir`, with two + /// package definitions nested under `definitions//` (registry.md + /// §8.2's layout), and commit it — the fixture for exercising `fetch`'s + /// subdir handling the same way `make_definition_repo` exercises the + /// plain, single-package case. + fn make_monorepo(dir: &Path) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + write_file( + dir, + "definitions/dplyr/typr-def.toml", + "format_version = 1\n\ + [package]\nname = \"dplyr\"\nsince = \"1.1.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T3\"\n\ + [provider]\ntype = \"generated\"\nrepository = \"github:we-data-ch/registry\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n", + ); + write_file( + dir, + "definitions/dplyr/ty/core.ty", + "#! pkg: dplyr\n#! tier: T3\n@importFrom dplyr filter;\n@filter: (Any, Any) -> Any;\n", + ); + write_file( + dir, + "definitions/ggplot2/typr-def.toml", + "format_version = 1\n\ + [package]\nname = \"ggplot2\"\nsince = \"3.4.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T3\"\n\ + [provider]\ntype = \"generated\"\nrepository = \"github:we-data-ch/registry\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n", + ); + write_file( + dir, + "definitions/ggplot2/ty/core.ty", + "#! pkg: ggplot2\n#! tier: T3\n@importFrom ggplot2 ggplot;\n@ggplot: (Any) -> Any;\n", + ); + + let run = |args: &[&str]| -> Result<(), String> { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).into_owned()); + } + Ok(()) + }; + run(&["init", "--quiet"])?; + run(&["config", "user.email", "test@example.com"])?; + run(&["config", "user.name", "test"])?; + run(&["add", "."])?; + run(&["commit", "--quiet", "-m", "initial"])?; + Ok(()) + } + + #[test] + fn fetch_resolves_a_definition_nested_in_a_monorepo_subdir() { + if !git_available() { + eprintln!("skipping: git not on PATH"); + return; + } + let repo_dir = std::env::temp_dir().join(format!("typr_monorepo_{}", std::process::id())); + let _ = fs::remove_dir_all(&repo_dir); + if let Err(e) = make_monorepo(&repo_dir) { + eprintln!("skipping: could not set up local git fixture: {e}"); + let _ = fs::remove_dir_all(&repo_dir); + return; + } + + // Same "bypass RepoSpec::parse, drive the clone directly" pattern as + // `add_update_list_vendor_round_trip_against_a_local_repo`: only + // `github:` URLs are a supported host, so a `file://` fixture is + // cloned directly and `fetch`'s own subdir-joining logic is exercised + // by hand, against the two sibling package directories the fixture + // ships. + let (root, rev) = clone_repo(&file_url(&repo_dir), None).expect("clone should succeed"); + let base_dir = root.join("definitions").join("dplyr"); + let manifest = parse_manifest(&fs::read_to_string(base_dir.join(MANIFEST_NAME)).unwrap()).unwrap(); + assert_eq!(manifest.package.name, "dplyr"); + check_capabilities(&manifest, &base_dir).unwrap(); + let digest = compute_digest(&base_dir).unwrap(); + + // The digest and tracked-file set only cover `dplyr`'s own files — + // changing the sibling `ggplot2` definition must not move it, and no + // `ggplot2` path leaks into what would be admitted to the cache. + let files = tracked_files(&base_dir).unwrap(); + assert!(files.iter().all(|p| !p.to_string_lossy().contains("ggplot2"))); + write_file(&root, "definitions/ggplot2/ty/core.ty", "@ggplot: (int) -> int;\n"); + assert_eq!(compute_digest(&base_dir).unwrap(), digest); + + let fetched = FetchedDefinition { + manifest, + rev, + digest, + dir: base_dir, + root: root.clone(), + }; + admit_to_cache(&fetched, "dplyr").unwrap(); + let cache_dir = cache_dir_for("dplyr", &fetched.digest).unwrap(); + assert!(cache_dir.join("ty").join("core.ty").is_file()); + assert!(!cache_dir.join("definitions").exists()); + + let _ = fs::remove_dir_all(&repo_dir); + let _ = fs::remove_dir_all(&fetched.root); + let _ = fs::remove_dir_all(&cache_dir); + } + #[test] fn add_update_list_vendor_round_trip_against_a_local_repo() { if !git_available() { @@ -1326,7 +1506,8 @@ mod tests { manifest, rev, digest, - dir, + dir: dir.clone(), + root: dir, }; admit_to_cache(&fetched, "shiny").unwrap(); @@ -1364,7 +1545,7 @@ mod tests { let _ = fs::remove_dir_all(&repo_dir); let _ = fs::remove_dir_all(&project_dir); - let _ = fs::remove_dir_all(&fetched.dir); + let _ = fs::remove_dir_all(&fetched.root); if let Some(cache_dir) = cache_dir_for("shiny", &fetched.digest) { let _ = fs::remove_dir_all(&cache_dir); } @@ -1423,6 +1604,7 @@ mod tests { rev: "0000000000000000000000000000000000000000".to_string(), digest, dir: src_dir.clone(), + root: src_dir.clone(), }; admit_to_cache(&fetched, package).unwrap(); @@ -1637,6 +1819,32 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn monorepo_subdir_repository_resolves_with_subdir_preserved() { + let dir = std::env::temp_dir().join(format!("typr_registry_subdir_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "dplyr", + r#"{ + "name": "dplyr", + "definitions": [ + {"repository": "we-data-ch/registry/definitions/dplyr", "rev": "abc", "source": "generated", "tier": "T3"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "dplyr").unwrap(); + assert_eq!(spec, "github:we-data-ch/registry/definitions/dplyr@abc"); + // and it parses back into a RepoSpec with the subdir intact. + let parsed = RepoSpec::parse(&spec).unwrap(); + assert_eq!(parsed.owner, "we-data-ch"); + assert_eq!(parsed.repo, "registry"); + assert_eq!(parsed.subdir.as_deref(), Some("definitions/dplyr")); + + let _ = fs::remove_dir_all(&dir); + } + #[test] fn entry_without_rev_resolves_to_head() { let dir = std::env::temp_dir().join(format!("typr_registry_norev_{}", std::process::id())); From ce87ec209475fc9d90c962ec4d59671c63c9af5b Mon Sep 17 00:00:00 2001 From: Fabrice Date: Mon, 14 Sep 2026 15:07:45 +0200 Subject: [PATCH 15/18] =?UTF-8?q?Add=20`typr=20types=20submit`:=20open=20a?= =?UTF-8?q?=20registry=20PR=20via=20`gh`=20(registry.md=20=C2=A713=20J6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped-down implementation of "Add to Registry" — a CLI command that shells out to the caller's own authenticated `gh`/`git` (fork, branch, upsert packages/.json, push, open PR) rather than the GitHub-App web backend §12/D6 describes and defers as a separate project. Runs the same checks as `typr types validate` first and refuses both a failing definition and a no-op PR. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014brvdoSu1AUJnY3mFzSc8C --- crates/typr-cli/src/cli.rs | 34 ++ crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 1 + crates/typr-cli/src/registry_submit.rs | 543 +++++++++++++++++++++++++ 4 files changed, 579 insertions(+) create mode 100644 crates/typr-cli/src/registry_submit.rs diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index cafa55c..c071e89 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -257,6 +257,23 @@ enum TypesCommands { #[arg(long, short, value_name = "FILE")] out: Option, }, + /// Open a pull request against the community registry adding or updating + /// `packages/.json` for this definition — "Add to Registry" + /// from the CLI, no Store account needed. Runs the same checks as + /// `Validate` first and refuses to submit anything that fails them. + /// Needs the `gh` CLI, already authenticated (`gh auth login`): the PR + /// is opened as you, from your own fork of the registry. + Submit { + /// The package this definition describes (e.g. `shiny`). + package: String, + /// `github:owner/repo[/subdir][@rev]`. Omit to use whatever this + /// project already has pinned for `package` in `typr.lock` (i.e. + /// after `typr types add`). + repo: Option, + /// `owner/repo` of the registry to submit to. + #[arg(long, value_name = "OWNER/REPO", default_value = crate::registry_submit::DEFAULT_REGISTRY_REPO)] + registry: String, + }, } #[derive(Subcommand, Debug)] @@ -720,6 +737,23 @@ fn run_types_command(command: TypesCommands) { std::process::exit(1); } } + TypesCommands::Submit { + package, + repo, + registry, + } => { + use crate::registry_submit::{self, SubmitOutcome}; + match registry_submit::submit(root, &package, repo.as_deref(), ®istry) { + Ok(SubmitOutcome::Opened(pr_url)) => println!("opened {pr_url}"), + Ok(SubmitOutcome::AlreadyUpToDate) => { + println!("`{package}` is already indexed identically in {registry} — nothing to submit.") + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } + } TypesCommands::Revalidate { dir, out } => { use crate::registry_revalidate; match registry_revalidate::revalidate(dir.as_deref(), out.as_deref()) { diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index e4ac736..2ecd166 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -55,6 +55,7 @@ pub mod r_name_cache; pub mod r_name_lint; pub mod rd_renderer; pub mod registry_revalidate; +pub mod registry_submit; pub mod registry_validate; pub mod repl; pub mod standard_library; diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index abc000d..41d719a 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -18,6 +18,7 @@ mod r_name_cache; mod r_name_lint; mod rd_renderer; mod registry_revalidate; +mod registry_submit; mod registry_validate; mod repl; mod standard_library; diff --git a/crates/typr-cli/src/registry_submit.rs b/crates/typr-cli/src/registry_submit.rs new file mode 100644 index 0000000..a1bd362 --- /dev/null +++ b/crates/typr-cli/src/registry_submit.rs @@ -0,0 +1,543 @@ +//! `typr types submit [github:owner/repo[/subdir][@rev]]` — open a pull +//! request against `we-data-ch/registry` adding or updating +//! `packages/.json`. This is the "« Add to Registry » → PR automatique" +//! item of `typR/registry.md` §13 J6 (second half) — the one item that was +//! still unchecked once J0-J5 and J6's static Store page were done. +//! +//! Deliberately **not** what registry.md §12/D6 describes and defers as its +//! own project: a Store web form that generates a PR "on behalf of a user" +//! needs a GitHub App, stored tokens, a backend, and anti-spam moderation. +//! This is the CLI shape instead — it shells out to the caller's own, +//! already-authenticated `gh` (the GitHub CLI), the same "shell out rather +//! than add an in-process client" choice `type_registry.rs` already made for +//! `git` and `gen_types.rs` made for `Rscript`. There is no backend and no +//! app: the PR is opened as the actual signed-in `gh` user, from their own +//! fork, exactly as if they had typed the `gh repo fork`/`git push`/`gh pr +//! create` sequence by hand. +//! +//! Never opens a PR for a definition that hasn't been mechanically checked: +//! `submit` runs the same checks `typr types validate` runs +//! (`registry_validate::validate`) first and refuses to proceed if any of +//! them fails. It also refuses to open a no-op PR when the target +//! `packages/.json` already contains an identical entry for the same +//! repository. + +use crate::type_definition::ProviderType; +use crate::type_registry::{self, FetchedDefinition, LockedDefinition, Lockfile, RepoSpec, LOCKFILE_NAME}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The registry this build submits to by default — same one +/// `type_registry.rs`'s `REGISTRY_REPO_URL` points at, but as `owner/repo` +/// rather than a clone URL, since `gh repo fork`/`gh pr create` want it in +/// that form. +pub const DEFAULT_REGISTRY_REPO: &str = "we-data-ch/registry"; + +// --------------------------------------------------------------------- +// packages/.json — full read/write shape +// --------------------------------------------------------------------- +// +// `type_registry.rs`'s own `RegistryDefinitionEntry`/`RegistryPackageFile` +// are intentionally partial — "only the fields the selection logic needs" +// (its own doc comment). Submitting has to read and write the *whole* file +// (every field `schema/package.schema.json` requires) and must not lose an +// existing sibling entry for another repository, so this module keeps its +// own full round-trippable shape instead of widening that one. + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct CapabilitiesJson { + r_shims: bool, + extern_raw: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct DefinitionEntryJson { + /// `owner/repo` or `owner/repo/subdir...` — no `github:` scheme, matching + /// `schema/package.schema.json`'s `repository` pattern. + repository: String, + version: String, + rev: String, + since: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + until: Option, + /// `official` | `community` | `generated` | `local`. + source: String, + /// `T1` | `T2` | `T3`. + tier: String, + capabilities: CapabilitiesJson, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct PackageFileJson { + name: String, + #[serde(default)] + definitions: Vec, +} + +fn provider_type_str(kind: ProviderType) -> &'static str { + match kind { + ProviderType::Official => "official", + ProviderType::Community => "community", + ProviderType::Generated => "generated", + ProviderType::Local => "local", + } +} + +/// `spec.owner/spec.repo[/spec.subdir]` — the `repository` field's shape in +/// the registry (registry.md §8.1/§8.2), derived from the same `RepoSpec` a +/// user would otherwise write into `typr.toml [types]` or pass to `typr +/// types add`. +fn repository_field(spec: &RepoSpec) -> String { + match &spec.subdir { + Some(sub) => format!("{}/{}/{}", spec.owner, spec.repo, sub), + None => format!("{}/{}", spec.owner, spec.repo), + } +} + +fn build_registry_entry(spec: &RepoSpec, fetched: &FetchedDefinition) -> DefinitionEntryJson { + DefinitionEntryJson { + repository: repository_field(spec), + version: fetched.manifest.definition.version.clone(), + rev: fetched.rev.clone(), + since: fetched.manifest.package.since.clone(), + until: fetched.manifest.package.until.clone(), + source: provider_type_str(fetched.manifest.provider.kind).to_string(), + tier: fetched.manifest.definition.tier.clone(), + capabilities: CapabilitiesJson { + r_shims: fetched.manifest.capabilities.r_shims, + extern_raw: fetched.manifest.capabilities.extern_raw, + }, + } +} + +/// Insert or replace `entry` in `/packages/.json`, keyed +/// by `repository` (a package can list several definitions, registry.md +/// §8.3 — this only ever touches the one matching this submission's +/// repository, every sibling entry is preserved byte-for-byte apart from +/// JSON re-formatting). Returns `false` without touching the file when an +/// identical entry is already present, so the caller can skip opening a +/// no-op PR. +fn upsert_package_entry(work_dir: &Path, package: &str, entry: DefinitionEntryJson) -> Result { + let path = work_dir.join("packages").join(format!("{package}.json")); + let mut file = if path.is_file() { + let source = fs::read_to_string(&path).map_err(|e| format!("could not read {}: {e}", path.display()))?; + serde_json::from_str::(&source).map_err(|e| { + format!( + "{} does not match the registry's own schema — refusing to overwrite it: {e}", + path.display() + ) + })? + } else { + PackageFileJson { + name: package.to_string(), + definitions: Vec::new(), + } + }; + + let changed = match file.definitions.iter_mut().find(|d| d.repository == entry.repository) { + Some(existing) if *existing == entry => false, + Some(existing) => { + *existing = entry; + true + } + None => { + file.definitions.push(entry); + true + } + }; + if !changed { + return Ok(false); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + let rendered = + serde_json::to_string_pretty(&file).map_err(|e| format!("could not serialize {}: {e}", path.display()))?; + fs::write(&path, format!("{rendered}\n")).map_err(|e| format!("could not write {}: {e}", path.display()))?; + Ok(true) +} + +// --------------------------------------------------------------------- +// gh / git plumbing +// --------------------------------------------------------------------- + +fn gh_available() -> bool { + Command::new("gh") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn gh_authenticated() -> bool { + Command::new("gh") + .args(["auth", "status"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn now_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// Fork `registry_repo` into the signed-in `gh` user's account (a no-op if +/// they already have one) and clone that fork into a fresh temp directory, +/// with `origin` pointing at the fork and `upstream` at `registry_repo` — +/// exactly the layout `gh repo fork --clone --remote` sets up by hand, and +/// exactly what `gh pr create` below needs to infer the PR's head without +/// being told explicitly. +fn fork_and_clone(registry_repo: &str) -> Result { + let repo_name = registry_repo.rsplit('/').next().unwrap_or(registry_repo); + let parent = std::env::temp_dir().join(format!("typr_types_submit_{}_{}", std::process::id(), now_millis())); + fs::create_dir_all(&parent).map_err(|e| format!("could not create temp dir: {e}"))?; + + let fork = Command::new("gh") + .args(["repo", "fork", registry_repo, "--clone", "--remote"]) + .current_dir(&parent) + .output() + .map_err(|e| format!("could not run `gh repo fork`: {e}"))?; + if !fork.status.success() { + let _ = fs::remove_dir_all(&parent); + return Err(format!( + "`gh repo fork {registry_repo}` failed: {}", + String::from_utf8_lossy(&fork.stderr).trim() + )); + } + + let work_dir = parent.join(repo_name); + if !work_dir.is_dir() { + let _ = fs::remove_dir_all(&parent); + return Err(format!( + "`gh repo fork {registry_repo}` reported success but {} was not created", + work_dir.display() + )); + } + Ok(work_dir) +} + +fn run_git(work_dir: &Path, args: &[&str], what: &str) -> Result<(), String> { + let out = Command::new("git") + .arg("-C") + .arg(work_dir) + .args(args) + .output() + .map_err(|e| format!("could not run `git {}`: {e}", args.join(" ")))?; + if !out.status.success() { + return Err(format!( + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(()) +} + +fn commit_and_push(work_dir: &Path, package: &str, branch: &str) -> Result<(), String> { + run_git(work_dir, &["checkout", "-b", branch], "`git checkout -b`")?; + run_git(work_dir, &["add", &format!("packages/{package}.json")], "`git add`")?; + let message = format!("Add/update packages/{package}.json via `typr types submit`"); + run_git(work_dir, &["commit", "--quiet", "-m", &message], "`git commit`")?; + run_git(work_dir, &["push", "--quiet", "-u", "origin", branch], "`git push`") +} + +/// `gh pr create`, run from inside the fork clone so `gh` infers the head +/// branch/owner from the checked-out branch and its `origin` remote — the +/// same thing a person would get running it by hand right after `git push`. +fn open_pr(work_dir: &Path, registry_repo: &str, package: &str, entry: &DefinitionEntryJson) -> Result { + let title = format!("Add/update {package}: {} (tier {})", entry.repository, entry.tier); + let body = format!( + "Adds or updates `packages/{package}.json` for `{}` at rev `{}` (tier `{}`, source `{}`).\n\n\ + Opened by `typr types submit` (`typR/registry.md` §13 J6). Passed `typr types \ + validate {package} github:{}@{}` locally before this PR was opened — see that command's \ + own output for exactly what was and was not mechanically checked (`registry.md` §9: \ + this PR is not a claim that the definition is correct beyond what was mechanically \ + verifiable).\n", + entry.repository, entry.rev, entry.tier, entry.source, entry.repository, entry.rev, + ); + let pr = Command::new("gh") + .current_dir(work_dir) + .args([ + "pr", + "create", + "--repo", + registry_repo, + "--title", + &title, + "--body", + &body, + ]) + .output() + .map_err(|e| format!("could not run `gh pr create`: {e}"))?; + if !pr.status.success() { + return Err(format!( + "`gh pr create` failed: {}", + String::from_utf8_lossy(&pr.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&pr.stdout).trim().to_string()) +} + +// --------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubmitOutcome { + /// `packages/.json` already had this exact entry — no PR opened. + AlreadyUpToDate, + Opened(String), +} + +/// A repository spec to submit for `package`: the one given explicitly, or — +/// mirroring `type_registry::update`'s own fallback — the one already +/// resolved in `typr.lock` (pinned rev included, so what gets submitted is +/// exactly what this project already trusts, not a possibly-moved `HEAD`). +fn resolve_spec_to_submit(project_root: &Path, package: &str, spec_override: Option<&str>) -> Result { + if let Some(spec) = spec_override { + return Ok(spec.to_string()); + } + let lockfile = Lockfile::read(&project_root.join(LOCKFILE_NAME)); + let locked: &LockedDefinition = lockfile.find(package).ok_or_else(|| { + format!( + "no repository given and `{package}` is not resolved in typr.lock — run `typr types \ + add {package} github:owner/repo` first, or pass one explicitly: `typr types submit \ + {package} github:owner/repo[@rev]`" + ) + })?; + Ok(format!("{}@{}", locked.repository, locked.rev)) +} + +/// `typr types submit [repo]` — resolve a spec, validate it +/// mechanically, and open (or update) a pull request against `registry_repo` +/// indexing it. Every temp directory this creates (the fetch's clone, the +/// fork's clone) is cleaned up before returning, success or failure. +pub fn submit( + project_root: &Path, + package: &str, + spec_override: Option<&str>, + registry_repo: &str, +) -> Result { + if !gh_available() { + return Err( + "`gh` (the GitHub CLI) is not on PATH — install it from https://cli.github.com, run \ + `gh auth login`, then retry; `typr types submit` opens the PR as you, from your own \ + fork, it never touches your GitHub credentials directly" + .to_string(), + ); + } + if !gh_authenticated() { + return Err("`gh` is not authenticated — run `gh auth login` first".to_string()); + } + + let spec_str = resolve_spec_to_submit(project_root, package, spec_override)?; + let repo_spec = RepoSpec::parse(&spec_str)?; + + let report = crate::registry_validate::validate(package, &spec_str); + if !report.ok() { + return Err(format!( + "`{package}` at {spec_str} fails mechanical validation — fix it before submitting \ + (rerun `typr types validate {package} {spec_str}` for details):\n{}", + report.render() + )); + } + + let (fetched, _warnings) = type_registry::fetch(&repo_spec)?; + let entry = build_registry_entry(&repo_spec, &fetched); + let _ = fs::remove_dir_all(&fetched.root); + + let work_dir = fork_and_clone(registry_repo)?; + let result = (|| { + if !upsert_package_entry(&work_dir, package, entry.clone())? { + return Ok(SubmitOutcome::AlreadyUpToDate); + } + let branch = format!("typr-types-submit-{package}-{}", &entry.rev[..entry.rev.len().min(12)]); + commit_and_push(&work_dir, package, &branch)?; + let pr_url = open_pr(&work_dir, registry_repo, package, &entry)?; + Ok(SubmitOutcome::Opened(pr_url)) + })(); + let _ = fs::remove_dir_all(work_dir.parent().unwrap_or(&work_dir)); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_entry(repository: &str) -> DefinitionEntryJson { + DefinitionEntryJson { + repository: repository.to_string(), + version: "0.1.0".to_string(), + rev: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string(), + since: "1.0.0".to_string(), + until: None, + source: "community".to_string(), + tier: "T2".to_string(), + capabilities: CapabilitiesJson { + r_shims: false, + extern_raw: false, + }, + } + } + + #[test] + fn repository_field_includes_subdir_when_present() { + let spec = RepoSpec::parse("github:we-data-ch/registry/definitions/dplyr").unwrap(); + assert_eq!(repository_field(&spec), "we-data-ch/registry/definitions/dplyr"); + + let spec = RepoSpec::parse("github:alice/typr-shiny").unwrap(); + assert_eq!(repository_field(&spec), "alice/typr-shiny"); + } + + #[test] + fn upsert_creates_a_new_file_when_none_exists() { + let dir = std::env::temp_dir().join(format!("typr_submit_new_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let changed = upsert_package_entry(&dir, "shiny", sample_entry("alice/typr-shiny")).unwrap(); + assert!(changed); + + let written = fs::read_to_string(dir.join("packages").join("shiny.json")).unwrap(); + let parsed: PackageFileJson = serde_json::from_str(&written).unwrap(); + assert_eq!(parsed.name, "shiny"); + assert_eq!(parsed.definitions.len(), 1); + assert_eq!(parsed.definitions[0].repository, "alice/typr-shiny"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_preserves_sibling_entries_for_other_repositories() { + let dir = std::env::temp_dir().join(format!("typr_submit_sibling_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + let existing = PackageFileJson { + name: "shiny".to_string(), + definitions: vec![sample_entry("bob/other-typr-shiny")], + }; + fs::write( + dir.join("packages").join("shiny.json"), + serde_json::to_string_pretty(&existing).unwrap(), + ) + .unwrap(); + + let changed = upsert_package_entry(&dir, "shiny", sample_entry("alice/typr-shiny")).unwrap(); + assert!(changed); + + let written = fs::read_to_string(dir.join("packages").join("shiny.json")).unwrap(); + let parsed: PackageFileJson = serde_json::from_str(&written).unwrap(); + assert_eq!(parsed.definitions.len(), 2); + assert!(parsed + .definitions + .iter() + .any(|d| d.repository == "bob/other-typr-shiny")); + assert!(parsed.definitions.iter().any(|d| d.repository == "alice/typr-shiny")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_replaces_the_matching_repository_entry_in_place() { + let dir = std::env::temp_dir().join(format!("typr_submit_replace_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + let mut first = sample_entry("alice/typr-shiny"); + first.tier = "T3".to_string(); + let existing = PackageFileJson { + name: "shiny".to_string(), + definitions: vec![first], + }; + fs::write( + dir.join("packages").join("shiny.json"), + serde_json::to_string_pretty(&existing).unwrap(), + ) + .unwrap(); + + let mut updated = sample_entry("alice/typr-shiny"); + updated.tier = "T1".to_string(); + let changed = upsert_package_entry(&dir, "shiny", updated).unwrap(); + assert!(changed); + + let written = fs::read_to_string(dir.join("packages").join("shiny.json")).unwrap(); + let parsed: PackageFileJson = serde_json::from_str(&written).unwrap(); + assert_eq!(parsed.definitions.len(), 1); + assert_eq!(parsed.definitions[0].tier, "T1"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_is_a_no_op_when_the_entry_is_already_identical() { + let dir = std::env::temp_dir().join(format!("typr_submit_noop_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + let entry = sample_entry("alice/typr-shiny"); + let existing = PackageFileJson { + name: "shiny".to_string(), + definitions: vec![entry.clone()], + }; + fs::write( + dir.join("packages").join("shiny.json"), + serde_json::to_string_pretty(&existing).unwrap(), + ) + .unwrap(); + + let changed = upsert_package_entry(&dir, "shiny", entry).unwrap(); + assert!(!changed); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn resolve_spec_prefers_explicit_override_over_typr_lock() { + let dir = std::env::temp_dir().join(format!("typr_submit_resolve_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let spec = resolve_spec_to_submit(&dir, "shiny", Some("github:alice/typr-shiny@abc123")).unwrap(); + assert_eq!(spec, "github:alice/typr-shiny@abc123"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn resolve_spec_falls_back_to_typr_lock_pinned_rev() { + let dir = std::env::temp_dir().join(format!("typr_submit_resolve_lock_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let mut lockfile = Lockfile::default(); + lockfile.upsert(LockedDefinition { + package: "shiny".to_string(), + repository: "github:alice/typr-shiny".to_string(), + version: "0.3.0".to_string(), + rev: "a1b2c3d4e5f6".to_string(), + digest: "sha256:deadbeef".to_string(), + tier: "T2".to_string(), + r_version_seen: None, + }); + lockfile.write(&dir.join(LOCKFILE_NAME)).unwrap(); + + let spec = resolve_spec_to_submit(&dir, "shiny", None).unwrap(); + assert_eq!(spec, "github:alice/typr-shiny@a1b2c3d4e5f6"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn resolve_spec_errors_when_nothing_is_locked_and_nothing_is_given() { + let dir = std::env::temp_dir().join(format!("typr_submit_resolve_missing_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let err = resolve_spec_to_submit(&dir, "shiny", None).unwrap_err(); + assert!(err.contains("typr types add"), "unexpected error: {err}"); + + let _ = fs::remove_dir_all(&dir); + } +} From 7163903f2501c65897295e9b29a86b9d5a9671fb Mon Sep 17 00:00:00 2001 From: Fabrice Date: Mon, 14 Sep 2026 15:13:21 +0200 Subject: [PATCH 16/18] update --- std.ty | 204 --------------------------------------------------------- 1 file changed, 204 deletions(-) delete mode 100644 std.ty diff --git a/std.ty b/std.ty deleted file mode 100644 index 02e434c..0000000 --- a/std.ty +++ /dev/null @@ -1,204 +0,0 @@ -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: integer sum -#! example: 1L + 2L # -> 3L -@`+`: (int, int) -> int; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: numeric sum -#! example: 1.5 + 2.5 # -> 4 -@`+`: (num, num) -> num; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: integer difference -#! example: 5L - 3L # -> 2L -@`-`: (int, int) -> int; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: numeric difference -#! example: 5.0 - 3.0 # -> 2 -@`-`: (num, num) -> num; -#! pkg: base -#! tier: T1 -#! param a: dividend -#! param b: divisor -#! ret: integer quotient -#! example: 10L / 3L # -> 3L -@`/`: (int, int) -> int; -#! pkg: base -#! tier: T1 -#! param a: dividend -#! param b: divisor -#! ret: numeric quotient -#! example: 10.0 / 3.0 # -> 3.333... -@`/`: (num, num) -> num; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: integer product -#! example: 3L * 4L # -> 12L -@`*`: (int, int) -> int; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: numeric product -#! example: 3.0 * 4.0 # -> 12 -@`*`: (num, num) -> num; -#! pkg: base -#! tier: T1 -#! param a: dividend -#! param b: divisor -#! ret: integer modulo -#! note: %% is the modulo operator (not remainder) -#! example: 7L %% 3L # -> 1L -@`%%`: (int, int) -> int; -#! pkg: base -#! tier: T1 -#! param a: dividend -#! param b: divisor -#! ret: numeric modulo -#! example: 7.0 %% 3.0 # -> 1 -@`%%`: (num, num) -> num; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: logical AND -#! example: TRUE && FALSE # -> FALSE -@`&&`: (bool, bool) -> bool; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: logical OR -#! example: TRUE || FALSE # -> TRUE -@`||`: (bool, bool) -> bool; -#! pkg: base -#! tier: T1 -#! param a: left vector -#! param b: right vector -#! ret: element-wise sum of two vectors -#! coercion: vectors must be same length (recycling applies in R) -#! example: c(1,2) + c(3,4) # -> c(4,6) -@`+`: (Vec[#M, T], Vec[#M, T]) -> Vec[#M, T]; -#! pkg: base -#! tier: T1 -#! param a: vector to reduce -#! param f: binary combining function -#! ret: single value after left-fold -#! note: equivalent to base R Reduce() -#! example: reduce(c(1,2,3,4), add) # -> 10 -@reduce: ([#N, T], (T, T) -> T) -> T; -#! pkg: base -#! tier: T1 -#! param a: vector to fold -#! param init: initial accumulator value -#! param f: combining function (accumulator, element) -> accumulator -#! ret: final accumulator -#! note: equivalent to base R Reduce() with init -#! example: fold(c(1,2,3), 0, add) # -> 6 -@fold: ([#N, T], U, (U, T) -> U) -> U; -#! pkg: base -#! tier: T1 -#! param a: vector to extend -#! param b: element to append -#! ret: new vector with b appended -#! example: extend(c(1,2), 3) # -> c(1,2,3) -@extend: ([#N, T], T) -> [#N, T]; -#! pkg: base -#! tier: T3 -#! param desc: test description -#! param expr: test expression -#! ret: invisibly NULL -#! note: testing utility — not for production code -@test_that: (char, Any) -> Empty; -#! pkg: base -#! tier: T3 -#! param condition: expected condition -#! ret: invisibly NULL -#! note: testing utility -@expect_true: (bool) -> Empty; -#! pkg: base -#! tier: T3 -#! param actual: actual value -#! param expected: expected value -#! ret: invisibly NULL -#! note: testing utility -@expect_false: (T, T) -> Empty; -#! pkg: base -#! tier: T3 -#! param actual: actual value -#! param expected: expected value -#! ret: invisibly NULL -#! note: testing utility -@expect_equal: (T, T) -> Empty; -#! pkg: base -#! tier: T2 -#! param values: values to concatenate and print -#! ret: invisibly NULL -#! note: variadic, side-effectful; R's paste(..., sep="") + cat() -#! example: cat("hello", "world") -@cat: (...values: Any) -> Empty; -#! pkg: base -#! tier: T1 -#! param a: TypR value -#! ret: same value as native R -#! note: pass-through for interop boundaries -@to_native: (T) -> T; -#! pkg: base -#! tier: T2 -#! param a: native R value -#! param type: target TypR type name -#! ret: coerced value -#! note: runtime cast — type must match or error -@from_native: (Any, char) -> Any; -#! pkg: base -#! tier: T1 -#! param a: value to convert -#! ret: integer representation -#! coercion: numeric/logical -> integer -#! example: from_int(3.7) # -> 3L -@from_int: (Any) -> int; -#! pkg: base -#! tier: T1 -#! param a: value to convert -#! ret: numeric representation -#! coercion: integer/logical -> numeric -#! example: # noplayground: from_num(3L) # -> 3.0 -@from_num: (Any) -> num; -#! pkg: base -#! tier: T1 -#! param a: value to convert -#! ret: character representation -#! coercion: any atomic -> character -#! example: from_char(42) # -> "42" -@from_char: (Any) -> char; -#! pkg: base -#! tier: T1 -#! param a: value to convert -#! ret: logical representation -#! coercion: numeric -> logical (0 = FALSE, non-zero = TRUE) -#! example: from_bool(1) # -> TRUE -@from_bool: (Any) -> bool; -#! pkg: base -#! tier: T1 -#! param x: numeric vector -#! ret: sum over all elements -#! example: sum(c(1, 2, 3)) # -> 6 -@sum: ([#N, T]) -> T; -#! pkg: base -#! tier: T1 -#! param x: value to display -#! ret: prints to the console, returns nothing -#! example: print(sum(c(1, 2, 3))) -@print: (Any) -> Empty; From 0c4b74d06b20112e3d852f462e5b71ea3d3c2f81 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Tue, 15 Sep 2026 12:54:40 +0200 Subject: [PATCH 17/18] added styler --- crates/typr-cli/src/format_r.rs | 106 ++++++++++++++++++++++++++++++++ crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 1 + crates/typr-cli/src/project.rs | 3 + 4 files changed, 111 insertions(+) create mode 100644 crates/typr-cli/src/format_r.rs diff --git a/crates/typr-cli/src/format_r.rs b/crates/typr-cli/src/format_r.rs new file mode 100644 index 0000000..9664299 --- /dev/null +++ b/crates/typr-cli/src/format_r.rs @@ -0,0 +1,106 @@ +//! Formats generated R code with the `styler` package, so `R/*.R` files stay +//! readable when a user opens them to debug a transpilation. +//! +//! Formatting is best-effort: a missing `Rscript`/`styler` or a styling +//! failure must never fail the build, so callers always get code back +//! (formatted if possible, the original otherwise). + +use std::io::Write; +use std::process::{Command, Stdio}; +use std::sync::OnceLock; + +/// Formats `code` with `styler::style_text()` when available; returns `code` +/// unchanged otherwise (missing Rscript, missing `styler`, or a styling +/// error). +pub fn format_r_code(code: &str) -> String { + if !styler_available() { + return code.to_string(); + } + match run_styler(code) { + Ok(formatted) => formatted, + Err(e) => { + eprintln!("Warning: could not format R output with styler ({e}); writing unformatted code"); + code.to_string() + } + } +} + +/// `Rscript`/`styler` availability rarely changes within a single run, and +/// probing it spawns a process — check once per process instead of once per +/// file. +fn styler_available() -> bool { + static AVAILABLE: OnceLock = OnceLock::new(); + *AVAILABLE.get_or_init(|| { + Command::new("Rscript") + .arg("-e") + .arg("quit(status = if (requireNamespace('styler', quietly = TRUE)) 0L else 1L)") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + }) +} + +fn run_styler(code: &str) -> Result { + let script = "con <- file('stdin'); \ + code <- readLines(con, warn = FALSE); \ + close(con); \ + out <- styler::style_text(code); \ + cat(paste(as.character(out), collapse = '\n'), '\n', sep = '')"; + + let mut child = Command::new("Rscript") + .arg("-e") + .arg(script) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("failed to spawn Rscript: {e}"))?; + + child + .stdin + .take() + .expect("piped stdin") + .write_all(code.as_bytes()) + .map_err(|e| format!("failed to write code to Rscript stdin: {e}"))?; + + let output = child + .wait_with_output() + .map_err(|e| format!("failed to wait on Rscript: {e}"))?; + + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + + let formatted = String::from_utf8(output.stdout).map_err(|e| format!("styler produced non-UTF-8 output: {e}"))?; + if formatted.trim().is_empty() && !code.trim().is_empty() { + return Err("styler produced empty output".to_string()); + } + Ok(formatted) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn leaves_code_unchanged_when_styler_is_unavailable() { + if styler_available() { + return; // exercised by `styles_messy_code_when_available` instead + } + let messy = "f<-function(x){x+1}"; + assert_eq!(format_r_code(messy), messy); + } + + #[test] + fn styles_messy_code_when_available() { + if !styler_available() { + return; // no Rscript/styler on this machine — nothing to check + } + let messy = "f<-function(x){\nx+1\n}"; + let formatted = format_r_code(messy); + assert_ne!(formatted, messy); + assert!(formatted.contains("f <- function(x) {")); + } +} diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index 2ecd166..d9c9394 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -43,6 +43,7 @@ pub mod cache; pub mod cases; pub mod cli; pub mod engine; +pub mod format_r; pub mod fuzz; pub mod gen_types; pub mod io; diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index 41d719a..89e285f 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -6,6 +6,7 @@ mod cache; mod cases; mod cli; mod engine; +mod format_r; mod fuzz; mod gen_types; mod io; diff --git a/crates/typr-cli/src/project.rs b/crates/typr-cli/src/project.rs index 422528c..13e8ef5 100644 --- a/crates/typr-cli/src/project.rs +++ b/crates/typr-cli/src/project.rs @@ -508,6 +508,7 @@ pub fn write_header(context: Context, output_dir: &Path, environment: Environmen app.write_all(types_content.as_bytes()).unwrap(); } _ => { + let types_content = crate::format_r::format_r_code(&types_content); let path = output_dir .join(context.get_environment().to_base_path()) .join("types.R"); @@ -548,6 +549,7 @@ pub fn write_header(context: Context, output_dir: &Path, environment: Environmen app.write_all(generic_content.as_bytes()).unwrap(); } _ => { + let generic_content = crate::format_r::format_r_code(&generic_content); let path = output_dir .join(context.get_environment().to_string()) .join("generic_functions.R"); @@ -599,6 +601,7 @@ pub fn write_to_r_lang(content: String, output_dir: &Path, file_name: &str, envi app.write_all(full_content.as_bytes()).unwrap(); } _ => { + let full_content = crate::format_r::format_r_code(&full_content); cache::write_if_changed(&app_path, &full_content).unwrap(); } } From 586211cc0be7f45f4cb735b3d9b68cf8668dddf2 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Tue, 15 Sep 2026 13:19:25 +0200 Subject: [PATCH 18/18] fixed styler performance with cache and targeted file --- crates/typr-cli/src/format_r.rs | 54 +++++++++++++++++++++++++++++++++ crates/typr-cli/src/project.rs | 15 +++++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/typr-cli/src/format_r.rs b/crates/typr-cli/src/format_r.rs index 9664299..7b2f13d 100644 --- a/crates/typr-cli/src/format_r.rs +++ b/crates/typr-cli/src/format_r.rs @@ -4,8 +4,19 @@ //! Formatting is best-effort: a missing `Rscript`/`styler` or a styling //! failure must never fail the build, so callers always get code back //! (formatted if possible, the original otherwise). +//! +//! Only content that genuinely benefits from re-indentation goes through +//! `styler` at all: `main.R` (the transpiled user program, with real nested +//! control flow) does, but `types.R`/`generic_functions.R` are one +//! definition per line by construction (simple `format!` templates in +//! `project.rs`) and never need it — callers should skip this module for +//! those. `styler` also spawns and loads a fresh R process per call, which +//! dominates build time on a project with several generated files; Project +//! builds should go through [`format_r_code_cached`] so unchanged content +//! (by hash, regardless of which file it ends up in) is never re-styled. use std::io::Write; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::OnceLock; @@ -25,6 +36,29 @@ pub fn format_r_code(code: &str) -> String { } } +/// Same as [`format_r_code`], but content-addressed under +/// `/format/.R`: a `typr build` that regenerates the exact +/// same source for a file (the common case — most files in a project don't +/// change between builds) reuses the previously styled output instead of +/// spawning another `Rscript`. Keyed on the raw pre-format content, so it's +/// shared across files and across builds. +pub fn format_r_code_cached(code: &str, cache_dir: &Path) -> String { + let entry = format_cache_entry_path(cache_dir, code); + if let Ok(cached) = std::fs::read_to_string(&entry) { + return cached; + } + let formatted = format_r_code(code); + if let Some(parent) = entry.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&entry, &formatted); + formatted +} + +fn format_cache_entry_path(cache_dir: &Path, code: &str) -> PathBuf { + cache_dir.join("format").join(format!("{:016x}.R", crate::cache::hash_str(code))) +} + /// `Rscript`/`styler` availability rarely changes within a single run, and /// probing it spawns a process — check once per process instead of once per /// file. @@ -103,4 +137,24 @@ mod tests { assert_ne!(formatted, messy); assert!(formatted.contains("f <- function(x) {")); } + + #[test] + fn cached_format_reuses_disk_entry_without_reformatting() { + let dir = std::env::temp_dir().join(format!("typr_format_cache_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::create_dir_all(&dir); + + let code = "f<-function(x){x+1}"; + let first = format_r_code_cached(code, &dir); + + // Pre-seed a distinguishable value directly at the cache entry, so a + // cache hit (vs. a fresh, indistinguishable-from-`first` styler run) + // is unambiguous. + let entry = format_cache_entry_path(&dir, code); + std::fs::write(&entry, "# from cache\n").unwrap(); + assert_eq!(format_r_code_cached(code, &dir), "# from cache\n"); + + let _ = std::fs::remove_dir_all(&dir); + let _ = first; // first run succeeded without panicking, cache dir was created + } } diff --git a/crates/typr-cli/src/project.rs b/crates/typr-cli/src/project.rs index 13e8ef5..87fd751 100644 --- a/crates/typr-cli/src/project.rs +++ b/crates/typr-cli/src/project.rs @@ -508,7 +508,6 @@ pub fn write_header(context: Context, output_dir: &Path, environment: Environmen app.write_all(types_content.as_bytes()).unwrap(); } _ => { - let types_content = crate::format_r::format_r_code(&types_content); let path = output_dir .join(context.get_environment().to_base_path()) .join("types.R"); @@ -549,7 +548,6 @@ pub fn write_header(context: Context, output_dir: &Path, environment: Environmen app.write_all(generic_content.as_bytes()).unwrap(); } _ => { - let generic_content = crate::format_r::format_r_code(&generic_content); let path = output_dir .join(context.get_environment().to_string()) .join("generic_functions.R"); @@ -601,7 +599,18 @@ pub fn write_to_r_lang(content: String, output_dir: &Path, file_name: &str, envi app.write_all(full_content.as_bytes()).unwrap(); } _ => { - let full_content = crate::format_r::format_r_code(&full_content); + // Project builds are a repeated dev-loop command (`typr build`), + // so cache the styled output by content hash — most files are + // unchanged between builds and shouldn't pay for another + // `Rscript` spawn. One-off StandAlone runs skip the cache: no + // project root to anchor `.typr_cache/` to, and no build loop + // to amortize it over. + let full_content = if environment.is_project() { + let cache_dir = Path::new(cache::CACHE_DIR); + crate::format_r::format_r_code_cached(&full_content, cache_dir) + } else { + crate::format_r::format_r_code(&full_content) + }; cache::write_if_changed(&app_path, &full_content).unwrap(); } }