Skip to content

Commit 7137fee

Browse files
committed
feat(validation): guard README dependency snippets
- Add a generic docs-version sync check that compares Markdown dependency snippets against the Cargo package name and version - Run the docs-version check from the repository Semgrep policy lane - Refresh README determinant examples with explicit fallible handling and hidden doctest mirrors - Update CI uv pins to 0.11.19
1 parent f473ec5 commit 7137fee

11 files changed

Lines changed: 331 additions & 32 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ env:
3030
RUMDL_VERSION: "0.2.6"
3131
TAPLO_VERSION: "0.10.0"
3232
TYPOS_VERSION: "1.47.1"
33-
UV_VERSION: "0.11.18"
33+
UV_VERSION: "0.11.19"
3434
ZIZMOR_VERSION: "1.25.2"
3535

3636
jobs:

.github/workflows/semgrep-sarif.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ permissions:
2424
actions: read
2525

2626
env:
27-
UV_VERSION: "0.11.18"
27+
UV_VERSION: "0.11.19"
2828

2929
jobs:
3030
semgrep-sarif:

AGENTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,20 @@ When user requests commit message generation:
159159
take references (`&T`, `&mut T`, `&[T]`) as arguments and return borrowed views (`&T`, `&[T]`) when possible.
160160
Only take ownership or return `Vec`/allocated data when required.
161161

162+
### Documentation
163+
164+
- `src/lib.rs` includes `README.md` with `#![doc = include_str!("../README.md")]`, so README examples are the
165+
docs.rs landing page examples.
166+
- When changing Rust examples in `README.md`, mirror executable versions in the private `readme_doctests` module in
167+
`src/lib.rs`. Keep mirrors hidden/private so they do not duplicate the docs.rs landing page, but make them runnable
168+
by `cargo test --doc`.
169+
- README examples that require optional features may remain `rust,ignore` in README for default-feature doctest
170+
compatibility, but must have a `#[cfg(feature = "...")]` hidden doctest mirror in `src/lib.rs` and be verified with
171+
the matching feature set (for example, `cargo test --features exact --doc`).
172+
- When intentionally updating package versions or dependency snippets, keep README `la-stack` dependency examples in
173+
sync with the package `version` in `Cargo.toml`. Do not perform version bumps unless explicitly requested by the
174+
maintainer; see **Public-API stability** above.
175+
162176
### Dimension Coverage (2D–5D)
163177

164178
This library uses `const`-generic dimensions. Tests for dimension-generic code

README.md

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ Add this to your `Cargo.toml`:
6868

6969
```toml
7070
[dependencies]
71-
la-stack = "0.4.1"
71+
la-stack = "0.4.2"
7272
```
7373

7474
Solve a 5×5 system via LU:
@@ -120,7 +120,16 @@ fn main() -> Result<(), LaError> {
120120
[0.0, 0.0, 0.0, 1.0, 2.0],
121121
])?;
122122

123-
let det = a.ldlt(DEFAULT_SINGULAR_TOL)?.det()?;
123+
let ldlt = match a.ldlt(DEFAULT_SINGULAR_TOL) {
124+
Ok(ldlt) => ldlt,
125+
Err(err @ LaError::Asymmetric { row, col, .. }) => {
126+
eprintln!("LDLT requires symmetry; first mismatch at ({row}, {col})");
127+
return Err(err);
128+
}
129+
Err(err) => return Err(err),
130+
};
131+
132+
let det = ldlt.det()?;
124133
assert!((det - 1.0).abs() <= 1e-12);
125134

126135
Ok(())
@@ -149,18 +158,20 @@ evaluation when inputs are known:
149158
use la_stack::prelude::*;
150159

