---
name: al-build
description: Build and test AL/Business Central projects. Use after modifying AL code or tests to verify the build gate passes. Runs compilation, publishing, and test execution in a single command. Required gate before committing AL changes.
---

**Style:** Be extremely concise. Sacrifice grammar for concision. Opinionated — pick a side. Arrows (→) for causality. Technical terms exact, code and errors quoted verbatim.

# /al-build — Build and test gate

Run after every AL change. Zero warnings, zero errors. Anything else is red.

Required gate before committing AL changes. `/al-implement` calls this after every RED, GREEN, `/al-refactor`.

**Layer.** Executes the **Unit** (AL-Runner, fast pre-gate) and **Integration** (container + TestPage) layers of the test pyramid — the assertion-oracle, red-first foundation that E2E and Contract reds push *down* to. See [`test-strategy.md`](../../references/test-strategy.md).

## First time

1. `pwsh "<skill-folder>/scripts/init.ps1"` → drops `al-build.json` in repo root.
2. Set `testApps` to list your test app directories.
3. `pwsh "<skill-folder>/scripts/provision.ps1"` → one-time symbol + container setup.

`Stop. Run pwsh "<skill-folder>/scripts/provision.ps1" first.` if `test.ps1` complains compiler or symbols are missing.

## Canonical gate

Set location to consumer repo root, then:

```powershell
pwsh "<skill-folder>/scripts/test.ps1"
```

Always run full gate. Do not filter tests by codeunit.
Force republish: `pwsh "<skill-folder>/scripts/test.ps1" -Force`

### Gate metrics (automatic)

Every `test.ps1` run self-records one entry — `gate`, `outcome`, `branch`, `headSha`, dirty-workspace fingerprint (`dirty.app`/`dirty.tests`/`dirty.other`), per-runner test totals, step timings — to `.output/logs/build-timing.jsonl` plus a user-level mirror at `~/.al-build/gate-metrics.jsonl` (override: `ALBT_GATE_METRICS_GLOBAL_PATH`). No caller flags; phase attribution derives from the recorded evidence at report time (prod-only dirty ≈ mutation gates, test-only ≈ RED proofs, mixed ≈ TDD inner loop, clean ≈ closeout). Summarize where gate time goes: `pwsh "<skill-folder>/scripts/report-gate-metrics.ps1"` (repo-local) or `-GlobalLog` (cross-repo).

### Fast unit test (inner loop)

When `unitTestApp` configured in `al-build.json`, run only AL Runner unit tests:

```powershell
pwsh "<skill-folder>/scripts/test.ps1" -UnitTestOnly
```

Compiles all apps, runs AL Runner against unit test app, exits. No container needed. Use during RED→GREEN inner loop in `/al-implement` for fast feedback.

**Outputs (per test run):**

- `.output/TestResults/<dirName>/last.xml` → JUnit XML from the container run.
- `.output/TestResults/<dirName>/al-runner.xml` → JUnit XML from the AL Runner run. Separate file — a full gate must never overwrite the unit result.
- `.output/TestResults/<dirName>/telemetry.jsonl` → feature telemetry per container run. `/al-debug-logging` reads this.
- `.output/TestResults/summary.json` → machine-readable summary: `gate` (`full`/`unit`), `totals` per runner, `runs[]` with one record per test run (`runner`, `appName`, `dir`, `passed`, `counts`, `resultFile`, `telemetryFile`).
- `.output/logs/build-timing.jsonl` → one gate-metrics entry per run (every exit path: pass, fail, throw), mirrored to `~/.al-build/gate-metrics.jsonl`.

Take `resultFile` paths from `summary.json` run records — don't glob; a stale file from an earlier gate may sit beside a fresh one.

Test failures → dispatch `/al-debug-logging`. Don't grep build log for clues telemetry already answers.

## Delegation

Always delegate `/al-build` to one general subagent. Build output is verbose; keep it out of the main session.

After the worker returns the gate report, close the completed subagent thread before interpreting or reporting the result.

