The JSON contract
zzop exposes one JSON contract, and every surface speaks it — the zzop CLI subcommands, the zzop-mcp MCP tools, and an in-process call all send the same request shape and get back the same output shape. This page is that contract: the input (AnalyzeRequest), the output (AnalyzeOutputView), and the vocabulary in between. To just run zzop, see Usage. To embed it, shell out to the binary's JSON subcommands — JSON in, JSON out, no linkage; the zzop-facade / zzop-summary crates it is built from are workspace-internal (not published to crates.io), so an in-process Rust dependency means vendoring the workspace, not cargo add.
The shared operations
Every operation below is reached three identical ways — a zzop CLI subcommand, a zzop-mcp MCP tool, and an in-process call — over one shared implementation (crates/facade, plus crates/summary for the config auto-discovery and result-shaping the CLI and MCP tools get). Same request in, same JSON out, whichever surface you use; all are JSON-string-in / JSON-string-out except version.
| Function | Signature | Description |
|---|---|---|
analyze_json | (config_json: &str) -> Result<String, String> | AnalyzeRequest → AnalyzeOutputView. Analyzes one tree. Backs the zzop analyze <path> / analyze --config <path> CLI subcommand and the analyze_repo MCP tool (via zzop-summary). |
analyze_trees_json | (config_json: &str) -> Result<String, String> | AnalyzeTreesRequest ({ trees: AnalyzeRequest[] }) → MultiAnalyzeOutputView. Analyzes several trees and joins them cross-layer. Backs zzop cross / the cross_repo MCP tool. |
analyze_envelope_json | (envelope_json: &str, config_json: &str) -> Result<String, String> | NormalizedEnvelope + EnvelopeAnalyzeRequest → AnalyzeOutputView. Analyzes a Normalized AST envelope produced by an external parser adapter. Backs zzop analyze-envelope / the analyze_envelope MCP tool. |
validate_envelope_only_json | (envelope_json: &str) -> String | Envelope JSON → { valid: boolean, issues: string[], hints: string[] } — hints is always present, and is where a structurally VALID envelope is told why it will still join nothing. Runs the same structural/semantic checks analyze_envelope_json applies to its envelope and stops there — no config, no pack loading, no engine run — fast, offline "is my envelope well-formed" feedback for adapter authors. Never fails: an invalid envelope still returns an ordinary { valid: false, issues: [...] } result. Backs zzop validate-envelope. |
validate_rule_pack_json | (pack_json: &str) -> String | Rule-pack JSON → { valid: boolean, issues: string[] }. Runs the exact structural judgments the engine's pack loader applies at load time (bad JSON, missing field, wrong type, too-new schema_version) plus every rule that would load but could silently never fire — a matcher regex that fails to compile, a line-scan declaring neither line_pattern nor any, and a method-scan whose trigger names a label no patterns entry declares — shape only, never rule-quality semantics. Pre-ship feedback for pack authors. Never fails: an invalid pack still returns an ordinary { valid: false, issues: [...] } result. Backs zzop validate-rule-pack. |
query_io_json | (analysis_json: &str, query_json: &str) -> Result<String, String> | analyze_trees_json output + { pattern: string } → definitive endpoint/io-key answer. Pure post-processing over an already-produced multi-tree analysis — no re-analysis: pattern is case-insensitively substring-matched against every cross-layer io key (HTTP routes, env keys, DB tables, topics, plus raw for unresolved consumes), and the result carries a sealed verdict vocabulary (linked | provided-only | consumed-unprovided | external | unresolved-only | ambiguous | mixed | not-found) with the matches, counts, and related findings behind it. Errs on a malformed query and on single-tree analyze_json output — a guided error, since verdicts are join facts (run analyze_trees_json, which joins even a single tree). Backs zzop endpoint / the check_endpoint MCP tool. |
query_file_json | (analysis_json: &str, query_json: &str) -> Result<String, String> | analyze_trees_json output + { path: string, sourceId?: string } → everything zzop knows about ONE file. The same pure-post-processing contract as query_io_json — no re-analysis — with a file path as the target instead of an io key: the tree it belongs to, its symbols, io facts, dependency edges in BOTH directions, and every finding anchored in it. Uncapped, deliberately: a single file is bounded, so nothing is dropped and there is no truncation to disclose. Its sealed verdict vocabulary (analyzed | lexical-only | degraded | not-found) answers whether the file was ANALYZED, not whether it is healthy — an empty findings list means "clean" for the first and "nothing structural ever ran" for the next two; as with query_io_json, the reply's own verdictMeaning field carries the returned token's definition, so no document is a second owner of it. Without sourceId every tree is searched and the reply names the tree the match came from, listing the rest in otherTrees rather than picking silently; a not-found reply carries suggestions, the nearest walked paths. Errs on a query with no path and on single-tree analyze_json output, which has no tree identity to report. Backs zzop file / the check_file MCP tool. |
query_coverage_json | (analysis_json: &str) -> Result<String, String> | analyze_trees_json output → the AGGREGATE-VISIBILITY view: “how much of this tree does zzop actually see?” Per tree, an extension-by-dispatch table (structural / lexical-only / degraded, plus inDepGraph), blindSpots — the CAPABILITY axis, each compiled-in rule sightline crossed with the tree’s structural extension mix — the tree’s own engine warnings forwarded verbatim (the framework-silence self-reports ride there), the coverage census, and joinVisibility as a sentence. Deliberately no single score: an axis zzop never measured on your tree rides in an unmeasured FIELD rather than being folded into a number that would get quoted without it. Backs zzop coverage, which has no MCP tool twin. |
version_string | () -> String | Engine + parser fingerprint version string. Has no Result — cannot fail. Reaches a user surface as the tool field of zzop manifest and zzop facts, as zzop graph's %% tool: census line, and as what zzop version --verbose / zzop-mcp version --verbose print; plain zzop version stays the bare release number and carries no fingerprints. |
version | () -> String | The bare release number, no fingerprints — what plain zzop version / zzop-mcp version print. Split from version_string deliberately: this one is a single token scripts parse, so lengthening it would break every caller that does. |
explain | (query: &str) -> Result<String, String> | One rule id → that bundled DSL rule’s compiled-in data as human-readable lines. Reads nothing from a run — the pack data is compiled INTO the binary. Err is guided: it names what the id actually is (a native analysis id, a whole pack id, an output field id, an ambiguous bare id, or unknown). Backs zzop explain, which has no MCP tool twin — an agent reads the rule-catalog contract resource instead. |
A config is required; a starter config runs the full analysis
Every analysis lane refuses a tree with no zzop.config.jsonc — the same refusal, naming the same document, on both shipped binaries. It is not ceremony: the vocabulary block holds the names zzop would otherwise have to guess about your project, and an undeclared key is a judgment zzop does not make — name-based auth exemptions, generated-file detection and write-site judgments all switch off with it. zzop init writes that file with zzop's own values already in it.
Everything else still defaults. A starter config that declares only roots runs the full analysis on both binaries alike — both route through the shared zzop-summary layer and its zzop-config crate, which injects the bundled DSL rule packs (compiled into the binary), the engine's recentDays: 30 git default, and a cacheDir of .zzop/cache:
The one thing a starter config cannot supply is a second tree: a single root leaves the cross-layer join nothing to join. On a workspace root whose pnpm-workspace.yaml (or package.json workspaces) resolves to 2+ packages, the run's first configWarnings entry names the manifest, the exact package count, and the {"trees": "auto"} remedy.
zzop init # writes zzop.config.jsonc — required once per tree
zzop analyze . # that config is auto-discovered; bundled packs +
# git defaults fill in everything it did not say
Calling zzop_facade::analyze_json directly has no such wrapper in front of it — the facade applies no implicit defaults. A bare {"root": "."} config JSON loads zero DSL packs (native analyses only) and runs with git collection off (scores/health/recommendations stay null) unless you set packsDir/packDefs and git yourself:
let config = serde_json::json!({ "root": ".", "git": {} }).to_string();
let result: serde_json::Value =
serde_json::from_str(&zzop_facade::analyze_json(&config)?)?;
- packsDir omitted (facade) — no DSL packs load at all; pass
packsDir(a directory of*.jsonpacks) orpackDefs(inline definitions — see below) explicitly. - packsDir given, multiple directories — all 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).
- packDefs given — loaded before any
packsDirentries, so a directory pack with the same id wins the collision whole. This is how a host with no pack directory on disk (likezzop-mcp, whosezzop-configlayer injects the bundled packs this way) supplies rules at all. - git omitted (facade) — git collection is off;
scores,health,recommendations,critical,seams, andlayerCoChurnstaynull. Passgit: {}to enable them with the engine's ownrecentDays: 30default, orgit: { "recentDays": N }to override it. Ifrootis not a git repository, the engine degrades gracefully and reports it inwarnings. - cacheDir omitted — the one default that writes to disk, and so the one worth knowing which dialect you are in. Under
zzop/zzop-mcp/a config file it defaults to.zzop/cache, resolved against the config file's directory (or the analyzed root when there is no config file), and the first run creates it inside the tree you analyzed — put an anchored**/.zzop/in that repo's.gitignore, not azzop*glob, which would also swallow the authoredzzop/directory (custom rule packs, adapter overlays) that belongs in version control. Set the key tonullto turn caching off and write nothing. Calling the facade directly injects no default at all: omit the field and the run is uncached, with nothing written. - vocabulary omitted — nothing is injected and nothing falls back — on every path, a key you do not declare is simply not judged (the built-in fallback arm was removed 2026-07-27). The built-in values survive only as what
zzop initwrites into your starter file, so they reach a run because your config says them. Writing the block therefore changes a great deal: leaving it out makes rules fire more, not less, because an undeclared guard vocabulary proves no guard and an undeclared exemption grants no exemption.
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. |
packDefs | object[] (default []) | Inline rule-pack definitions handed to the engine as data — the self-contained-binary alternative to packsDir for hosts with no pack directory on disk (e.g. zzop-mcp's compile-time-embedded packs). Loaded before packsDir directories, so a directory pack with the same id wins the collision whole. Additive (v0.16.0); the retired JS wrapper never sent it. Also accepted on analyzeEnvelope's config with the identical contract. |
cacheDir | string (optional) | Per-file IR/rule-result cache directory (content hash + parser/ruleset fingerprint keyed). Omit to run uncached — that is the answer on this wire, where no default is ever injected. A zzop/zzop-mcp/zzop.config.jsonc run is the other dialect: its config front end defaults the key to .zzop/cache and creates that directory on the first run — see A config is required. |
git | { since?: string, recentDays?: number, commitTypePatterns?: { pattern, tag }[], commitSubjectPatterns?: { pattern, label }[] } (optional) | Enables git-derived scores/health/recommendations/critical/seams. recentDays defaults to 30; commitTypePatterns, when non-empty, replaces the default FIX/FEAT/REVERT/... classifier table entirely. commitSubjectPatterns is the declared subject-label axis and differs from its sibling in three deliberate ways: it has no default table (absent or empty labels nothing at all — what a "revert"/"ticket"/"hotfix" subject looks like is a per-project convention the engine will not guess), it is not first-match-wins (every matching declaration contributes its label, in declaration order, a repeated label kept once at its first position), and the pattern is compiled exactly as written — no implicit (?i) — against the raw subject. Two warnings self-reports: a pattern that fails to compile (skipped, matches nothing), and a declared table that matched zero collected commits. Today those warnings are this key's only observable effect — the preserved subject and its labels stay on the engine-internal per-commit record and are not yet carried on any output channel. Known limit: git output is decoded with from_utf8_lossy, so a legacy-encoded subject (a commit object with no encoding header) is matched with each non-UTF-8 byte already replaced by U+FFFD — a pattern spelling those original characters cannot match it, and the zero-match warning says so when a U+FFFD is observed. |
vocabulary | { authGuardPattern?, authGuardQualifierTokens?, authAcquisitionStandalonePattern?, authAcquisitionConditionalPattern?, authFamilyPathPattern?, apiSegmentPattern?, javaSourceRoot?, pythonPackageRoots?, skipDirs? } (optional) | Convention vocabulary — the names a project picks, declared instead of guessed. A name a framework fixed (@GetMapping, router.post) stays built in because nobody can rename it; a name the project chooses — what it calls its auth guards, which URL segments mark its API, where its Java sources live, which directories hold build output — is declarable here, because holding it as a built-in literal means the engine guesses and silently misclassifies every project that names it differently. Per key, whole replacement: a key you name replaces its built-in list or pattern outright, never an element-wise merge (the same one-origin rule packs.extraDirs and git.commitTypePatterns state); a key you leave out is not judged, and a declared-but-empty value (null, "", []) means the same — there is no built-in fallback (removed 2026-07-27; the built-in values survive only as the defaults zzop init writes into your starter config, so they reach a run because your config says them). Leaving a key out makes rules fire more, not less: an undeclared exemption grants no exemption and an undeclared guard vocabulary proves no guard. That is also why "declare nothing" is deliberately not spellable as "treat everything as a guard" — an empty guard pattern would be a regex matching every name (disable a judgment with rules: { "<id>": "off" } instead). A declared pattern that will not compile matches nothing rather than failing the run — it never falls back to a built-in, because substituting our pattern for the author's is exactly the guessing this vocabulary removes. skipDirs lands on the walker's own skip list so one list has one owner. This is deliberately not the same roof as git.commitTypePatterns/git.commitSubjectPatterns: those configure the git collector and match commit messages, while every key here names something the analyzed code itself spells. zzop init writes every key with its built-in value, so the starter file documents these assumptions instead of hiding them. |
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). |
globalExcludes | object[] (default []) | Config-wide, rule-agnostic report filter — the top-level "exclude" config key. Same path/glob matching as suppressions, but drops matching paths from every rule at once and from every other reporting channel — recommendations, crossLayerFindings, critical, and every per-metric violation list under scores.*. It also removes the file from SCORING, as of 0.27: an excluded file stops being a judged subject, leaving both the violation list and the denominator behind every per-file score, so health.pain moves with it. The graph itself is untouched — the file is still analyzed, still in the dep graph, and still a real import target, so no other file's coupling, fan-out or blast radius changes. Note the direction is not predictable: excluding code that is cleaner than average raises pain, because the figure describes the population this config judges rather than the whole tree, and is only comparable against runs using the same exclude. A run whose exclude removed at least one file says so in warnings. Two channels take no filter at all: warnings itself (which reports an exclude so broad the problem only looks absent — filtering it would let the filter erase its own warning), and rows keyed by a slice or module rather than a file (cohesion.slices, sdp.violations, mainSequence.modules, modularity), whose subject is a directory rather than a file. |
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. |
mountedAt | string (optional) | Deployment-topology whole-tree gateway/ingress mount prefix — shorthand for a mounts entry with dir: "", folded in last so an explicit equal-length mounts entry wins a tie. Applies to http provides only, stacking on top of any code-extracted prefix (e.g. NestJS's setGlobalPrefix). |
mounts | { dir: string, at: string }[] | Deployment-topology per-directory mounts: prepends at to an http provide's key when its file path falls under dir (longest matching dir wins per provide). |
clientBase | string (optional) | The calling side's mirror of mountedAt — the path prefix this tree's own outbound http calls carry, for when the base is assigned from a cross-file constant (axios.defaults.baseURL = settings.baseApiUrl) and the never-guess extractor therefore reads nothing. Prepended to every keyed relative kind=http consume of the tree, unscoped by client — a declaration speaks for the whole tree, as mountedAt does on the serving side. An unresolved consume and an absolute-URL key are never touched. Unlike mountedAt, stacking is warned, not silent: if a readable literal base was already applied from the code, the declaration still wins but warnings names both prefixes, because on the calling side a second prefix is usually a duplicate rather than a second real layer. A declaration that rewrites nothing warns too. |
hosts | string[] | Hosts this tree owns. An absolute-URL consume from another tree targeting one of these hosts is re-keyed to an internal joinable key at cross-layer link time instead of counting as external egress. |
routes | { key: string, role?: "provide" | "consume" }[] (default []) | Lightweight route-fact injection for the common "inject one route zzop could not resolve from source" case (a non-literal path, a dynamic verb, a computed URL). key is a "METHOD PATH" interface key (e.g. "GET /api/users"), normalized through the same transform the extractors use for that side; role picks whether the route is served here (provide, default) or called from here (consume). The whole array expands into one synthetic adapter overlay of http provides/consumes, composing through the same cross-layer join path as a hand-authored overlay. A malformed key is soft-skipped with a warning, never a hard error. |
parsers | { globOverrides?: { glob: string, language: string }[] } (default {}) | Parser routing — force-routes paths matching glob to a named language, applied in order (first match wins) ahead of the extension map. For the files whose extension lies about what they contain: a .txt holding SQL, a vendored .inc that is really PHP-free Java. An entry naming a language this build does not have is skipped with a warning rather than failing the run — an unknown language is a config-authoring mistake, and the run's other trees still have honest answers to give. A separate roof from vocabulary on purpose: every key under that one names something the project calls its own, while this one names a path→parser mapping. |
analyzeEnvelope's config (EnvelopeAnalyzeRequest) is a smaller shape: sourceId, packsDir, packDefs, disabledRules, severityOverrides, suppressions, globalExcludes, mountedAt, mounts, and clientBase only (packDefs and the middle four behave identically to their AnalyzeRequest counterparts). mountedAt/mounts carry the same deployment-topology mount semantics described above, applied uniformly to the envelope's http provides, with the same fold order (every mounts entry first, mountedAt as the implicit whole-tree dir: "" entry last). 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. The native call-graph-BFS rules (mutating-route-no-auth, unsafe-read-endpoint, non-idempotent-write) additionally run when the envelope supplies its calls channel (per-file call edges, files[].calls — see NORMALIZED_AST.md's calls section, floor version >= 0.29.0); an envelope with http routes and no calls keeps them silent and says so in warnings, naming the silent rules. The two shipped io-scan rules (http/protected-path-no-auth-evidence, http/dev-path-no-guard-hint) do run here, but their anchor-line channels do not: with no source text there is no line to read, so the derived zzop-<rule-id>-ok suppress marker and dev-path-no-guard-hint's anchor_exclude_pattern guard-hint carve-out are both inert. Both fail toward FIRING rather than silence — a matching route reports even when its registration line carries a marker or a guard-hint argument — so clear a vetted route by injecting the attribute the rule reads (auth-guarded for protected-path-no-auth-evidence) or by disabling the rule in config. Mode-B adapterOverlays are unaffected: they merge onto a natively-parsed tree whose source text is readable, so both channels stay live there.
Reference Mode-B adapters ship in the repo as worked examples — a minimal one-channel adapter (fills a missing imports channel in ~90 lines, the smallest on-ramp) and an attribute-injection adapter (injects router-level guards as file attributes), both built on the shared adapter-kit. They demonstrate the contract rather than one framework each: framework-specific flavors were removed in favour of the channels an adapter actually has to fill.
Beyond IO and dependency facts, an overlay can carry generic entity attributes: open-vocabulary { target, key, value } annotations attached to a route, symbol, file, or path scope that a rule consumes by key, without the engine ever knowing what the key means. This is how a cross-cutting fact the native pass can't see on its own — a rate limit, a validation layer, or (for anything outside the common Express shapes the native parser now recognizes directly) a router-level auth guard applied by middleware — is completed by injection instead of by ever-growing native modeling. The first consumer is mutating-route-no-auth: inject an auth-guarded attribute on a route's ioKey (or a pathScope prefix a middleware guards) and the rule clears it, composing with its native call-graph scan and with the same attribute the native parser itself emits for a recognized Express guard. The second consumer is cross-layer/retrying-write-no-idempotency: an idempotency-guarded attribute on the provider route — set natively by the TypeScript parser when a handler reads the Idempotency-Key header, or injected for any other provider language — vetoes the finding once a guard is witnessed.
Analyzing multiple repositories together
analyze_trees_json (CLI: zzop cross; MCP tool: cross_repo) 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.
let config = serde_json::json!({
"trees": [
{ "root": "../frontend", "sourceId": "web" },
{ "root": "../backend", "sourceId": "api" },
]
}).to_string();
let result: serde_json::Value = serde_json::from_str(&zzop_facade::analyze_trees_json(&config)?)?;
// Or from the CLI (trees tagged by directory name; use --config for custom sourceIds / topology):
// zzop cross ../frontend ../backend
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. When any tree declares topology hosts, crossLayer also carries hostRekeyCounts — one [host, rekeyedConsumeCount] pair per declared host, omitted entirely when no tree declares any hosts. crossLayerFindings is the output of the 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, parserDispatched, symbols, resolvedImportEdges, declaredImportsByExt — the per-extension declared-specifier denominator for that edge count, counted before resolution; an absent extension key means never measured, not 0 — 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. Every total* field is the judged-population denominator behind its own score, never a tree total: the top-level exclude removes a path from both the violation list and the denominator, and sfc/godFile judge source files only. What each key means rides beside it in scoreMeanings. |
scoreMeanings | object | One sentence per scores key, keyed identically — present exactly when scores is, absent (not null) otherwise. Four score keys are bare acronyms and the legend is where they are expanded: sdp (Stable Dependencies Principle), sfc (one-file-one-responsibility — not Vue's Single-File Component), lod (Law of Demeter, not Level of Detail), fsd (Feature-Sliced Design). Each sentence says what a low number means; all scores are 0–100, higher is healthier. |
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 size-weighted blast radius — blastRadius * ln(loc + 2), blast radius as tie-break — because a 5-line re-export barrel and a 400-line core of equal blast are not equal danger. blastRadius itself is the transitive dependent count; re-sorting this array by it alone gives a different order. |
seams | object[] | Folders that are good first-extraction candidates (low boundary-crossing coupling). files counts that folder's dep-graph keys, not files walked, and noise folders (tests, dist, docs, …) are skipped whole. temporalBoundary is filtered twice over — only commits touching 2–25 files, and only each file's top 10 co-change partners — so it is the strongest measured cross-folder co-change, not a total. |
folders | object | Folder-granularity rollup of nodes and the dependency graph. Not gated by git — always present, even for an empty tree. Each row counts nodeCount, not fileCount: a node exists only for a dep-graph key or a git-touched path, so summing these rows does not reproduce the top-level fileCount (files walked), and with git off a lexical-only file is in no row at all. |
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. coChanges is a subset total: commits touching fewer than 2 or more than 25 files are skipped as noise, so read it as "N filtered co-changes", never "these layers changed together N times". |
gitWindow | { recentDays, since } | null | Echoes the resolved git-history window — always serialized; null is the "git didn't run" signal (same gating as scores). recentDays is the resolved number (the caller's value, or the 30 default); since is the caller's raw filter string, or null for full history. |
packsLoaded | { id, rules, source, filesInScope }[] | Positive pack-load confirmation: one entry per loaded DSL pack, sorted by id, with its rule count and provenance (source: "dir" = read from a packsDir directory, "inline" = packDefs). filesInScope counts the files a pack's rules are path-eligible to scan (file_pattern candidacy, before any content check) — pair filesInScope > 0 with zero findings to read "ran, found nothing" versus filesInScope: 0 "nothing in scope." Always present — [] is the honest "zero DSL packs loaded" state. Verifies a custom pack actually loaded without inferring it from findings deltas. |
ruleOverridesApplied | { disabled, severityRemapped, only } | Positive confirmation that disabledRules/severityOverrides/packsOnly were applied, listing the affected rule ids — only being the honored pack allowlist (packs.only), outside which a pack never ran. Omitted (or empty) when none of the three was requested — treat an absent key as "no overrides," never null. |
warnings | string[] | Non-fatal issues plus capability self-report notes — see below. |
configWarnings | string[] | Config-authoring problems computed at analysis time, kept OUT of warnings: a disabledRules/severityOverrides entry matching no known rule id is reported here (only analysis time has the full known-id set). Always present; [] means neither knob had a matching-nothing entry. |
cache | { hits, misses } | null | Set only when cacheDir was given. |
ruleTimings | object[] | null | Per-rule id + elapsed time + finding count, when profiling is enabled. Set profileRules: true on the request to populate it (CLI dialect: zzop analyze --profile-rules / zzop analyze-envelope --profile-rules / zzop cross --profile-rules); null when profiling was off, which is the default. It has no zzop.config.jsonc key — a timing report is a question about one invocation on one machine, not a fact about the project — and no MCP tool argument turns it on, so an analyze_repo reply carries no timings today. EnvelopeAnalyzeRequest carries the same field: Mode A's pack evaluation (symbol-scan per file, io-scan whole-tree) and its whole-graph analyses feed the same timing accumulator the native path uses. Profiling never changes findings/ir and takes no part in the cache key; a file served whole from cache re-runs no per-file rule and contributes no timing, so a WARM run reports only the whole-graph native analyses. |
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. This table is the FACADE wire, which carries the full array — it is the derivation source. The shaped product replies (zzop analyze/cross/endpoint and their MCP twins) carry a fold instead since 2026-07-29: the counts plus a pointer (zzop contract disclosure-classes / zzop://contract/disclosure-classes). The prose is run-invariant, so it ships once rather than on every call. |
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 finding's effective severity: the rule's default, as remapped by severityOverrides, and as de-escalated by the rules that lower their own confidence when the run is blind (see below).
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.
What a severity asserts. Severity states zzop's confidence in that one finding, and that confidence is bounded by what this run could see — zzop reads source and never probes a running system. Two cross-layer rules make the bound visible by de-escalating themselves: cross-layer/unconsumed-mutation-endpoint and cross-layer/unprovided-mutation-call report at info rather than warning when the run holds a source whose HTTP calls came back mostly unresolved (respectively, a source that imports a server framework yet yielded almost no routes), naming that source in the message; the finding fires either way, so this is a confidence match and not suppression. The converse does not follow, and the warning-branch message says so itself: each blindness check is one narrow predicate, so a rule holding at warning means blindness was not witnessed — not that coverage was proven complete. A caller in a call shape or language this extraction does not model, or in a repository outside the run, is invisible to the check exactly as it is to the rule. Read warnings and the coverage census below for the run's own account of its limits before treating a severity as a verdict.
files / symbols / resolvedImportEdgesHow 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. resolvedImportEdges (renamed from importEdges, 2026-07-31) counts only edges the resolver mapped to a file in this tree: an import of a published package, and a specifier nothing could resolve, are dropped during dep resolution and never counted. A low number can mean unresolved imports rather than few imports.
parserDispatchedThe subset of files a native frontend dispatched on (or an overlay covers) — files counts every walked path including docs and assets, so read code scale here, not there. Dispatch is by extension: a size-capped or unparsable file still counts, so this is "a frontend existed for it", not "structure was extracted" (see degraded). Envelope ingest sets it equal to files, where the equality is construction rather than a coverage claim.
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
crates/facade/src/lib.rs (crate zzop-facade) never panics by contract — every fallible path (malformed JSON, a missing root, an invalid envelope) returns a Result<String, String> instead. The engine already isolates a single file's parse/rule failure internally, well before it would ever reach that outer boundary. A direct Rust caller sees either Ok(String) or Err(String), never a process abort; the zzop-mcp binary calls these functions with no FFI boundary in between, so there is no separate addon-side catch_unwind layer to reason about — the facade's own contract is the whole story. version_string has no Result at all — it cannot fail.