Project 2: Library Dependency Visualizer

Build a CLI tool that parses binaries, resolves shared library dependencies the same way the dynamic loader does, and outputs a deterministic dependency graph.

Quick Reference

Attribute Value
Difficulty Level 3: Advanced
Time Estimate 1-2 weeks
Main Programming Language C (Alternatives: Python, Rust)
Alternative Programming Languages Python (parsing), Rust
Coolness Level Level 3: Genuinely Clever
Business Potential Level 3: Service & Support Model
Prerequisites ELF basics, filesystem traversal, graph fundamentals
Key Topics ELF .dynamic, DT_NEEDED, loader search paths, graph traversal

1. Learning Objectives

By completing this project, you will:

  1. Parse ELF (or Mach-O/PE) metadata to extract DT_NEEDED dependencies.
  2. Reimplement the runtime loader’s search order to resolve library paths.
  3. Build a dependency graph with cycle detection and deterministic output.
  4. Generate DOT/SVG/ASCII graphs for complex dependency trees.
  5. Explain loader failures by showing exactly which dependency could not be resolved.

2. All Theory Needed (Per-Concept Breakdown)

2.1 ELF Dynamic Section and DT_NEEDED

Fundamentals The ELF dynamic section is the loader’s roadmap. It contains tagged entries that tell the runtime loader which shared libraries must be loaded (DT_NEEDED), the shared object’s identity (DT_SONAME), and where to find relocation tables. For a dependency visualizer, the most important tag is DT_NEEDED, which is a list of library names like libcrypto.so.3. The loader uses these names plus its search paths to locate actual files. If you can parse the dynamic section and read the string table that backs it, you can reconstruct the full dependency list for any binary.

Deep Dive into the concept ELF files have two major tables: the section header table and the program header table. The dynamic section is a section called .dynamic, but it is usually mapped into memory because there is also a PT_DYNAMIC program header pointing to it. The loader ignores most sections but uses the program header to find .dynamic. Each dynamic entry is a Elf64_Dyn structure with a tag and a value. Tags like DT_STRTAB and DT_STRSZ point to the dynamic string table, which stores strings like libc.so.6 or libpthread.so.0. DT_NEEDED entries are indexes into that string table. Therefore, to parse them correctly, you must read DT_STRTAB and then resolve offsets for each DT_NEEDED entry.

The dynamic section also contains DT_RPATH or DT_RUNPATH, which are strings in the same dynamic string table. These are not dependencies themselves, but they influence search paths. For a visualizer, you should capture them because they explain why a particular library was resolved from a non-standard path. Another useful tag is DT_SONAME for libraries, which defines the ABI identity of the library. When you resolve a dependency by file path, it is good practice to record both the file path and its DT_SONAME.

Remember that the dynamic section is different from the symbol table. A dependency visualizer does not need to parse .dynsym to list dependencies, but if you want to explain missing symbols or unresolved relocations later, .dynsym becomes relevant. For this project, focus on dependencies and their paths. Use readelf -d as a reference to validate your parser output.

Even within ELF, variations exist: 32-bit vs 64-bit, endianness, and different machine architectures. Your tool should either explicitly support only the platform it runs on (simpler) or include parsing logic that checks e_ident fields and rejects unsupported files with a clear error. Deterministic output means your graph should always present nodes in a stable order (e.g., sorted by name), regardless of filesystem traversal order.

Finally, recognize that executables may use DT_NEEDED indirectly: if app depends on libA, which depends on libB, you must recursively parse each library. You need to avoid cycles and duplicates; the DT_NEEDED list alone does not encode full paths. That is where loader search rules come in.

How this fits in this project Your parser will extract DT_NEEDED for each binary and library and feed these into the graph builder. The dynamic section is the single source of truth for dependency names.

Definitions & key terms

  • .dynamic -> Section containing runtime linking metadata.
  • DT_NEEDED -> List of library names required by the binary.
  • DT_STRTAB -> Pointer to dynamic string table.
  • DT_SONAME -> ABI identity string for a shared library.
  • PT_DYNAMIC -> Program header pointing to the dynamic section.

Mental model diagram (ASCII)

ELF file
+-------------------+
| ELF header        |
+-------------------+
| Program headers   | -> PT_DYNAMIC
+-------------------+
| .dynamic section  | -> DT_NEEDED -> string table
+-------------------+

