Skip to content

Commit a1106d8

Browse files
committed
Preserve Lake declarations and traces when vendoring
Vendoring must change dependency locations while retaining the declarations which consume them. Stop each TOML require block at the next table so that library and executable declarations survive. Add fixtures from the pinned aesop and importGraph packages to cover those boundaries. Reject symlinks outside the private archive roots before rewriting any file. Keep path components separate in trace matching so a long absolute path cannot trigger exponential regular-expression backtracking. These changes let native Lake traces remain usable after archive relocation. gherrit-pr-id: Gxizdpw52bljtxjfng3ervgk5xnca6mow Agent-authored-by: AI agent acting on joshlf's behalf
1 parent 70fa387 commit a1106d8

5 files changed

Lines changed: 175 additions & 2 deletions

File tree

‎anneal/rewrite-lake-vendor.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,26 @@
1313
REMOTE_TOML_KEYS = {"git", "url", "rev", "scope", "subDir", "inputRev"}
1414

1515

16+
def validate_vendoring_roots(root: Path, packages_dir: Path) -> None:
17+
"""Reject links that could route a private rewrite into another installation."""
18+
roots = [root.absolute(), packages_dir.absolute()]
19+
for directory in roots:
20+
if directory.is_symlink() or not directory.is_dir():
21+
raise ValueError(f"Vendoring root must be a physical directory: {directory}")
22+
allowed = [directory.resolve() for directory in roots]
23+
def fail_walk(error: OSError) -> None:
24+
raise error
25+
26+
for directory in roots:
27+
for current, directories, files in os.walk(directory, followlinks=False, onerror=fail_walk):
28+
for name in directories + files:
29+
path = Path(current) / name
30+
if path.is_symlink():
31+
target = path.resolve(strict=True)
32+
if not any(target.is_relative_to(parent) for parent in allowed):
33+
raise ValueError(f"Vendoring symlink leaves private roots: {path} -> {target}")
34+
35+
1636
def quote_name(name: str) -> str:
1737
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_']*", name):
1838
return name
@@ -34,7 +54,9 @@ def package_dirs(packages_dir: Path) -> dict[str, Path]:
3454

3555
def replace_require_toml_blocks(path: Path, packages: dict[str, Path]) -> bool:
3656
content = path.read_text()
37-
block_re = re.compile(r"(?ms)^\s*\[\[require\]\]\s*\n(?:(?!^\s*\[\[).*\n?)*")
57+
# Stop at every TOML table, including single-bracket option tables. A
58+
# greedy dot with DOTALL would also consume later library/executable tables.
59+
block_re = re.compile(r"(?ms)^[ \t]*\[\[require\]\][ \t]*\n.*?(?=^[ \t]*\[|\Z)")
3860

3961
changed = False
4062

@@ -151,7 +173,7 @@ def rewrite_trace_prefixes(
151173
prefixes = trace_prefixes(root, packages_dir, packages, extra_prefixes)
152174
package_root_patterns = [
153175
re.compile(
154-
rf"(?<![A-Za-z0-9_.-])(?:/[^\s\"':]+)+/(?:packages|\.lake/packages)/{re.escape(name)}/"
176+
rf"(?<![A-Za-z0-9_.-])(?:/[^/\s\"':]+)+/(?:packages|\.lake/packages)/{re.escape(name)}/"
155177
)
156178
for name in packages
157179
]
@@ -215,6 +237,7 @@ def main() -> None:
215237
)
216238
args = parser.parse_args()
217239

