Skip to content

X++: add extractor and QL library generated from the compiler's own AST - #392

Draft
Giulia Stocco (gfs) wants to merge 10 commits into
mainfrom
gfs-xpp-extractor
Draft

X++: add extractor and QL library generated from the compiler's own AST#392
Giulia Stocco (gfs) wants to merge 10 commits into
mainfrom
gfs-xpp-extractor

Conversation

@gfs

@gfs Giulia Stocco (gfs) commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Why

CodeQL has no support for X++, the language behind Dynamics 365 Finance & Operations. The only existing option is running the C# extractor over compiled X++, which captures CIL semantics but loses everything X++-specific: select statements, ttsbegin blocks, tables, forms, chain-of-command. Those are exactly where the interesting security questions live.

This adds a first-class X++ extractor. The guiding decision is that the dbscheme and QL classes are generated 1:1 from the X++ compiler's own AST hierarchy, so there is no hand-maintained mapping to keep in sync and no second-guessing the first-party model.

About the file count

Most of this diff is generated and marked linguist-generated, so it collapses in review. For calibration, xpp/ql/lib is about 1000 files; rust/ql/lib is 1019 and swift/ql/lib is 1578. Per schema class we are slightly leaner than rust. The stubs cannot be dropped without patching misc/codegen, which is shared with rust and swift, because generated classes import XImpl::Impl and constructX.