Model:
- Codex `spawn_agent`: `model=gpt-5.4-mini`, `reasoning_effort=low`
- Claude Code `Agent`: `model=haiku`

### Worker rules

```
Do not edit source, specs, tasks, config, or git state. Running `test.ps1` may write build/test artifacts under `.output`; that is allowed.

Run exactly one requested gate. Do not rerun on failure. Do not run multiple `/al-build` gates in parallel. Do not shadow the worker with an inline build.

Return observed outcome only. Do not make routing decisions. Do not invoke follow-up skills. Do not inspect or summarize telemetry; return the telemetry path when present.
```

### Worker return contract

Return YAML-like plain text in a fenced `text` block.

Take `gate`, `totals`, and all counts from `.output/TestResults/summary.json` — it is the source of truth. **Never derive counts from console lines: `Codeunit … Success` lines are test codeunits (containers of tests), not tests.** Echo `appName`, `dir`, `resultFile`, `telemetryFile`, and every `counts` number exactly as emitted; do not normalize or reinterpret. Report totals per runner; never sum across runners — the unit test app runs through both al-runner and the container, so a cross-runner sum counts the same tests twice. If `counts` is `null` for a run, report `counts: unavailable` — do not substitute zeros. Omit `totals` and `runs` if no summary exists.

On failure, parse the failing run's `resultFile` (JUnit XML, both runners) for failing test names and the `<failure message=…>` text. If XML is unavailable but console output has explicit failure lines, use those. If neither exists, omit `failing_tests`. Omit `first_error` and `log_excerpt` unless corresponding evidence exists. `log_excerpt` is capped at 20 relevant lines. `root_signal` is mandatory for `FAIL` and must compress observed output only; no cause speculation.

PASS example:

```text
VERDICT: PASS
cmd: pwsh "<skill-folder>/scripts/test.ps1"
gate: full
exit_code: 0

totals:
  al-runner: 1 run - 563 tests in 54 test codeunits - 563 passed, 0 failed, 0 skipped
  container: 2 runs - 601 tests in 58 test codeunits - 601 passed, 0 failed, 0 skipped

runs:
- runner: al-runner | app: unit-tests | passed: true | tests: 563 (54 test codeunits)
  resultFile: .output/TestResults/unit-tests/al-runner.xml
- runner: container | app: unit-tests | passed: true | tests: 563 (54 test codeunits)
  resultFile: .output/TestResults/unit-tests/last.xml
  telemetryFile: .output/TestResults/unit-tests/telemetry.jsonl
- runner: container | app: integration-tests | passed: true | tests: 38 (4 test codeunits)
  resultFile: .output/TestResults/integration-tests/last.xml
  telemetryFile: .output/TestResults/integration-tests/telemetry.jsonl
```

FAIL example:

```text
VERDICT: FAIL
cmd: pwsh "<skill-folder>/scripts/test.ps1" -UnitTestOnly
gate: unit
exit_code: 1

totals:
  al-runner: 1 run - 563 tests in 54 test codeunits - 561 passed, 2 failed, 0 skipped

runs:
- runner: al-runner | app: unit-tests | passed: false | tests: 563 (54 test codeunits, 2 failed)
  resultFile: .output/TestResults/unit-tests/al-runner.xml

failed_phase: test
root_signal: two tests failed on `Combination Logic`; expected `OR`, actual `AND`
failing_tests:
- DefaultRuleNestedMintPersistsRevisedDescriptionAndPreservesPriorHeader: Assert.AreEqual failed. Expected: OR. Actual: AND.
- DefaultWarningNestedMintPersistsRevisedDescriptionAndPreservesPriorHeader: Assert.AreEqual failed. Expected: OR. Actual: AND.
```

### Full gate delegation

Run:

```powershell
pwsh "<skill-folder>/scripts/test.ps1"
```

Report `gate: full`.

### Fast unit test delegation (inner loop)

Run:

```powershell
pwsh "<skill-folder>/scripts/test.ps1" -UnitTestOnly
```

