left-code.AlWasp
0.3.7
See the version list below for details.
dotnet tool install --global left-code.AlWasp --version 0.3.7
dotnet new tool-manifest
dotnet tool install --local left-code.AlWasp --version 0.3.7
#tool dotnet:?package=left-code.AlWasp&version=0.3.7
nuke :add-package left-code.AlWasp --version 0.3.7
ALWasp
ALWasp is a .NET 8 CLI tool for Microsoft Dynamics 365 Business Central AL projects.
It provides:
- Symbol restore from NuGet feeds (
alwasp restore) - Config-driven multi-project build via
alwasp.json(alwasp build), including build-time app.json transformations (versioning, Application Insights, resource exposure policy) and git-based change detection - Single-project build with
alc(fallback when noalwasp.jsonis present) - Permanent app.json version updates via
alwasp version apply - Low-level workspace build with
altool workspace compile(alwasp workspace build)
Installation
dotnet tool install --global left-code.AlWasp
Verify:
alwasp --help
Quick Start
# 1. Create alwasp.json in the current directory (scans for app.json folders)
alwasp init
# 2. Edit alwasp.json — move test projects from 'apps' to 'tests'
# 3. Validate the config
alwasp config validate
# 4. Restore symbol packages
alwasp restore
# 5. Build with the default target
alwasp build
# 6. Build a named target (e.g. CI — runs appsource + test profiles)
alwasp build ci
Command Overview
alwasp [--format text|json|ndjson] [--result-file <path>]
├── restore [appJsonPath] Restore AL symbol packages
├── build [targetOrPath] Config-driven or single-project build
├── plan [targetOrProfile] Create a deterministic changed-only CI plan
├── init Create alwasp.json in the current directory
├── config
│ └── validate Validate alwasp.json and print errors
├── version
│ └── apply [targetOrProfile] Calculate and permanently write app.json versions
├── tools
│ └── update [--clean] [--check] Update cached alc/altool to the latest version
├── analyze [targetOrProfile] Static source analysis: graphs, changes, API, impacted tests
├── compare <baseline> <current> Compare public symbols in two compiled .app packages
├── validate
│ ├── compatibility [target] Recompile with AppSourceCop against a baseline .app
│ └── translations [target] Check that translations cover every translatable string
├── app
│ └── set-package-id <appPath> Assign a new package ID so a .app can be re-uploaded
└── workspace
├── restore [workspacePath] Restore for a .code-workspace file
└── build [workspacePath] Low-level workspace compile (altool)
Preferred commands for multi-project repositories:
alwasp build # run the defaultTarget from alwasp.json
alwasp build release # run a named target
alwasp build --profile appsource # run a named profile
alwasp build --clean # discard prior ALWasp artifacts, restore, and rebuild
alwasp plan ci --changed-since origin/main --plan-file .output/alwasp-plan.json
alwasp build --plan .output/alwasp-plan.json
alwasp restore # restore symbols without building
For localized Business Central symbols, set restore.country in alwasp.json.
For example, "country": "DE" makes restore prefer German variants of Microsoft
symbols, including explicit test-library dependencies; omit it or use "W1" to
prefer the default unlocalized/W1 packages.
Machine-Readable Output
Every command accepts --format:
| Value | Behavior |
|---|---|
text (default) |
Human-readable console output, unchanged |
json |
One result document on stdout; human output moves to stderr |
ndjson |
One JSON record per line, written as the command runs (jsonl is accepted too) |
alwasp restore --format json | jq '.data.packagesFolder'
alwasp build release --format ndjson | jq -r 'select(.type=="event") | .event'
alwasp validate translations --format json | jq '.data.report.projects[].coverage'
The result document
--format json prints exactly one document; --format ndjson ends with the same document
inside a result record. The exit code is unchanged — the document just makes it explainable.
{
"schemaVersion": 1,
"command": "build",
"success": true,
"exitCode": 0,
"startedAtUtc": "2026-07-31T09:12:03.114+00:00",
"completedAtUtc": "2026-07-31T09:14:52.881+00:00",
"durationMs": 169767,
"data": { "mode": "config", "configPath": "...", "profiles": ["appsource"], "build": { } },
"warnings": [],
"errors": []
}
data is per command: restore reports the packages folder and download counts, build
carries the full build manifest (profiles, groups, per-project versions) whether or not
--manifest was passed, compare and validate translations carry their reports, version apply lists each project's old and new version, and app set-package-id reports both package
IDs.
The ndjson record stream
type |
Meaning |
|---|---|
log |
A line of human output. Lines captured from the AL compiler carry "stream": "stdout" or "stderr" |
event |
A structured progress event — profileStarted, groupCompleted, profileCompleted |
warning / error |
A warning or error, also collected in the result document |
result |
The result document — always the last record |
In ndjson both of the compiler's streams are wrapped, so nothing escapes the record stream as
loose text. text and json leave stderr where it is: neither reserves it.
{"type":"event","timestampUtc":"...","command":"build","event":"profileStarted","data":{"profile":"appsource","projects":["Base"]}}
--result-file for builds
build and workspace build shell out to altool, which writes straight to the inherited
console and cannot be redirected (it reads Console.WindowWidth and fails when stdout is a
pipe). Its output would therefore interleave with the machine-readable stream on stdout. Pass
--result-file <path> for those commands: the document or record stream goes to the file and
the console keeps behaving exactly as in text mode.
alwasp build release --format json --result-file .output/build-result.json
The same flag is available on every command. Note that with --format json (and no
--result-file) CI annotations from workspace build move to stderr along with the rest of
the human output, so pipelines that rely on them should use text, ndjson, or
--result-file.
Initializing a Config
Use alwasp init to create a starter alwasp.json:
alwasp init
This scans direct child folders in the current directory for app.json files and
adds them all to the apps list. After running init, open alwasp.json and move
any test projects from apps to tests manually.
If your test project folder names follow a consistent convention (ending in Test or
Tests), pass --include-tests-by-name to auto-classify them:
alwasp init --include-tests-by-name
Note:
--include-tests-by-nameis a convenience heuristic only. Always verify the result matches your repository structure before running a build.
Use --force to overwrite an existing alwasp.json:
alwasp init --force
Validating the Config
# Validate alwasp.json in the current directory
alwasp config validate
# Validate a specific file
alwasp config validate --config path/to/alwasp.json
Exits 0 when the config is valid, 1 when errors are found. All errors are printed
before exiting so you can fix them in a single pass.
JSON Schema
A JSON Schema for alwasp.json is published at
https://alwasp.dev/schema/alwasp.schema.json (and ships in the repo at
schemas/alwasp.schema.json). Add the $schema reference to your config file
to get editor autocompletion and inline documentation in VS Code and other
editors:
{
"$schema": "https://alwasp.dev/schema/alwasp.schema.json",
"version": 1,
...
}
Config-Driven Build (Recommended)
For repositories with more than one AL project, create an alwasp.json at the
repository root. Config-driven builds use a three-level hierarchy:
| Layer | Purpose |
|---|---|
| apps / tests | Inventory — which project directories exist |
| profiles | Build configuration — what to include and how to compile it |
| targets | Workflow shortcuts — which profiles to run together |
Test projects compile to .app artifacts in the packages folder, just like
app projects. ALWasp does not execute AL tests — test execution belongs to
BC-DevX or your deployment pipeline.
Example alwasp.json
See alwasp.example.json at the repository root for a complete working example.
A minimal working example:
{
"version": 1,
"defaultTarget": "release",
"apps": [
{ "id": "Broker", "path": "./Broker" },
{ "id": "Monet", "path": "./Monet" },
{ "id": "HappyTexts", "path": "./HappyTexts" }
],
"tests": [
{ "id": "BrokerTest", "path": "./BrokerTest", "app": "Broker" },
{ "id": "MonetTest", "path": "./MonetTest", "app": "Monet" },
{ "id": "HappyTextsTest", "path": "./HappyTextsTest", "app": "HappyTexts" }
],
"profiles": {
"appsource": {
"include": "apps",
"appSourceCop": true
},
"developer": {
"include": "apps",
"resourceExposurePolicy": {
"allowDebugging": true,
"includeSourceInSymbolFile": true
},
"overrides": {
"HappyTexts": {
"defines": ["DEV"]
}
}
},
"test": {
"include": "tests"
}
},
"targets": {
"release": ["appsource"],
"ci": ["appsource", "test"],
"dev": ["developer", "test"]
}
}
Profile-bound baseline compatibility
A profile can make its normal build output the authoritative compatibility-validated artifact:
{
"apps": [
{
"id": "Broker",
"path": "./Broker",
"compatibility": { "baseline": "./previous/Broker.app" }
}
],
"profiles": {
"release": {
"include": "apps",
"outFolder": "output/release",
"compatibility": { "enabled": true }
},
"development": {
"include": "apps",
"appSourceCop": true,
"outFolder": "output/development",
"outputSuffix": "_develop"
}
}
}
For release, apps declaring compatibility.baseline are compiled once with AppSourceCop bound
to that baseline, and the successful package is collected through the profile's normal
outFolder/outputSuffix flow. compatibility.enabled implies AppSourceCop only for matched apps.
Selected apps without a baseline are reported as new and compiled normally. Other profiles are
unaffected: appSourceCop: true still enables ordinary AppSourceCop without ALWasp overlaying a
baseline. User-owned AppSourceCop.json files are restored byte-for-byte after each compile group.
Historical baseline dependencies are staged in a separate temporary cache and cannot contaminate
the build's normal package cache.
warningPolicy
altool workspace compile has no "treat warnings as errors" flag, so ALWasp
implements warningPolicy.treatWarningsAsErrors itself: after each compile group
finishes, ALWasp parses the compiler's diagnostic logs and fails the build (exit
code 1) if any non-suppressed warnings remain — codes you suppressed via
nowarn or a ruleset never count toward this check. When the policy is about to
fail an otherwise-successful compile, ALWasp prints the offending warnings to the
console regardless of --diagnostics, so it's clear which ones caused the failure.
"defaults": {
"warningPolicy": {
"treatWarningsAsErrors": true
}
}
It can be set at defaults, profile, or per-project overrides level, with the
usual most-specific-wins resolution. Because the policy is enforced per compile
group from that group's aggregated diagnostics, projects with different
treatWarningsAsErrors values are never batched into the same group.
Legacy alias:
warnAsError(a plain boolean, at the same three levels) is still accepted as a backward-compatible alias forwarningPolicy.treatWarningsAsErrors. When both are set at the same level,warningPolicy.treatWarningsAsErrorswins. New configs should preferwarningPolicy.treatWarningsAsErrors.
Package cache concurrency and AL1028
altool workspace compile writes compiled .app files into the same package
cache it reads symbols from. When the output file already exists there,
overwriting it can race with any process holding it open (a parallel compile
resolving references, the VS Code AL extension, an antivirus scan) and fail
intermittently with AL1028 ("the process cannot access the file … because it
is being used by another process"). ALWasp defends against this in three layers:
Stale-output sweep (automatic). Before each compile, ALWasp deletes from the cache exactly the output files it is about to produce (matched by the full
{Publisher}_{Name}_{Version}.appname — packages at other versions, or different apps sharing publisher/name, are restored symbols and are never touched). The compiler then creates each output fresh. A stale file that stays locked after a short retry fails the build up front with the offending path and a hint to close the program holding it.Package cache lock (automatic). Every command that writes to a package cache —
build,workspace build,restore, andworkspace restore— holds an exclusive.alwasp.lockinside the packages folder for the duration of its cache work (builds: restore, sweep, compile, and output collection), so concurrent ALWasp processes sharing one cache serialize instead of interleaving. A blocked command waits (up to 15 minutes) and reports which cache it is waiting for; permission problems fail immediately instead of being mistaken for contention. The lock coordinates ALWasp processes only; it does not affect VS Code or other programs.workspace.isolateDependencyLevels(opt-in, defaultfalse). Splits build groups at topological dependency-level boundaries so a project and one of its dependencies never share the samealtoolinvocation, even with identical settings; mutually independent projects in a level still compile in parallel. Enable it if a repository still observes compiler-side package-cache races under--maxcpucountafter layers 1–2. Each dependency level costs one extra compiler invocation.Scope:
isolateDependencyLevelsapplies to config-driven builds only (alwasp buildwithalwasp.json). The low-levelalwasp workspace buildcommand always compiles the whole.code-workspacein a singlealtoolinvocation and is unaffected by this setting.
"workspace": {
"isolateDependencyLevels": true
}
Profile includes
A profile's include field selects which projects to build:
| Value | Selects |
|---|---|
"apps" |
All entries from the apps list |
"tests" |
All entries from the tests list |
"<id>" |
A single project by id |
["a", "b", ...] |
An ordered combination of any of the above |
Profile overrides
The overrides map applies per-project settings on top of profile-level settings:
"overrides": {
"HappyTexts": {
"defines": ["DEV"],
"codeCop": false
}
}
Settings merge order: defaults → profile → overrides (most specific wins or accumulates).
Running targets and profiles
# defaultTarget from alwasp.json
alwasp build
# Named target (runs its profiles in order)
alwasp build release
alwasp build ci
alwasp build dev # runs developer + test profiles (from alwasp.example.json)
# Named profile directly
alwasp build --profile appsource
alwasp build --profile developer
alwasp build --profile test
Migration from removed commands
The workspace build apps and workspace build tests commands have been removed.
Those commands classified projects by whether the directory name ended with Test,
which is unreliable for real repositories. Use config-driven targets instead:
| Old | New |
|---|---|
alwasp workspace build apps |
alwasp build --profile appsource |
alwasp workspace build tests |
alwasp build --profile test |
| Both in sequence | alwasp build ci |
To migrate an existing workspace-based project:
- Run
alwasp initto generate a starteralwasp.json - Move test projects from
appstotestsin the generated file - Run
alwasp config validateto confirm the config is correct - Replace
alwasp workspace build ...calls withalwasp build <target>
Migration from removed config fields
defaults.target/profile.target, showMyCode, and enableDebugging have been
removed from alwasp.json — they were never forwarded to altool workspace compile
and either produced "unsupported setting" warnings or had no effect at all:
| Old field | New equivalent |
|---|---|
showMyCode / enableDebugging |
resourceExposurePolicy |
warnAsError |
warningPolicy.treatWarningsAsErrors (still accepted as a legacy alias) |
App.json Transformations and Versioning
Config-driven builds can optionally transform each selected project's app.json
at build time — calculating and writing a version, setting Application Insights
properties, and/or writing a resource exposure policy. These transformations are
temporary: ALWasp backs up the original app.json to app.json.alwasp.bak,
modifies the original for the build, and always restores it from the backup
afterwards — even when the build fails. The source app.json is therefore never
left permanently modified by alwasp build.
{
"versioning": {
"enabled": true,
"releaseType": "Release",
"source": "appJson",
"applyTo": "all",
"includeDependencies": true,
"dependencyUpdateScope": "directlyChanged"
},
"resourceExposurePolicy": {
"allowDebugging": false,
"allowDownloadingSource": false,
"includeSourceInSymbolFile": false
},
"applicationInsights": {
"enabled": true,
"source": "environmentByProject",
"mode": "auto",
"environmentVariables": {
"Broker": "BROKER_AI_CONNECTION_STRING"
}
},
"changeDetection": {
"mode": "git",
"base": "latest:v*",
"includeDependents": true
}
}
Each of versioning, resourceExposurePolicy, and applicationInsights can be
overridden per profile (the profile's section wins field-by-field over the
top-level section). All four sections are entirely optional — when omitted,
app.json is left untouched.
versioning
| Field | Purpose |
|---|---|
enabled |
Turn version calculation on/off |
releaseType |
Release, Preview, Dev, or None — drives the next-version calculation. Dev keeps Major.Minor.Build from the current version and only forces Revision to 1, a fixed marker for non-release builds (e.g. testing-branch Sandbox deploys) that's always higher than a real release's Revision 0 |
source |
Where the current-version baseline comes from: appJson, nuget (latest published package), or explicit |
explicitVersion |
Required when source is explicit |
fallbackToAppJson |
When source is nuget and no published package is found, fall back to the app.json version instead of failing |
applyTo |
all (every selected project) or changedOnly (only projects identified as changed by git change detection) |
includeDependencies |
Update internal dependency entries to selected projects' calculated versions; defaults to true |
dependencyUpdateScope |
directlyChanged (default) updates a dependency reference only when both the consuming project and dependency project have direct Git changes; allVersioned propagates every selected calculated version |
The Release/Preview period switches on the Friday closest to the 15th of each month, inclusive. Before that Friday, Release targets the previous month and Preview targets the current month. Starting on that Friday, Release targets the current month and Preview targets the next month.
ALWasp never integrates with Azure DevOps Variable Groups for version baselines — that integration belongs to your pipeline, not AlWasp.
resourceExposurePolicy
resourceExposurePolicy is the supported way to control whether a built app
allows debugging, source download, or embeds source in its symbol package — there
is no altool/alc compiler flag for any of this; it is purely an app.json
property that Business Central reads at publish/install time. ALWasp writes (or
updates) the resourceExposurePolicy object in app.json before compiling,
preserving any fields not configured here, and restores the original app.json
afterwards (see App.json Transformations and Versioning):
| Field | app.json property |
|---|---|
allowDebugging |
resourceExposurePolicy.allowDebugging |
allowDownloadingSource |
resourceExposurePolicy.allowDownloadingSource |
includeSourceInSymbolFile |
resourceExposurePolicy.includeSourceInSymbolFile |
It can be set at the top level and overridden per profile (the profile's section wins field-by-field over the top-level section):
"profiles": {
"developer": {
"resourceExposurePolicy": {
"allowDebugging": true,
"includeSourceInSymbolFile": true
}
}
}
applicationInsights
| Field | Purpose |
|---|---|
enabled |
Turn Application Insights injection on/off |
source |
literal (use value directly), environment (read environmentVariable), environmentByProject (look up the project id in environmentVariables to find which environment variable to read), or literalByProject (look up the project id directly in values — no environment-variable indirection) |
mode |
auto (derive from the app's runtime/configured bcVersion), connectionString, or instrumentationKey |
The exact app.json property names written are always
applicationInsightsConnectionString or applicationInsightsKey — never anything
else. Application Insights values, connection strings, instrumentation keys, and
any other secrets are never written to the console, log files, or the build
manifest — only a presence flag and the resolved property name are ever recorded
or logged.
changeDetection and --changed-since
Set changeDetection.mode to git (or pass --changed-since, which always
overrides the config) to have ALWasp determine which selected projects changed
since a base reference, using your local git checkout only — never the GitHub
API:
alwasp build ci --changed-since latest # newest local tag
alwasp build ci --changed-since "latest:v*" # newest local tag matching a glob
alwasp build ci --changed-since "latest-merge:version-increase" # nearest matching release merge
alwasp build ci --changed-since v2026.6.0 # explicit tag/branch/commit
build --changed-since retains its existing semantics: change detection influences
changed-only versioning, but the selected target still builds in full. Use plan for an explicit
changed-only CI selection:
alwasp plan ci \
--changed-since origin/main \
--plan-file .output/alwasp-plan.json
The plan is a deterministic, timestamp-free JSON document. It selects directly changed projects,
downstream applications, affected test apps, and the internal prerequisites needed to compile
them. It records dependency build levels, deployment order, repository commit IDs, changed-file
hashes, the configuration hash, and an input fingerprint. A change to alwasp.json, a configured
NuGet config, or an active ruleset selects the complete requested target. No relevant changes
produce a successful empty plan. Dependency cycles fail plan creation.
Relative --plan-file paths are resolved from the current directory. When omitted, the plan is
written to .output/alwasp-plan.json.
Consume that selection with alwasp build --plan <path>. Plan builds fail closed if the schema
major version or fingerprint is invalid, the configuration, repository HEAD, changed-file set, or
changed-file contents have drifted, or planned profiles/project App IDs are no longer available.
The build restores, transforms, and compiles only the projects selected for each planned profile;
an empty plan is a successful no-op. --plan cannot be combined with a positional target,
--profile, or --changed-since. Builds without --plan retain their existing behavior.
latest-merge:<text> searches from the configured head (default HEAD) along
its first-parent history and selects the nearest merge commit whose commit message
contains <text>. This is useful when a release tag precedes a version-increase PR:
using latest-merge:version-increase makes the merged version state the baseline,
so the release's own version changes are not counted again. CI checkouts must contain
enough history to reach that merge (for example, use fetch-depth: 0).
ALWasp discovers the Git repository from each selected project folder (the folder
containing alwasp.json does not itself need to be in a checkout), runs
git diff --name-only base..head independently for every discovered repository,
maps changed files to the project folder that contains them, and (when
includeDependents is true, the default)
expands the changed set to include every selected project that transitively
depends on a changed project.
validate translations uses that changed set as its project filter by default: when Git
change detection is active, unchanged apps are skipped and a run with no changed apps succeeds
without performing translation checks. Direct --project and --project-root validation is
unchanged. The command also accepts --changed-since to activate change detection or override
the configured base reference.
changeDetection.includeDependencies is retained as a deprecated alias for
includeDependents; new configurations should use the corrected name.
When versioning.includeDependencies is true (the default), ALWasp can update a
project's app.json references to other selected/versioned projects by app GUID.
dependencyUpdateScope: directlyChanged (the default) limits those updates to
references where both the consuming project's files and the dependency project's
files changed directly. An unchanged dependant is therefore not rewritten merely
because one of its dependencies changed. Set the scope to
allVersioned for full A → B → C propagation. External dependencies are always
untouched. Build applies dependency versions temporarily; version apply writes
them permanently.
Change detection, profile selection, and versioning.applyTo are independent
concepts: profiles decide what gets built, change detection decides what counts
as changed, and versioning.applyTo: changedOnly decides which of the selected
projects get a version update. A project can be built without its version being
touched, and vice versa.
alwasp version apply
alwasp version apply [targetOrProfile] resolves the same project selection,
versioning settings, and git change detection as alwasp build, but
permanently writes the calculated version to each selected project's
app.json — there is no backup/restore. It does not restore symbol packages,
compile anything, or touch git (no commits, tags, or pushes — that belongs to
BC-DevX or your pipeline):
alwasp version apply # defaultTarget from alwasp.json
alwasp version apply release # named target or profile
alwasp version apply --changed-since latest:v*
alwasp tools update
ALWasp downloads the AL compiler tools (alc / altool) from the
Microsoft.Dynamics.BusinessCentral.Development.Tools NuGet package and caches
each version under ~/.alwasp/tools/<version>/. During a normal build or
restore it always resolves the latest available version on demand, so it stays
current automatically. alwasp tools update lets you do this explicitly — for
example, to warm the cache before an offline build or to clean up disk space:
alwasp tools update # download the latest version if not already cached
alwasp tools update --check # dry run: report whether an update is available
alwasp tools update --clean # update, then delete all other cached versions
Behavior:
- Resolves the latest version (including pre-release) from NuGet.org.
- If that version is already cached, reports "up to date" and downloads nothing.
--checknever downloads or deletes; it exits0when up to date and2when a newer version is available (handy for CI gates).--cleanremoves every cached version except the latest after the update succeeds. Without it, older versions are kept.
alwasp app set-package-id
Business Central tracks each .app uploaded to an online sandbox by its
deployment package ID — a GUID that is unique per build, not per app.
Re-uploading the exact same compiled .app is rejected because the package ID
already exists. alwasp app set-package-id assigns a fresh random package ID so
the same artifact can be re-uploaded without recompiling:
alwasp app set-package-id ./MyApp.app # rewrite in place
alwasp app set-package-id ./MyApp.app --out ./out.app # write a copy instead
This is the equivalent of BcContainerHelper's
Replace-DependenciesInAppFile -replacePackageId.
This command is where AlWasp's responsibility ends: it prepares an artifact for re-upload, it does not upload it. The upload itself belongs to BC-DevX, which calls this command automatically before every Development-scope publish — you only need to run it by hand when uploading through some other route (the BC admin centre, a custom pipeline step, the AL extension).
Behavior:
- A
.appfile is a 40-byte NAVX header followed by a ZIP archive. The package ID lives only in the header, so the change is a clean in-place rewrite of those 16 bytes — the ZIP payload (symbols, source, manifest) is left byte-for-byte identical. - The app identity is unchanged: the
idinapp.jsonkeeps the same value. Only the per-build package ID changes. - By default the file is overwritten in place;
--out <path>writes the modified copy to a new path and leaves the original untouched. - Fails with a non-zero exit code if the file is missing or is not a valid
.app(the NAVX header and the ZIP payload that follows it are validated before any bytes are written).
Source analysis (alwasp analyze)
alwasp analyze answers "what did this commit actually touch, and what has to be
rebuilt, reviewed, or re-tested because of it?" — from the source alone. It reads
.al files and git; it never invokes a compiler, restores symbols, or reaches the
network, so it runs in seconds on a fresh checkout and returns the same answer every
time for the same input.
alwasp analyze # whole config, no change detection
alwasp analyze ci --changed-since latest # everything a release tag's diff implies
alwasp analyze --project src/Core --json out/analysis.json
alwasp analyze ci --changed-since main --include impacted-tests,affected-apps
It produces seven sections:
| Section | What it answers |
|---|---|
affected-apps |
Which apps a change reaches, and by which of three routes |
dependency-graph |
App-level dependencies, build order by level, and any cycles |
object-graph |
Every declared object, where it lives, and how connected it is |
changed-objects |
Which objects were added, modified, removed, or merely moved |
public-api |
The surface another app can bind to, per object |
references |
Which object references which, what is external, and what could not be resolved |
impacted-tests |
Which test codeunits a change reaches, and the chain that proves it |
Every section is produced by default; --include narrows the report to the sections
you name (repeat the option or use a comma-separated list). A section that was not
requested is absent from the JSON rather than empty, so a consumer can tell "not
asked for" from "nothing found".
schemaVersion, scope, summary, and diagnostics are not sections: they are always
present, and --include never affects them or the numbers in summary.
Changed objects, not changed files
Change detection uses the same --changed-since / changeDetection configuration
as build and version apply (see changeDetection and
--changed-since), including latest,
latest:<glob>, and latest-merge:<text>.
Where the build maps changed files to projects, analyze goes one level deeper: it
parses each changed file at both refs and compares them object by object. Editing one
codeunit in a file that declares three does not report the other two as changed, and
moving an object to another file — with its text unchanged — is reported as Moved
and impacts nothing. Moving source into .alpackages or bin takes it out of
analyzable scope and is reported as Removed, since that is what it is as far as the
compiler is concerned.
Removing an object propagates too. The head revision has no node for it, but the objects that still name it do — so deleting a public object while a downstream app or test still references it reports those dependents, and warns that the references no longer resolve:
Diagnostics (1): 1 error(s), 0 warning(s), 0 info
x RemovedObjectStillReferenced: Removed object codeunit "Wasp Mgt" is still referenced
by codeunit "Sales Poster"; those references no longer resolve.
Impacted tests
Impact propagates along the object graph in the direction changes travel: from an
object to everything that references it. A test codeunit — Subtype = Test, or any
codeunit with a [Test] procedure — that is reached this way is reported with the
chain that reached it:
Impacted tests (1 codeunit(s), 2 test procedure(s)):
sales-tests: codeunit "Sales Post Tests" — 2 test(s), distance 2
via codeunit "Wasp Mgt" -> codeunit "Sales Poster" -> codeunit "Sales Post Tests"
The path is the evidence for the verdict, which is what makes the result reviewable
rather than something to take on faith. Use --max-depth to cap how far impact
propagates.
What counts as a reference
Only forms that can only mean a reference are counted: extends/implements, a
declared type (Record "Customer", Codeunit "Foo", a parameter or return type), an
object-id expression (Codeunit::"Foo", Database::"Customer"), an
[EventSubscriber] binding, and the properties whose value names an object —
SourceTable, TableRelation, CalcFormula, RunObject, Permissions,
IncludedPermissionSets, and a report/query dataitem. Names are never guessed from
text, and anything inside a comment or a string literal is not source.
Reference kinds
Every recorded reference carries the syntactic form it came from. Forms the parser can already tell apart stay apart — a parameter type and a return type are not merged into one generic "declared type", because deciding whether a signature change is breaking needs the difference.
kind |
The form that produced it |
|---|---|
Extends |
An extension object's extends target |
Implements |
An implements interface, or a page customization's customizes target |
DeclaredType |
A declared type outside a member signature: a variable, a table field |
ParameterType |
A declared type in a procedure's parameter list |
ReturnType |
A procedure's declared return type |
ObjectIdExpression |
Codeunit::"Foo", Database::"Customer" |
EventSubscriber |
The publisher named by an [EventSubscriber] attribute |
PropertyReference |
A typed object in a property value, e.g. RunObject = Page "X" |
SourceTable |
A page/query/report SourceTable |
TableRelation |
A TableRelation target table |
CalcFormula |
A table named by a field's CalcFormula |
DataItem |
A report or query dataitem table |
Permission |
A Permissions entry or an IncludedPermissionSets member |
Reference resolution
Each reference also carries what is actually known about its target, so a parser limitation, invalid source, and a legitimate external reference can never be mistaken for one another.
resolution |
Meaning | Where it appears |
|---|---|---|
Resolved |
The target is declared exactly once in the analyzed source | references.resolved |
External |
Not declared in the analyzed source — the base application, or a dependency outside the selection. Normal, and never diagnosed | references.external |
Ambiguous |
The name is declared more than once; the edge points at the first declaration | references.resolved |
Invalid |
The reference form is present but names no target | references.unresolvable |
Unsupported |
A form that cannot be resolved without a compiler | references.unresolvable |
// Resolved — both endpoints are analyzed source.
{ "from": { "app": "sales", "type": "Codeunit", "name": "Sales Poster" },
"to": { "app": "core", "type": "Codeunit", "name": "Wasp Mgt" },
"kind": "DeclaredType", "resolution": "Resolved", "line": 6 }
// External — 'Record "Customer"' is the base application. Not a finding.
{ "type": "Table", "name": "Customer", "kinds": ["TableRelation"],
"resolution": "External", "referenceCount": 1, "fromApps": ["core"] }
// Ambiguous — two projects declare codeunit "Wasp Shared"; the edge takes the first.
{ "from": { "app": "sales", "type": "Codeunit", "name": "Shared User" },
"to": { "app": "core", "type": "Codeunit", "name": "Wasp Shared" },
"kind": "DeclaredType", "resolution": "Ambiguous", "line": 5 }
// Unsupported — the subscriber's publisher is a variable, not a static expression.
{ "from": { "app": "core", "type": "Codeunit", "name": "Wasp Mgt" },
"kind": "EventSubscriber", "resolution": "Unsupported",
"target": "PublisherHolder",
"reason": "the [EventSubscriber] publisher is not a static <ObjectType>::\"<Name>\" expression.",
"file": "apps/Core/src/Mgt.Codeunit.al", "line": 13 }
References whose target is not declared in the analyzed projects — Microsoft's base application, above all — are reported as external references rather than dropped: what an app reaches outside itself is as interesting as what it reaches inside. They produce no diagnostics; only ambiguity, invalidity, and unsupported forms do.
Object identity
Report identity is the object's kind plus name, compared case-insensitively — the same identity AL resolves a reference by. The declared object number is not part of it: renumbering an object does not change what other source binds to.
That has consequences worth stating exactly:
- Renaming an object is a
Removedplus anAdded, because that is what it is for every referrer of the old name. There is noRenamedchange kind — the current identity model cannot support one without claiming certainty it does not have. When a removal and an addition share a kind and an object number, anObjectRenameddiagnostic (info) names both sides. - Renumbering keeps the identity. The object is
Modified,idandpreviousIdcarry both numbers, and anObjectIdChangeddiagnostic (warning) reports it — anything binding to the number, such as an object-id expression or a permission entry, is affected. - Changing an object's kind creates a new identity. Removal plus addition, with an
ObjectTypeChangeddiagnostic (warning) when the name and number are otherwise the same. - An identity conflict is never smoothed into an ordinary modification. A removal and an addition are paired only when they are in the same app and share an object number and either the kind or the name. Object numbers are not unique across a workspace — two analyzed apps can each declare 50100 — and they are not unique across kinds either, so "core removes table 50100, sales adds table 50100" and "table 50100 deleted, codeunit 50100 added" both stay two unrelated events with no diagnostic. The consequence is deliberate: a rename that also crosses an app boundary gets no identity diagnostic, because nothing in the source distinguishes it from two unrelated changes.
- A declaration that crosses an app boundary is still
Moved(text unchanged) orModified, withpreviousAppset and anObjectMovedBetweenAppsdiagnostic (warning). Both apps are directly affected — one lost a source file and the other gained one. - Two declarations of one identity is invalid AL. Both are reported as a duplicate,
the graph resolves the name to the first, every reference to it becomes
Ambiguous, and aDuplicateObjectIdentitydiagnostic (error) is emitted.
Diagnostics
diagnostics is always present — it is not a section and --include never gates it,
because a consumer must not have to infer from a missing section whether the analysis had
something to say about its own reliability. Each entry is:
{
"code": "RemovedObjectStillReferenced",
"severity": "error", // info | warning | error
"message": "Removed object codeunit \"Wasp Mgt\" is still referenced by …",
"path": "apps/Core/src/Mgt.Codeunit.al", // optional
"object": { "app": "core", "type": "Codeunit", "name": "Wasp Mgt" } // optional
}
Codes are stable and suitable for pipeline policy:
| Code | Severity | Meaning |
|---|---|---|
SourceFileUnreadable |
error | A file inside a selected project could not be read |
UnterminatedObjectBody |
error | An object declaration has no closing brace; its body was read to end of file |
DuplicateObjectIdentity |
error | Two declarations share one kind + name |
RemovedObjectStillReferenced |
error | A deleted object is still named by source that remains |
DependencyCycle |
error | The analyzed apps form a dependency cycle |
ChangeDetectionFailed |
error | The git diff behind change detection failed |
AmbiguousObjectReference |
warning | A reference whose target is declared more than once |
UnsupportedObjectReference |
warning | A reference site the analyzer cannot resolve without a compiler |
InvalidObjectReference |
warning | A reference form that names no target |
ObjectIdChanged |
warning | An object kept its identity but changed its object number |
ObjectTypeChanged |
warning | One name and number, two different object kinds |
ObjectMovedBetweenApps |
warning | A declaration moved from one analyzed app to another |
BaseRevisionUnavailable |
warning | A file in the diff could not be read at the base ref |
ObjectRenamed |
info | A removal and an addition share a kind and an object number |
RemovedObjectUnreferenced |
info | An object was removed and nothing analyzed still references it |
Severity classifies the finding; it does not set the exit code. analyze stays a
reporting command that exits 0 whenever the analysis completed. A pipeline decides its
own policy from the codes.
warnings remains in the report as the messages of every diagnostic at warning severity
or above. It is derived output — diagnostics is the source of truth.
Documented limitations
analyze reads source and git, never symbols, so some things it deliberately cannot
answer. Each is reported rather than silently dropped:
- Objects resolve by name, not by number.
Codeunit::50100names an object this analyzer cannot look up; the site is reported asUnsupported, not as an edge. [EventSubscriber]publishers must be static.ObjectType::Codeunit, Codeunit::"Sales-Post"resolves; a variable, a constant, or an expression in that slot isUnsupported.- A duplicated name resolves to the first declaration. The alternative — refusing to
resolve it — would lose every downstream edge; the choice is reported as
Ambiguousinstead of hidden. - A reference to an undeclared name is assumed external. Without symbol packages there
is no way to prove a name belongs to the base application rather than to a typo, so it
is reported as
Externaland not diagnosed. #ifbranches are all kept. Directive lines are dropped and the code inside every branch is read, because impact analysis wants the superset.- Only one declaration per identity is analyzed, and only the first one; the second is reported as a duplicate.
- Identity-change diagnostics are scoped to one app. Object numbers repeat across apps,
so a rename detected across an app boundary would be indistinguishable from two unrelated
changes; that case is reported as a removal plus an addition with no
ObjectRenamed.
Affected apps
An app is affected for one of three reasons, reported strongest-evidence-first:
DirectlyChanged— a file inside the project changed (source or not:app.json, XLIFF, and permission XML count).ReferencesChangedObject— one of its objects references a changed object.DependsOnChangedApp— itsapp.jsondepends, transitively, on a directly changed app, even with no object-level reference.
Output
The console form is a summary capped at --max-items entries per section (0 or
--verbose lists everything). --json <path> writes the complete report, as does
--format json, which carries it in the result document's data.report.
analyze is a reporting command: it exits 0 whenever the analysis completed, and
non-zero only for a usage or I/O error. Everything it found — duplicate object names,
dependency cycles, unreadable files, unresolvable references — is reported in
diagnostics, never by an exit code.
Report contract
The report is a versioned contract meant to be read by CI pipelines, other tools, and agents, so it is pinned rather than left to drift.
{ "schemaVersion": "1.0" }
schemaVersionidentifies the JSON contract, not the ALWasp package version. A patch release never changes it. MAJOR changes when a consumer of the previous version can break — a property removed or renamed, a type changed, an enum member removed, a section restructured. MINOR changes when the document is extended in a way an existing consumer can ignore: a new optional property, a new enum member, a new diagnostic code.- The full document is described by a checked-in JSON Schema at
schemas/alwasp-analyze-report.schema.json. It covers all seven sections plusscope,summary, anddiagnostics, usesadditionalProperties: falsethroughout, and enumerates every enum value. The test suite validates serialized reports against it — through both the--jsonwriter and the--format jsonresult document — so the schema cannot drift from the code.
Determinism
The report carries no timestamps, no durations, no absolute paths, and no random identifiers. Paths are relative to the repository root with forward slashes, so a report produced on a Windows agent and one produced on Linux compare equal byte for byte and a pipeline can diff two reports and act on the difference.
Every collection has one documented order. Ordering never depends on file-system enumeration, dictionary or hash-set iteration, the operating system, the current culture, or thread scheduling; all comparisons are ordinal or ordinal-ignore-case, and every sort key is a value the document itself carries — so a consumer can reproduce the order from the JSON alone rather than trusting it.
| Collection | Order |
|---|---|
scope.projects |
app id |
affectedApps |
reason (DirectlyChanged, ReferencesChangedObject, DependsOnChangedApp), then app id |
affectedApps[].via |
app id |
dependencyGraph.nodes |
analyzed apps by id, then external dependencies by id |
dependencyGraph.edges |
from, to, required version |
dependencyGraph.buildOrder |
levels in build order; ids inside a level by id |
dependencyGraph.cycles |
each cycle rotated to its smallest member, then by the joined member list |
objectGraph.objects |
app, object kind, object name |
objectGraph.duplicates |
object kind, object name, first file, second file |
changedObjects |
app, object kind, object name, change, file |
publicApi |
app, object kind, object name |
publicApi[].members |
member kind, declared number (members without one last), name |
references.resolved |
from app, from kind, from name, to kind, to name, reference kind, line |
references.external |
object kind, name (its kinds and fromApps sorted too) |
references.unresolvable |
from app, from kind, from name, reference kind, resolution, target, line |
impactedTests |
distance, app, object kind, codeunit name |
impactedTests[].tests |
procedure name |
impactedTests[].path |
the shortest reference chain, changed object first; ties broken by sorted adjacency |
diagnostics |
severity (error first), code, path, object, message |
The project selection order is a caller concern and is not part of the answer: the same projects selected in a different order produce the same bytes, including which of two duplicate declarations the graph resolves a name to.
Compatibility checks
ALWasp keeps package comparison and compiler validation separate from normal builds.
List public-symbol changes between two already-compiled packages without a compiler or Business Central environment:
alwasp compare previous/MyApp.app output/MyApp.app
alwasp compare previous/MyApp.app output/MyApp.app --json output/compatibility.json
compare is an informational package change log. It groups findings by namespace and reports
symbols as REMOVED, CHANGED, or ADDED; it does not classify compatibility or fail because
changes were found. A completed comparison exits with code 0, while unreadable packages,
mismatched app IDs, invalid arguments, and report-write failures exit with code 1.
Use validate compatibility for an authoritative compatibility gate. It recompiles the current
source with Microsoft's AppSourceCop against the baseline package.
Validate current source with Microsoft's AppSourceCop (this recompiles the project):
alwasp validate compatibility \
--project src/Core \
--baseline previous/Core.app \
--ruleset rulesets/AppSourceCop.ruleset.json
For a dynamically populated multi-app baseline directory, use discovery mode:
alwasp validate compatibility `
--project-root .\src `
--baseline-directory .\latest `
--ruleset .\dyce.ruleset.json
Select the Business Central release lane and localization with --bc-target and
--bc-country. The country defaults to W1; country codes are case-insensitive.
# Current release symbols from NuGet, German localization
alwasp validate compatibility --project-root .\src --baseline-directory .\latest `
--bc-target current --bc-country DE
# German application symbols plus the matching Insider platform/compiler
alwasp validate compatibility --project-root .\src --baseline-directory .\latest `
--bc-target next-minor --bc-country DE
alwasp validate compatibility --project-root .\src --baseline-directory .\latest `
--bc-target next-major --bc-country DE
For Insider targets, localized and W1 artifacts use separate cache entries. They can also be
downloaded explicitly with alwasp artifacts download --bc-target next-minor --bc-country DE.
In config-driven current-release validation, restore.country remains the default when
--bc-country is omitted; an explicit command-line country takes precedence.
Directory mode recursively discovers app.json and .app files, matches them by app ID,
and validates matched projects in dependency order. Projects without a baseline are reported
as new and skipped unless a matched project depends on them; required new dependency apps are
compiled first and staged in the isolated validation cache, without running AppSourceCop against
them. Multiple projects or multiple baseline packages with the same matching app ID are rejected.
If a matched app fails AppSourceCop validation, ALWasp compiles it once without AppSourceCop and
stages that package for downstream projects while preserving the original validation failure.
Every package under --baseline-directory is available as a historical baseline dependency.
Generated/cache folders such as bin, obj, .alpackages, .alwasp, and .output are excluded
from project discovery. The final multi-app summary includes total processing time.
Directory mode seeds its isolated validation cache from <project-root>/.alpackages. When
the repository cache lives elsewhere (for example at the repository root while projects are
under src), pass it explicitly with --packages .\.alpackages.
When an alwasp.json is present in the current directory, directory mode resolves its default
build target and temporarily applies the effective applicationInsights setting to the matching
projects. This keeps AppSourceCop validation consistent with the build that follows.
For config-driven validation, configure the baseline on each app and select the same target
or profile model used by build:
{
"apps": [
{
"id": "core",
"path": "src/Core",
"compatibility": {
"baseline": "previous/Core.app"
}
}
]
}
alwasp validate compatibility release
The validator restores current dependencies plus Application, Platform, and explicit dependencies
recorded in the historical baseline's NavxManifest.xml, temporarily overlays only AppSourceCop's
baseline identity/cache properties, honors the remaining user-owned AppSourceCop.json
settings, and restores that file byte-for-byte afterward. Restored dependency packages are
staged into the baseline cache so AppSourceCop can resolve the old symbols; .app files
beside the baseline take precedence, allowing exact historical dependencies to be supplied.
Compatibility validation temporarily applies the resolved applicationInsights setting to each
selected project's app.json, matching the subsequent config-driven build and avoiding
AppSourceCop diagnostics about a property that the build supplies. The original app.json is
restored byte-for-byte after each validation, including when compilation fails.
Baseline identity is matched by AppId; app name and publisher changes are reported but allowed.
The standalone command always enables AppSourceCop. Config-driven alwasp build performs the same
baseline-bound validation only for profiles declaring compatibility.enabled: true.
--ruleset is relative to the current directory and overrides config/profile rulesets.
When direct --project mode omits it, ALWasp auto-detects ruleset.json and then the
first *.ruleset.json in the project directory, matching single-project alwasp build.
Translation coverage
alwasp validate translations answers the pre-release question "is everything translated?"
It compares each project's generated Translations/<App>.g.xlf against the language files
beside it and reports what is missing, untranslated, or still flagged for review. No compiler,
symbol restore, or Business Central environment is involved — it only reads XLIFF files.
alwasp validate translations # current directory or alwasp.json
alwasp validate translations release # configured target or profile
alwasp validate translations --project src/Core --languages da-DK,de-DE
alwasp validate translations --project-root ./src --json output/translations.json
What each finding means:
| Finding | Meaning |
|---|---|
| missing language file | A selected language (--languages / translations.languages) has no .xlf file |
| missing unit | A translatable unit of the .g.xlf is absent from a language file — usually a new caption that was never synced |
| untranslated | The unit exists but has no usable translation: no <target>, an empty one, or state new, needs-translation, needs-adaptation, needs-l10n |
| outdated | The unit is translated, but its <source> no longer matches the generated one — the caption was reworded while its unit id stayed the same, so the translation is stale |
| unverifiable | The unit has no <source> at all (invalid XLIFF), so nothing can be checked against it |
| needs review | Translated, but state is needs-review-* |
| obsolete | The language file still carries a unit the .g.xlf no longer contains (reported, never fails) |
Units marked translate="no" and units without source text are excluded from the generated
file's baseline — they are nothing to translate. In a language file only translate="no" is
skipped: a unit that carries a target but no <source> is still matched by id, so it counts as
translated rather than being misreported as missing.
Outdated translations count against coverage and fail at the same level as untranslated ones.
Source texts are compared exactly apart from line endings: AL writes trans-units with
xml:space="preserve", so a changed run of spaces is a real change to the caption. A unit
without a <source> is still matched by id — it is never reported as missing — but its
translation cannot be verified, so it is reported and counts against coverage rather than being
waved through.
Only the accepted <target> of a trans-unit is read. An <alt-trans> proposal from a
translation memory is not a translation and never counts as one.
A project may also hold translation files for another app (<file original="OtherApp">).
Those are grouped by original plus language, so they neither collide with this app's files
of the same language nor get diffed against its .g.xlf — they are checked on their own for
untranslated units and labelled [for OtherApp] in the report. A file for another app never
satisfies a required language: when there is no .g.xlf to state the identity, the app name
from app.json is used instead.
A translation artifact that cannot be trusted fails the gate rather than passing quietly:
- a second file covering the same app and language (Business Central allows only one);
- a file whose language cannot be determined from
target-languageor the file name; - a file without the required
<file original>attribute — it cannot be shown to belong to this app, so it never satisfies a required language; - more than one
.g.xlfin the folder — which one is the baseline is ambiguous, and a stale one left behind by a rename could hide everything missing from the current one, so no baseline is used at all.
Only .xlf files are considered, because that is what Business Central loads.
Working with XLIFF Sync and similar tooling
The check is designed to agree with how XLIFF Sync maintains AL translation files:
- XLIFF Sync marks a translation that needs work with
state="needs-adaptation"(XLIFF 1.2), which this check counts as untranslated — the same verdict the tool intends. - Its
detectSourceTextChangesdoes at sync time what the outdated check does at gate time, so a synced repository and this command agree on which translations went stale. - When it keeps
state="translated"and records the problem another way — anxliffSync:needsWorksub-state, or a note of its own explaining why the unit was flagged — the unit is reported as needing review rather than counted as finished. - Its
missingTranslationsetting decides what goes into a new target. The default%EMPTY%means "leave it empty", but a configured literal is written into the file and would read as a finished translation. Such texts count as untranslated:
{
"translations": {
"untranslatedPlaceholders": ["%EMPTY%", "(!TODO!)"]
}
}
Omit the setting to keep the defaults (["%EMPTY%"]), or set it to [] to disable the check.
A configured list replaces the defaults, so include every marker your tooling writes.
Placeholder rule
Borrowed from XLIFF Sync's technical translation rules: a translation must use the same format
placeholders as its source. Dropping %2 from Order %1 was posted on %2 produces text that
renders wrong at runtime, no matter how complete the coverage report looks.
alwasp validate translations --check-placeholders
{ "translations": { "checkPlaceholders": true } }
%1-style, {0}-style, and AL # fields (#1, ###2, #3##, as filled by
Text.StrSubstNo) are recognized and compared as a set, so a translation may reorder them —
word order differs per language — but may not drop, add, or renumber any. For # fields only
the parameter number matters: the padding sets the display width, which a translation may
legitimately change. Note that this also treats ordinary text such as Order #5 as a
placeholder, which is one reason the rule is opt-in.
Findings fail from the untranslated level upward. The rule is off by default and does not
affect the coverage percentage: it is about translation quality, not translation presence.
alwasp validate translations only reads files — it never syncs or rewrites them. Pair it with
XLIFF Sync (or its
PowerShell module) to create and maintain the
translation files, and use this command as the release gate that verifies the result.
Warning or error
--fail-on sets where warnings turn into a failure. Levels are cumulative, from lenient to
strict: none, missing-language, missing-unit, untranslated (default), needs-review.
Findings below the threshold are still reported — they just do not fail the command.
alwasp validate translations --fail-on none # report only, always exit 0
alwasp validate translations --fail-on needs-review # strictest release gate
alwasp validate translations --min-coverage 95 # per-language coverage floor
Exit codes: 0 clean (or below the threshold), 2 the gate failed, 1 bad usage or an
unreadable file. Selecting only apps with translations.enabled: false is a valid
configuration, not an error: the command reports that it skipped them and exits 0.
--require-generated is a per-project requirement, so it also fails a project that has no
translation files at all.
Under GitHub Actions or Azure Pipelines the findings are also emitted as build annotations —
errors for whatever fails the gate, warnings for the rest — so missing translations surface in
the pipeline UI without reading the log. A language below --min-coverage is annotated as an
error too, including its individual findings, even when --fail-on alone would let them pass.
The generated .g.xlf is a build output and is often not committed. When it is absent, each
language file is checked on its own (untranslated units are still found, missing ones cannot
be) and a warning says so. Pass --require-generated to make that a failure instead. For a
complete check, run this after a build that has the TranslationFile feature enabled.
Configuration
{
"translations": {
"languages": ["da-DK", "de-DE"],
"failOn": "untranslated",
"minCoverage": 95,
"requireGeneratedFile": false
},
"apps": [
{ "id": "Broker", "path": "./Broker" },
{
"id": "HappyTexts",
"path": "./HappyTexts",
"translations": { "languages": ["da-DK"] }
},
{
"id": "Internal",
"path": "./Internal",
"translations": { "enabled": false }
}
]
}
Strictness (failOn, minCoverage, requireGeneratedFile) is repository-wide; an app may
only opt out of the check or declare its own language list, which replaces the shared one.
When a language list is configured, it also scopes validation: translation files for other
languages are ignored. An omitted or empty list checks every language file found in the project.
CLI options override the configured values. Projects selected by a target or profile follow
the same include model as build, so alwasp validate translations ci checks exactly the
projects that target builds. Selected languages apply to entries in apps only — test
projects are checked solely on the translation files they actually contain, so a target that
includes a test profile does not report every test project as missing translations.
Config-Driven Build Manifest
When running alwasp build with an alwasp.json, a structured JSON manifest can be
written after the build that records exactly what was built, from which source directories,
with which compiler settings, and how each group finished.
Enabling the manifest
Three ways to request a manifest, in priority order:
| Method | Where |
|---|---|
profile.manifest in alwasp.json |
Overrides all other settings for that profile |
workspace.manifest in alwasp.json |
Used when no profile specifies a manifest |
--manifest <path> on the command line |
Fallback when no alwasp.json manifest is configured |
When a target runs multiple profiles, the manifest path is resolved from the first
profile whose manifest is set, then from workspace.manifest, then from --manifest.
All profiles are written into a single combined manifest — not one file per profile.
{
"workspace": {
"manifest": "output/build-manifest.json",
"logDirectory": "output/logs"
},
"profiles": {
"appsource": {
"include": "apps",
"appSourceCop": true,
"outFolder": "output/appsource"
},
"developer": {
"include": "apps",
"warningPolicy": { "treatWarningsAsErrors": true },
"manifest": "output/developer-manifest.json"
}
}
}
Manifest format
{
"schemaVersion": 1,
"generatedAtUtc": "2026-06-05T10:00:00+00:00",
"configPath": "/repo/alwasp.json",
"targetName": "ci",
"profiles": [
{
"profileName": "appsource",
"artifactType": "apps",
"outputFolder": "/repo/output/appsource",
"selectedProjects": ["Broker", "Monet"],
"groups": [
{
"groupIndex": 1,
"temporaryWorkspacePath": "/tmp/alwasp-cfg-appsource-g1-abc123.code-workspace",
"projectIds": ["Broker", "Monet"],
"projectPaths": ["/repo/Broker", "/repo/Monet"],
"settings": {
"features": [],
"noWarn": [],
"codeCop": false,
"appSourceCop": true,
"perTenantExtensionCop": false,
"uiCop": false,
"customAnalyzers": [],
"treatWarningsAsErrors": false,
"maxCpuCount": 4,
"logDirectory": "/repo/output/logs/appsource/group-1"
},
"diagnostics": {
"errorCount": 0,
"warningCount": 2,
"infoCount": 5,
"logFileCount": 2
},
"exitCode": 0,
"startedAtUtc": "2026-06-05T10:00:01+00:00",
"finishedAtUtc": "2026-06-05T10:00:45+00:00",
"durationMs": 44000
}
]
}
]
}
Each profile records an artifactType of "apps", "tests", or "mixed", derived from its
selected projects. This lets a pipeline distinguish application and test output profiles without
relying on profile names, selection syntax, or output-folder conventions.
diagnostics summarizes the AL compiler diagnostics parsed from that group's
altool log files, regardless of whether they were printed to the console (see
--diagnostics under Workspace for display behavior). It is omitted
when no log files could be parsed for the group. settings.treatWarningsAsErrors
records the effective warningPolicy.treatWarningsAsErrors value used to decide
pass/fail for the group: when it is true and diagnostics.warningCount is
greater than zero after a successful compile, ALWasp fails the group (exitCode
is forced to 1) even though altool itself reported success.
Per-project transformation/version records
The manifest's top-level projects array has one entry per uniquely selected
project (regardless of whether versioning/resourceExposurePolicy/
applicationInsights/changeDetection are configured), recording:
sourceProjectPath,originalVersion,effectiveVersion,versionSource,versioningReleaseType,internalDependencyVersionsresourceExposurePolicyApplied,applicationInsightsApplied,applicationInsightsMode,applicationInsightsSource,applicationInsightsProperty,applicationInsightsValuePresentpreprocessorSymbols— the effective defines written into this project'sapp.jsonpreprocessorSymbolsfor the build (config-driven build applies defines per project via app.json rather than a group-wide/define); omitted when the project has no defineschangedByGit,changeDetectionBase,changeDetectionHeadappJsonTemporarilyModified,appJsonBackupPath,sourceAppJsonRestored
Application Insights values (connection strings, instrumentation keys, or any
other secret) are never written here — only applicationInsightsValuePresent
(a boolean) and the resolved, non-secret property name.
Log directory subfolders
When workspace.logDirectory is set in alwasp.json, ALWasp creates per-profile
and per-group subfolders automatically:
output/logs/
appsource/
group-1/ ← altool log files for appsource group 1
test/
group-1/ ← altool log files for test group 1
This keeps log files for different profiles and groups separate, which is useful when a target runs multiple profiles and each group produces its own diagnostics.
CI usage
In a GitHub Actions workflow:
- name: Build
run: alwasp build ci --manifest output/build-manifest.json
- name: Upload manifest
uses: actions/upload-artifact@v4
with:
name: build-manifest
path: output/build-manifest.json
Or configure the manifest path in alwasp.json to avoid repeating it on the CLI:
{
"workspace": {
"manifest": "output/build-manifest.json"
}
}
Out of Scope
AlWasp focuses on symbol restore and compilation only. It stops when a .app file
exists on disk. Anything that requires talking to a running Business Central
instance belongs to BC-DevX or your
deployment pipeline:
- Publishing apps to Business Central environments —
bcdevx app publish,bcdevx app publish-folder,bcdevx workspace deploy - Deploying to sandboxes or containers —
bcdevx container create/bcdevx app install|sync|upgrade|unpublish - Test execution (running AL test suites) —
bcdevx test run/run-failed/diagnose - Git operations beyond local, read-only inspection — AlWasp reads tags/refs/diffs
for change detection (
changeDetection,--changed-since) but never creates commits, tags, branches, or pushes. Committing/tagging/pushing the version changes written byalwasp version applyis your responsibility (or your pipeline's, e.g. BC-DevX)
The dependency runs one way: BC-DevX invokes alwasp as a subprocess, never the
reverse. AlWasp has no HTTP client, no BC credentials, and no knowledge of
BC-DevX. It is a build tool that produces artifacts; BC-DevX consumes them.
BC-DevX resolves the executable from ALWASP_PATH, falling back to alwasp on
PATH, and calls it for the package-ID rewrite before publishing, for
bcdevx workspace build, and for cop/compatibility validation.
Restore
Default (locked mode):
alwasp restore
Latest mode:
alwasp restore --latest
Custom app.json:
alwasp restore path/to/app.json
Custom output folder (resolved relative to app.json directory when relative):
alwasp restore --output ./symbols
Restore Behavior
- Default mode is
Locked;--latestswitches toLatestAll - Adds implicit dependencies: System Application, Base Application, System (
Microsoft.Platform.symbols), and Business Foundation (BC 26+) - Traverses transitive dependencies breadth-first
- Re-resolves a package when a transitive dependency requires a newer version than the version already selected for a direct dependency
- Uses a manifest at
<output>/.alwasp-packagesfor incremental restore (packageId@versionper line) - Skips already-restored exact package/version pairs from the manifest
- Feed order: CLI
--feedentries, built-in public feeds, then enablednuget.configfeeds - Config-driven builds only:
restore.overridesFolderreplaces a restored.appwith a matching pre-built one from another folder, matched by embedded AppId, not file name (seedocs/restore.md)
Built-in public feeds:
https://dynamicssmb2.pkgs.visualstudio.com/DynamicsBCPublicFeeds/_packaging/MSSymbolsV2/nuget/v3/index.jsonhttps://dynamicssmb2.pkgs.visualstudio.com/DynamicsBCPublicFeeds/_packaging/AppSourceSymbols/nuget/v3/index.json
Authentication
Private feed with PAT:
alwasp restore \
--feed https://pkgs.dev.azure.com/my-org/_packaging/my-feed/nuget/v3/index.json \
--pat "$MY_PAT"
PAT via environment variable fallback:
export ALWASP_PAT="$MY_PAT"
alwasp restore --feed https://pkgs.dev.azure.com/my-org/_packaging/my-feed/nuget/v3/index.json
Per-feed token mapping:
export MY_FEED_PAT="$MY_PAT"
alwasp restore \
--feed https://pkgs.dev.azure.com/my-org/_packaging/my-feed/nuget/v3/index.json \
--feed-token-env https://pkgs.dev.azure.com/my-org/_packaging/my-feed/nuget/v3/index.json=MY_FEED_PAT
Use nuget.config:
alwasp restore --nuget-config ./nuget.config
Auth mode:
--auth-mode auto(default): interactive locally, non-interactive in CI--auth-mode interactive: enables credential-provider interactive flow (browser/device)--auth-mode noninteractive: disables prompts (NUGET_EXE_NO_PROMPT=true)
CI is detected from CI, TF_BUILD, GITHUB_ACTIONS, or BUILD_BUILDID.
Build (single project)
Note: The following flags apply only to single-project mode and are ignored when
alwasp.jsonis present:--out,--outfolder,--warnaserror,--errorlog,--target,--parallel,--max-parallelism. In config-driven mode,--outand--outfoldercurrently emit warnings. For config-driven builds use the equivalent settings inalwasp.json(profile.outFolder,defaults.warningPolicy.treatWarningsAsErrors, etc.).
Pass --clean to build (or low-level workspace build) to remove artifacts from the previous
ALWasp build before restoring and compiling again. It removes .app files and the incremental
restore manifest from the active package cache, compiled .app files from the selected output
folder(s), compiler .log files, and the active build manifest. Unrelated files in custom output
and log directories are preserved.
When no alwasp.json is present, alwasp build falls back to single-project mode:
alwasp build
Single-project build pipeline:
- Uses
app.json(default./app.json) - Runs incremental restore into the package folder
- Resolves
alcvia:ALC_PATH- cache at
~/.alwasp/tools/<version>/ - auto-download of latest
Microsoft.Dynamics.BusinessCentral.Development.Toolsfrom NuGet.org
- Runs compiler with selected flags
Common examples:
alwasp build --restore --latest
alwasp build --out ./artifacts/MyApp.app
alwasp build --outfolder ./artifacts
alwasp build --codecop --appsourcecop --ptecop --uicop
alwasp build --define DEBUG --define CI --features FeatureA
Ruleset behavior:
- If
--rulesetis omitted, ALWasp auto-detects in project dir:ruleset.json- first
*.ruleset.json
Workspace
Restore all projects from a .code-workspace:
alwasp workspace restore
alwasp workspace restore path/to/project.code-workspace
Build all projects in a workspace file directly:
alwasp workspace build
alwasp workspace build path/to/project.code-workspace
Generate a build manifest after successful compilation:
# Default path: .output/build-manifest.json (relative to workspace directory)
alwasp workspace build --manifest
# Custom path
alwasp workspace build --manifest ci/build-manifest.json
Workspace build details:
- Runs incremental restore to shared package folder (default
.alpackages) - Reuses compiled apps already in the package folder by matching their AL app ID/version
- Uses
altool workspace compile - Streams
altooloutput directly to the terminal --log-directoryis passed toaltool; without it ALWasp uses./.alwasp- In local CLI runs,
--diagnosticsparses updated*.logfiles in the log directory and reports AL compiler errors - In GitHub Actions, parsed diagnostics are emitted as workflow annotations; in Azure Pipelines, errors and warnings are emitted as task log issues
--nowarnand--rulesetare mutually exclusive--outfoldercopies only newly compiled.appfiles (not pre-existing symbol.appfiles)--manifestis generated only after successful compilation; omit to skip
Build Manifest
The build manifest (build-manifest.json) is a JSON file that describes the completed build:
{
"schemaVersion": 1,
"workspace": {
"name": "Contoso ERP"
},
"build": {
"tool": "alwasp",
"toolVersion": "1.0.0",
"generatedAt": "2026-06-03T12:00:00+00:00"
},
"apps": [
{
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"publisher": "Contoso",
"name": "Foundation",
"version": "1.0.0.0",
"path": "../.alpackages/Contoso_Foundation_1.0.0.0.app",
"dependencies": []
}
],
"dependencies": [
{
"id": "ffffffff-1111-2222-3333-444444444444",
"publisher": "Microsoft",
"name": "Base Application",
"version": "27.0.0.0"
}
],
"buildOrder": [
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
]
}
Fields:
| Field | Description |
|---|---|
schemaVersion |
Schema version (currently 1) |
workspace.name |
Workspace name from .code-workspace or file name |
workspace.version |
Workspace version (optional, from .code-workspace) |
build.toolVersion |
ALWasp version that produced the manifest |
build.generatedAt |
ISO 8601 UTC timestamp |
apps |
One entry per compiled workspace project |
apps[].dependencies |
GUIDs of this app's declared dependencies |
dependencies |
Direct external dependencies resolved from app.json |
buildOrder |
App GUIDs in dependency-first compilation order |
Analyzer Support
Supported switches:
--codecop--appsourcecop--ptecop--uicop--custom-analyzers <path>(repeatable)
ALWasp probes analyzer DLL locations relative to compiler binaries and injects Microsoft.Dynamics.Nav.Analyzers.Common.dll first when built-in analyzers are enabled.
CI/CD Examples
GitHub Actions (private feed)
- name: Restore symbols
env:
ALWASP_PAT: ${{ secrets.AZURE_ARTIFACTS_PAT }}
run: |
alwasp restore \
--feed https://pkgs.dev.azure.com/my-org/_packaging/my-feed/nuget/v3/index.json \
--auth-mode noninteractive
Azure Pipelines
- task: NuGetAuthenticate@1
- script: |
alwasp restore \
--feed https://pkgs.dev.azure.com/my-org/_packaging/my-feed/nuget/v3/index.json \
--pat $(System.AccessToken)
displayName: Restore AL symbols
Translation gate before a release
- name: Build
run: alwasp build release
- name: Check translations
run: alwasp validate translations release --json output/translations.json
- name: Publish translation report
if: always()
uses: actions/upload-artifact@v4
with:
name: translation-coverage
path: output/translations.json
Run it after the build so the generated .g.xlf files exist. Findings appear as workflow
annotations; a failing gate exits with code 2. Use --fail-on none while a repository is
still catching up on translations to get the annotations without failing the job.
Continuous integration in this repository
.github/workflows/ci.yml runs on every pull request and every push to master:
- Builds and tests
AlWasp.slnxin Release on bothubuntu-latestandwindows-latest(the code branches on OS for path comparison and file-name casing, so both are checked) - Publishes the
.trxtest results as artifacts, including on failure - Packs
src/AlWasp/AlWasp.csprojthe way the release workflow does, so a packaging break shows up on the pull request instead of when a release tag is pushed
The workflow installs both the .NET 8 and .NET 9 SDKs: the projects target net8.0, but the
XML solution file (AlWasp.slnx) requires the .NET 9 SDK to build.
Package release workflow in this repository
The repository contains .github/workflows/release.yml that:
- Builds and packs
src/AlWasp/AlWasp.csproj - Authenticates to NuGet via OIDC (
NuGet/login@v1) - Pushes
src/AlWasp/nupkg/*.nupkg - Creates a GitHub release for
v*tags
Environment Variables
ALWASP_PAT: fallback for--patALC_PATH: override path toalc/alc.exeAL_PATH: override path toaltool/altool.exeCI,TF_BUILD,GITHUB_ACTIONS,BUILD_BUILDID: used for auto auth-mode detection
More Documentation
docs/cli-reference.mddocs/architecture.mddocs/authentication.mddocs/build.mddocs/restore.mddocs/documentation-audit.md
License
MIT
Download compatibility baselines
Use --get-baseline-symbols instead of --baseline-directory to download the latest
stable published symbols for each app discovered under --project-root:
alwasp validate compatibility --project-root G:\Singhammer\BCSyncApp --get-baseline-symbols --ruleset .\dyce.ruleset.json --bc-target next-major --bc-country de
# Use a private NuGet feed instead of Microsoft AppSourceSymbols:
alwasp validate compatibility --project-root ./src --get-baseline-symbols https://example.com/nuget/v3/index.json
Without a URL, only Microsoft's AppSourceSymbols feed is queried for baselines.
An explicit URL replaces that baseline feed; existing --pat / ALWASP_PAT,
--feed-token-env, --nuget-config, and --auth-mode options apply.
The custom feed also participates in dependency restore alongside the normal feeds.
Baseline versions are independent of --bc-target and the current app version.
Downloaded .app symbols are matched by app ID and stored temporarily outside the
project tree, then removed even when validation fails. Historical dependencies use
the existing compatibility restore workflow. Apps with no published stable baseline
are reported and skipped; no matching baselines or a feed/download error fails the command.
This option requires --project-root and cannot be combined with --baseline-directory,
--baseline, --project, or configured target/profile options. Keep directory mode
when you need to pin baseline versions for reproducible validation.
| 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.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.3.9 | 53 | 9/10/2026 |
| 0.3.8 | 74 | 9/9/2026 |
| 0.3.7 | 83 | 9/8/2026 |
| 0.3.6 | 83 | 9/8/2026 |
| 0.3.5 | 109 | 9/4/2026 |
| 0.3.4 | 100 | 9/4/2026 |
| 0.3.3 | 99 | 9/3/2026 |
| 0.3.2 | 97 | 9/3/2026 |
| 0.3.1 | 102 | 8/31/2026 |
| 0.3.0 | 132 | 8/28/2026 |
| 0.2.5 | 140 | 8/22/2026 |
| 0.2.4 | 108 | 8/22/2026 |
| 0.2.3 | 110 | 8/21/2026 |
| 0.2.2 | 105 | 8/21/2026 |
| 0.2.1 | 109 | 8/20/2026 |
| 0.2.0 | 103 | 8/17/2026 |
| 0.1.0-preview.14 | 307 | 7/25/2026 |
| 0.1.0-preview.13 | 66 | 7/24/2026 |
| 0.1.0-preview.12 | 77 | 7/24/2026 |
| 0.1.0-preview.11 | 75 | 7/20/2026 |
Fixes missing Azure DevOps and GitHub Actions compiler diagnostic annotations in compatibility
validation and single-project builds, matching workspace builds.
CHANGELOG.md contains the complete details.