Embedding zzop

If you're building a tool on top of zzop rather than running it as a CLI, depend on @zzop/native directly and call it JSON-in / JSON-out — the same engine the CLI drives. Install it the same way; npm install @zzop/native pulls the matching prebuilt binary — nothing to compile. This page documents that library layer; if you just want to run zzop, use the CLI instead.

@zzop/native

Four functions, JSON in / JSON out

All four are JSON-string-in / JSON-string-out, except version. Core logic lives in packages/napi/src/api.rs as plain Rust with no napi dependency at all (so it compiles and unit-tests under the workspace's default toolchain); packages/napi/src/addon.rs is a thin #[napi]-annotated wrapper over those same functions, gated behind the addon Cargo feature.

FunctionSignatureDescription
analyze(configJson: string) -> stringAnalyzeRequestAnalyzeOutputView. Analyzes one tree.
analyzeTrees(configJson: string) -> stringAnalyzeTreesRequest ({ trees: AnalyzeRequest[] }) → MultiAnalyzeOutputView. Analyzes several trees and joins them cross-layer.
analyzeEnvelope(envelopeJson: string, configJson: string) -> stringNormalizedEnvelope + EnvelopeAnalyzeRequestAnalyzeOutputView. Analyzes a Normalized AST envelope produced by an external parser adapter.
version() -> stringEngine + parser fingerprint version string. Has no Result — cannot fail.
Defaults

Zero config runs the full analysis

A bare { root: "." } request is the entire required configuration:

analyze() call
import zzop from '@zzop/native';

const result = JSON.parse(
  zzop.analyze(JSON.stringify({ root: '.' }))
);

The package is CommonJS, so both module systems work: import zzop from '@zzop/native' in ESM, const zzop = require('@zzop/native') in CommonJS.

Before the config crosses into Rust, the JS wrapper (index.js) injects two defaults — so a bare { root } request runs the full analysis instead of quietly degrading to native analyses only:

  • packsDir omitted — defaults to the bundled DSL rule packs: <repo root>/rules/dsl in a source checkout (always the live copy), or the rules/ directory copied into the installed package at publish time by the prepack script.
  • packsDir given — the wrapper prepends the bundled directory rather than replacing it: the effective load order becomes [bundled, ...yourDirs]. All listed directories are loaded and merged; if two directories ship a pack with the same id, the later directory replaces that pack whole (not a rule-by-rule merge) — so a caller's pack always wins a collision against a shipped one.
  • packsDir: null — the one case the wrapper leaves untouched: disables all DSL pack loading, bundled packs included.
  • git omitted — defaults to git: {}, so the engine applies its own recentDays: 30 default. git: null disables git collection entirely. If root is not a git repository, the engine degrades gracefully and reports it in warnings.
Config reference

AnalyzeRequest

#[serde(rename_all = "camelCase", default)] — every field is optional except root, and unknown fields are ignored rather than rejected.

FieldTypeNotes
rootstring (required)Tree root to analyze. An empty string is rejected with Err.
sourceIdstring (default "")Free-form label carried through into cross-tree output.
packsDirstring | string[] (optional)Directory (or directories) of *.json DSL rule packs to load. Multiple directories are loaded and merged — a pack id repeated across directories is taken whole from the later directory. A missing/unreadable directory is a non-fatal warnings entry, not a failure.
cacheDirstring (optional)Per-file IR/rule-result cache directory (content hash + parser/ruleset fingerprint keyed). Omit to run uncached.
git{ since?: string, recentDays?: number } (optional)Enables git-derived scores/health/recommendations/critical/seams. recentDays defaults to 30.
sizeCapnumber (optional)Default 1,500,000 bytes (~1.5 MB). Files larger than this skip structural parsing and are listed under degraded.
disabledRulesstring[]Rule / native-analysis ids to disable entirely (exact match).
severityOverridesobjectRule id → "critical" | "warning" | "info". Promote or demote a specific id without forking its pack.
suppressionsobject[]Finding accept-list: each { rule, path?, glob? } drops findings for rule — everywhere (no filter), in files whose path contains path (substring), or in files matching glob (full-path glob; glob wins over path).
adapterOverlaysobject[]Mode-B adapter overlays: partial Normalized-AST envelopes (typically just io + fragment channels for a handful of files) merged on top of this tree's native analysis — how a framework/SDK adapter adds IoFacts the engine does not parse natively, without reimplementing the parser. Each overlay is re-validated and soft-skipped with a warning if invalid. Contrast analyzeEnvelope, where a full envelope replaces native analysis. See NORMALIZED_AST.md.

analyzeEnvelope's config (EnvelopeAnalyzeRequest) is a smaller shape: sourceId, packsDir, and disabledRules only. An envelope carries no filesystem location the engine can re-read, so root, cacheDir, git, and sizeCap don't apply — only symbol-scan/io-scan DSL rules ever fire in envelope mode, since no source text is available.

Two reference Mode-B adapters ship in the repo as worked examples: an OpenAPI-SDK adapter (resolves generated-SDK cross-layer consumption) and a Svelte/SvelteKit adapter (fills .svelte import fan-in and exempts route entrypoints via each file's is_entry marker).

Cross-repo

Analyzing multiple repositories together

analyzeTrees({ trees: [...] }) runs analyze once per tree, then joins every tree's declared IoFacts (HTTP/DB/tRPC provides and consumes) across all of them. A frontend checkout and a backend checkout can be two entirely separate git repositories that share nothing on disk and still get joined.

Frontend + backend
const result = JSON.parse(zzop.analyzeTrees(JSON.stringify({
  trees: [
    { root: '../frontend', sourceId: 'web' },
    { root: '../backend', sourceId: 'api' },
  ],
})));

The result shape is { trees: [{ root, sourceId, output }], crossLayer, crossLayerFindings, disclosure }. Each tree's output carries its own coverage census; disclosure (the silent-failure-class registry) is run-global and appears once. crossLayer carries the raw join result — matched edges, unconsumedProvides, unprovidedConsumes, unresolvedConsumes, ambiguousConsumes multi-tree matches, and externalConsumes (absolute-URL) consumes. crossLayerFindings is the output of the 20 cross-layer/* native rules that run over that join (see the rule catalog for the full id list). No single tree owns a cross-layer finding, so disabling one of these rule ids via disabledRules on any one tree drops it from the combined array for every tree — a union, not a per-tree gate. A tree whose coverage.joinContributionZero is true contributed no IO to this join — discount any cross-layer finding that references it.

Output

AnalyzeOutputView

Same input, byte-identical output — no timestamps, no unstable map/array ordering. A capability a given run cannot provide is absent from the schema and self-reported in warnings, never stubbed with a fake empty value. An empty array, by contrast, always means "this was analyzed and nothing was found."

FieldTypeMeaning
irobjectLanguage-neutral common IR: symbols, dep (import graph), loc, io (IoFacts).
findingsobject[]Sorted (severity, file, line, ruleId) ascending, critical first. A finding suppressed by an inline marker comment never appears at all.
degradedstring[]Paths that hit sizeCap or otherwise failed to parse structurally.
fileCountnumberFiles walked.
coverageobjectStructural coverage census — always present. Vocab-free counts of which channels this tree filled (files, symbols, importEdges, ioProvides, ioConsumesKeyed, ioConsumesUnresolved, degraded) plus the active-blindness fact joinContributionZero — see below.
nodesobject[]Per-file churn/fan-in/fan-out/risk metrics — fully populated only when git is set.
scoresobject | nullStructural sub-scores, 0–100. null unless git is set.
healthobject | nullOne composite index rolled up from scores.
recommendationsobject[]ROI-ranked refactor candidates. An item whose file carries a rule-confirmed critical finding moves (never copies) into a synthetic urgent-bug-risk group; its roi number never changes.
criticalobject[]Files ranked by blast radius (transitive dependent count).
seamsobject[]Folders that are good first-extraction candidates (low boundary-crossing coupling).
foldersobjectFolder-granularity rollup of nodes and the dependency graph. Not gated by git — always present, even for an empty tree.
layerCoChurnobject[] | nullCross-layer commit co-churn pairs. null unless git is set; [] (not null) when git is active but no pair meets the co-change threshold.
warningsstring[]Non-fatal issues plus capability self-report notes — see below.
cache{ hits, misses } | nullSet only when cacheDir was given.
ruleTimingsobject[] | nullPer-rule id + elapsed time + finding count, when profiling is enabled. AnalyzeRequest does not currently expose a field to request profiling, so this is null in every analyze/analyzeTrees/analyzeEnvelope call today.
disclosureobject[]Run-global silent-failure-class registry — zzop's honest list of which classes of blindness it does and does not yet detect. Static and identical every run; on a multi-tree analyzeTrees call it sits once beside trees, never per tree. See below.

The whole JSON tree is camelCase, top to bottom — every nested type carries its own casing rule, not just the top-level view. Finding.data is the one deliberate exception: opaque, rule-authored JSON with no uniform casing rule.

Finding — the common shape every finding shares
ruleId"{pack}/{rule}" for a DSL rule (e.g. "sql/nplus1"), or a plain id for a native analysis (e.g. "circular"). severity"critical" | "warning" | "info" — the rule's default severity. filePath relative to root. line1-based line number. messageHuman-facing cause/fix hint, copied verbatim from the rule definition. dataMatcher-specific JSON payload — opaque, rule-specific keys.
coverage — structural census, always present
files / symbols / importEdgesHow much of each channel this tree filled. A 0 means "counted and found none", never "not run" — the census lets a consumer tell an empty result apart from a dark one. ioProvides / ioConsumesKeyed / ioConsumesUnresolvedProvides, resolved consumes, and recognized-but-unresolved consumes — the substrate the cross-layer join reasons over. degradedFiles that fell back to a lexical count (same as degraded.length). joinContributionZerotrue when this tree analyzed files but extracted zero IO — the active-blindness fact: it is invisible to the cross-layer join, so any join finding referencing it is not meaningful for it. A client the extractor can't see (a hand-rolled HTTP wrapper, a generated SDK) is a common cause.
disclosure — what zzop does and doesn't detect
id / groupA stable kebab-case class id and its taxonomy group: extraction-blind, analysis-dark, input-config, or trust-calibration. summaryThe concrete way an agent could silently misread the output for this class. status"asserted" (surfaced from a structural fact every run — cannot be silently missed), "partial" (detected in common cases, a member can still slip past), or "notYetDetected" (a real class zzop does not yet detect — declared so you never assume coverage it lacks).
Honest output

A narrowed scope self-reports in warnings, never silently

An error means the call failed. A missing capability is not an error — the analysis completes normally, and the engine says exactly what it skipped and why. This self-report happens inside the engine itself, not the JS wrapper, so it applies identically to a non-JS consumer calling the Rust engine directly.

git option omitted
"warnings": [
  "git history not requested (git option omitted): scores, health,
   recommendations, criticality, seams and layerCoChurn are null.
   Pass git: {} to enable them."
]

The same pattern applies when no DSL rule packs could be found: the engine reports that only its native analyses ran, and names how many, rather than returning a quietly smaller findings array with no explanation.

Panic safety

The process never crashes

Two layers. api.rs never panics by contract — every fallible path (malformed JSON, a missing root, an invalid envelope) returns a Result<String, String>. addon.rs wraps each fallible call in a second, outer safety net:

addon.rs — outer safety net
fn catch<F: FnOnce() -> Result<String, String> + UnwindSafe>(f: F) -> napi::Result<String> {
    match std::panic::catch_unwind(f) {
        Ok(Ok(json)) => Ok(json),
        Ok(Err(message)) => Err(Error::from_reason(message)),
        Err(_) => Err(Error::from_reason("zzop-napi: internal panic (this is a bug — please report it)")),
    }
}

This exists because unwinding across a #[napi]-exported boundary is undefined behavior and must never happen — the engine already isolates a single file's parse/rule failure internally, well before this outer net is ever needed. In practice a JS caller sees either a resolved value or a rejected/thrown Error, never a process crash. version has no Result and no catch wrapper — it cannot fail.