Report `gate: unit`.

## Configuration

Resolution order, highest wins:

1. **CLI flag** — script switches such as `-Force`; app/test paths come from env/config.
2. **Env var** — `ALBT_APP_DIR`, `ALBT_BC_CONTAINER_NAME`, `WARN_AS_ERROR`.
3. **`al-build.json`** in repo root.
4. **Built-in defaults.**

Key config fields:
- `appDir` — path to main app folder (default: `"app"`)
- `testApps` — array of test app directory paths (default: `["test"]`)
- `unitTestApp` — path to AL Runner unit test app (default: `""`, disabled). When set, `test.ps1` runs AL Runner unit tests as fast gate before container tests. App may also appear in `testApps` → container tests run all `testApps` regardless.
- `unitTestInitEvents` — fire BC lifecycle events (`OnCompanyInitialize`, `OnInstallAppPerCompany`) before AL Runner tests (default: `false`). Enable if unit tests depend on install-time data.
- `breakingChange.enabled` — enable breaking-change detection (default: `false`). See below.
- `breakingChange.baselinePackageCachePath` — baseline package cache dir (default: `.output/baseline-cache`, gitignored).

_Avoid_: editing plugin's template `config/al-build.json`. It's a template, not the live config. Repo-root copy is the live one.

## Breaking-change detection

Off by default (`breakingChange.enabled`). When on, two mechanisms, split by cost:

- **Compile-time, in every gate.** `provision.ps1` caches the latest release + deps and points `AppSourceCop.json` at them. A break then surfaces as a normal `AS00xx` diagnostic inside `test.ps1`'s compile (`-UnitTestOnly` included). `AS0001`–`AS0018` default to Error → red gate, like any cop. Not a special verdict — the rule ID is the signal; tune severity in `al.ruleset.json`. No `summary.json` change.
- **Standalone heavyweight.** `validate-breaking-changes.ps1` runs the broader AppSource sim (per-country, install/upgrade) against the same cache. Reads the cache, never downloads; empty cache → stops with *"run provision.ps1"*. Not wired into `test.ps1` — a feature-end / pre-release check, never the inner loop.

`provision.ps1` is the sole baseline fetcher and now refreshes per feature (re-run when a new release is cut). No release yet → detection stays cleanly off, never a false green.

## Container recovery

Situation → action:

| Symptom | Action |
|---|---|
| `test.ps1` fails on container connect / publish | `docker restart <container>`, re-run `test.ps1`. |
| Restart didn't fix it | `docker rm -f <container>`, re-run `test.ps1`. Script recreates it. |
| Recreate didn't fix it | Re-run `provision.ps1`, then `test.ps1`. |

**Anti-pattern: edit container manually.** No `docker exec`, no `Invoke-ScriptInBcContainer` to patch state, no hand-installing apps. Container is disposable; reproducibility lives in scripts.

## Yes / No

**No:** `alc.exe app.json` directly. Use `/al-build` for tests, not bare `alc.exe` → symbol resolution, container publish, telemetry capture live in `test.ps1`.
**Yes:** `pwsh scripts/test.ps1` after every AL edit.

**No:** `docker exec <container> bash` to fix it in place.
**Yes:** `docker rm -f <container>` and re-run.

## Composition

- `/al-implement` — calls this after every RED, GREEN, `/al-refactor`, before marking `[x]`. Use `-UnitTestOnly` for RED→GREEN inner loop when `unitTestApp` configured; use full gate before committing.
- `/al-debug-logging` — consumes `telemetry.jsonl` produced here (in per-app subfolders).
- `pwsh "<skill-folder>/scripts/init.ps1"`, `pwsh "<skill-folder>/scripts/provision.ps1"` — one-time setup before this skill is usable.

## Out of scope

- Provisioning symbols or installing compiler → `pwsh "<skill-folder>/scripts/provision.ps1"`.
- Debugging test failures → `/al-debug-logging`.
- Editing AL code → caller's job.