240+
validate_vendoring_roots(args.root, args.packages_dir)
218241
root = args.root.resolve()
219242
packages_dir = args.packages_dir.resolve()
220243
packages = package_dirs(packages_dir)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
These unmodified Lake configurations are regression inputs for vendoring, not
2+
dependency pins. They come from the dependency revisions in Anneal's pinned
3+
Aeneas archive:
4+
5+
- `aesop.toml`: aesop `e3cb2f741431ce31bf73549fb52316a57368b06f`.
6+
- `importGraph.toml`: import-graph `5c7542ed018c78194f1e2b903eaf6a792b74c03d`.
7+
8+
Both projects distribute these files under Apache-2.0; see the repository's
9+
`LICENSE-APACHE`. The regression checks that replacing a remote dependency does
10+
not remove the library and executable declarations that follow it.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name = "aesop"
2+
defaultTargets = ["Aesop"]
3+
testDriver = "AesopTest"
4+
precompileModules = false # We would like to turn this on, but it breaks the Mathlib cache.
5+
platformIndependent = true
6+
7+
[[require]]
8+
name = "batteries"
9+
scope = "leanprover-community"
10+
rev = "v4.31.0"
11+
12+
[[lean_lib]]
13+
name = "Aesop"
14+
15+
[[lean_lib]]
16+
name = "AesopTest"
17+
globs = ["AesopTest.+"]
18+
leanOptions = {linter.unusedVariables = false}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name = "importGraph"
2+
description = "Tools to analyse and visualise the import structure of Lean packages and their files."
3+
license = "Apache-2.0"
4+
licenseFiles = ["LICENSE"]
5+
6+
defaultTargets = ["ImportGraph", "graph"]
7+
testRunner = "ImportGraphTest"
8+
9+
[[require]]
10+
name = "Cli"
11+
scope = "leanprover"
12+
rev = "v4.31.0"
13+
14+
[[lean_lib]]
15+
name = "ImportGraph"
16+
17+
# `lake exe graph` constructs import graphs in `.dot` or graphical formats.
18+
[[lean_exe]]
19+
name = "graph"
20+
root = "MainGraph"
21+
# Enables the use of the Lean interpreter by the executable (e.g., `runFrontend`)
22+
# at the expense of increased binary size on Linux.
23+
# Remove this line if you do not need such functionality.
24+
supportInterpreter = true
25+
26+
# `lake exe unused_transitive_imports` prints unused transitive imports from amongst a given list of modules.
27+
[[lean_exe]]
28+
name = "unused_transitive_imports"
29+
root = "MainUnusedTransitiveImports"
30+
supportInterpreter = true
31+
32+
[[lean_lib]]
33+
name = "ImportGraphTest"

‎anneal/tests/test_rewrite_lake_vendor.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
import importlib.util
1616
import tempfile
17+
import subprocess
18+
import sys
1719
import unittest
1820
from pathlib import Path
1921

@@ -31,6 +33,93 @@ def write(path: Path, contents: str = "") -> None:
3133

3234

3335
class RewriteLakeVendorTests(unittest.TestCase):
36+
def test_toml_require_rewrite_preserves_following_tables(self) -> None:
37+
with tempfile.TemporaryDirectory() as tmp:
38+
root = Path(tmp)
39+
lakefile = root / "lakefile.toml"
40+
following = ('[[lean_lib]]\nname = "Library"\n\n'
41+
'[[lean_exe]]\nname = "driver"\nroot = "Main"\n'
42+
'[leanOptions]\nautoImplicit = false\n')
43+
write(lakefile, 'name = "consumer"\n\n[[require]]\nname = "dep"\n'
44+
'scope = "owner"\nrev = "main"\n\n' + following)
45+
self.assertTrue(rewrite_lake_vendor.replace_require_toml_blocks(
46+
lakefile, {"dep": root / "packages/dep"}))
47+
rewritten = lakefile.read_text()
48+
self.assertTrue(rewritten.endswith(following))
49+
self.assertIn('path = "packages/dep"', rewritten)
50+
self.assertNotIn('rev =', rewritten)
51+
self.assertFalse(rewrite_lake_vendor.replace_require_toml_blocks(
52+
lakefile, {"dep": root / "packages/dep"}))
53+
54+
def test_toml_require_stops_before_single_bracket_table(self) -> None:
55+
with tempfile.TemporaryDirectory() as tmp:
56+
path = Path(tmp) / 'lakefile.toml'
57+
following = '[leanOptions]\nautoImplicit = false\n'
58+
write(path, '[[require]]\nname = "dep"\nrev = "main"\n' + following)
59+
rewrite_lake_vendor.replace_require_toml_blocks(path, {"dep": Path(tmp) / 'dep'})
60+
self.assertTrue(path.read_text().endswith(following))
61+
62+
def test_pinned_aesop_and_importgraph_keep_all_declared_targets(self) -> None:
63+
fixtures = SCRIPT.parent / "tests/fixtures/lake-vendor"
64+
for name, dependency in [("aesop", "batteries"), ("importGraph", "Cli")]:
65+
with self.subTest(package=name), tempfile.TemporaryDirectory() as tmp:
66+
path = Path(tmp) / "lakefile.toml"
67+
original = (fixtures / (name + ".toml")).read_text()
68+
write(path, original)
69+
following = original[original.index('[[lean_lib]]'):]
70+
self.assertTrue(rewrite_lake_vendor.replace_require_toml_blocks(
71+
path, {dependency: Path(tmp) / dependency}))
72+
self.assertTrue(path.read_text().endswith(following))
73+
self.assertIn('path = "' + dependency + '"', path.read_text())
74+
75+
def test_external_symlink_rejected_before_any_rewrite(self) -> None:
76+
for entry in [".lake", "lakefile.lean"]:
77+
with self.subTest(entry=entry), tempfile.TemporaryDirectory() as tmp:
78+
base = Path(tmp)
79+
root, packages, shared = base / "private", base / "packages", base / "shared"
80+
root.mkdir()
81+
packages.mkdir()
82+
shared.mkdir()
83+
original = 'require dep from git "https://example.invalid/dep" @ "rev"\n'
84+
write(root / "lakefile.lean", original)
85+
write(shared / "lakefile.lean", original)
86+
if entry == ".lake":
87+
(root / entry).symlink_to(shared, target_is_directory=True)
88+
else:
89+
(root / entry).unlink()
90+
(root / entry).symlink_to(shared / entry)
91+
(packages / "dep").mkdir()
92+
result = subprocess.run([sys.executable, str(SCRIPT), "--root", str(root),
93+
"--packages-dir", str(packages)],
94+
capture_output=True, text=True)
95+
self.assertNotEqual(result.returncode, 0)
96+
self.assertIn("symlink leaves private roots", result.stderr)
97+
self.assertEqual((shared / "lakefile.lean").read_text(), original)
98+
self.assertEqual((root / "lakefile.lean").read_text(), original)
99+
100+
def test_internal_payload_links_are_allowed(self) -> None:
101+
with tempfile.TemporaryDirectory() as tmp:
102+
root, packages = Path(tmp) / "private", Path(tmp) / "packages"
103+
root.mkdir()
104+
packages.mkdir()
105+
write(root / "real.lean", "def value := 1\n")
106+
(root / "linked.lean").symlink_to("real.lean")
107+
rewrite_lake_vendor.validate_vendoring_roots(root, packages)
108+
109+
def test_long_unmatched_trace_path_does_not_backtrack_exponentially(self) -> None:
110+
with tempfile.TemporaryDirectory() as tmp:
111+
root, packages = Path(tmp) / 'root', Path(tmp) / 'packages'
112+
root.mkdir()
113+
(packages / 'mathlib').mkdir(parents=True)
114+
unrelated = '/' + '/'.join(['longsegment'] * 80) + '/Unrelated.lean'
115+
trace = root / 'Module.trace'
116+
write(trace, unrelated + '\n/upstream/packages/mathlib/Mathlib.lean\n')
117+
result = subprocess.run([sys.executable, str(SCRIPT), '--root', str(root),
118+
'--packages-dir', str(packages), '--rewrite-traces'],
119+
capture_output=True, text=True, timeout=5)
120+
self.assertEqual(result.returncode, 0, result.stderr)
121+
self.assertEqual(trace.read_text(), unrelated + '\nMathlib.lean\n')
122+
34123
def test_rewrites_upstream_aeneas_backend_trace_prefix(self) -> None:
35124
with tempfile.TemporaryDirectory() as tmp:
36125
root = Path(tmp) / "aeneas" / "backends" / "lean"

0 commit comments

Comments
 (0)