151160
// Evaluated entirely at compile time — no runtime cost.
152-
const DET: Result<Option<f64>, LaError> = {
153-
let m = match Matrix::<3>::try_from_rows([
154-
[2.0, 0.0, 0.0],
155-
[0.0, 3.0, 0.0],
156-
[0.0, 0.0, 5.0],
157-
]) {
158-
Ok(matrix) => matrix,
159-
Err(_) => panic!("matrix entries must be finite"),
160-
};
161-
m.det_direct()
161+
const DET: Result<Option<f64>, LaError> = match Matrix::<4>::try_from_rows([
162+
[2.0, 0.0, 0.0, 0.0],
163+
[0.0, 3.0, 0.0, 0.0],
164+
[0.0, 0.0, 5.0, 0.0],
165+
[0.0, 0.0, 0.0, 7.0],
166+
]) {
167+
Ok(matrix) => matrix.det_direct(),
168+
Err(err) => Err(err),
162169
};
163-
assert_eq!(DET, Ok(Some(30.0)));
170+
171+
fn main() -> Result<(), LaError> {
172+
assert_eq!(DET?, Some(210.0));
173+
Ok(())
174+
}
164175
```
165176

166177
The public `det()` method automatically dispatches through the closed-form path
@@ -180,13 +191,14 @@ rationals (this pulls in `num-bigint`, `num-rational`, and `num-traits` for
180191

181192
```toml
182193
[dependencies]
183-
la-stack = { version = "0.4.1", features = ["exact"] }
194+
la-stack = { version = "0.4.2", features = ["exact"] }
184195
```
185196

186197
**Determinants:**
187198

188199
- **`det_exact()`** — returns the exact determinant as a `BigRational`
189200
- **`det_exact_f64()`** — returns the exact determinant converted to the nearest `f64`
201+
(or `LaError::Overflow` when the exact value is unrepresentable)
190202
- **`det_sign_exact()`** — returns the provably correct sign (−1, 0, or +1)
191203

192204
**Linear system solve:**
@@ -208,6 +220,19 @@ fn main() -> Result<(), LaError> {
208220
209221
let det = m.det_exact()?;
210222
assert_eq!(det, BigRational::from_integer(0.into())); // exact zero
223+
let det_f64 = m.det_exact_f64()?;
224+
assert_eq!(det_f64, 0.0);
225+
226+
// If the exact determinant cannot fit in f64, keep the BigRational value.
227+
let big = f64::MAX / 2.0;
228+
let huge = Matrix::<3>::try_from_rows([
229+
[0.0, 0.0, 1.0],
230+
[big, 0.0, 1.0],
231+
[0.0, big, 1.0],
232+
])?;
233+
let huge_det = huge.det_exact()?;
234+
assert_eq!(huge.det_exact_f64(), Err(LaError::Overflow { index: None }));
235+
println!("exact determinant = {huge_det}");
211236
212237
// Exact linear system solve
213238
let a = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?;

docs/BENCHMARKING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ just bench-vs-linalg
4646
just bench-exact
4747

4848
# Save an exact baseline (e.g., before optimising)
49-
just bench-save-baseline v0.4.1
49+
just bench-save-baseline <baseline>
5050

5151
# Compare current code against a saved baseline
52-
just bench-compare v0.4.1
52+
just bench-compare <baseline>
5353

5454
# Generate a snapshot without comparison
5555
just bench-compare

examples/const_det_4x4.rs

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,38 @@
66
use la_stack::prelude::*;
77

88
/// An example 4×4 matrix with small integer entries.
9-
const MAT: Matrix<4> = match Matrix::<4>::try_from_rows([
9+
const MAT: Result<Matrix<4>, LaError> = Matrix::<4>::try_from_rows([
1010
[1.0, 2.0, 3.0, 4.0],
1111
[5.0, 6.0, 7.0, 8.0],
1212
[2.0, 6.0, 1.0, 5.0],
1313
[3.0, 8.0, 2.0, 9.0],
14-
]) {
15-
Ok(matrix) => matrix,
16-
Err(_) => panic!("matrix entries must be finite"),
17-
};
14+
]);
1815

1916
/// Determinant computed at compile time.
20-
const DET: f64 = match MAT.det_direct() {
21-
Ok(Some(d)) => d,
22-
Ok(None) => panic!("det_direct only supports D <= 4"),
23-
Err(_) => panic!("matrix entries must be finite"),
17+
const DET: Result<Option<f64>, LaError> = match MAT {
18+
Ok(matrix) => matrix.det_direct(),
19+
Err(err) => Err(err),
2420
};
2521

2622
fn main() -> Result<(), LaError> {
23+
let mat = MAT?;
24+
2725
println!("4×4 matrix:");
2826
for r in 0..4 {
2927
print!(" [");
3028
for c in 0..4 {
3129
if c > 0 {
3230
print!(", ");
3331
}
34-
print!("{:5.1}", MAT.get_checked(r, c)?);
32+
print!("{:5.1}", mat.get_checked(r, c)?);
3533
}
3634
println!("]");
3735
}
3836
println!();
39-
println!("det (computed at compile time) = {DET}");
37+
match DET? {
38+
Some(det) => println!("det (computed at compile time) = {det}"),
39+
None => println!("det_direct is only available for D <= 4"),
40+
}
4041

4142
Ok(())
4243
}

justfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,11 +461,12 @@ python-sync: _ensure-uv
461461

462462
python-typecheck: python-sync
463463
uv run ty check scripts/
464-
uv run mypy scripts/archive_changelog.py scripts/bench_compare.py scripts/check_semgrep_fixtures.py scripts/criterion_dim_plot.py scripts/tag_release.py scripts/postprocess_changelog.py scripts/subprocess_utils.py
464+
uv run mypy scripts/archive_changelog.py scripts/bench_compare.py scripts/check_docs_version_sync.py scripts/check_semgrep_fixtures.py scripts/criterion_dim_plot.py scripts/tag_release.py scripts/postprocess_changelog.py scripts/subprocess_utils.py
465465

466466
# Repository-owned Semgrep rules for project-specific diagnostics.
467467
semgrep: _ensure-uv
468468
uv run semgrep --metrics off --error --strict --timeout 30 --config semgrep.yaml .
469+
uv run check-docs-version-sync
469470

470471
# Fixture tests for repository-owned Semgrep rules.
471472
semgrep-test: _ensure-uv

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,12 @@ bench-compare = "bench_compare:main"
4141
criterion-dim-plot = "criterion_dim_plot:main"
4242
postprocess-changelog = "postprocess_changelog:main"
4343
tag-release = "tag_release:main"
44+
check-docs-version-sync = "check_docs_version_sync:main"
4445

4546
# Configure setuptools to find modules in scripts/ directory.
4647
[tool.setuptools]
4748
package-dir = { "" = "scripts" }
48-
py-modules = [ "archive_changelog", "bench_compare", "check_semgrep_fixtures", "criterion_dim_plot", "postprocess_changelog", "subprocess_utils", "tag_release" ]
49+
py-modules = [ "archive_changelog", "bench_compare", "check_docs_version_sync", "check_semgrep_fixtures", "criterion_dim_plot", "postprocess_changelog", "subprocess_utils", "tag_release" ]
4950

5051
[tool.ruff]
5152
line-length = 160

scripts/check_docs_version_sync.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Check that documentation dependency snippets match Cargo.toml."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
import sys
7+
import tomllib
8+
from dataclasses import dataclass
9+
from pathlib import Path
10+
from typing import Any
11+
12+
SKIP_DIRS = frozenset(
13+
{
14+
".git",
15+
".mypy_cache",
16+
".pytest_cache",
17+
".ruff_cache",
18+
".tmp_pycache",
19+
".venv",
20+
"target",
21+
}
22+
)
23+
24+
25+
@dataclass(frozen=True)
26+
class PackageInfo:
27+
"""Cargo package identity used in documented dependency snippets."""
28+
29+
name: str
30+
version: str
31+
32+
33+
@dataclass(frozen=True)
34+
class DependencySnippet:
35+
"""A documented dependency version snippet for the current package."""
36+
37+
path: Path
38+
line: int
39+
version: str
40+
text: str
41+
42+
43+
@dataclass(frozen=True)
44+
class VersionMismatch:
45+
"""A dependency snippet whose version does not match Cargo.toml."""
46+
47+
snippet: DependencySnippet
48+
package: PackageInfo
49+
50+
51+
def _read_cargo_package_info(cargo_toml: Path) -> PackageInfo:
52+
data: dict[str, Any] = tomllib.loads(cargo_toml.read_text(encoding="utf-8"))
53+
package = data.get("package")
54+
if not isinstance(package, dict):
55+
msg = f"{cargo_toml} is missing a [package] table"
56+
raise TypeError(msg)
57+
58+
name = package.get("name")
59+
if not isinstance(name, str):
60+
msg = f"{cargo_toml} is missing a string package.name"
61+
raise TypeError(msg)
62+
63+
version = package.get("version")
64+
if not isinstance(version, str):
65+
msg = f"{cargo_toml} is missing a string package.version"
66+
raise TypeError(msg)
67+
return PackageInfo(name=name, version=version)
68+
69+
70+
def _iter_markdown_files(root: Path) -> list[Path]:
71+
return sorted(path for path in root.rglob("*.md") if path.is_file() and not (set(path.relative_to(root).parts) & SKIP_DIRS))
72+
73+
74+
def _dependency_regex(package_name: str) -> re.Pattern[str]:
75+
escaped_name = re.escape(package_name)
76+
return re.compile(rf'(?<![\w.-]){escaped_name}\s*=\s*(?:"(?P<plain>[^"]+)"|\{{\s*version\s*=\s*"(?P<table>[^"]+)")')
77+
78+
79+
def _dependency_snippets(path: Path, package_name: str) -> list[DependencySnippet]:
80+
dependency_re = _dependency_regex(package_name)
81+
snippets: list[DependencySnippet] = []
82+
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
83+
for match in dependency_re.finditer(line):
84+
version = match.group("plain") or match.group("table")
85+
snippets.append(
86+
DependencySnippet(
87+
path=path,
88+
line=line_number,
89+
version=version,
90+
text=line.strip(),
91+
)
92+
)
93+
return snippets
94+
95+
96+
def find_version_mismatches(root: Path) -> list[VersionMismatch]:
97+
"""Return documented dependency snippets for this crate that are stale."""
98+
99+
package = _read_cargo_package_info(root / "Cargo.toml")
100+
mismatches: list[VersionMismatch] = []
101+
for path in _iter_markdown_files(root):
102+
for snippet in _dependency_snippets(path, package.name):
103+
if snippet.version != package.version:
104+
mismatches.append(VersionMismatch(snippet=snippet, package=package))
105+
return mismatches
106+
107+
108+
def main() -> int:
109+
root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd()
110+
try:
111+
mismatches = find_version_mismatches(root)
112+
except (OSError, TypeError, tomllib.TOMLDecodeError) as error:
113+
print(f"Could not check documentation dependency versions: {error}", file=sys.stderr)
114+
return 1
115+
116+
if not mismatches:
117+
return 0
118+
119+
print("Documentation dependency snippets are out of sync with Cargo.toml:", file=sys.stderr)
120+
for mismatch in mismatches:
121+
snippet = mismatch.snippet
122+
rel_path = snippet.path.relative_to(root)
123+
print(
124+
f" {rel_path}:{snippet.line}: {mismatch.package.name} found {snippet.version}, expected {mismatch.package.version}: {snippet.text}",
125+
file=sys.stderr,
126+
)
127+
return 1
128+
129+
130+
if __name__ == "__main__":
131+
sys.exit(main())

0 commit comments

Comments
 (0)