
al graph: Auditing Who Can Reach Your Code in AL 18.0
AppSourceCop is very good at telling you what you broke. It has almost nothing to say about what you exposed.
Those are different questions. "Did I remove a public procedure someone depends on?" is a diff against a baseline. "Which of my public entry points can reach the codeunit that handles the API key?" is a question about reachability across my whole app and everything it depends on - and until now the only way to answer it was to read the code and hope you held all of it in your head at once.
AL Language 18.0 (Business Central 2026 release wave 2, published 19 August 2026) adds a command group that answers the second kind of question directly:
al graph
It builds a static call graph across your app and its dependencies, then lets you query it with selectors: what internal code is reachable from a public surface, which public method exposes this specific procedure, what non-debuggable code hands data to code I can step through. It exports to SARIF, so any of those becomes a clickable code flow in Visual Studio Code.
The changelog entry is short and the docs page hasn't caught up yet, so this post is the hands-on version: a nine-file demo corpus, every command, every real output, and the rough edges I hit. Everything below ran on my machine against the demo corpus further down - no Business Central environment involved.
Note
The whole walkthrough runs with no sandbox, no container, no symbol download, and no
.alpackagesfolder. That surprised me more than anything else in the feature. You need the CLI and nine text files.
How it works
al graph is four steps, and the middle two usually collapse into one command.
flowchart LR SRC["AL source<br/>2 apps · 9 files"] -->|"al graph extract-whole"| SH["fact shard<br/>corpus.graphshard.jsonl"] SH -->|"stitch<br/>(--graph does it inline)"| G["global graph<br/>g.jsonl · 30 nodes · 41 edges"] G -->|"al graph query"| Q["answer on stdout"] G -->|"al graph export"| X["dgml · graphml · sarif"]
The model is simple enough to hold in your head:
- A node is a method - a procedure, a trigger, an interface member, or a platform built-in. Objects are nodes too, as containers.
- An edge is a call. It carries a kind (
Direct,Event,Interface,Run,Trigger) and a confidence (ResolvedorOverApprox). - Every node carries the facts you want to filter on: accessibility, scope, namespace, owning app, whether it is
[NonDebuggable], whether its app allows debugging at all, whether it is a test, whether it is obsolete.
Queries are set operations over those facts. That is the whole idea.
Getting the tool
al graph lives in the AL Development Tools NuGet package, not in the Visual Studio Code extension. At the time of writing the newest version is 18.0.40.43394-beta.
If you already have an older al installed globally - I had 16.0.24, which has four verbs and no graph - don't overwrite it. Install it as a local tool instead, scoped to one folder:
dotnet new tool-manifest
dotnet tool install --local Microsoft.Dynamics.BusinessCentral.Development.Tools --version 18.0.40.43394-betaThe template "Dotnet local tool manifest file" was created successfully.
You can invoke the tool from this directory using the following commands: 'dotnet tool run al' or 'dotnet al'.
Tool 'microsoft.dynamics.businesscentral.development.tools' (version '18.0.40.43394-beta') was successfully installed.
From here on, dotnet al runs 18.0 inside that folder and your global al is untouched. If you don't care about that, dotnet tool install -g and plain al work identically.
Tip
It runs on the .NET SDK you already have. The AL Visual Studio Code extension for 18.0 needs the .NET 10 runtime, but the CLI ran fine for me on 9.0.102 - no extra runtime install.
dotnet al graph --help gives the six subcommands:
Commands:
extract-all Compile every app under --corpus (source-only, OnPrem target) into content-hashed shards.
extract Extract a single app folder into a fact shard.
extract-whole Compile ALL apps under the corpus root(s) as one compilation so cross-app direct calls
resolve (heavier; use for a complete cross-app graph). Pass --graph to auto-stitch.
stitch Merge shards into one global graph, resolving cross-app + event/interface edges.
export Export the graph as dgml|graphml|sarif - the query selectors scope it to a consumable
subgraph; with no selectors the whole graph is exported.
query Run an arbitrary reachability/path query over the graph.
Only extract-whole appears in the changelog. The distinction between it and extract-all matters: extract-all compiles each app on its own, so a call from app B into app A never resolves. extract-whole compiles everything as one compilation, which is what you want for a cross-app audit and what every example below uses.
The demo corpus
Two apps. The provider has a public facade over internal implementation code, an interface with two implementations, an event publisher with a subscriber, an [Scope('OnPrem')] method, a secret handler with a deliberate leak, and a test codeunit in its own namespace. The consumer just calls the facade.
flowchart TB
subgraph consumer["app: Graph Demo Consumer"]
subgraph ceObj["Codeunit Consumer Entry"]
A["Run()<br/>Public"]
end
end
subgraph provider["app: Graph Demo Provider"]
subgraph testsNs["namespace ...Provider.Tests"]
T["TestPostPayment()"]
end
subgraph facadeObj["Codeunit Payment Facade"]
B["PostPayment()<br/>Public"]
C["PostPaymentUnattended()<br/>Public · OnPrem"]
end
subgraph implObj["Codeunit Payment Impl"]
E["Execute()<br/>Internal"]
EV["OnBeforeCharge()<br/>Publisher"]
end
subgraph secretObj["Codeunit Secret Handler"]
F["BuildAuthHeader()"]
G["GetApiKey()<br/>NonDebuggable"]
S["Sanitize()"]
end
subgraph ifaceObj["Interface IPaymentGateway"]
I["Charge()"]
end
subgraph primObj["Codeunit Primary Gateway"]
P["Charge()"]
end
subgraph fallObj["Codeunit Fallback Gateway"]
FB["Charge()"]
end
subgraph subObj["Codeunit Charge Subscriber"]
SUB["HandleBeforeCharge()<br/>Local"]
end
end
A --> B
T --> B
B --> E
C --> E
E --> F
E --> I
E --> EV
F --> G
G --> S
I -.->|Interface| P
I -.->|Interface| FB
EV -.->|Event| SUB
classDef danger stroke:#e35b4d,stroke-width:2px
class G dangerEach box is a codeunit; the nodes inside it are its methods, which is how al graph models them. Solid arrows are Direct calls the compiler resolved. Dashed arrows are the interface and event edges, which are over-approximated - more on that below.
The provider manifest
Note resourceExposurePolicy.allowDebugging. It defaults to false, which makes every method in the app non-debuggable and turns the debuggability queries into "everything". Setting it to true is what makes [NonDebuggable] mean something specific.
{
"id": "56d45870-088b-46f7-891a-78e87c01e4bf",
"name": "Graph Demo Provider",
"publisher": "SSOSIC",
"version": "1.0.0.0",
"platform": "1.0.0.0",
"runtime": "18.0",
"target": "Cloud",
"idRanges": [ { "from": 50100, "to": 50149 } ],
"resourceExposurePolicy": {
"allowDebugging": true,
"allowDownloadingSource": false,
"includeSourceInSymbolFile": true
}
}The public surface
Two public entry points into the same internal implementation. The second is [Scope('OnPrem')], which is the boundary the onprem-surface selector picks up.
namespace SSOSIC.GraphDemo.Provider;
codeunit 50100 "Payment Facade"
{
Access = Public;
procedure PostPayment(Amount: Decimal): Boolean
var
Impl: Codeunit "Payment Impl";
begin
exit(Impl.Execute(Amount));
end;
[Scope('OnPrem')]
procedure PostPaymentUnattended(Amount: Decimal): Boolean
var
Impl: Codeunit "Payment Impl";
begin
exit(Impl.Execute(Amount));
end;
}The internal implementation
This is the node that should not be reachable from outside the app - except that it is, through the facade, which is exactly the transition worth auditing. It also gives us an interface call and an event publisher.
namespace SSOSIC.GraphDemo.Provider;
codeunit 50101 "Payment Impl"
{
Access = Internal;
internal procedure Execute(Amount: Decimal): Boolean
var
Gateway: Interface IPaymentGateway;
Secrets: Codeunit "Secret Handler";
Header: Text;
begin
Header := Secrets.BuildAuthHeader();
Gateway := SelectGateway();
OnBeforeCharge(Amount);
exit(Gateway.Charge(Amount, Header));
end;
local procedure SelectGateway(): Interface IPaymentGateway
var
Primary: Codeunit "Primary Gateway";
Fallback: Codeunit "Fallback Gateway";
begin
if Amountless() then
exit(Fallback);
exit(Primary);
end;
local procedure Amountless(): Boolean
begin
exit(false);
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeCharge(Amount: Decimal)
begin
end;
}The leak
GetApiKey is [NonDebuggable], so nobody can step into it and read the key. Then it hands the value to Sanitize, which has no attribute at all - so in Sanitize the key is an ordinary parameter, visible in the debugger like any other. That is the bug al graph is built to find, and I planted it here so we can watch it get caught.
namespace SSOSIC.GraphDemo.Provider;
codeunit 50102 "Secret Handler"
{
Access = Internal;
internal procedure BuildAuthHeader(): Text
begin
exit('Bearer ' + GetApiKey());
end;
[NonDebuggable]
internal procedure GetApiKey(): Text
var
RawKey: Text;
begin
RawKey := ReadFromIsolatedStorage();
exit(Sanitize(RawKey));
end;
[NonDebuggable]
local procedure ReadFromIsolatedStorage(): Text
var
Value: Text;
begin
if IsolatedStorage.Get('payment-api-key', DataScope::Module, Value) then
exit(Value);
exit('');
end;
internal procedure Sanitize(Value: Text): Text
begin
exit(DelChr(Value, '<>', ' '));
end;
}The interface, its implementations, and the subscriber
namespace SSOSIC.GraphDemo.Provider;
interface IPaymentGateway
{
procedure Charge(Amount: Decimal; AuthHeader: Text): Boolean
}namespace SSOSIC.GraphDemo.Provider;
codeunit 50103 "Primary Gateway" implements IPaymentGateway
{
Access = Internal;
procedure Charge(Amount: Decimal; AuthHeader: Text): Boolean
begin
exit(Amount > 0);
end;
}namespace SSOSIC.GraphDemo.Provider;
codeunit 50104 "Fallback Gateway" implements IPaymentGateway
{
Access = Internal;
procedure Charge(Amount: Decimal; AuthHeader: Text): Boolean
begin
exit(false);
end;
}namespace SSOSIC.GraphDemo.Provider;
codeunit 50105 "Charge Subscriber"
{
Access = Internal;
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Payment Impl", OnBeforeCharge, '', false, false)]
local procedure HandleBeforeCharge(Amount: Decimal)
var
Secrets: Codeunit "Secret Handler";
begin
Secrets.Sanitize(Format(Amount));
end;
}The test codeunit, in its own namespace
Deliberately in ...Provider.Tests so we can watch --exclude remove it from the answers.
namespace SSOSIC.GraphDemo.Provider.Tests;
using SSOSIC.GraphDemo.Provider;
codeunit 50106 "Payment Tests"
{
Access = Internal;
internal procedure TestPostPayment()
var
Facade: Codeunit "Payment Facade";
begin
Facade.PostPayment(100);
end;
}The consumer
A separate app, depending on the provider, calling only the public facade. This is the caller that makes "reachable from outside" concrete.
namespace SSOSIC.GraphDemo.Consumer;
using SSOSIC.GraphDemo.Provider;
codeunit 50150 "Consumer Entry"
{
Access = Public;
procedure Run(Amount: Decimal): Boolean
var
Facade: Codeunit "Payment Facade";
begin
exit(Facade.PostPayment(Amount));
end;
}Building the graph
One command. --corpus is the root it scans for app.json files, --out takes the intermediate shard, and --graph skips the separate stitch step by writing the stitched graph directly.
dotnet al graph extract-whole --corpus .\apps --out .\.graph\shards --graph .\.graph\g.jsonlWhole-corpus: 2 app(s), 9 file(s) compiled together -> 30 node(s), 35 edge(s) in 00:00 -> .graph\shards\corpus.graphshard.jsonl
Stitched 30 node(s), 41 edge(s) -> .graph\g.jsonl
Two numbers worth reading. Nine files compiled with no symbol packages present at all - the compiler resolved everything from source plus its own built-ins. And the edge count went from 35 to 41 in the stitch: those six extra edges are the interface and event edges, which cannot be resolved inside a single app's facts and only appear once the graph is stitched together.
The graph is JSONL - one node or edge per line, and each node carries its source location:
{"t":"node","v":{"id":"al . b80316a6-383a-4359-b1e5-c8de9a701454 / Codeunit 50150 Consumer Entry","kind":"Object","namespace":"SSOSIC.GraphDemo.Consumer","objectName":"Consumer Entry","objectId":50150,"sourcePath":"...\\apps\\Consumer\\src\\ConsumerEntry.Codeunit.al","line":5,"scope":"Cloud","isSink":false,"sinkCategory":"None","elevation":"None","isNonDebuggable":false,"allowDebugging":true,"eventRole":"None","accessibility":"Public","isObsolete":false,"isTest":false}}That fact list is the query language's vocabulary. accessibility, scope, namespace, isNonDebuggable, allowDebugging, eventRole, isTest, isObsolete, elevation, and a sinkCategory I'll come back to.
Query 1 - what does my public surface actually expose?
The first real question. Which internal code in the provider can be reached starting from anything public?
dotnet al graph query --graph .\.graph\g.jsonl `
--from access:public `
--to "access:internal+app:56d45870-088b-46f7-891a-78e87c01e4bf"6 node(s):
Codeunit Fallback Gateway.Charge
Codeunit Primary Gateway.Charge
Codeunit Payment Impl.Execute
Codeunit Secret Handler.BuildAuthHeader
Codeunit Secret Handler.GetApiKey
Codeunit Secret Handler.Sanitize
Six internal methods are reachable from a public entry point. Three of them are the secret handler. Nothing in the source says that - Secret Handler is Access = Internal and never mentioned outside its own app - but two public procedures make it reachable anyway, and this is the first time that has been mechanically checkable.
Add --paths to see how:
dotnet al graph query --graph .\.graph\g.jsonl `
--from access:public `
--to "access:internal+app:56d45870-088b-46f7-891a-78e87c01e4bf" --paths5 path(s):
Codeunit Consumer Entry.Run -[Direct]-> Codeunit Payment Facade.PostPayment -[Direct]-> Codeunit Payment Impl.Execute
Interface IPaymentGateway.Charge -[Interface]-> Codeunit Fallback Gateway.Charge
Interface IPaymentGateway.Charge -[Interface]-> Codeunit Primary Gateway.Charge
Codeunit Payment Facade.PostPayment -[Direct]-> Codeunit Payment Impl.Execute
Codeunit Payment Facade.PostPaymentUnattended -[Direct]-> Codeunit Payment Impl.Execute
warning: 3 reachable target(s) have no emitted path (truncated by --depth/--max-paths); rerun without --paths for complete reachability.
The first line is the one that matters: a method in a different app reaches internal provider code in two hops.
Warning
Read that warning carefully.
--pathsis a sample, not an enumeration. Here it emitted paths for 3 of the 6 reachable targets and blamed--depth/--max-paths- but I set neither, and the default cap is 1000 paths on a 30-node graph. Use the plain node list when you need completeness and--pathsonly to see how something is reached.
Query 2 - which public method exposes this exact procedure?
This is the one I expect to use most. Invert the question: given a procedure I care about, what is the outermost public thing a caller could actually hit to get to it? That is --frontier.
dotnet al graph query --graph .\.graph\g.jsonl `
--to "obj:Codeunit/Secret Handler#GetApiKey" `
--frontier access:public2 node(s):
Codeunit Payment Facade.PostPayment
Codeunit Payment Facade.PostPaymentUnattended
Two lines, and they are the actionable answer: these two procedures are why your secret handler is reachable. Not "here are 40 nodes on 12 paths" - the nearest public surface, which is the thing you would change.
Note the selector: obj:Codeunit/Secret Handler#GetApiKey. Short object name, # for the member. No namespace needed, even though the object is in one.
Query 3 - where does a secret become inspectable?
al graph collapses three separate mechanisms into one concept called debuggable. A method is not debuggable if any of these hold:
- Its app never opted into debugging -
resourceExposurePolicy.allowDebuggingisfalse, which is the default. - The method is marked
[NonDebuggable]. - It is a platform built-in.
Which gives you the interesting query. A secret handled inside non-debuggable code stays out of the debugger - right up until it crosses into a method you can step through, where it becomes an ordinary inspectable value:
flowchart LR
subgraph nd["!debuggable - cannot be stepped into"]
G["Secret Handler.GetApiKey<br/>NonDebuggable"]
R["Secret Handler.ReadFromIsolatedStorage<br/>NonDebuggable"]
end
subgraph d["debuggable - parameters and return values visible"]
S["Secret Handler.Sanitize<br/>Internal · no attribute"]
end
G -->|Direct| R
G ==>|"returns the raw key"| S
classDef danger stroke:#e35b4d,stroke-width:2px
class G danger
class S dangerExpressed as a query, that is just "from not-debuggable, to debuggable":
dotnet al graph query --graph .\.graph\g.jsonl `
--from "!debuggable+app:56d45870-088b-46f7-891a-78e87c01e4bf" `
--to "debuggable+app:56d45870-088b-46f7-891a-78e87c01e4bf" --paths1 path(s):
Codeunit Secret Handler.GetApiKey -[Direct]-> Codeunit Secret Handler.Sanitize
One path. Exactly the leak I planted, and nothing else. No false positives on a corpus with an interface, an event, a cross-app call and a platform call in it.
--frontier again gives you the shorter version - the first place execution becomes inspectable:
dotnet al graph query --graph .\.graph\g.jsonl `
--from "!debuggable+app:56d45870-088b-46f7-891a-78e87c01e4bf" `
--frontier "debuggable+app:56d45870-088b-46f7-891a-78e87c01e4bf"1 node(s):
Codeunit Secret Handler.Sanitize
Note
Scope the query to your own app with
+app:<guid>. Platform built-ins are non-debuggable by definition, so an unscoped!debuggablequery drags everyFormat,DelChrandIsolatedStorage.Getcall into the answer.
Query 4 - the OnPrem surface
onprem-surface is broader than scope:onprem: it also covers OnPrem Scope tables, which compile under Cloud scope. Here it starts from the one [Scope('OnPrem')] procedure:
dotnet al graph query --graph .\.graph\g.jsonl --from onprem-surface17 node(s):
Codeunit Payment Impl.Execute
Codeunit Secret Handler.BuildAuthHeader
Codeunit Payment Impl.SelectGateway
Codeunit Payment Impl.OnBeforeCharge
Interface IPaymentGateway.Charge
Codeunit Fallback Gateway.Charge
Codeunit Primary Gateway.Charge
Text.op_Addition
Codeunit Secret Handler.GetApiKey
Codeunit Payment Impl.Amountless
Codeunit Charge Subscriber.HandleBeforeCharge
Decimal.GreaterThan
Codeunit Secret Handler.ReadFromIsolatedStorage
Codeunit Secret Handler.Sanitize
System.Format
IsolatedStorage.Get
Text.DelChr
Everything an OnPrem-only entry point can reach - platform built-ins included, which is why this list is long. For a real app this is the "what is behind the OnPrem gate" inventory, and it is the query I would run before promising a customer that a feature is cloud-safe.
The node facts explain how it found the entry point. PostPaymentUnattended is recorded as "scope":"OnPrem" with "isSink":true,"sinkCategory":"AlScope" - so [Scope('OnPrem')] is modelled as a sink category, not just a scope string. sinkCategory doesn't appear in the changelog at all, and it suggests there are more sink kinds in there than the OnPrem one.
Query 5 - callers, and getting test code out of the way
--direction callers walks the graph backwards. Who calls the facade?
dotnet al graph query --graph .\.graph\g.jsonl `
--to "obj:Codeunit/Payment Facade#PostPayment" --direction callers2 node(s):
Codeunit Consumer Entry.Run
Codeunit Payment Tests.TestPostPayment
The test codeunit is a caller, and on a real app it will be most of your callers. --exclude takes a selector and prunes matching nodes from the whole query - the namespace glob does the job:
dotnet al graph query --graph .\.graph\g.jsonl `
--to "obj:Codeunit/Payment Facade#PostPayment" --direction callers `
--exclude "ns:*Test*"1 node(s):
Codeunit Consumer Entry.Run
One real consumer. This is why namespacing your test code properly pays off twice.
Confidence: what the graph is guessing about
Every edge carries a confidence. Direct calls are Resolved - the compiler knows the target. Interface and event edges are OverApprox, because a static analysis cannot know which implementation runs or which subscribers are installed, so it assumes all of them.
You can see the difference. All edges out of Execute, one hop:
dotnet al graph query --graph .\.graph\g.jsonl --from "obj:Codeunit/Payment Impl#Execute" --depth 16 node(s):
Codeunit Secret Handler.BuildAuthHeader
Codeunit Payment Impl.SelectGateway
Codeunit Payment Impl.OnBeforeCharge
Interface IPaymentGateway.Charge
Codeunit Fallback Gateway.Charge
Codeunit Primary Gateway.Charge
Now only the edges the compiler actually resolved:
dotnet al graph query --graph .\.graph\g.jsonl --from "obj:Codeunit/Payment Impl#Execute" --depth 1 --resolved-only4 node(s):
Codeunit Secret Handler.BuildAuthHeader
Codeunit Payment Impl.SelectGateway
Codeunit Payment Impl.OnBeforeCharge
Interface IPaymentGateway.Charge
The two gateway implementations drop out. Execute calls Gateway.Charge through an interface variable; the graph over-approximates that to both implementations, and --resolved-only says "only what you actually know".
Which way you want depends on the question. For a security audit, keep the over-approximation - an attacker gets whichever implementation is installed. For "what does this call, really", --resolved-only is the honest answer.
You can also isolate one edge kind with --edge-kinds. The interface fan-out on its own:
dotnet al graph query --graph .\.graph\g.jsonl `
--from "obj:Interface/IPaymentGateway#Charge" `
--to "app:56d45870-088b-46f7-891a-78e87c01e4bf" `
--edge-kinds Interface --paths2 path(s):
Interface IPaymentGateway.Charge -[Interface]-> Codeunit Fallback Gateway.Charge
Interface IPaymentGateway.Charge -[Interface]-> Codeunit Primary Gateway.Charge
And the event fan-out:
dotnet al graph query --graph .\.graph\g.jsonl `
--from "obj:Codeunit/Payment Impl#OnBeforeCharge" `
--to "app:56d45870-088b-46f7-891a-78e87c01e4bf" `
--edge-kinds Event --paths2 path(s):
Codeunit Payment Impl.OnBeforeCharge -[Event]-> Codeunit Charge Subscriber.HandleBeforeCharge
Codeunit Payment Impl.OnBeforeCharge -[Event]-> Codeunit Charge Subscriber.HandleBeforeCharge
There is exactly one subscriber, and it is listed twice - the same edge is emitted twice into the graph. Harmless here, noise on a real app.
Exporting: SARIF is the good one
al graph export takes the same selectors and writes dgml, graphml or sarif. SARIF is the one that changes how the feature feels, because Visual Studio Code's SARIF viewer turns a query result into a clickable code flow.
dotnet al graph export --graph .\.graph\g.jsonl --format sarif `
--from access:public `
--to "!debuggable+app:56d45870-088b-46f7-891a-78e87c01e4bf" `
--out .\.graph\leak.sarifWrote sarif export -> .graph\leak.sarif
Three results, each a full call chain with a file and line per step. The first one:
'Consumer Entry.Run' can reach 'Secret Handler.GetApiKey'.
Consumer Entry.Run - source ConsumerEntry.Codeunit.al:9
Payment Facade.PostPayment - Direct call (Resolved) ConsumerEntry.Codeunit.al:13
Payment Impl.Execute - Direct call (Resolved) PaymentFacade.Codeunit.al:15
Secret Handler.BuildAuthHeader - Direct call (Resolved) PaymentImpl.Codeunit.al:17
Secret Handler.GetApiKey - Direct call (Resolved) SecretHandler.Codeunit.al:14
Every step opens at the right line. It is a proper taint-flow view of AL code, which is not something we have had before.
The dgml export is for Visual Studio's graph viewer and groups nodes by object and namespace:
<DirectedGraph GraphDirection="LeftToRight" Layout="Sugiyama" xmlns="http://schemas.microsoft.com/vs/2009/dgml">
<Nodes>
<Node Id="group::al . 56d45870-.../ Codeunit 50100 Payment Facade" Label="Payment Facade"
Group="Expanded" Category="ObjectGroup" />
<Node Id="ns::SSOSIC.GraphDemo.Provider" Label="SSOSIC.GraphDemo.Provider"
Group="Expanded" Category="NamespaceGroup" />Selector reference
Selectors are the whole interface, and the help text compresses them into one line. Expanded:
| Primitive | Matches | Example |
|---|---|---|
access: | Accessibility | access:public, access:internal, access:local |
scope: | Compilation scope | scope:cloud, scope:onprem |
kind: | Node kind | kind:method, kind:object |
ns: | Namespace, globs allowed | ns:*Test* |
obj: | Object, #member optional | obj:Codeunit/Payment Facade#PostPayment |
app: | Owning app id | app:56d45870-... |
id: | Node id |
| Alias | Means |
|---|---|
debuggable | Steppable, with parameters, locals and return value inspectable |
onprem-surface | The OnPrem-gated boundary - wider than scope:onprem |
elevation | Methods that elevate permissions or entitlements |
| Operator | Means |
|---|---|
+ | AND - access:internal+app:<guid> |
, | OR - access:public,access:internal |
! or not: | NOT, on a single term - !debuggable |
And the flags that shape the traversal rather than the selection: --direction callees|callers|both, --depth, --edge-kinds Direct,Event,Interface,Run,Trigger, --scope cloud|onprem, --resolved-only, --exclude, --paths, --max-paths, --frontier.
Rough edges
It is a first release and it shows in places. None of these stopped me, but you'll hit them.
--paths without both ends crashes. Not a friendly error - a raw stack trace:
Unhandled exception: System.ArgumentException: --paths requires both --from and --to.
at Microsoft.BusinessCentral.CallGraph.Store.GraphQuery.RunPaths(...)
at Microsoft.BusinessCentral.CallGraph.Store.GraphQuery.Run(GraphQueryRequest request)
at Microsoft.BusinessCentral.CallGraph.Pipeline.GraphPipeline.Query(...)
The message is right there in the exception; it just isn't caught.
--paths under-reports, then blames flags you didn't set. Covered above: 3 of 6 targets got a path, with truncated by --depth/--max-paths on a 30-node graph and no caps in play.
Duplicate event edges. One publisher, one subscriber, two identical edges.
Every method has an edge to itself. A Direct self-edge on each method, pointing at its own declaration line. It inflates the edge count in the summary line, and you will see it if you read the JSONL.
A stray internal URL. The SARIF informationUri points at https://microsoft.ghe.com/bic/BC-DeveloperExperience, which is Microsoft's internal GitHub Enterprise. Cosmetic, but it did make me smile.
Where I would actually use this
Three places, in order of how soon I'll get to them.
Before publishing a breaking-change-free release. --from access:public --to access:internal tells you the real size of your public surface - not the procedures you marked public, but everything a dependent app can reach through them. That number is usually bigger than you think, and every method on that list is something you have promised not to break.
As a security gate in the pipeline. --from !debuggable --to debuggable on your own app id, and fail the build if it returns anything you haven't reviewed. It found a real class of mistake here with no false positives, and the SARIF export means the finding lands in a code-scanning view rather than a log file.
When auditing what is behind the OnPrem gate. --from onprem-surface before you tell anyone a feature is cloud-ready.
It is not a replacement for AppSourceCop - it doesn't know about versions or baselines, and it will not tell you that you broke a contract. It answers the other question, the one nothing else answered: given everything I ship and everything I depend on, what can actually get to this code?
The demo corpus is nine files and the whole walkthrough runs in about two minutes with no Business Central environment. If you ship an AppSource app, spend the two minutes.




