A call graph of glowing nodes on a dark background, with one highlighted path running from a bright entry node through the graph to a red key icon, under the title "AL Graph: What Can Actually Reach Your Code?"

al graph: Auditing Who Can Reach Your Code in AL 18.0

11 min read

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 .alpackages folder. 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 (Resolved or OverApprox).
  • 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:

PowerShell
dotnet new tool-manifest
dotnet tool install --local Microsoft.Dynamics.BusinessCentral.Development.Tools --version 18.0.40.43394-beta
The 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 danger

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

JSON
{
  "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.

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

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

AL
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

AL
namespace SSOSIC.GraphDemo.Provider;

interface IPaymentGateway
{
    procedure Charge(Amount: Decimal; AuthHeader: Text): Boolean
}
AL
namespace SSOSIC.GraphDemo.Provider;

codeunit 50103 "Primary Gateway" implements IPaymentGateway
{
    Access = Internal;

    procedure Charge(Amount: Decimal; AuthHeader: Text): Boolean
    begin
        exit(Amount > 0);
    end;
}
AL
namespace SSOSIC.GraphDemo.Provider;

codeunit 50104 "Fallback Gateway" implements IPaymentGateway
{
    Access = Internal;

    procedure Charge(Amount: Decimal; AuthHeader: Text): Boolean
    begin
        exit(false);
    end;
}
AL
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.

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

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

PowerShell
dotnet al graph extract-whole --corpus .\apps --out .\.graph\shards --graph .\.graph\g.jsonl
Whole-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:

JSON
{"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?

PowerShell
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:

PowerShell
dotnet al graph query --graph .\.graph\g.jsonl `
  --from access:public `
  --to "access:internal+app:56d45870-088b-46f7-891a-78e87c01e4bf" --paths
5 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. --paths is 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 --paths only 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.

PowerShell
dotnet al graph query --graph .\.graph\g.jsonl `
  --to "obj:Codeunit/Secret Handler#GetApiKey" `
  --frontier access:public
2 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:

  1. Its app never opted into debugging - resourceExposurePolicy.allowDebugging is false, which is the default.
  2. The method is marked [NonDebuggable].
  3. 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 danger

Expressed as a query, that is just "from not-debuggable, to debuggable":

PowerShell
dotnet al graph query --graph .\.graph\g.jsonl `
  --from "!debuggable+app:56d45870-088b-46f7-891a-78e87c01e4bf" `
  --to "debuggable+app:56d45870-088b-46f7-891a-78e87c01e4bf" --paths
1 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:

PowerShell
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 !debuggable query drags every Format, DelChr and IsolatedStorage.Get call 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:

PowerShell
dotnet al graph query --graph .\.graph\g.jsonl --from onprem-surface
17 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?

PowerShell
dotnet al graph query --graph .\.graph\g.jsonl `
  --to "obj:Codeunit/Payment Facade#PostPayment" --direction callers
2 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:

PowerShell
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:

PowerShell
dotnet al graph query --graph .\.graph\g.jsonl --from "obj:Codeunit/Payment Impl#Execute" --depth 1
6 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:

PowerShell
dotnet al graph query --graph .\.graph\g.jsonl --from "obj:Codeunit/Payment Impl#Execute" --depth 1 --resolved-only
4 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:

PowerShell
dotnet al graph query --graph .\.graph\g.jsonl `
  --from "obj:Interface/IPaymentGateway#Charge" `
  --to "app:56d45870-088b-46f7-891a-78e87c01e4bf" `
  --edge-kinds Interface --paths
2 path(s):
  Interface IPaymentGateway.Charge -[Interface]-> Codeunit Fallback Gateway.Charge
  Interface IPaymentGateway.Charge -[Interface]-> Codeunit Primary Gateway.Charge

And the event fan-out:

PowerShell
dotnet al graph query --graph .\.graph\g.jsonl `
  --from "obj:Codeunit/Payment Impl#OnBeforeCharge" `
  --to "app:56d45870-088b-46f7-891a-78e87c01e4bf" `
  --edge-kinds Event --paths
2 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.

PowerShell
dotnet al graph export --graph .\.graph\g.jsonl --format sarif `
  --from access:public `
  --to "!debuggable+app:56d45870-088b-46f7-891a-78e87c01e4bf" `
  --out .\.graph\leak.sarif
Wrote 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:

XML
<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:

PrimitiveMatchesExample
access:Accessibilityaccess:public, access:internal, access:local
scope:Compilation scopescope:cloud, scope:onprem
kind:Node kindkind:method, kind:object
ns:Namespace, globs allowedns:*Test*
obj:Object, #member optionalobj:Codeunit/Payment Facade#PostPayment
app:Owning app idapp:56d45870-...
id:Node id
AliasMeans
debuggableSteppable, with parameters, locals and return value inspectable
onprem-surfaceThe OnPrem-gated boundary - wider than scope:onprem
elevationMethods that elevate permissions or entitlements
OperatorMeans
+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.

Related posts

Comments

    Leave a comment

    Reviewed before it appears.