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.
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.
| Function | Signature | Description |
|---|---|---|
analyze | (configJson: string) -> string | AnalyzeRequest → AnalyzeOutputView. Analyzes one tree. |
analyzeTrees | (configJson: string) -> string | AnalyzeTreesRequest ({ trees: AnalyzeRequest[] }) → MultiAnalyzeOutputView. Analyzes several trees and joins them cross-layer. |
analyzeEnvelope | (envelopeJson: string, configJson: string) -> string | NormalizedEnvelope + EnvelopeAnalyzeRequest → AnalyzeOutputView. Analyzes a Normalized AST envelope produced by an external parser adapter. |
version | () -> string | Engine + parser fingerprint version string. Has no Result — cannot fail. |
Zero config runs the full analysis
A bare { root: "." } request is the entire required configuration:
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/dslin a source checkout (always the live copy), or therules/directory copied into the installed package at publish time by theprepackscript. - 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 ownrecentDays: 30default.git: nulldisables git collection entirely. Ifrootis not a git repository, the engine degrades gracefully and reports it inwarnings.
AnalyzeRequest
#[serde(rename_all = "camelCase", default)] — every field is optional except root, and unknown fields are ignored rather than rejected.
| Field | Type | Notes |
|---|---|---|
root | string (required) | Tree root to analyze. An empty string is rejected with Err. |
sourceId | string (default "") | Free-form label carried through into cross-tree output. |
packsDir | string | 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. |
cacheDir | string (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. |
sizeCap | number (optional) | Default 1,500,000 bytes (~1.5 MB). Files larger than this skip structural parsing and are listed under degraded. |
disabledRules | string[] | Rule / native-analysis ids to disable entirely (exact match). |
severityOverrides | object | Rule id → "critical" | "warning" | "info". Promote or demote a specific id without forking its pack. |
suppressions | object[] | 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). |
adapterOverlays | object[] | 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).
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.
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.
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."
| Field | Type | Meaning |
|---|---|---|
ir | object | Language-neutral common IR: symbols, dep (import graph), loc, io (IoFacts). |
findings | object[] | Sorted (severity, file, line, ruleId) ascending, critical first. A finding suppressed by an inline marker comment never appears at all. |
degraded | string[] | Paths that hit sizeCap or otherwise failed to parse structurally. |
fileCount | number | Files walked. |
coverage | object | Structural 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. |
nodes | object[] | Per-file churn/fan-in/fan-out/risk metrics — fully populated only when git is set. |
scores | object | null | Structural sub-scores, 0–100. null unless git is set. |
health | object | null | One composite index rolled up from scores. |
recommendations | object[] | 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. |
critical | object[] | Files ranked by blast radius (transitive dependent count). |
seams | object[] | Folders that are good first-extraction candidates (low boundary-crossing coupling). |
folders | object | Folder-granularity rollup of nodes and the dependency graph. Not gated by git — always present, even for an empty tree. |
layerCoChurn | object[] | null | Cross-layer commit co-churn pairs. null unless git is set; [] (not null) when git is active but no pair meets the co-change threshold. |
warnings | string[] | Non-fatal issues plus capability self-report notes — see below. |
cache | { hits, misses } | null | Set only when cacheDir was given. |
ruleTimings | object[] | null | Per-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. |
disclosure | object[] | 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.
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.
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.
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).
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.
"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.
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:
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.