How it works (step-by-step, with invariants and failure modes)

  1. Read ELF header and validate magic (0x7f 'E' 'L' 'F').
  2. Locate program headers and find PT_DYNAMIC.
  3. Parse dynamic entries and locate DT_STRTAB.
  4. Read each DT_NEEDED entry and resolve it via the string table.
  5. Use the loader search algorithm to map each name to a path.

Invariants: ELF headers must be valid, string table offsets must be in range. Failure modes: corrupted ELF, missing DT_STRTAB, or unknown endianness.

Minimal concrete example

$ readelf -d /usr/bin/ssh | grep NEEDED
 0x0000000000000001 (NEEDED)             Shared library: [libcrypto.so.3]
 0x0000000000000001 (NEEDED)             Shared library: [libz.so.1]

Common misconceptions

  • ldd output is the same as DT_NEEDED.” -> ldd already resolves paths using loader rules.
  • “Sections map directly into memory.” -> The loader uses program headers, not sections.

Check-your-understanding questions

  1. Why does DT_NEEDED store library names instead of full paths?
  2. What information do you need from DT_STRTAB?
  3. Why should a tool verify ELF endianness and class?

Check-your-understanding answers

  1. The loader resolves names using search paths that can vary by environment.
  2. It provides the string table to convert offsets into actual names.
  3. Parsing with the wrong endianness or class yields invalid offsets and crashes.

Real-world applications

  • Diagnosing startup failures for large binaries.
  • Auditing dependencies for security or packaging.

Where you’ll apply it

  • In this project: see Section 3.2 Functional Requirements and Section 4.4 Algorithm Overview.
  • Also used in: P06-minimal-dynamic-linker.

References

  • System V ABI (gABI) - ELF specification.
  • “Linkers and Loaders” (Levine), Ch. 10.

Key insights DT_NEEDED is the loader’s dependency list; parse it correctly and the rest becomes a path resolution problem.

Summary The dynamic section encodes dependency names and essential loader metadata. Your tool must read it faithfully and deterministically to build accurate graphs.

Homework/Exercises to practice the concept

  1. Use readelf -d on a system binary and list its dependencies.
  2. Write a script that reads DT_NEEDED entries and prints them sorted.
  3. Compare .dynamic output of an executable and a shared library.

Solutions to the homework/exercises

  1. readelf -d /bin/ls | grep NEEDED.
  2. Use pyelftools or a small C parser to read DT_NEEDED.
  3. readelf -d /lib/x86_64-linux-gnu/libc.so.6.

2.2 Loader Search Paths (RPATH, RUNPATH, LD_LIBRARY_PATH, ldconfig)

Fundamentals The dynamic loader locates libraries by searching a set of paths in a defined order. The default is system directories (e.g., /lib, /usr/lib), but binaries can embed RPATH or RUNPATH entries, and users can override paths via LD_LIBRARY_PATH. On Linux, ldconfig maintains a cache of library locations. If your visualizer resolves dependencies incorrectly, your graph will not match reality. Therefore you must implement the search order exactly, or explicitly document which parts you skip.

Deep Dive into the concept The loader’s search algorithm is nuanced. For an executable, the loader uses (roughly) this order:

  1. LD_LIBRARY_PATH (unless DT_RUNPATH is present and secure-exec disables it)
  2. DT_RPATH (if DT_RUNPATH is absent)
  3. DT_RUNPATH
  4. ldconfig cache (/etc/ld.so.cache)
  5. Default directories (/lib, /usr/lib, and multilib variants)

For shared libraries, the rules differ slightly because the library’s own RPATH/RUNPATH may apply to its dependencies. Also, DT_RPATH is deprecated in favor of DT_RUNPATH, and the presence of RUNPATH changes how LD_LIBRARY_PATH is applied. The loader also respects environment variables like LD_PRELOAD and can be forced to debug via LD_DEBUG=libs.

To emulate this, your tool should model the search order and allow the user to pass in an environment configuration (e.g., a fake LD_LIBRARY_PATH). For determinism, you should not depend on current environment unless explicitly requested; instead, allow flags like --ld-path or --no-env. When resolving a library name, you should check each candidate directory in order. If multiple paths contain the same library, the first match wins. This detail is important because it can explain subtle differences between machines.

On macOS and Windows, the search order differs (install names, @rpath, PATH, DYLD_LIBRARY_PATH). If you plan cross-platform support, you should abstract the resolver so each platform has its own path resolution strategy. For this project, implementing Linux accurately is sufficient, but document the limitations.

Finally, remember that ldd is just a tool that uses the loader. It is not an oracle for path order if environment variables are set. That is why your tool must be explicit about which environment it emulates.

How this fits in this project Your resolver will implement the search algorithm and feed fully resolved paths into the graph. The visualizer’s credibility depends on path accuracy.

Definitions & key terms

  • RPATH -> Embedded search path used when RUNPATH is absent.
  • RUNPATH -> Embedded search path with different precedence rules.
  • LD_LIBRARY_PATH -> Environment override for library search.
  • ldconfig cache -> System cache of library locations.

Mental model diagram (ASCII)

Resolve "libfoo.so" by checking:
1) LD_LIBRARY_PATH
2) RPATH (if no RUNPATH)
3) RUNPATH
4) ld.so.cache
5) /lib, /usr/lib

How it works (step-by-step, with invariants and failure modes)

  1. Read binary’s DT_RPATH and DT_RUNPATH.
  2. Build an ordered list of search directories.
  3. For each DT_NEEDED name, check each directory for a matching file.
  4. If found, record path; if not, mark as unresolved.

Invariants: search order must be deterministic. Failure modes: missing cache, environment differences, or missing permissions.

Minimal concrete example

$ LD_LIBRARY_PATH=/tmp/libs ./libviz ./app
# libviz resolves first match in /tmp/libs before system dirs

Common misconceptions

  • “RUNPATH is the same as RPATH.” -> RUNPATH changes precedence rules.
  • LD_LIBRARY_PATH always applies.” -> Secure execution can disable it.

Check-your-understanding questions

  1. Why does RUNPATH change the effect of LD_LIBRARY_PATH?
  2. What happens if two directories contain the same SONAME?
  3. Why is ldconfig cache important?

Check-your-understanding answers

  1. With RUNPATH, LD_LIBRARY_PATH is searched first, whereas with RPATH it may not be.
  2. The first directory in search order wins, which can change the resolved library.
  3. It speeds up lookup and includes system-defined library locations.

Real-world applications

  • Debugging “works on my machine” dependency issues.
  • Packaging software with custom library paths.

Where you’ll apply it

  • In this project: see Section 3.5 Data Formats and Section 5.10 Phase 2.
  • Also used in: P03-ld-preload-interceptor for symbol resolution precedence.

References

  • man ld.so (loader search order).
  • “The Linux Programming Interface” (Kerrisk), Ch. 41.

Key insights Library resolution is deterministic but environment-sensitive; your tool must model both to be trustworthy.

Summary Implementing the loader’s search order accurately is the difference between a pretty graph and a correct one.

Homework/Exercises to practice the concept

  1. Create two directories with different versions of the same library and observe resolution order.
  2. Use readelf -d to compare RPATH and RUNPATH in two binaries.
  3. Inspect /etc/ld.so.cache with ldconfig -p.

Solutions to the homework/exercises

  1. Place libfoo.so in /tmp/a and /tmp/b, set LD_LIBRARY_PATH=/tmp/a:/tmp/b and observe the match.
  2. Build one binary with -Wl,-rpath and another with -Wl,-runpath.
  3. ldconfig -p | grep libfoo.

2.3 Graph Traversal, Cycles, and Deterministic Output

Fundamentals A dependency graph is a directed graph where nodes are binaries/libraries and edges represent DT_NEEDED dependencies. Graph traversal is required to walk transitive dependencies. Cycles can exist (e.g., libA depends on libB and libB depends on libA), so your traversal must track visited nodes to avoid infinite recursion. Determinism is important for testing: the same input must always produce the same graph output, even if file system iteration order changes.

Deep Dive into the concept Graph traversal for dependency resolution is similar to DFS or BFS. The simplest approach is DFS: start at the root binary, visit each dependency, and recursively visit their dependencies. To avoid cycles, maintain a visited set keyed by canonical path or SONAME. The choice of key matters. If you use raw file paths, you might treat two paths to the same file as different nodes. A robust solution is to resolve to real paths (realpath) and store those, along with SONAME metadata to handle symlinks.

Deterministic output requires that you consistently order both nodes and edges. That means you must sort dependency lists before traversing, and you must generate output in a stable order. If you parse DT_NEEDED entries, they are typically already ordered as written by the linker, but filesystem search can yield different results on different machines. Sorting by resolved path or SONAME ensures your output is stable. If you output DOT, you should also sort nodes and edges when printing.

Cycle representation should be explicit. If you detect a cycle, you can still output the edge but mark it as a cycle edge. This makes the graph informative rather than misleading. Additionally, when a dependency cannot be resolved, you should include a placeholder node like libfoo.so (missing) and still draw the edge. This helps users see what is missing.

Complexity matters: large binaries can have many dependencies. Use a queue/stack for traversal and store adjacency lists. Complexity is O(V + E) where V is the number of libraries and E is the number of dependency edges. For deterministic output, you might pay a cost for sorting; that is O(E log E) overall but acceptable for typical sizes.

How this fits in this project The graph traversal algorithm is the core of your tool. It determines which libraries are visited, avoids cycles, and produces the output formats.

Definitions & key terms

  • Directed graph -> Edges have direction (A depends on B).
  • DFS/BFS -> Graph traversal strategies.
  • Cycle -> A path that returns to a previously visited node.
  • Deterministic output -> Output order is stable across runs.

Mental model diagram (ASCII)

app
 |-> libA
 |     |-> libC
 |-> libB
       |-> libC   (shared node)

How it works (step-by-step, with invariants and failure modes)

  1. Add root binary as the first node.
  2. Extract dependencies and sort them.
  3. For each dependency, resolve path and add edge.
  4. If node not visited, traverse recursively.
  5. If node already visited, mark a cycle edge.

Invariants: each node is visited once, edges preserve correct direction. Failure modes: cycles causing recursion overflow or non-deterministic ordering.

Minimal concrete example

Nodes: app, libA, libB, libC
Edges: app->libA, app->libB, libA->libC, libB->libC

Common misconceptions

  • ldd shows a tree.” -> It is a graph, not a tree.
  • “Cycles cannot exist.” -> Cycles are possible in shared libraries.

Check-your-understanding questions

  1. Why is a visited set required in dependency traversal?
  2. How does deterministic output help testing?
  3. When would you choose DFS over BFS here?

Check-your-understanding answers

  1. To avoid infinite recursion in cycles.
  2. It allows golden files and stable diffs.
  3. DFS is simpler to implement and adequate for dependency graphs.

Real-world applications

  • Build systems that need to calculate link order.
  • Security scanning for transitive dependencies.

Where you’ll apply it

  • In this project: see Section 4.4 Algorithm Overview and Section 6.2 Critical Test Cases.
  • Also used in: P06-minimal-dynamic-linker for dependency resolution.

References

  • “Algorithms” (Sedgewick/Wayne), graph traversal chapters.
  • Graphviz DOT format reference.

Key insights Treat dependency graphs as graphs, not trees, and enforce determinism to make your tool testable.

Summary Dependency visualization is a graph problem. The correctness of your tool depends on robust traversal, cycle handling, and stable output ordering.

Homework/Exercises to practice the concept

  1. Build a graph with a known cycle and ensure your traversal terminates.
  2. Output DOT and render it with Graphviz.
  3. Implement deterministic sorting of edges and verify identical output across runs.

Solutions to the homework/exercises

  1. Use a visited set keyed by node ID.
  2. dot -Tsvg deps.dot -o deps.svg.
  3. Sort edges by (from, to) before printing.

3. Project Specification

3.1 What You Will Build

A CLI tool named libviz that:

  • Reads an executable or shared library file.
  • Recursively resolves DT_NEEDED dependencies using loader search rules.
  • Emits a dependency graph in DOT, SVG, and ASCII formats.
  • Produces deterministic output given fixed inputs and search paths.

3.2 Functional Requirements

  1. ELF parser: Extract DT_NEEDED, DT_RPATH, DT_RUNPATH.
  2. Resolver: Implement loader search order with optional --ld-path override.
  3. Graph builder: Build directed graph with cycle detection.
  4. Output formats: DOT, SVG (via Graphviz), ASCII.
  5. Error reporting: Missing dependency nodes and clear diagnostics.

3.3 Non-Functional Requirements

  • Determinism: Same input yields same output.
  • Portability: Linux focus; reject unsupported formats gracefully.
  • Usability: Clear flags and helpful error messages.

3.4 Example Usage / Output

$ ./libviz /usr/bin/ssh --output deps.dot --format dot --no-env
[scan] /usr/bin/ssh
[deps] libcrypto.so.3 -> /lib/x86_64-linux-gnu/libcrypto.so.3
[deps] libz.so.1 -> /lib/x86_64-linux-gnu/libz.so.1
[graph] wrote deps.dot (27 nodes, 42 edges)

3.5 Data Formats / Schemas / Protocols

DOT output

digraph deps {
  "app" -> "libcrypto.so.3";
  "app" -> "libz.so.1";
}

ASCII output (minimal)

app
|-- libcrypto.so.3
|-- libz.so.1

3.6 Edge Cases

  • Binary with missing PT_DYNAMIC.
  • Dependencies resolved via RUNPATH only.
  • Cycles between libraries.
  • Libraries missing on disk.

3.7 Real World Outcome

3.7.1 How to Run (Copy/Paste)

./libviz ./app --format dot --output deps.dot --ld-path /custom/libs --no-env

3.7.2 Golden Path Demo (Deterministic)

  • Input: tests/fixtures/app
  • Search paths: tests/fixtures/libs
  • Output: tests/golden/app_deps.dot

3.7.3 CLI Transcript (Success + Failure)

$ ./libviz tests/fixtures/app --format dot --output deps.dot --ld-path tests/fixtures/libs --no-env
[scan] tests/fixtures/app
[deps] libfoo.so.1 -> tests/fixtures/libs/libfoo.so.1
[deps] libc.so.6 -> /lib/x86_64-linux-gnu/libc.so.6
[graph] wrote deps.dot (3 nodes, 2 edges)
[exit] code=0

$ ./libviz tests/fixtures/app --format dot --output deps.dot --ld-path /tmp/empty --no-env
[scan] tests/fixtures/app
[error] unresolved dependency: libfoo.so.1
[graph] wrote deps.dot (2 nodes, 1 edges, 1 missing)
[exit] code=5

3.7.4 If CLI: Exit Codes

  • 0: success
  • 4: invalid ELF or unsupported format
  • 5: unresolved dependency
  • 6: Graphviz rendering failed

4. Solution Architecture

4.1 High-Level Design

+------------------+
| libviz (CLI)     |
| - ELF parser     |
| - path resolver  |
| - graph builder  |
| - output writer  |
+---------+--------+
          |
          v
  DOT / SVG / ASCII output

4.2 Key Components

Component Responsibility Key Decisions
ELF parser Extract DT_NEEDED Support native ELF only
Resolver Map names to paths Emulate ld.so order
Graph builder Store nodes/edges Stable sort for determinism
Output writer DOT/SVG/ASCII DOT is source of truth

4.3 Data Structures (No Full Code)

typedef struct {
    char* soname;
    char* path;
    int missing;
} node_t;

typedef struct {
    int from;
    int to;
    int is_cycle;
} edge_t;

4.4 Algorithm Overview

Key Algorithm: Dependency Resolution

  1. Parse DT_NEEDED for a node.
  2. For each name, resolve path with search order.
  3. Add edge; if node not visited, recurse.
  4. Sort nodes/edges before output.

Complexity Analysis:

  • Time: O(V + E) + sorting cost.
  • Space: O(V + E).

5. Implementation Guide

5.1 Development Environment Setup

sudo apt-get install build-essential graphviz

5.2 Project Structure

libviz/
|-- src/
|   |-- main.c
|   |-- elf_parser.c
|   |-- resolver.c
|   |-- graph.c
|   `-- output.c
|-- tests/
|   |-- fixtures/
|   `-- golden/
|-- Makefile
`-- README.md

5.3 The Core Question You’re Answering

“Can I replicate the loader’s dependency resolution and explain it visually?”

5.4 Concepts You Must Understand First

  1. Dynamic section parsing
  2. Loader search order
  3. Graph traversal and cycles

5.5 Questions to Guide Your Design

  1. How will you handle unresolved dependencies in the graph?
  2. What is your canonical node identifier (path or SONAME)?
  3. How will you keep output deterministic?

5.6 Thinking Exercise

Design a dependency graph for a binary where libA and libB both depend on libC. How will you avoid duplicating libC?

5.7 The Interview Questions They’ll Ask

  1. “Where does DT_NEEDED live in ELF?”
  2. “What is the difference between RUNPATH and RPATH?”
  3. “How do you avoid cycles in dependency graphs?”

5.8 Hints in Layers

Hint 1: Start with readelf output

readelf -d ./app | grep NEEDED

Hint 2: Use a visited set

Hint 3: Emit DOT first, then convert to SVG

5.9 Books That Will Help

Topic Book Chapter
ELF metadata TLPI Ch. 41
Dynamic linking Linkers and Loaders Ch. 10
Graph traversal Algorithms (Sedgewick) Graphs

5.10 Implementation Phases

Phase 1: Foundation (2-3 days)

  • Parse ELF and list DT_NEEDED.
  • Print dependencies for one binary.

Phase 2: Core Functionality (4-5 days)

  • Implement search path resolver.
  • Build graph and detect cycles.

Phase 3: Polish & Edge Cases (2-3 days)

  • Add DOT/SVG output and deterministic ordering.
  • Add missing dependency placeholders.

5.11 Key Implementation Decisions

Decision Options Recommendation Rationale
Node ID Path vs SONAME Path + SONAME metadata Avoid duplicates
Output DOT only vs multiple DOT as source, convert Simpler tests
Env usage Use env vs ignore Flag-controlled Deterministic tests

6. Testing Strategy

6.1 Test Categories

Category Purpose Examples
Unit Tests ELF parsing DT_NEEDED extraction
Integration Tests Resolver + graph Compare to golden DOT
Edge Case Tests Missing deps, cycles Placeholder nodes

6.2 Critical Test Cases

  1. Binary with RUNPATH overriding defaults.
  2. Missing dependency -> graph includes missing node.
  3. Cycle detection -> no infinite recursion.

6.3 Test Data

fixtures/app
fixtures/libs/libfoo.so.1
fixtures/libs/libbar.so.1

7. Common Pitfalls & Debugging

7.1 Frequent Mistakes

Pitfall Symptom Solution
Parsing wrong class Garbage names Validate ELF class and endianness
Ignoring RUNPATH Graph mismatch Implement correct precedence
Non-deterministic order Unstable diffs Sort nodes/edges

7.2 Debugging Strategies

  • Use readelf -d as a ground truth.
  • Compare resolved paths with LD_DEBUG=libs output.

7.3 Performance Traps

  • Re-parsing the same library multiple times without caching.

8. Extensions & Challenges

8.1 Beginner Extensions

  • Add --json output format.
  • Add --max-depth to limit recursion.

8.2 Intermediate Extensions

  • Include symbol-level dependencies (from .dynsym).
  • Highlight missing libs in red in SVG.

8.3 Advanced Extensions

  • Support Mach-O and PE formats.
  • Add SBOM output for dependencies.

9. Real-World Connections

9.1 Industry Applications

  • Packaging and deployment validation.
  • Security audits of runtime dependency trees.
  • ldd: loader-based dependency display.
  • pax-utils (lddtree): richer dependency analysis.

9.3 Interview Relevance

  • Demonstrates understanding of loaders and ELF internals.

10. Resources

10.1 Essential Reading

  • “The Linux Programming Interface” (Kerrisk), Ch. 41.
  • “Linkers and Loaders” (Levine), Ch. 10.

10.2 Video Resources

  • “ELF Internals” conference talks.

10.3 Tools & Documentation

  • readelf, objdump, ldd, ldconfig.

11. Self-Assessment Checklist

11.1 Understanding

  • I can explain DT_NEEDED and DT_RUNPATH.
  • I can describe loader search order accurately.
  • I can explain why dependency graphs can contain cycles.

11.2 Implementation

  • Output is deterministic across runs.
  • Missing dependencies are clearly marked.
  • DOT output renders correctly to SVG.

11.3 Growth

  • I can explain loader resolution to a teammate.
  • I documented at least one tricky dependency issue.

12. Submission / Completion Criteria

Minimum Viable Completion:

  • Parse ELF and list DT_NEEDED dependencies.
  • Resolve paths using at least system directories.
  • Output DOT graph.

Full Completion:

  • Full search order emulation with RPATH/RUNPATH and LD_LIBRARY_PATH overrides.
  • Deterministic output with golden tests.

Excellence (Going Above & Beyond):

  • Cross-platform format support.
  • Visual diff tool for comparing graphs across machines.