Skip to content

A second, flag-gated static-analysis stack ("v2") on JuliaSyntax 2.0 + JuliaLowering - #219

Open
davidanthoff wants to merge 163 commits into
mainfrom
bodytree
Open

davidanthoff wants to merge 163 commits into
mainfrom
bodytree

Conversation

@davidanthoff

@davidanthoff davidanthoff commented Aug 11, 2026

Copy link
Copy Markdown
Member

This PR introduces a complete new static-analysis stack for JuliaWorkspaces, built on vendored copies of JuliaSyntax 2.0 and JuliaLowering instead of CSTParser + StaticLint. The entire stack is inert by default: it hangs off a single feature flag (input_v2_enabled, toggled via set_v2_enabled!), and with the flag off the package runs the legacy code paths unchanged. The goal is to let the two stacks coexist in one module while the v2 stack matures, and eventually to retire the v1 stack.

The core idea: BodyTree

The heart of the new stack is BodyTree, a position-free, trivia-free value type that every v2 analysis layer is written against. Because trees carry no positions, most edits produce values that are isequal to the old ones, which lets Salsa's early-cutoff ("backdating") stop invalidation from propagating — a whitespace edit no longer re-runs downstream analysis. Item identity is content-addressed, and positions are reconstructed only at the last mile when diagnostics are emitted. A consequence worth knowing: we no longer cache syntax trees at all — re-parsing turned out to be cheaper than the invalidation churn of cached, position-carrying trees (the design doc has measurements).

What's in the stack

Everything lives under src/v2/ and is layered: an inventory walker (skeleton + body forest), a module tree across include edges, a tree-only visibility layer, a JuliaLowering-based binding analysis per item, and the lint rules that consume it. The core is CSTParser/StaticLint-free by construction (enforced by a guard test); the few pieces that must honour a v1 contract live in src/v2/bridge/.

On top of that substrate the branch delivers:

  • New parsers on the JuliaSyntax 2.0 core: TomlSyntax (a TOML parser, validated against the vendored toml-test suite) and MarkdownSyntax (a block-level markdown parser that lets markdown documents feed Julia code into analysis).
  • A v2 project/environment model: project files parsed via TomlSyntax, with [workspace]/[sources]/extension support, environment selection, and a plain-data "environment seam" that gives the tree-based rules access to indexed environment information (exported names, method arities, member kinds).
  • Macro expansion via the dynamic child processes: opaque macrocalls are expanded by the persistent DynamicJuliaProcess child that indexed the file's environment (it already has the packages loaded), and the expansions are spliced back into analysis. This comes with lifecycle work — an LRU cap on live children, revive-on-demand, and post-index teardown — gated so v1 behaviour is untouched.
  • Lint rules re-based on lowering: a long series of "takeover" commits reimplements existing StaticLint rules on the new stack (missing_reference, unresolved_import, incorrect_call_args' arity arm, type_piracy, kw_default_mismatch, const rules, literal_use, and more), adds new rules that were previously infeasible (lowering_errors, a static soft_scope_ambiguity), and includes four Aqua.jl-derived rules that are v2-only. A large portion of the commit history is false-positive elimination from differential sweeps over real-world corpora.
  • Interactive features on v2: references, document/workspace symbols, highlights, selection ranges, document links, hover, signature help, and module-at-position all have v2 backings behind the flag, checked against v1 with parity probes.
  • Test item detection on the v2 skeleton, so @testitem discovery no longer needs its own parse.

How the two stacks coexist

One convention governs every fork point: a v1 query that has a v2 counterpart keeps its name and body byte-identical to main, with a single inserted gate line (input_v2_enabled(rt) && return <name>_v2(...)), and the counterpart lives in src/v2/. The lint-rule registry is split so v1 stays frozen at main while v2 evolves independently. scripts/check_v1_parity.sh and test/test_v1_parity.jl enforce this property mechanically, so reviewing the v1 side of the diff reduces to checking a small allowlist of one-line gates.

What v1 still does that v2 doesn't

The remaining gap is essentially everything that needs type-level information from the indexed environment — the seam deliberately ships only name- and arity-level data so far:

  • Type-dependent rule arms stay v1-only: the positional-type arm of incorrect_call_args ("at argument i: expected T"), and the typed arm of incorrect_iter_spec. The arity-level halves of these rules are taken over; only the parts that need method signatures with types remain.
  • Completions are still entirely v1, as is hover/completions content drawn from store docs (documentation of external packages), and most code actions. The v2 feature layer answers the structural queries (references, symbols, navigation, signature help) but not the doc/type-hungry ones.
  • Two known visibility blind spots: names a module gets from its own implicit using Base via colon-list members, and names that modelled macros declare, bind as :unknown — deliberately silent (no false missing-reference findings), but hover/goto don't see through them.
  • index_from_length is deferred on measurement — it produced exactly one finding on the corpus, not worth the machinery yet.
  • Rules that would need control-flow analysis (v2 lowers without a CFG) are out of scope for now; use-before-definition-style rules also wait on macro expansion being gated on by default.

So v2 today is a complete spine — identity, module structure, per-item binding semantics, a name-level environment edge — with the type-level half of the environment (method tables with types, store docs) as the main remaining frontier. docs/design/v2-portability-survey.md has the rule-by-rule accounting.

Where to start reading

docs/design/v2-architecture.md is the full design document (layer by layer, with the reasoning and measurements); docs/design/v2-portability-survey.md tracks which StaticLint rules have been ported and which remain. The vendored packages are under packages/, with vendoring notes alongside.

Status

The flag is off by default, so merging this changes nothing for users. With the flag on, the gaps in the section above are what still silently falls back to v1 behaviour or stays unavailable.

@davidanthoff davidanthoff changed the title Add BodyTree layer Integrate JuliaLowering Aug 11, 2026
davidanthoff and others added 25 commits August 11, 2026 14:55
Main's inventory now descends into @testitem/@testsnippet bodies as
synthetic `#test…` scopes; v2 deliberately keeps those macrocalls opaque
at the inventory level, so the differential excludes them. Also sort
import tuples by repr, since `alias` can be `nothing` on both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…c 12.1)