The genuinely hand-written surface is about 20 files, all under xpp/extractor/** (excluding Generated/), plus xpp/schema/prelude.py, xpp/codegen.conf, xpp/codeql-extractor.yml and xpp/tools/.

One cleanup is still outstanding: xpp/ql/test/extractor-tests/ holds 209 empty MISSING_SOURCE.txt placeholders, one per node type, generated before any fixtures existed. codegen.conf no longer produces them, but the committed files still need deleting:

git rm -r xpp/ql/test/extractor-tests

Approach

Xlnt.XppCore.dll --reflect--> xpp/schema/ast.py --misc/codegen--> dbscheme + QL classes + stubs
                                     |
                                     +--> generated C# TRAP writer

AxClass/*.xml --> Pass1 --> Ast --> TRAP --> CodeQL database

Xpp.SchemaGenerator reflects over the 243 types deriving from Microsoft.Dynamics.AX.Metadata.XppCompiler.Ast, using MetadataLoadContext so no code from the proprietary compiler package is executed. The resulting schema/ast.py feeds misc/codegen, the same suite Swift and Rust use.

Extraction calls Pass1, the compiler's own parser, in process. There is no reimplemented grammar anywhere in this change.

Reconciling the CLR hierarchy with the dbscheme

Most of the interesting work was in the mismatches between what the compiler's class model expresses and what a dbscheme can:

  • Tuples. The compiler uses CLR tuples for grouped values (a catch with its handler, a switch case with its body, a call argument with its by-ref flag). The schema has no tuple type, so the generator synthesizes a named class per tuple-valued property.
  • Dictionaries of AST values are represented by their values, since the key is always the value's own name.
  • Re-declared inherited properties are collapsed onto the ancestor, which would otherwise emit conflicting non-overriding QL predicates.
  • HasX booleans that duplicate the hasX() predicate generated for an optional X are dropped.
  • Instantiable base classes. A class that is both instantiable and extended gets only a union in the dbscheme, leaving instances of exactly that class with nowhere to bind. Seven X++ classes are in that position, so the generator emits an ...Internal leaf for each, following the Rust schema.
  • Open generics cannot be named in generated code, so they are treated as abstract with members bound through dynamic.

Five properties remain unmapped, all reasonably so: two open generic parameters, two object-typed accessors that have typed equivalents on subclasses, and one System.Type.

The TRAP writer is checked against the dbscheme

The TRAP writer is generated from the reflected model rather than from schema/ast.py, because the schema keeps only snake_case names and loses the CLR binding needed to read a property back off a node. Relation names, though, come from inflection.tableize inside misc/codegen, so the generator reimplements those rules and then validates every relation it emits against the committed dbscheme. A divergence fails generation rather than silently producing a database with missing tuples.

That check earned its keep immediately, catching two naming bugs: plural rules applied in the wrong order, and predicate tables using underscore rather than tableize.

Verification

  • codeql database create --language=xpp over four AxClass objects: 65 source blocks, 2224 nodes, no unsupported nodes, no errors. TRAP imported, source archive written, and queries return the expected counts.
  • A QL library test covers the class and its methods, control flow, and the database statements. Writing it immediately found a bug: the tree walk stepped over CLR tuples, so a ttsabort inside a catch extracted as zero statements. Traversal now looks through tuple slots and the test pins counts for both tuple shapes.
  • Three AxClass objects from the MIT-licensed TrudAX/XppTools parse to 2181 nodes with no unsupported nodes and no errors.
  • The generated QL library compiles, and regeneration is byte-for-byte deterministic.

Worth noting: Pass1 runs standalone on .NET 8 including macOS, so the extractor is not tied to Windows. Method bodies parse with no metadata provider; only class and interface headers need one to resolve extends, and an empty one supplied through DispatchProxy is sufficient.

Notes for reviewers

  • The compiler package is proprietary and LCS-gated. It is not committed, is never copied into build output, and is resolved at run time from XPP_COMPILER_PACKAGE. The generated schema, dbscheme and QL are committed so the QL layer can be worked on without it.
  • No CI workflow yet, deliberately. Building or testing the extractor needs the LCS-gated package, which a public runner cannot fetch. That needs either a private feed plus a secret, or splitting the job so QL tests run against committed TRAP fixtures with no proprietary assembly present. Adding a workflow that cannot pass seemed worse than adding none.
  • misc/codegen requires Python 3.13 or earlier. On 3.14, PEP 649 lazy annotations cause cls.__dict__["__annotations__"] to come back empty and every property is silently dropped from the schema.
  • ql/lib/codeql/xpp/elements/internal/ElementImpl.qll is hand-edited to supply the default toStringImpl. Its "generated" marker comment has been removed deliberately; leave it off, and do not delete the generated tree wholesale when regenerating.

Not included

Still to come: the base QL layer over the shared/ packs (CFG, SSA, dataflow), Concepts and MaD models, CI and packaging wiring, and the security queries themselves.

Giulia Stocco (gfs) and others added 7 commits August 17, 2026 12:44
Adds the first stage of X++ CodeQL support: a schema generated directly from
the X++ compiler's own AST hierarchy, rather than a hand-maintained mapping.

Xpp.SchemaGenerator reflects over the 243 types deriving from
Microsoft.Dynamics.AX.Metadata.XppCompiler.Ast in
Microsoft.Dynamics.AX.Framework.Xlnt.XppCore.dll, using MetadataLoadContext so
no code from the proprietary compiler package is executed. It emits
xpp/schema/ast.py, which misc/codegen turns into the dbscheme, the QL classes
and their hand-editable stubs.

The generator resolves several mismatches between the CLR hierarchy and the
schema:

- CLR tuples (try/catch pairs, switch cases, call arguments) have no schema
  equivalent, so a named class is synthesized per tuple-valued property.
- Dictionaries of AST values are represented by their values, since the key is
  always the value's own name.
- Subclasses that re-declare an inherited property are collapsed onto the
  ancestor's declaration, which would otherwise emit conflicting QL predicates.
- HasX booleans that duplicate the hasX() predicate generated for an optional X
  are dropped.
- Table names that would collide with a class table get an explicit override.

The compiler package is proprietary and is not committed. Point
XPP_COMPILER_PACKAGE at an extracted copy and run xpp/tools/generate-schema.sh;
the generated output is committed so the QL layer can be worked on without it.

Verified: generation is deterministic across runs and the full generated
library compiles against the dbscheme.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1f6fb880-dfd8-4fba-91a2-903f43dc287d
Options were scanned from index 1 to length-1 and the `type` command read its
operand from a fixed position, so a type name followed by `--package` was
shadowed by the option itself. Parse options and positionals separately.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1f6fb880-dfd8-4fba-91a2-903f43dc287d
Adds the second stage of X++ support: a generated TRAP writer, and an extractor
that turns a D365 F&O metadata object into a CodeQL database.

The TRAP writer is generated from the same reflected AST model as the schema
rather than from a Python generator reading schema/ast.py. The schema keeps
only snake_case names and loses the CLR binding needed to read a property back
off a node, so the generator has to be where the model already lives. Relation
names, however, are produced by inflection.tableize inside misc/codegen, so the
generator reimplements those naming rules and validates every relation it emits
against the committed dbscheme. A divergence fails generation instead of
silently dropping tuples, and that check already caught two naming bugs: plural
rules applied in the wrong order, and predicate tables using underscore rather
than tableize.

Two further mismatches between the CLR hierarchy and the dbscheme surfaced:

- A class that is instantiable and also extended gets only a union in the
  dbscheme, leaving instances of exactly that class with nowhere to bind. Seven
  X++ classes are in that position, so the generator emits an `...Internal` leaf
  for each, as the Rust schema does.
- Open generic AST classes cannot be named in generated code, and a closed
  instance's runtime name would not match a dispatch case. They are treated as
  abstract and their members bound through `dynamic`.

Extraction reads the X++ out of the CDATA blocks in an object's XML and passes
each block's line offset to the parser, so positions come back relative to the
file rather than the fragment. Method bodies parse without metadata; class and
interface headers need a provider to resolve `extends`, so an empty one is
supplied through DispatchProxy rather than hand-writing eighty-odd members. The
compiler package stays out of build output and is resolved at run time from
XPP_COMPILER_PACKAGE.

Verified end to end. Three AxClass objects from TrudAX/XppTools yield 63 source
blocks and 2181 nodes with no unsupported nodes and no errors; the resulting
TRAP imports cleanly and queries return the expected method names and counts. A
fixture covering `while select`, ttsbegin/ttscommit/ttsabort, try/catch and
changecompany confirms the X++-specific constructs reach the database.
Regeneration remains deterministic.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1f6fb880-dfd8-4fba-91a2-903f43dc287d
`codeql database create --language=xpp` now produces a queryable database.

Adds the tool scripts the CLI invokes and a script that assembles the extractor
pack. X++ has no build step, so as with the PowerShell extractor the work
happens in autobuild rather than in a build wrapper.

The extractor now understands the environment the CLI sets up: it reads
`--file-list`, writes one TRAP file per source file under
CODEQL_EXTRACTOR_XPP_TRAP_DIR, and copies each object into
CODEQL_EXTRACTOR_XPP_SOURCE_ARCHIVE_DIR so alerts have something to display.
Passing `--trap` or leaving the environment unset keeps the single-stream
behaviour that is convenient outside the CLI.

Also stops generating the per-node-type test placeholders. They were 209 empty
MISSING_SOURCE.txt markers with no value until real fixtures exist, and
per-node-type generated tests are the wrong shape for this extractor anyway.
The already-committed placeholder files still need removing separately.

Verified with `codeql database create --language=xpp` over four AxClass objects:
65 source blocks, 2224 nodes, no unsupported nodes and no errors, TRAP imported
and source archive written. Queries against the resulting database return the
expected counts, including `while select`, ttsbegin and try/catch.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1f6fb880-dfd8-4fba-91a2-903f43dc287d
Adds the first QL test, covering the class and its methods, the control-flow
statements, and the database statements that have no equivalent in other
languages.

Writing it immediately found a bug. The compiler groups some children in CLR
tuples, such as a `catch` with its handler body and a switch case with its
statements. The tree walk only followed properties that were an AST node or a
sequence of them, so it stepped over those tuples and never visited the
subtrees inside. A `ttsabort` in a catch block was extracted as zero statements.
Traversal now looks through tuple slots, and the test pins the counts for both
shapes.

Extraction during `codeql test run` uses the qltest protocol, which needs
`legacy_qltest_extraction` in the extractor config.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1f6fb880-dfd8-4fba-91a2-903f43dc287d
The check-implicit-this workflow requires the property on every qlpack.yml, and
the newly added test pack was missing it. The library pack already had it.

Enabling it produces no warnings in the generated or hand-written QL, and the
library test still passes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1f6fb880-dfd8-4fba-91a2-903f43dc287d
These were 209 empty MISSING_SOURCE.txt markers, one per AST node type,
generated before any fixtures existed. codegen.conf no longer produces them.
The hand-written library test under xpp/ql/test/library-tests covers this
ground far better - writing it found a real traversal bug that the generated
placeholders never would have.
@gfs
Giulia Stocco (gfs) requested a balanced review from Copilot August 17, 2026 23:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

Giulia Stocco (gfs) and others added 3 commits August 17, 2026 16:49
All five reported issues reproduced, so all five are fixed.

Dictionary-held children were dropped. The compiler keeps some children in
dictionaries keyed by name, such as a class's fields and methods. Iterating one
as a bare IEnumerable yields KeyValuePair rather than the child, so the emitter
labelled the pair and the tree walk, seeing neither an Ast nor a tuple, skipped
the subtree entirely. Both now go through AstSequence, which reduces entries to
their values. A class with two fields extracted zero FieldDeclarations before
and two after.

Source positions were wrong on CRLF files. The line offset was found by
searching the raw file for the CDATA payload, but an XML parser normalises CRLF
to LF inside element values, so on the Windows-authored files that F&O actually
produces the search never matched and every multiline block silently fell back
to offset zero. Offsets now come from XML line information.

That fix was unverifiable at first because locations were never written at all,
so this also emits them: `Ast.Position` supplies the extent for each node.
Nodes the parser synthesises have no extent and are left without a location
rather than being pinned to the top of the file.

Normal indexing processed every XML file. The extractor declares the .xml
extension, so the CLI's file list covers the whole tree; entries were used
unfiltered, letting an unrelated or malformed XML file fail an otherwise valid
build. File-list entries are now filtered the same way directory walks are.

Pack creation could delete the source tree. Passing the repository root made
the target `<repo>/xpp` and the script removed it. It now refuses to build into
the source tree, and only removes a directory that is itself a generated pack.

Combined TRAP output reused labels. A writer was created per input while
sharing one stream, so each restarted at #1 and collided. One writer is now
created per stream. Extracting three files to a single file yields 5943 label
definitions, all distinct and monotonic.

Removing the codegen test output also turned out to break regeneration, since
qlgen requires the path. It now points at a gitignored scratch directory.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1f6fb880-dfd8-4fba-91a2-903f43dc287d
Adds the pieces the PowerShell extractor has that make a language easy to wire
into the CLI build: build-{win64,linux64,osx64}.ps1 at the language root taking
a $cliFolder, and a solution file.

The build scripts publish the Xpp.Extractor project rather than the solution.
Publishing a solution to a single output directory warns (NETSDK1194) and would
ship Xpp.SchemaGenerator, which is a build-time code generator rather than part
of the extractor.

Not added: an entry in microsoft-codeql-pack-publish.yml. That job downloads the
already-published pack and increments its version, so listing a language whose
pack has never been published fails the release workflow. microsoft/xpp-all
needs a first publish out of band before it can join the matrix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1f6fb880-dfd8-4fba-91a2-903f43dc287d
`codeql dataset check` reported 286 VALUE_NOT_IN_TYPE violations: every value of
compilation_unit_comments was outside @comment_or_none. The database imported
and queried fine, so nothing had caught it.

There were two causes. Comments were never emitted at all: Comment is not an Ast
subclass, so the tree walk skipped it while the generated emitter still
referenced it, leaving every reference dangling.

Emitting them was not enough on its own. Comment is a struct, so each read of
the property boxes it into a new object. The emitter read the property to write
the reference and the tree walk read it again to visit the node, producing two
distinct boxes and therefore two distinct labels: 286 defined, 286 referenced,
no overlap.

Rather than special-case structs, the emitter now records each child as it
labels it and the walk consumes that list, so both sides use the same instances
by construction. This also removes the reflective property walk, and covers any
other property that allocates on read rather than just this one.

Locations are also emitted for comments, and TextPosition is null-checked, since
it is a reference type and some nodes carry no extent.

`codeql dataset check` now reports 0 violations. The library test pins the
comment count so a regression cannot go unnoticed again, and regeneration
remains deterministic.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1f6fb880-dfd8-4fba-91a2-903f43dc287d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants