left-code.AlWasp 0.3.9

dotnet tool install --global left-code.AlWasp --version 0.3.9
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local left-code.AlWasp --version 0.3.9
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=left-code.AlWasp&version=0.3.9
                    
nuke :add-package left-code.AlWasp --version 0.3.9
                    

ALWasp

CI

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 no alwasp.json is 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-name is 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,
  ...
}

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 for warningPolicy.treatWarningsAsErrors. When both are set at the same level, warningPolicy.treatWarningsAsErrors wins. New configs should prefer warningPolicy.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:

  1. 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}.app name — 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.

  2. Package cache lock (automatic). Every command that writes to a package cache — build, workspace build, restore, and workspace restore — holds an exclusive .alwasp.lock inside 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.

  3. workspace.isolateDependencyLevels (opt-in, default false). Splits build groups at topological dependency-level boundaries so a project and one of its dependencies never share the same altool invocation, 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 --maxcpucount after layers 1–2. Each dependency level costs one extra compiler invocation.

    Scope: isolateDependencyLevels applies to config-driven builds only (alwasp build with alwasp.json). The low-level alwasp workspace build command always compiles the whole .code-workspace in a single altool invocation 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: defaultsprofileoverrides (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:

  1. Run alwasp init to generate a starter alwasp.json
  2. Move test projects from apps to tests in the generated file
  3. Run alwasp config validate to confirm the config is correct
  4. Replace alwasp workspace build ... calls with alwasp 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.
  • --check never downloads or deletes; it exits 0 when up to date and 2 when a newer version is available (handy for CI gates).
  • --clean removes 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 .app file 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 id in app.json keeps 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 Removed plus an Added, because that is what it is for every referrer of the old name. There is no Renamed change 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, an ObjectRenamed diagnostic (info) names both sides.
  • Renumbering keeps the identity. The object is Modified, id and previousId carry both numbers, and an ObjectIdChanged diagnostic (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 ObjectTypeChanged diagnostic (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) or Modified, with previousApp set and an ObjectMovedBetweenApps diagnostic (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 a DuplicateObjectIdentity diagnostic (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::50100 names an object this analyzer cannot look up; the site is reported as Unsupported, 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 is Unsupported.
  • A duplicated name resolves to the first declaration. The alternative — refusing to resolve it — would lose every downstream edge; the choice is reported as Ambiguous instead 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 External and not diagnosed.
  • #if branches 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 — its app.json depends, 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" }
  • schemaVersion identifies 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 plus scope, summary, and diagnostics, uses additionalProperties: false throughout, and enumerates every enum value. The test suite validates serialized reports against it — through both the --json writer and the --format json result 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-language or 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.xlf in 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 detectSourceTextChanges does 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 — an xliffSync:needsWork sub-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 missingTranslation setting 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, internalDependencyVersions
  • resourceExposurePolicyApplied, applicationInsightsApplied, applicationInsightsMode, applicationInsightsSource, applicationInsightsProperty, applicationInsightsValuePresent
  • preprocessorSymbols — the effective defines written into this project's app.json preprocessorSymbols for the build (config-driven build applies defines per project via app.json rather than a group-wide /define); omitted when the project has no defines
  • changedByGit, changeDetectionBase, changeDetectionHead
  • appJsonTemporarilyModified, 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 environmentsbcdevx app publish, bcdevx app publish-folder, bcdevx workspace deploy
  • Deploying to sandboxes or containersbcdevx 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 by alwasp version apply is 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; --latest switches to LatestAll
  • 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-packages for incremental restore (packageId@version per line)
  • Skips already-restored exact package/version pairs from the manifest
  • Feed order: CLI --feed entries, built-in public feeds, then enabled nuget.config feeds
  • Config-driven builds only: restore.overridesFolder replaces a restored .app with a matching pre-built one from another folder, matched by embedded AppId, not file name (see docs/restore.md)

Built-in public feeds:

  • https://dynamicssmb2.pkgs.visualstudio.com/DynamicsBCPublicFeeds/_packaging/MSSymbolsV2/nuget/v3/index.json
  • https://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.json is present: --out, --outfolder, --warnaserror, --errorlog, --target, --parallel, --max-parallelism. In config-driven mode, --out and --outfolder currently emit warnings. For config-driven builds use the equivalent settings in alwasp.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:

  1. Uses app.json (default ./app.json)
  2. Runs incremental restore into the package folder
  3. Resolves alc via:
    • ALC_PATH
    • cache at ~/.alwasp/tools/<version>/
    • auto-download of latest Microsoft.Dynamics.BusinessCentral.Development.Tools from NuGet.org
  4. 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 --ruleset is 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 altool output directly to the terminal
  • --log-directory is passed to altool; without it ALWasp uses ./.alwasp
  • In local CLI runs, --diagnostics parses updated *.log files 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
  • --nowarn and --ruleset are mutually exclusive
  • --outfolder copies only newly compiled .app files (not pre-existing symbol .app files)
  • --manifest is 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:

  1. Builds and tests AlWasp.slnx in Release on both ubuntu-latest and windows-latest (the code branches on OS for path comparison and file-name casing, so both are checked)
  2. Publishes the .trx test results as artifacts, including on failure
  3. Packs src/AlWasp/AlWasp.csproj the 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:

  1. Builds and packs src/AlWasp/AlWasp.csproj
  2. Authenticates to NuGet via OIDC (NuGet/login@v1)
  3. Pushes src/AlWasp/nupkg/*.nupkg
  4. Creates a GitHub release for v* tags

Environment Variables

  • ALWASP_PAT: fallback for --pat
  • ALC_PATH: override path to alc/alc.exe
  • AL_PATH: override path to altool/altool.exe
  • CI, TF_BUILD, GITHUB_ACTIONS, BUILD_BUILDID: used for auto auth-mode detection

More Documentation

  • docs/cli-reference.md
  • docs/architecture.md
  • docs/authentication.md
  • docs/build.md
  • docs/restore.md
  • docs/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:

# From a directory containing app.json (also discovers nested projects):
alwasp validate compatibility --get-baseline-symbols

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 defaults --project-root to the current directory when it contains app.json; otherwise an explicit --project-root is required. It cannot be combined with --baseline-directory, --baseline, --project, --config, or a positional target/profile. Use --profile appsource to apply that profile's Application Insights settings from the current directory's alwasp.json:

alwasp validate compatibility --project-root . --get-baseline-symbols --profile appsource

Without --profile, the default target supplies those settings. In directory mode, the profile controls telemetry transforms for its included projects, not which projects are discovered or validated. Keep directory mode with --baseline-directory when you need to pin baseline versions for reproducible validation.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
Loading failed

Simplifies baseline downloads beside app.json and adds profile selection for compatibility telemetry.
     Groups skipped baselines in Azure DevOps and GitHub Actions logs.
     Fixes Microsoft Application wrapper restore and telemetry transforms for upcoming Business Central targets.
     CHANGELOG.md contains the complete details.