MSDyn365BC.AL.Runner
2.12.0
dotnet tool install --global MSDyn365BC.AL.Runner --version 2.12.0
dotnet new tool-manifest
dotnet tool install --local MSDyn365BC.AL.Runner --version 2.12.0
#tool dotnet:?package=MSDyn365BC.AL.Runner&version=2.12.0
nuke :add-package MSDyn365BC.AL.Runner --version 2.12.0
BusinessCentral.AL.Runner
Run Business Central AL unit tests in milliseconds — no BC service tier, no Docker, no SQL Server, no license required.
What It Is
AL Runner is a standalone test executor for Business Central AL code. It loads the unmodified Microsoft Microsoft.Dynamics.Nav.* DLLs (those shipped inside .app packages and BC artifacts), compiles your AL source through BC's own Compilation.Emit pipeline, and executes the resulting test codeunits in-process against the real BC business logic.
There is no service tier, no SQL Server, no NST, and no rendered UI. There is also no "mock layer" — types and method bodies inside the precompiled MS / ISV DLLs run exactly as MS / the ISV compiled them. Where the runner stands in for the service tier (database persistence, session state, table provider, event dispatch) it does so by patching the runtime engine (Microsoft.Dynamics.Nav.Ncl.dll) at load time, not by editing business-logic DLLs.
See docs/cecil-migration.md for the Cecil-rewrite contract, docs/scope.md for what is in / out of scope, and docs/limitations.md for the hard architectural limits.
Architecture
AL source (.al files)
| BcCompiler — BC's own Compilation.Emit() drives AL → IL
| BcAssembler — Roslyn compiles the small C# polyfill bodies BC asks for
| (call-site arg-wraps, lambda thunks); IL is byte-equivalent
| to what BC's pipeline would produce
Test assembly (in-memory, cacheable)
| TestExecutor — discovers [NavTest] methods, runs them with the chosen
| isolation mode against real BC dispatch
Results in milliseconds
What the runner does not do:
- It does not rename BC types. No
NavRecordHandle → MockRecordHandlesubstitution. The sameNavRecordHandlethat the precompiled BaseApp / SystemApp DLLs reference is the one your tests execute against. - It does not rewrite method bodies in
*.SystemApplication.dll,*.BaseApplication.dll, or any ISV business-logic DLL. Those bodies are the contract the runner exists to validate. - It does not stub dependencies. MS apps (System App, Base App, test toolkit) load as their real DLLs; ISV dependencies load the same way.
What the runner does modify:
Microsoft.Dynamics.Nav.Ncl.dll— rewritten once via Cecil at startup and cached at~/.cache/al-runner/ncl-cecil/<key>.dll. This is the runtime-engine layer. SeeAlRunner/Infrastructure/NclCecilRewrite.cs. The tool package does not ship this DLL (it's Microsoft's, resolved from your own BC artifact cache at runtime, same as the rest of the BC service-tier closure) — on first run for a given install + BC version,AlRunner/Infrastructure/NclShadowRuntime.csbuilds a small shadow runtime directory containing the rewritten copy and re-execs into it once; that shadow dir is then reused on every later run.- Remaining R2R-reachable entry points — patched via JMP-hooks installed by
BcRuntime.EnsureApplied()(AlRunner/BcRuntime.cs,AlRunner/Patches/*.cs).
This is the precompiled-DLL contract described in .claude/rules/precompiled-dll-respect.md. AL output the runner emits is governed by the same contract once finalised — it is meant to be cacheable on disk and reusable like any MS or ISV DLL.
Quick Start
Prerequisites
.NET SDK 9 or 10 — download from https://aka.ms/dotnet/download.
Linux: none — the BC service-tier DLLs contain a handful of genuine Win32
P/Invokes (e.g. kernel32's locale APIs, reached by anything that evaluates a
TextConstant, including the standard upgrade-tag install-trigger pattern) that
the runner redirects to a small shim. A prebuilt libwin32_stubs.so for linux-x64
and linux-arm64 ships with the tool, so no C toolchain is required out of the box.
If you're on a RID the release pipeline didn't prebuild for, the runner falls back
to compiling AlRunner/Win32Stubs/win32_stubs.c on first use, which does need a C
compiler (cc, gcc, or clang) on PATH; without one it fails loudly and names
the missing tool and the two ways to fix it — install one (e.g.
apt install build-essential) or build the shim yourself and point
AL_RUNNER_WIN32_STUBS_SO at the resulting .so. Not needed on Windows or macOS.
Install
dotnet tool install --global MSDyn365BC.AL.Runner
On first run, the AL compiler and the BC service-tier DLLs (around 11 MB via HTTP range requests) are downloaded and cached. Works on Windows, Linux, and macOS.
On Windows, exclude the runner's cache/output directories from real-time antivirus scanning if you hit slow cold-start times — Windows Defender locking a just-written DLL for scanning is a known source of first-run delay (worked around automatically with a bounded retry, but excluding the folder avoids the wait entirely).
Run
The runner takes one or more bundle directories. A bundle is an app.json-rooted AL project (the same shape every BC extension has). The corpus in tests/al-language/ is a canonical example.
# Run a single bundle
al-runner tests/al-language/tests/al-language
# Multiple bundles
al-runner ./app1 ./app2
# Specify package caches for dependency resolution (repeatable)
al-runner --package-cache "$HOME/.al-runner/platform-apps" tests/al-language/tests/al-language
# Choose test isolation — the modes are AL's own TestIsolation values;
# see docs/limitations.md's "Test isolation modes" section for the full mapping
al-runner --isolation codeunit ./my-bundle # default — AL TestIsolation = Codeunit (BC's 130450)
al-runner --isolation test ./my-bundle # AL TestIsolation = Function
al-runner --isolation disabled ./my-bundle # AL TestIsolation = Disabled (BC's 130451)
# Cache compiled AL output between invocations
al-runner --cache ~/.cache/al-runner/al-out ./my-bundle
# JSON classification output
al-runner --out results.json ./my-bundle
# Verbose internal logging
al-runner --verbose ./my-bundle
With several bundle directories, the runner loads every bundle before any of them runs tests,
as it does for the app folders under one root: the bundles count as apps installed together, so
an event raised in one bundle's test reaches another bundle's subscriber (#4931). Set
AL_RUNNER_SEQUENTIAL_BUNDLES=1 to run each bundle start to finish before loading the next;
--per-suite always does.
Besides the AL-output cache above, the runner keeps the result of the dependency apps'
Install triggers plus Company-Initialize (codeunit 2) at
~/.cache/al-runner/install-baseline/<key>.bin, keyed by the dependency assembly set, the
runner build and the BC version. It is the same seeding either way — reloading it just
skips re-running those AL bodies in every new process (measured: 6.3s → 0.8s on a warm
single-fixture run). --cache <dir> relocates it with the other caches; set
AL_RUNNER_NO_DEP_COMPANY_CACHE=1 to bypass it entirely (no read, no write) and force the
full computation.
The runner also writes short-lived scratch directories under the OS temp directory
(Path.GetTempPath() — $TMPDIR, or /tmp, which is a tmpfs, i.e. RAM, on most Linux
desktops): al-runner-deps/p<pid> (dependency source extraction), al-runner-navserver/
(the report engine's per-session temp files), al-runner-jobs-*, al-runner-no-cache-*,
al-runner-resume-*, al-runner-server-inline/. Each carries a <dir>.owner sidecar naming
the process that created it; the owner deletes its directories at exit, and every runner
start sweeps the ones whose owner no longer exists (killed, OOM'd, watchdog-aborted). The
sweep never deletes a live process's directory and never guesses by age, so any number of
runners can share one TMPDIR; directories from builds before this mechanism (no sidecar,
no pid in the name) are left alone and can be removed by hand. See
AlRunner/Infrastructure/ScratchDirs.cs.
One temp directory is not owned by any single process: al-runner-pkgdedup/, where the
compiler stages a deduplicated set of .app packages under a name derived from that set.
Sharing it across concurrent and later runs is the point, so a stage has to outlive the run
that created it and the ownership sweep above cannot reclaim anything there. It is pruned on
a separate rule instead: every run writes a <stage>.inuse-<pid> claim on the stage it uses,
and a stage is deleted only when no live process claims it and nothing has used it for
seven days. A directory whose name is not one the compiler writes is never touched. Set
AL_RUNNER_PKGDEDUP_MAX_AGE_DAYS to change the threshold; anything unparseable or
non-positive falls back to the default rather than being honoured, and any positive value
under one hour is raised to one hour — a stage still being staged into carries no claim yet,
so a sub-minute threshold would delete it out from under the run that is writing it. See
AlRunner/Infrastructure/PkgDedupCache.cs.
Watch mode (live dashboard)
al-runner <bundle-dir> --watch [--package-cache PATH ...] [--cache DIR]
Stays resident with dependencies + BC patches loaded once, and re-runs the bundle in-process on every .al save (~seconds/save after a one-time cold first cycle).
On an interactive terminal --watch renders a live, non-scrolling dashboard that repaints in place on each cycle (like vitest / cargo-watch):
╭──────────────────────────────────────────────────────────────────────────────╮
│ al-runner my-app · ● watching · last run 11.45.05 · 0,9s │
╰──────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────┬────────┬────┬───────────────────────────────╮
│ Test │ Status │ ms │ Message │
├────────────────────────────────┼────────┼────┼───────────────────────────────┤
│ Codeunit60110.Insert_OnInsert… │ FAIL │ 38 │ Assert.AreEqual failed. │
│ │ │ │ Expected:<1>. Actual:<9>. │
╰────────────────────────────────┴────────┴────┴───────────────────────────────╯
0P / 1F / 0E · 1 total Ctrl+C to quit
The header status flips to ⟳ running… while a cycle compiles+runs (so the cold first run never looks frozen) and back to ● watching when idle. Rendered cross-platform (Windows/macOS/Linux) via Spectre.Console.
When stdout is not an interactive terminal (CI, a pipe, VS Code, a test harness), --watch automatically falls back to plain line output (PASS/FAIL per test + a [watch] waiting for AL source changes… marker) and emits no ANSI/cursor control. There is no separate UI flag — --watch itself is the dashboard.
Server mode (warm daemon for editor integrations)
al-runner --server [--package-cache PATH ...] [--cache DIR]
A long-running JSON-RPC daemon over stdin/stdout. Dependencies and BC patches load once; each runTests request re-emits the bundle warm and runs it in-process (~19s→~4s). stdout carries only the newline-delimited JSON protocol; logs go to stderr. The VS Code extension uses this. Full protocol + the same-bundle reload contract: docs/server-mode.md.
Debug adapter mode (breakpoints + stepping)
al-runner --dap [PORT] <bundle-dir>
A real Debug Adapter Protocol server (default port 4711) over a TCP socket: set AL breakpoints, pause execution, step through the paused code (next/stepIn/stepOut), inspect locals. No new AL→source mapping — it reuses BC's own StmtHit/[SourceSpans] instrumentation, the same mechanism --coverage and --capture-values already consume. Full protocol + current limitations (no VS Code launch configuration in this repo): docs/dap-mode.md.
Precompile a single .app to a DLL
al-runner --precompile MyApp.app --out MyApp.dll [--package-cache PATH ...]
This dispatches the single-app compile-to-DLL path. The output DLL is bit-compatible with what BC's Compilation.Emit would produce against the same dependency set.
Build from source
git clone https://github.com/StefanMaron/BusinessCentral.AL.Runner
cd BusinessCentral.AL.Runner
tools/corpus-checkout.py # the corpus is not in git; this clones it
dotnet build AlRunner.slnx -c Release -p:AllowBcArtifactDownload=true
dotnet run --project AlRunner -c Release -- tests/al-language/tests/al-language
-p:AllowBcArtifactDownload=true is needed only until the BC service-tier DLLs are
present — the runner never downloads them implicitly, so without the opt-in a fresh
clone fails the build with the explicit download command. Later builds can drop it.
See CONTRIBUTING.md for provisioning them as a separate step.
CLI Flags
| Flag | Effect |
|---|---|
--out PATH |
Write classification JSON to PATH (default v2-classification.json). |
--package-cache PATH |
Extra .app-package cache directory. Repeatable. |
--cache PATH |
Cache compiled AL output keyed on source + dep set + runner mtime. |
--isolation codeunit\|test\|disabled |
Test isolation mode. Default codeunit. See docs/limitations.md's "Test isolation modes" section for the AL TestIsolation value each one matches. |
--watch |
Stay resident with warm dependencies; on every .al change reset + re-emit + run in-process (~seconds/save). Debounces on quiescence (default 250ms of no further .al event, capped at 10s) so a bulk multi-file rewrite — a branch switch, a rebase, a formatter run — settles before a cycle starts, instead of firing mid-checkout. Tune with AL_RUNNER_WATCH_QUIET_MS / AL_RUNNER_WATCH_MAX_WAIT_MS. |
--server |
Long-running JSON-RPC daemon over stdin/stdout (warm deps → ~19s→~4s/run). See docs/server-mode.md. |
--dap [PORT] |
Debug Adapter Protocol server (default port 4711): set AL breakpoints, pause execution, inspect locals. Requires exactly one bundle path. See docs/dap-mode.md. |
--per-suite |
Legacy per-suite compile mode (diagnostic). Default is bundled-per-bucket. |
--bundled |
No-op alias for backwards compatibility. |
--verbose |
Show internal [Component] diagnostic logs. Equivalent to AL_RUNNER_VERBOSE=1. |
--show-pass |
Include PASS lines in per-test output (off by default; --verbose also turns them on). Equivalent to AL_RUNNER_SHOW_PASS=1. |
--failures-only, --quiet |
No PASS lines, even under --verbose. Equivalent to AL_RUNNER_FAILURES_ONLY=1. |
--precompile <input.app> |
Subcommand: compile one .app to a DLL via --out. |
--test-data / --test-data=PATH |
Hydrate the in-memory database from a BC .bak, so tests find the setup records a real environment has. A table is read the first time the run touches it, never up front, so the cost tracks what the suite actually uses. Off by default. Resolves sandbox/<version>/<country>/BusinessCentral-<CC>.bak from the artifact cache, or the explicit path. When it is missing, auto-provision (or al-runner provision --test-data) downloads it (about 1 GB, once) into the runner's artifacts dir; under --no-auto-provision, or when the download fails, the run fails naming the paths probed or the URL — it never continues against an empty database. Needs the bcbak backup reader on PATH or at $AL_RUNNER_BCBAK. Table-extension ($ext) fields are merged into the base record; see docs/limitations.md for what is and is not hydrated. |
--test-data-company NAME |
Company inside the backup to hydrate; required when the backup holds more than one. NAME may be a case-insensitive prefix (cronus): a unique match is used and named, an exact name beats a prefix, and several matches are listed (a numbered prompt on a terminal, a failure otherwise). |
When a test fails on a table that has no rows in this run, the runner prints a one-line
[test-data] explanation under the failure naming that table and pointing at --test-data.
BC's own failure message, exception type and AL call stack are unchanged — the explanation sits
next to the failure, never in place of it. It appears only when the failure names a table and
that table is measurably empty, so a genuine bug against a populated table is never mislabelled
as missing data. With --test-data already on, the line says instead why the table is still
empty (refused, not in this backup, or empty in it).
A failure reported as The TestPage is not open. gets the same treatment. That is BC's own
message for a page whose row-load trigger raised an AL error — the page is torn down and the
raised error's text never reaches AL, on a real service tier too — so the message names the
symptom and not the cause. The runner prints the error it was reported in place of on a one-line
[testpage] note under the failure. What AL sees is unchanged: asserterror and
GetLastErrorText still read only BC's message.
Without --bc-version, the runner picks the newest BC version it ships an engine for that is at
or above the application/platform minimum declared by the app.json at the root of each path
you pass (the highest of them; app folders nested below a path are not read). It prefers a cached build that meets the minimum and provisions one otherwise.
It never falls back below the minimum: when no supported version meets it, the run stops and
names the minimum and the supported versions. An explicit --bc-version below the minimum still
runs, with a warning.
Environment variables: AL_RUNNER_VERBOSE=1, AL_RUNNER_SHOW_PASS=1, AL_RUNNER_TRACE_NRE=1 (logs every first-chance NRE before AL asserterror swallows it), AL_RUNNER_BCBAK (path to the bcbak backup reader used by --test-data), AL_RUNNER_ARTIFACTS_ROOT, AL_RUNNER_CACHE_ROOT and AL_RUNNER_SYMBOLS_ROOT (see below), AL_RUNNER_DEFAULT_TEST_TOOL=off (do not load Microsoft's Test Runner app unless the suite declares it; see docs/limitations.md).
AL_RUNNER_ARTIFACTS_ROOT=DIR moves the BC artifact cache off the home directory — useful when it has to sit on a different volume, or on a mounted path on a CI runner. DIR is the root the per-version subdirectories live under (default ~/.local/share/al-runner/artifacts), so --bc-version, latest-in-cache defaulting and provisioning keep working. That is what makes it different from --artifact-path, which pins one version's engine directory and skips version selection entirely. A relative value is resolved against the current directory. The build reads it too, so a relocated cache stays buildable from source. Moving $HOME instead would relocate every other runner path (~/.cache/al-runner, ~/.bcartifacts.cache, ~/.local/share/al-runner/symbols) along with it.
The two runner-owned siblings have their own variables, resolved the same way (blank means the default, a relative value is resolved against the current directory): AL_RUNNER_CACHE_ROOT=DIR moves ~/.cache/al-runner — the AL-output cache and every named cache beside it — while --cache and --no-cache still win for the run they are passed to, and a DIR that cannot be created exits 2 naming the variable. AL_RUNNER_SYMBOLS_ROOT=DIR moves the curated symbols tree ~/.local/share/al-runner/symbols, which the runner only reads. ~/.bcartifacts.cache is VS Code's AL-extension cache, not the runner's, so it has no variable.
Test Corpus
The canonical AL test corpus is StefanMaron/BusinessCentral.AL.Language.Tests, read-only here and checked out into tests/al-language/ by tools/corpus-checkout.py (or, in CI, per job). It is resolved rather than pinned: every run prints corpus: <full sha> (<ref>), which is what a result is attributable to. Each test is a behavioural contract validated against a real BC service tier. The runner runs that corpus unmodified; tests it cannot execute by design (SMTP, real HTTP, report rendering, etc.) are declared in tests/expectations/ using the schema in docs/expectations.md.
Runner-specific positive tests (e.g. "this surface must throw RunnerOutOfScopeException with reason X") live in tests/runner-extras/.
See .claude/rules/al-language-submodule.md for the read-only contract and how a run resolves the corpus.
What's Supported
The goal is broad AL-language compatibility: any AL code that can run without the BC service tier should compile and execute here. The runner targets the whole AL surface — records (CRUD, filters, keys, CalcFields, CalcSums, triggers), codeunits (interface dispatch, event subscribers, BC lifecycle events), test toolkit codeunits (LibraryAssert 130, Any 130500, etc.), test handlers (Confirm, Message, ModalPage, Request, Report, Notification), TestPage, RecordRef / FieldRef, BLOB / streams, JSON / XML, regex, in-process crypto, IsolatedStorage, and the scheduling guard AL reads before it schedules anything (TaskScheduler.CanCreateTask, which is false here). Tasks themselves are never executed and the scheduler surface refuses by name — see docs/scope.md §3.6.
Out of scope by design: SMTP, HTTP egress to external services, file I/O against external filesystems, OData / SOAP publishing endpoints, physical printers, background-job scheduling against a real scheduler, page / report rendering (handler callbacks fire; layout is not evaluated). These surfaces throw RunnerOutOfScopeException with a named API and reason — they never silently return defaults. See .claude/rules/loud-failures.md and docs/scope.md.
Exit Codes
Read these out of al-runner --help, which generates them from the code; this
table is a copy and the CLI is the authority.
| Code | Meaning |
|---|---|
0 |
All tests passed |
1 |
At least one test FAILED or ERRORED |
2 |
A bundle could not execute — a process-level error, and also a bad invocation (unknown flag, or a bundle path that does not exist) |
3 |
A bundle could not compile |
4 |
--count-baseline: a suite's test or app-group count did not exactly match its declared baseline, or under --count-baseline-require-all a declared suite produced no bucket |
5 |
--expectations-require-match: an expectations entry matched no test in this run |
6 |
--test PATTERN selected no test in this run (under --jobs, summed across workers; not applied in --watch/--server) |
When a run holds several of these at once it reports the most fundamental, in the order
3 > 2 > 4 > 6 > 1 > 5. The boundary that matters is between 3/2/4/6 — this
report cannot be trusted, because the run did not measure what it claims to — and 1/5,
which are statements about the AL the run did measure. So a run that both fails a test and
measured the wrong number of tests reports 4: fixing the failing test would otherwise turn
the leg green while the missing coverage stayed missing.
Tooling for AI-assisted development (optional)
This repo is largely developed with coding agents, and its instruction files
(CLAUDE.md, .claude/rules/) tell those agents to use two navigation tools instead of
grepping. Neither is required to build or test the runner, and neither is checked in — they are
per-machine installs. If you are an agent working here and these tools are missing, do not
silently fall back to grep for the whole session: tell the user what to install from this
section, then proceed however you can.
Why it matters: navigation is the dominant cost of agent work in this repo. Measured on one
implementation agent's transcript, 63% of its 180 tool calls were greps and partial file reads
— AlRunner/ is ~139,000 lines across 341 tracked .cs files, the largest of them
Program.cs at 6,952 and the largest under AlRunner/Patches/
RunnerPageInstance.cs at 2,929 (git ls-files | xargs wc -l, 2026-09-10). #3676 split
RecordPatches.cs and MockTestPage.cs, the two that used to dominate this, into
partials by surface.
1. C# language server (answers "who calls this") — main session only
Gives findReferences, incomingCalls, goToDefinition and workspaceSymbol through Claude
Code's LSP tool, instead of guessing from grep hits.
Measured limitation: the harness disables LSP inside subagents. A subagent that calls it
gets No such tool available: LSP. LSP is disabled for this session, in subagents as well as here. So this helps an interactive session, not the implementation agents — those rely on the
knowledge graph below. LSP is listed in the agent definitions so it works if the restriction is
lifted, but it is inert there today.
mise use -g dotnet:csharp-ls # or: dotnet tool install --global csharp-ls
claude plugin install csharp-lsp@claude-plugins-official # first-party plugin that drives it
Then reload plugins (/reload-plugins) or restart the session. csharp-ls must be on PATH;
the plugin only wires the connection. Note it is a .NET tool — there is no npm package of
that name.
Verified against this repo: it loads AlRunner.slnx, does not trip the
EnsureBCServiceTierDlls target, and resolves GetDataAccessForTableCore to its full signature
at AlRunner/Patches/RecordPatches.DataAccessDispatch.cs:87 (it moved there in #3676),
with findReferences returning its call sites across the partial-class family.
If the LSP tool reports "No LSP server available for file type: .cs", the plugin is not active
in that session. That is a setup answer, not a "no results" answer — never read it as
"nothing calls this".
2. Knowledge graph
This repo — the C# runner and AL sources alike — can be indexed into a queryable knowledge graph: communities, most-connected types, import cycles. Built with graphify.
Install the AL-aware fork, not the upstream package:
uv tool install --upgrade "git+https://github.com/ChristianHovenbitzer/graphify-al.git@al-support"
The fork adds .al to the file detector and an AL extractor. Upstream graphify has no AL
support, and it does not fail on .al files — it skips them silently, so a graph built with it
looks complete while containing none of the AL in this repo. Upstream is enough if you only ever
index the C# under AlRunner/, but anyone working on this project reaches AL sooner or later.
Install the fork once and the question does not come up.
Then — run the rebuild and the query from the same directory:
cd AlRunner
graphify update . # index / refresh the C# runner (~2s, 200 files)
graphify query "<question>" # ask it something
Every graphify command defaults to graphify-out/graph.json relative to the current
directory. Rebuilding from one directory and querying from another silently reads a different
file: that mismatch once left a root-level copy 13 days and 104 commits stale while the rebuild
appeared to succeed. Output (graph.html, graph.json, GRAPH_REPORT.md) is gitignored — it is
derived, several MB, and goes stale quickly. Rebuilding costs about two seconds, so rebuild
rather than wonder.
Phrase queries as bare symbols or Symbol callers, never as an English question. The
start-node resolver matches the words you type, so graphify query "what calls GetDataAccessForTableCore" matches CallSiteArgWrap on the word calls and returns two
unrelated nodes with no sign it failed. graphify query "GetDataAccessForTableCore callers"
returns the correct 66-node neighbourhood.
For an AL tree instead of the C# runner: graphify tests/runner-extras (needs the fork above).
AlRunner/ is code-only, so extraction is deterministic AST work and costs no LLM tokens.
One limit worth knowing before trusting it: the graph is static. A Hook(...) registration that
never fires and one that does look the same in it. For that question use AL_RUNNER_HOOK_AUDIT=1,
which measures at runtime.
Reporting Gaps
If AL code fails to run and the reason is not in docs/limitations.md or docs/scope.md, that is a runner gap. Open an issue with .github/ISSUE_TEMPLATE/runner-gap.md. Silent workarounds are forbidden (.claude/rules/file-issues-for-gaps.md).
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.
v2.0 is a ground-up rewrite — no type renaming, no AL stubbing, runs unmodified MS- and ISV-shipped DLLs in-process. See CHANGELOG.md.