Skip to content

feat: Lean 4 analyzer, with header-scanner structure and exact-name resolution - #158

Open
fabceolin wants to merge 4 commits into
FSoft-AI4Code:mainfrom
fabceolin:feat/lean-analyzer
Open

fabceolin wants to merge 4 commits into
FSoft-AI4Code:mainfrom
fabceolin:feat/lean-analyzer

Conversation

@fabceolin

@fabceolin fabceolin commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a Lean 4 analyzer, with the two-layer design agreed in #154.

Stacked on #156. This PR targets main, because GitHub cannot use a
fork branch as the base. Its first two commits are #156. Review only the
last two commits, feat: Lean 4 analyzer, ... and a
format fix of its test. I will rebase this PR when #156
is merged.

Design

  1. Structure, from a header scanner. Comments, strings and char literals
    are masked with spaces of the same UTF-8 length, so lines and byte offsets
    do not move. The scanner then reads the Lean commands line by line:
    • declarations: def, theorem/lemma, abbrev, instance,
      structure, class, class inductive, inductive, axiom and
      opaque, with attributes and modifiers
    • the namespace/section/mutual/end stack, and open, also
      open X in
    • import, spans, and /-- -/ docstrings
      It does not use the parse tree, so an ERROR region does not hide a
      declaration.
  2. References, from tree-sitter. The analyzer takes each identifier
    node in the lines of a declaration, also the nodes inside ERROR nodes.
    Text that an ERROR node does not cover with child nodes is tokenized
    from the masked source. Local names are removed: binders, let, have,
    fun, intro/obtain, match patterns, and a dotted name whose first
    part is local (p.x).

Resolution is in CallGraphAnalyzer, as for Go and Dart:

  • A reference resolves only to an exact fully qualified name. The candidates
    are, in Lean's order:
    1. the namespaces of the declaration, innermost first
    2. the root namespace
    3. the open namespaces
  • Only the caller's file and the files that it imports (transitively) are
    visible. An import maps to a file by its path suffix (Geo.Point to
    src/Geo/Point.lean), because the Lake source folder is not known. A
    private name is visible only in its own file.
  • No match, or more than one, drops the reference. A lean branch in
    _resolve_callee and _has_authoritative_resolution stops the name-only
    fallback.
  • Constructor and field names (Point.mk, Point.x, Shape.dot) link to
    their structure or inductive. When a whole dotted name matches nothing,
    its longest prefix that matches is the constant, and the rest are
    projections (origin.x links to origin).

Other parts:

  • Component types: structure is struct, class is interface,
    inductive is class, and the rest are function. node_type keeps the
    keyword (lemma becomes theorem).
  • An anonymous instance is named instance_L<line>.
  • .lake/ and lake-packages/ are skipped. lakefile.lean and
    lakefile.toml are build artifacts, and lake-manifest.json and
    lean-toolchain are manifests.
  • Lean is registered in the file discovery, the CLI validators, the MCP
    tools and the graph viewer (lang-lean). The Docker image downloads the
    lean grammar. The grammar test of deps: move tree-sitter-language-pack to 1.x, with a clear error when a grammar cannot be downloaded #156 checks the Dockerfile list.

Known limits

These are the limits from #154, plus three that I found during the work:

  • Generalized field notation on a value (v.norm for Vec.norm) needs type
    information, so it does not resolve.
  • References made through user notation/macro are lost.
  • The scanner assumes that each command starts a line. A declaration ends at
    the next command at the same or a smaller indentation.
  • Local names are removed by name for the whole declaration. So a global
    that has the same simple name as a local of that declaration is not linked.
  • Lean's overload resolution among open namespaces uses types. Here, two
    matches in the open namespaces drop the reference.

Measurement

I ran it on a private corpus of 1,066 Lean 4 files (programs and proofs, not
Mathlib), and I checked 300 sampled files:

