import { Callout } from "zudoku/ui/Callout";

# 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?**

```bash
albuild probe eval --vars 'r: Text' --code "r := CopyStr('ABC', 2, 10);"
#   r    Text    'BC'
```

`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.

<Callout type="info" title="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`](./commands) against a container.
</Callout>

---

### 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.

```bash
albuild probe scope --project ./app
albuild probe scope --json
```

### `probe call` — one procedure with real arguments

```bash
albuild probe call --target '"bdev.BNK Bank Formatter"::FormatIBAN' \
  --args '["de89 3704 0044 0532 0130 00"]' --project ./app
#   -> Code[50]  'DE89 3704 0044 0532 0130 00'
```

`--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

```bash
albuild probe eval --vars 'd: Date' --code "d := CalcDate('<CM>', 20260115D);"
albuild probe eval --vars 'n: Integer' --code "n := StrPos('hello world', 'world');"
```

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:

```json
[
  { "target": "\"bdev.BNK Bank Formatter\"::FormatIBAN", "args": ["de89370400440532013000"] },
  { "target": "\"bdev.BNK Bank Formatter\"::MaskIBAN", "args": ["DE89370400440532013000"] },
  { "code": "r := CopyStr('ABC', 2, 10);", "vars": "r: Text" }
]
```

```bash
albuild probe batch probes.json --json
```

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:

```bash
albuild probe call --target '"bdev.BNK Bank Formatter"::GetISOCurrencyCodeOrDefault' \
  --args '["EUR"]' --set '{"Currency":[{"Code":"EUR","ISO Code":"EUR"}]}'
```

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:

```
  trace:
      67  call   StrLen()
      67  assign ibanLength := 12
      72  call   CopyStr()
      72  assign visiblePart := '3000'
      73  call   PadStr()
      73  assign maskedPart := '********'
```

---

## 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).

<Callout type="caution" title="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.
</Callout>

---

## 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;
- `CopyStr` truncates on overrun, but an overlong assignment to `Text[n]`/`Code[n]` **raises**;
- `Code` is uppercased and trimmed on assignment, before the length check;
- `Format` groups thousands for a `Decimal` (`1,024`) but not for an `Integer` (`2026`);
- a discarded failing `Get` or `FindSet` **raises**;
- a table's `OnInsert` trigger 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](../devops-extension/index) and `albuild app deploy` are for.
