Probing AL (albuild probe)
Compiling proves the syntax. Publishing and testing costs minutes, a container and a test app. Between the two sits a question AL has never had a way to ask: what does this actually return?
Code
albuild probe runs one AL procedure, or a few AL statements, locally — in a fraction of a second, with no container, no publish and no test app.
A reasoning tool, not a gate
A probe says something about a function. It never says anything about a release. There is no service tier behind it, so a green probe is not a test and not a deployment check. Runtime validation stays albuild app deploy against a container.
How fast, really
Measured end to end, including .NET process start and reading the workspace:
| Wall clock | |
|---|---|
| No workspace (a self-contained expression) | ~130 ms |
Against a real product workspace (~100 .al files) | ~350 ms |
The evaluation itself is a few milliseconds; process start and parsing the workspace dominate. The comparison that matters is not against zero, it is against a container round-trip: publishing and running a test to answer the same question costs minutes.
How it works
An AL interpreter, shipped inside the CLI, reads the same AL source the compiler reads:
| Tier | Source |
|---|---|
| 1 | The workspace's own .al files |
| 2 | Dependency source, pulled lazily out of the .app packages in .alpackages (a BC app package carries its full AL source under src/) |
| 3 | Dependencies shipped without source — runtime packages — contribute their tables and enums from the compiled metadata |
No Microsoft binaries are loaded and nothing is patched. Workspace source always wins over package source for the same object, so you probe what you are editing.
Commands
probe scope
Run this first in a repository. It lists every probeable object with its procedure signatures, the symbol packages found, and — importantly — every source file the engine could not read, so a gap never looks like an object that does not exist.
Code
probe call — one procedure with real arguments
Code
--target is "<object>"::<procedure>; prefix it with Table or Codeunit when a name is ambiguous. Arguments are positional JSON, converted to each parameter's declared AL type — so a 60-character string into a Text[50] fails exactly as the AL would. var parameters are reported back under outputs[].
probe eval — an expression, with no object around it
Code
Declare variables in ordinary AL var syntax; every one is reported with its value and type.
probe batch — many probes, one process
The cost of a probe is the round trip, not the interpretation. Checking twenty inputs against one function should cost one invocation:
Code
Code
Exits non-zero if any probe in the batch failed.
Options
| Option | What it does |
|---|---|
--args '<json>' | Positional arguments for probe call, converted to each parameter's declared type. |
--set '<json>' | Seed the in-memory tables: {"Currency":[{"Code":"EUR","ISO Code":"EUR"}]}. |
--trace | Record every assignment and call with its value and source line. |
--vars '<decls>' | AL variable declarations for probe eval. |
--project <dir> | The AL project (or repo root) to load. Defaults to the working directory. |
--today, --work-date | Pin Today() / WorkDate() so a probe is reproducible. |
--user, --company | What UserId() / CompanyName() return. |
--steps <n> | Interpreter step budget before the run is abandoned (default 5,000,000). |
Paths always resolve locally — the engine reads this machine's source, so --server plays no part.
Seeding data
There is no Business Central data. Every table starts empty; a Customer exists only if you put one there:
Code
Without the seed the same call raises Business Central's own error — "The General Ledger Setup does not exist…" — which is the honest answer, not a fabricated default.
Tracing
--trace prints each step with its value and source line, so an off-by-one is located in one invocation instead of five:
Code
Reading the result
Every result states the conditions it was produced under — pinned Today, UserId, the number of seeded rows, "no service tier, tables start empty". A value is only meaningful together with them.
Failures carry a stable code; branch on that rather than on the message text:
| Code | Meaning | Exit |
|---|---|---|
PROBE_AL_ERROR | The AL itself raised — Error(), TestField(), a length overflow. This is an answer: Business Central would do the same. | 1 |
PROBE_UNSUPPORTED | A real AL construct the engine does not implement. Not a defect in your code. | 1 |
PROBE_SYMBOL_ONLY | The implementation is not available as source (a runtime package). | 1 |
PROBE_UNKNOWN_TARGET | No such object, procedure, field or variable — the error lists what does exist. | 4 |
PROBE_SYNTAX / PROBE_BAD_ARGUMENT | The AL could not be parsed, or the arguments do not fit the signature. | 2 |
PROBE_LIMIT | The step or time budget ran out — almost always a loop that never terminates. | 124 |
PROBE_WORKSPACE | No AL project could be loaded. | 3 |
Every failure also carries the AL call stack (object, member, file, line).
A refusal is an answer, not an obstacle
The engine is built so it can only ever be incomplete, never wrong. Commit(), Random(), an unsupported CalcFormula and Format with a format string are refused rather than approximated, because a plausible-looking wrong value is worse than no value. Never rewrite AL until a probe stops refusing — probe a different path, or use a container.
What is not there
No service tier: no company, no permission system, no transactions, no UI, no HTTP, no background sessions. Tables start empty. Commit/Rollback are refused rather than silently treated as no-ops.
Base Application logic runs where its source is available. A dependency shipped as a runtime package contains no AL at all — its tables and enums are usable as data shapes, its code raises PROBE_SYMBOL_ONLY naming the package.
Also refused rather than approximated: array variables, with … do, .NET interop, Format format strings, CalcFields for anything but count/sum, and page or report rendering.
Fidelity
The engine is verified against a real Business Central container by the differential harness in the ALbuild repository (test/differential/). Both sides execute the same AL source, and the probe's answer becomes the expectation in a generated BC test, so any disagreement is reported with both values.
That process has found and fixed real defects — including two where the engine's own unit tests confidently asserted the wrong behaviour, because they were written from the same belief as the code. Facts it settled, worth knowing while writing AL:
- text comparison is case-sensitive (
'abc' = 'ABC'is false), and so is table filtering; CopyStrtruncates on overrun, but an overlong assignment toText[n]/Code[n]raises;Codeis uppercased and trimmed on assignment, before the length check;Formatgroups thousands for aDecimal(1,024) but not for anInteger(2026);- a discarded failing
GetorFindSetraises; - a table's
OnInserttrigger runs before the row is written.
Deliberately out of scope
albuild probe is CLI-only. It is not an Azure DevOps task, not an MCP tool and not part of any pipeline. Its value is in answering a question while you write code; a pipeline gate needs the real service tier, and that is what the DevOps tasks and albuild app deploy are for.


