Skip to content

The Drift Log · Provenance, licensing and the paper trail

Verifying License Compatibility for Transitive AST Invocations

30 October 2026 · 3 min read · 722 words · established

A wax seal resting on a page of hash digests

Package manifests mask copyleft obligations. Verifying distribution safety requires tracing reachable AST call paths down to raw source headers.

A standard manifest scanner reports what sits in your node_modules or vendor/ directory. It reads top-level package.json fields, parses Cargo manifests, or checks license headers against an SPDX list. Then it issues a clean bill of health because every top-level dependency is MIT or Apache-2.0.

That bill of health is fictitious.

Software does not execute as package-level envelopes. It executes as abstract syntax tree (AST) nodes calling other AST nodes across module boundaries. When you pull in a permissive package that dynamically imports or statically links a copyleft module five layers deep, package metadata alone cannot tell you whether your production binary actually executes that code path.

Relying on metadata scans turns your vendor directory into a folder of hope. You assume that because the root entrypoint is permissive, your runtime footprint remains untouched by the terms of nested components.

Metadata Lies; AST Call Graphs Do Not

When you vendor code or consume transitive dependencies, the legal obligations of open-source licenses attach to distribution and compilation boundaries, not package boundaries.

Consider a utility library licensed under MIT. Inside its internals, it contains an optional formatting backend licensed under the GNU General Public License (GPL-3.0-only). A static metadata scanner looks at the top-level license declaration, records MIT, and logs the package into your SBOM.

// Your application entrypoint
import { formatRecord } from "permissive-data-tools";

export function handlePayload(raw: unknown) {
  // AST Call: Identifier(formatRecord) -> CallExpression
  return formatRecord(raw, { engine: "native-fast" });
}

If formatRecord dispatches dynamically to a GPL-encumbered routine when { engine: "native-fast" } is passed, your compiled output has crossed an execution boundary into copyleft territory.

// Inside permissive-data-tools/index.js
import { fastEngine } from "gpl-fast-engine"; // GPL-3.0-only

export function formatRecord(data, opts) {
  if (opts.engine === "native-fast") {
    // AST invocation crosses into GPL-3.0 runtime boundary
    return fastEngine(data);
  }
  return JSON.stringify(data);
}

If you do not trace the AST call graph from your application's entry points down through the imported symbols, you cannot verify license compliance. You are left guessing whether dead-code elimination stripped the restricted logic or whether it landed in your distributed binary.

Reconciling Invocations Against the License Matrix

True software provenance requires resolving three properties for every symbol in your runtime graph:

  1. Symbol Origin: Which exact file and revision defined the referenced function, class, or macro?
  2. Reachable Call Path: Does a valid execution path exist from an exposed export to the symbol's AST node?
  3. Licensing Invariant: Does the license attached to that specific file conflict with your target distribution model?

To resolve these properties, you construct a call graph over the combined AST of your application and its dependencies.

[App: src/index.ts] 
       │
       ▼ (CallExpression: handlePayload)
[Dependency: permissive-data-tools/index.js] 
       │
       ▼ (CallExpression: fastEngine) [Branch: opts.engine === 'native-fast']
[Transitive: gpl-fast-engine/core.js] <--- VIOLATION: Incompatible with proprietary distribution

If the call path to gpl-fast-engine is statically reachable, you have an immediate license conflict. If tree-shaking or constant folding proves the branch unreachable (for instance, if your code hardcodes { engine: "standard" } and dead branches are pruned before bundling), the AST node is never emitted into the shipping artifact.

Metadata scanners cannot make this distinction. They flag false positives on inert files you never call, and they miss real liabilities hidden behind multi-hop transitive imports.

The Limits of Static AST Resolution

Tracing call paths through static analysis is not a complete silver bullet. It has genuine limits that you must account for:

  • Dynamic Property Access: Invocations via obj[methodName]() or reflection defeat static edge construction. When the AST analyzer cannot determine the target symbol, it must treat the branch as unresolvable.
  • Conditional Compiles: Build-time environment flags can rewrite the call graph entirely between test and production builds.
  • Eval and Dynamic Imports: Runtimes loading modules from string variables break the static provenance chain.

When static resolution fails, the posture must default to conservative quarantine: if an ambiguous AST invocation can reach an unverified or incompatible module, the build must fail until the boundary is explicitly annotated or rewritten.

What to Do on Monday

Do not rely on high-level package audits to protect your intellectual property or satisfy client contracts.

  1. Extract your production bundle's symbol table. Run your production bundler with sourcemaps enabled and export the list of all external modules and files actually pulled into the final artifact.
  2. Intersect bundled files with license headers. For every file present in the output bundle, match its source path against your repository's raw license texts, rather than the top-level package manifests.
  3. Flag copyleft AST reachability. If any node in the bundled symbol table belongs to an AGPL, GPL, or poorly attributed file, trace its reference chain back to your entrypoint. Either eliminate the caller, replace the dependency with a clean-room alternative, or isolate the execution behind an out-of-process network boundary.

Keep reading

Next in the log

The Strategic Master Library · written and reviewed under the house's own epistemic rules: nothing claimed that we cannot show.