Metric Result
Declarations found, against a plain header regex 94.9%
Declarations with a docstring 54.4%
Edges per declaration 2.08
Declarations with at least one edge 84.8%
Edges that cross files 56.5%
Edges whose callee name is in the caller's text 100%
Time for all 1,066 files 130 s
  • The regex reads comments and syntax quotations as code. The misses that I
    checked were all in those regions, not real declarations.
  • The corpus includes generated files of more than 13,000 lines. The time
    is small next to the LLM phase of a run.

Tests

  • tests/test_lean_analyzer.py (new, 21 tests, synthetic Lean only):
    • declarations, namespaces and component types
    • spans, docstrings and attributes
    • skipped Lake folders
    • same-file, open X in, imported and alias edges
    • ERROR regions
    • locals, externals and recursion
    • private and not-imported names
    • ambiguous names, _root_ and mutual
    • masking, including interpolated strings and primes
  • tests/test_artifact_analyzer.py has rows for the Lake files.
  • The full suite gives 699 passed and 1 failed.
    test_doc_write_guard.py::test_warning_reports_rejected_tool_calls also
    fails on main, as noted in deps: move tree-sitter-language-pack to 1.x, with a clear error when a grammar cannot be downloaded #156.
  • ruff check and ruff format --check pass.

Note: tests/test_lean_analyzer.py is added with git add -f, because of
tests/* in .gitignore.

Closes #154

🤖 Generated with Claude Code

fabceolin and others added 4 commits November 6, 2026 08:15
…ents from the call

The pin goes from >=0.8.0,<0.9.0 to >=1.23.1,<2.0.0. Grammars that 0.8
does not ship (Lean, among others) are only in 1.x.

The 1.x Dart grammar parses `Foo<T>(x)` as an identifier with a call
selector whose argument_part holds the type_arguments, not as a
relational expression. `_handle_generic_invocation` never saw it, so the
type-argument edges (`Garage.open -> Item`, `carProvider -> Car`) were
lost. `_process_chain` now takes them from the call. The relational
expression path stays for grammars that still misparse it.

1.x downloads each grammar on first use and caches it. The Docker image
fetches the grammars the analyzers load at build time, so it runs
offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… cannot be downloaded

tree-sitter-language-pack 1.x downloads each grammar on first use. Without
network access the first load raises a DownloadError that names a URL but
not the cause or the fix, and the per-file except blocks of the analyzers
swallow it, so the language drops out of the graph with a traceback per file.

`utils/grammars.py` loads pack grammars through `get_pack_parser`, which turns
the DownloadError into a GrammarUnavailableError that names the grammar, the
cache folder and the `t.download([...])` command. `analyze_code_files` calls
`ensure_pack_grammars` before the first file, so the run stops once.
`PACK_GRAMMARS_BY_LANGUAGE` lists the grammars each language loads, and a test
checks that docker/Dockerfile downloads the same set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…esolution

The tree-sitter Lean grammar fails on many commands and notations and gives
`end` as a flat node, so the analyzer uses two layers. A header scanner reads
declarations, the namespace/section/mutual stack, `open` (also `open X in`),
imports, spans and docstrings from the source with comments and strings
masked. References come from the tree-sitter identifiers in each declaration,
also inside ERROR nodes, without binders, let/have/fun/intro names and match
patterns.

CallGraphAnalyzer resolves a Lean reference only to an exact fully qualified
name, in Lean's order: the declaration's namespaces, the root, then the open
namespaces, in the caller's file and the files it imports transitively.
Private names stay in their file. No match, or more than one, drops the
reference, and a `lean` branch keeps this authoritative like Go and Dart.
Constructor and field names link to their structure or inductive, and a
dotted name falls back to its longest matching prefix (`origin.x`).

Lean is registered with the file discovery, the CLI validators and the MCP
tools. `.lake/` and `lake-packages/` are skipped, lakefile.lean/.toml are
build artifacts, and lake-manifest.json and lean-toolchain are manifests. The
Docker image downloads the `lean` grammar.

Refs FSoft-AI4Code#154

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lean 4 analyzer: needs tree-sitter-language-pack 1.x (only source of a usable Lean grammar)

1 participant