Syntax diagnostics, test item detection and the syntax lint tier each ran
their own JuliaSyntax parse of every file per pass (10.4% of a cold pass).
`derived_julia_parse_products` now parses once and returns all three
products as plain data; each consumer reads its slice through a selector,
so the products backdate independently. The syntax checks all run
unconditionally inside the fused query — enabling is a filter in
`derived_syntax_lint_findings`, so a config edit no longer re-runs the
parse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…step 1)

The v2 walker already visited every @testitem/@testmodule/@testsnippet
macrocall and threw the detail away. It now scans the macrocall argument
shape (a port of TestItemDetection.find_test_detail! onto the position-free
BodyTree, same shapes accepted, same error strings) into V2TestItem /
V2TestError records on the skeleton, plus a derived_v2_file_testitems
projection. Records carry no ranges and no code string, so they backdate on
position-only and body edits; _v2_test_macro_addresses supplies the preorder
addresses the emission join will use to reattach ranges. Enumeration and
descent are unchanged — the isolation rule is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… 2+3)

`derived_v2_testitem_details` is the volatile emission join: it reattaches
range, code_range, the code slice and a non-literal skip expression from
`derived_v2_file_maps` onto the position-free V2TestItem records, then runs
the same assembly (ids, outside-package check, duplicate labels) as the
legacy path — factored out of `derived_testitems` into
`_assemble_test_details` so the two engines share everything below
detection. With `input_lowering_lint` on, `derived_testitems` reads the v2
path; flag-off keeps the fused-parse legacy path.

Tested with parity units over every well-formed and malformed macro shape,
backdating tests (position-only and body edits leave the records isequal),
and a corpus-wide differential against TestItemDetection that currently
declares zero divergences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`derived_v2_file_walk` parsed strictly, so any syntax error emptied the
whole walk — under the flag, a broken file lost all its test items (and
its entire v2 inventory), while legacy TestItemDetection kept finding
intact items in the recovered tree. The walk now parses with
`ignore_errors=true`: K"error" nodes flow into BodyTrees like any other
kind, the lowering layer's per-item degrade contains them, and the error
itself is still reported by derived_julia_syntax_diagnostics.

Broken-file shapes (error above an item, garbage inside and between
items, truncation at EOF inside a block) are parity-tested against the
legacy engine — both parsers recover them identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`juliadynamicanalysisprocess/expandMacros` takes a batch of macrocall
source texts sharing one module context (ctxId + canonical import
statements) and returns per-entry expansion text or error. The child
builds and caches a context module per ctxId (the packages behind the
imports are already loaded in its session from indexing), runs
Revise.revise() before each batch so deved-package macro edits are
picked up, and expands with Base.macroexpand recursive. First structured
result type on this protocol.

Part of DJP-side macro expansion M1a (see design plan).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The persistent env child (which already has the environment's packages
loaded from indexing) now serves expandMacros batches: the reactor routes
ExpansionBatchMsg to df.procs[env_key] through a per-key FIFO, the process
FSM gains its one new edge (Done -> Indexing), and batch failures kill the
child with every entry negative-cached — never touching pending_count, the
launch cap, or the env failure budget.

On the Salsa side (src/v2/layer_expansion.jl, behind the lazily-false
input_macro_expansion flag): expansion sites are harvested position-free
with _materialize's exact address accounting; cache keys are
(env content hash, module-context hash, macrocall BodyTree hash), where
the context hash folds in the own package's macro-definition body hashes
so editing a deved macro re-keys exactly the affected sites (D2b);
_reconcile_expansions! reattaches source text at the last mile (the
volatile maps' third legitimate reader, host-side) and sends capped
batches. Settled :ok text is re-parsed with the vendored JuliaSyntax into
a position-free BodyTree and spliced into _materialize at address 0 while
KEEPING the identifier-read fallback (the union guard), so a macro that
drops an argument can never create a false positive; :failed and
unparseable results keep today's behavior bit for bit.

Tested process-free through input_macro_expansions (splice, union guard,
negative cache, re-keying on macro-def edits) plus reactor settle/queue/
prune tests. End-to-end fixture test follows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A warm environment completes its work item without ever launching a
child (the prep task sees every symbol cache present), so expansion
batches found no process to ride on and settled as failed — the common
case, not the edge. The drain now waits while the env work item is still
in flight, and for a done-but-process-less env revives a child through
the refresh machinery (idle priority, at most once per key so a crashing
child cannot loop); the refresh completion path emits the idempotent
EnvironmentReadyResult for plain watched envs and kicks the drain.

Verified live: a fixture package defining @double expands through a
revived child in ~20s, and the expansion reaches lowered bindings
(`*` read at addr 0). The same scenario is now a testitem gated behind
JW_E2E_DYNAMIC=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
derived_file_expansion_ready mirrors derived_file_env_ready: true when
the flag is off, the file is outside the v2 lint, it has no expansion
sites, or every site's key has settled ok-or-failed. Nothing consults it
yet — it exists for future use-before-definition-class rules, which must
not run against the identifier-read fallback. Batches are now sent
regardless of dynamic mode (the reactor settles non-persistent entries
:failed immediately), so the gate cannot hang wherever a dynamic feature
exists; and _reconcile_expansions! prunes settled entries and requested
keys whose env_hash left the required set, bounding memory across env
edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The walker computed the preorder address ranges for every statement but
discarded them for module and using/import rows (which get no body).
Module- and import-level findings (module_name, relative_import) need
real ranges at the emission join, so the map — volatile anyway — is now
stored for those rows too; bodies stay unstored. A test pins preorder
address 3 as a module's name token.

Part of the Harvest JuliaLowering milestone (commit 1 of 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every LoweringError from desugaring/validation/scope resolution was
caught into ItemLowering(:error, ...) and silently discarded. The
findings now route out of derived_item_semantic_findings, reported under
a new lowering_errors rule (off by default, warning in strict) with a
message->rule-id table ready for takeover routing.

Three false-positive channels the corpus sweep exposed are guarded:
items whose lowered form is synthetic (test-block let-wrapping), items
enumerated from inside a macrocall's arguments (a `Salsa.@derived
function` or `@kwdef struct` may be transformed by the macro — a new
V2ItemRow.under_macrocall marker), and items containing stripped
macrocalls (`function (@main)(args)` loses its name to materialization —
detected via the expansion-sites query). Files with syntax errors also
suppress the catch-all id, since lowering recovered trees duplicates
what syntax_errors already reports.

The sweep testitem pins the corpus at zero lowering_errors findings with
an empty allowlist. Harvest JuliaLowering, commit 2 of 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
duplicate_function_argument, break_continue and global_const_decl now
come from JuliaLowering under the flag: the exact vendored message
strings map to the rule ids (plus a pinned prefix rule for the
parameterized destructured-argument conflicts), and the three ids join
LOWERING_TAKEOVER_RULES so StaticLint is suppressed when active. A
refresh-gate testitem lowers one snippet per table row and asserts the
routed id, so a vendored-JuliaLowering refresh that rewords a message
fails loudly instead of silently demoting findings to lowering_errors.
The corpus differential pins flag-off/flag-on agreement for the routed
ids with an empty allowlist.

Harvest JuliaLowering, commit 3 of 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A where-clause parameter mints two bindings at the declaration address:
a :typevar whose reads are the signature uses and a :static_parameter
whose reads are the body uses. The parameter is unused only when both
are unread. Struct and type-alias parameters lower as :local and are
deliberately not covered, matching v1's where-clause-only semantics
(pinned by tests). v1 message wording verbatim; the corpus differential
and sweep stay at empty allowlists.

Harvest JuliaLowering, commit 4 of 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A new position-free per-file producer reads the skeleton plus the
module-tree splice prefix (first root, the layer_expansion precedent):
module_name fires when a module is named like its parent — including
the cross-file case where the parent comes through the splice point,
which v1's same-file check misses — and relative_import fires on more
leading dots than the site's nesting allows (the exact unresolved-by-
pops condition the module tree itself uses). Findings attach to
module/import rows through the maps commit 1 started storing; both ids
join the takeover set. Corpus differential extended to all six
newly-taken ids, still with an empty allowlist.

Harvest JuliaLowering, commit 5 of 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
davidanthoff and others added 27 commits September 10, 2026 20:53
…d behaviours are v2-only

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n default; v2 keeps an explicitly imported name's import binding over a bare redefinition

main's default preset now has missing_reference / unresolved_import /
incorrect_call_args off, so the v2 suites that assert on them give their
workspaces a JuliaLint.toml turning them on. main's module tree also stopped
recording a bare function/assignment definition of an explicitly imported name
as a local declaration (the import stays the visible winner); the v2 module
tree mirrors that rule so the visibility differential is zero again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	test/test_includes.jl
#	test/test_scratch_env.jl
…is under main's borrowed manifests

main gained #300's _failure_folder_uri, #302's fused parse (RawTest*Detail),
#301's include walker (runtime targets, quoted skip, module scoping) and
#303's _watch_target_for_project / derived_workspace_members. The v2 twins
carried copies of all of these: the copies go (a same-name method would have
replaced main's on the v1 path), v2's differently-scoped workspace query is
renamed derived_workspace_members_v2, and the include twin shrinks to the
diagnostics emission — the walk is v1's now, including main's decision to
drop the @safetestset special case. With #291 a member borrows its parent's
manifest in the shared folder table; derived_project_v2 treats a manifest
that is not the folder's own as none, so the member is still synthesized
from its [deps] closure and covered by the root's watch item.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main's source-scan rule (#306): feature layers never read input_text_file
directly, so cross-file results landing in an indirect file do not throw.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…must name StaticLint

"v2 is everything under src/v2/" is literally true again. The analysis core
stays CSTParser/StaticLint-free (the guard test enumerates src/v2/ itself);
src/v2/bridge/ holds the four twins that honour a v1 contract and therefore
name StaticLint or CSTParser — the diagnostics join, the include diagnostics,
the feature layer and the environment seam. packagedef.jl still loads each
twin right after the v1 layer whose gate dispatches to it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
bodytree moved under the aqua branch: it merged origin/main and split the
code into v1/v2 twins (flag off == main exactly, v2 under src/v2 with
src/v2/bridge for files that must name the v1 pipeline). The four
package-quality rules follow that discipline — registration and option
validation stay shared, emission joins only derived_diagnostics_v2:

- missing_compat / unused_dependency: producers ride the
  layer_project_files -> v2/layer_project_files_v2 rename; their emission
  moves into the bridge diagnostics twin next to the project/manifest
  problem walk.
- unbound_type_parameter: out of the shared SYNTAX_CHECKS tuple
  (src/lint_syntax_rules stays byte-identical to main); the check becomes
  its own producer query in src/v2/bridge/lint_unbound_type_parameter_v2.jl,
  joined in the twin.
- undocumented_public_name: layer_undocumented_names moves to
  src/v2/bridge (it reads the v1 module tree); emission only in the twin.

Tests move with the rules: the three rule suites live under test/v2 and turn
the flag on, each pinning that strict emits nothing flag-off; the
undefined-exports regression item enables missing_reference explicitly (main
demoted it to off in default); the v1-parity flag-off code set gains the two
project-file codes. check_v1_parity.sh: layer_diagnostics.jl moves from the
gate-lines-only list to additions-only (the shared option validation).
Docs: v2-only annotations on all four rules; the duplicated
environment_errors testitem from the main merge gets a distinct (v2) name.

Full suite: 1505 passed, 1 skipped (pre-existing), 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ew-plan

Port Aqua.jl's static checks as four lint rules
Verbatim re-copy of the JuliaSyntax/ and JuliaLowering/ subdirectories of
JuliaLang/julia at 7c019557415a97d0bb60d11c42db5405e5203416 (2026-09-12),
replacing the previous pin b657e6a0 (2026-08-11). The two module entry
files are unchanged upstream, so src/v2/vendor_lowering.jl keeps its
structure and only its SHA comment moves; every API layer_lowering.jl
consumes (rebase_layers, expand_forms_1/2, resolve_scopes, LoweringError,
BindingInfo) and all six pinned diagnostic messages are unchanged.

Gates per packages/VENDOR_JuliaLowering.md: test_lowering_layer.jl and
test_lowering_differential.jl testitems, all of test/v2, and the full
suite pass on Julia 1.12.7 and 1.13.0 with no JW-side adaptation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
src/lint_rules.jl reverts to byte-identical with main; everything the v2
stack emits is registered in its own complete registry, LINT_RULES_V2 in
src/v2/bridge/lint_rules_v2.jl (bridge/, because the shared rules name
StaticLint codes). Which registry drives config validation, presets and
emission is decided by input_v2_enabled: the three lint-config queries in
layer_diagnostics.jl gain gate lines to _v2 twins in
src/v2/bridge/lint_config_v2.jl, which also owns materialize_v2 and the
option validation for the v2-only rules, so the v1 validator matches main
again. layer_diagnostics.jl moves from the additive to the gated class in
check_v1_parity.sh, and lint_rules.jl to byte-identical.

Intended behavior change: with v2 off, a JuliaLint.toml naming a v2-only
rule (e.g. missing_compat) is reported as an unknown rule, exactly as on
main; with v2 on, nothing changes. The registries agree on every shared
rule today - pinned by a guard test - and the split is the seam for a new
v2 rule-registration API that may diverge from the frozen v1 list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…v1-v2

Split the lint-rule registry: v1 frozen at main, v2 fully independent
…ulialowering-vendor

Refresh vendored JuliaSyntax v2 + JuliaLowering to julia master 7c019557
…ode!)

The DynamicMode was a constructor-only argument: changing it meant tearing
down the whole JuliaWorkspace. It is now a regular Salsa input
(input_dynamic_mode) with a public setter, and the set of running DJPs
adjusts immediately on every switch, so hosts can wire it to a live config
value (the LS's julia.enableDynamicIndexing) without a restart.

Following the set_v2_enabled!/set_max_alive_djps! pattern, the mode is
mirrored into the reactor as a RefValue (DynamicFeature.djp_mode) via a new
SetDynamicModeMsg, posted before the reconcile so the reconcile already runs
under the new rules. The transition handler enforces the new mode's child
lifecycle on existing state:

- down to DynamicOff: kill every child and settle all outstanding work the
  way the Off branches would have (environments best-effort ready, work
  needing a child skipped), keeping `done` so succeeded artifacts are not
  overwritten;
- up from DynamicOff: forget the Off-parked completion and failure
  bookkeeping wholesale (the retry_failed_dynamic_projects! license) and
  un-settle host-side readiness, so the re-dispatched work re-opens
  is_ready/wait_until_ready until it settles again;
- Persistent -> IndexingOnly: kill settled children and fail queued
  expansion batches; in-flight children settle under the new mode;
- IndexingOnly -> Persistent: flip only — children settling from now on
  stay alive, departed ones are revived on demand.

Off no longer means "no DynamicFeature": the feature and its reactor now
always exist (the symbolcache_download path already ran an Off-mode reactor),
which drops the Union{Nothing,DynamicFeature} guards throughout and gives
`is_ready` a single meaning; wait_until_ready sends the first reconcile
itself so a never-mutated workspace cannot block forever. The DynamicMode
enum moves to dynamic_messages.jl so the new message's field can reference
it at include time.

docs/design/2026-09-12-dynamic-config-audit.md classifies the remaining
constructor kwargs (max_concurrent_djps and resolve_workspace_environments
are the next candidates) and sketches the LS wiring that this unblocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…core

The TomlSyntax pattern applied to Markdown, minus the token lexer: block
structure is line-oriented, so a line classifier feeds the scanner, which
emits directly into JuliaSyntax's flat postorder green-node buffer and the
shared cursors and trees work unchanged. Deliberately block-level only  --
fenced code blocks (backtick and tilde, CommonMark rules), indented code
blocks (so fence markers inside them are not fences), front matter and ATX
headings; prose is opaque trivia and container blocks are not modelled.

On top of the tree, the two products the workspace needs: julia_chunks
classifies the fences whose info string marks them as Julia (plain julia
fences, jmd/Quarto/Weave {julia ...} chunk headers, and Documenter's
plain-Julia @example/@setup/@repl/@eval blocks; doctests are REPL transcripts
and excluded), and julia_shadow_source renders the document as
byte-offset-preserving Julia source: chunk bytes verbatim, every other
non-EOL byte a space.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre-JuliaWorkspaces language server's parse_jmd design, rebuilt on
MarkdownSyntax instead of regexes: derived_julia_source_view (the new
layer_markdown.jl) serves a markdown document as byte-offset-preserving Julia
source, so every parser  -- the fused JuliaSyntax parse, the legacy CSTParser
tree, and the v2 walk  -- reads real Julia at real document offsets, and no
feature grows a position-mapping layer. Slice-and-scan sites that consume
parse-derived ranges (testitem code slices, v2 docstring/signature slices,
code-action insertion scans, macro expansion text) read the view too, so a
range that leaks across a fence boundary can only ever pick up blanked prose.

Gating: derived_julia_files admits markdown documents unconditionally (a
chunk-less file's view is all whitespace, which parses to an empty file
cheaply, and unconditional admission keeps the root set stable under edits).
The public.jl entry points gate file-level queries on the widened
_is_julia_analysis_uri and position-taking queries on the position sitting
inside a Julia chunk, so completions and hovers never fire in prose.
Formatting stays strictly Julia, and include("x.md") still never joins the
includer's tree. Two Salsa wins fall out: the chunk table and the view are
value-stable, so a prose keystroke in a README never reaches any Julia
analysis.

Test items inside markdown fences are detected with package-scoped ids and
run as-is by TestItemControllers, whose test process evals the code slice
against the real file path with a line/column offset  -- extension-agnostic,
verified end to end (error stack frames point at the markdown line).

LanguageServer.jl needs no change: its document selector, file watchers and
per-language formatting registration already cover md/jmd, and its full test
suite passes against this JuliaWorkspaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ia-files

Markdown support: analyze Julia code in md/jmd documents through a MarkdownSyntax-based Julia view
…e-api

Make the dynamic mode a runtime-switchable Salsa input
Mechanical preparation for runtime setters: download_enabled, upstream_url,
max_concurrent_djps, max_failure_attempts and djp_request_timeout_seconds
become reactor-owned Base.RefValues like djp_mode/max_alive_djps/
v2_lifecycle, with every read site dereferencing. No behavior change; the
Set*Msg messages the field comments reference land in the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
set_max_concurrent_djps!, set_resolve_workspace_environments!,
set_symbolcache! (download + upstream in one call/message),
set_max_failure_attempts! and set_djp_request_timeout!, following the
established reactor-message pattern; all new handlers carry the
SetV2LifecycleMsg-style idempotence guard.

Semantics worth noting:
- Enabling env resolution or making downloads newly effective un-settles
  host readiness and forces a reconcile through (the set_dynamic_mode!
  Off->on triple), so is_ready/wait_until_ready genuinely track the
  re-dispatched work.
- The symbolcache flip clears only `done` watch-environment keys — the one
  kind whose prep downloads — so a policy change re-preps open projects
  without respawning scratch/test children; failure bookkeeping is kept in
  all cases (retry_failed_dynamic_projects! stays the lever).
- symbolcache values get eager Salsa mirrors (host-readable old values;
  the reactor Refs lag behind queued messages); the Int knobs need none.

Also forwards resolve_workspace_environments through
workspace_from_folders, the one constructor kwarg it was missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Constructor docstring bullets gain their "changeable at runtime" cross
references, functions.md lists the new setters, architecture.md states that
every remaining runtime-relevant knob is now switchable, and the config
audit records the decisions that differ from its original sketch (single
symbolcache setter/message, selective watch-env re-prep instead of the
wholesale reset, the readiness un-settle pairing, handler idempotence
guards) plus the simplified LS wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reactor-level: cap raise drains the queue (incl. 0 = unlimited), cap lower
gates admission only, symbolcache enable re-preps done watch-env keys while
keeping scratch/test completions and failures, upstream changes re-prep only
while downloads are on, failure-budget lowering exhausts and raising
un-exhausts an identity with failed_projects keys staying barred, timeout
Ref round-trip. Workspace-level: env-resolution toggle re-dispatches
fabricated keys and un-settles readiness deterministically, the
nothing-to-fabricate enable still settles wait_until_ready, set_symbolcache!
re-preps a real on-disc project offline via an unreachable upstream, and
workspace_from_folders forwards resolve_workspace_environments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Runtime setters for the remaining changeable dynamic config options
Conflicts were the kwarg/constructor unions: JuliaWorkspace,
workspace_from_folders and DynamicFeature keep bodytree's max_alive_djps /
v2_lifecycle / launcher / always-on reactor and gain main's status_callback
and err_handler, threaded through to the always-constructed DynamicFeature.
The reactor loop reports a status snapshot after every message, so bodytree's
expansion and v2-lifecycle messages feed the new seam with no further wiring.

One auto-merged hunk was a semantic conflict: main's language gate on
derived_testitems (#323) is _is_julia_uri, which on this branch would blank
test items in markdown fences (markdown documents are Julia analysis sources
here, layer_markdown.jl). The gate lands as _is_julia_analysis_uri — the same
swap this branch made in derived_diagnostics — keeping main's fix (TOML/plain
text stays out of the fused parse) and the markdown feature. The new
language-gating tests are adapted the same way: the markdown file appears in
the whole-workspace query with empty details rather than not at all.

The v2 twins pick up main's fixes in follow-up commits: the language gate on
derived_testitems_v2, the workspace-based test-env decision in
derived_required_dynamic_projects_v2, and the 32-bit skip-range widening.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes main made to the v1 query (#323, and the RawTestItemDetail widening)
had the same latent bugs in the twin: derived_testitems_v2 had no language
gate, so a TOML or plain-text URI handed to the per-file query went through
the v2 parse (derived_v2_file_walk reads the source view of any uri) and could
report items the whole-workspace query never does; and the join built
option_skip from `incl`, whose UnitRange{Int} is Int32 on a 32-bit build,
where the Union-typed field has no convert and construction throws.

The gate is _is_julia_analysis_uri, matching the v1 query on this branch
(markdown documents are Julia analysis sources), which also keeps the
v1/v2 differential suite aligned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirror of main's #320 in derived_required_dynamic_projects_v2: the isfile
probe was untracked (creating test/runtests.jl later never invalidated the
query), ignored a walk scope that deliberately excluded test/, and denied
in-memory workspaces a test environment. The other half of #320,
_covering_test_env_key, is shared v1 code this twin already calls, so it
arrived with the merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Main's dynamic_status_snapshot reads df.max_concurrent_djps as the plain Int
it is there; this branch made the knob a RefValue (set_max_concurrent_djps!),
so the merged snapshot constructor threw a convert MethodError as soon as a
status_callback was attached. The auto-merge could not see this: the field
read lives in main-only code, the Ref conversion in bodytree-only commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
packages/JuliaSyntax and packages/JuliaLowering (the v2 stack's vendors) come
from JuliaLang/julia subdirectories, so the release-based reconciliation in
update_vendored_packages.jl cannot manage them; VENDOR_JuliaLowering.md does.
The script ignores unlisted trees, so this is documentation, not behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@davidanthoff davidanthoff changed the title Integrate JuliaLowering A second, flag-gated static-analysis stack ("v2") on JuliaSyntax 2.0 + JuliaLowering Sep 14, 2026
@davidanthoff
davidanthoff marked this pull request as ready for review September 14, 2026 22:06
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.

1 participant