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 at least two ways — a zzop CLI subcommand 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. A zzop-mcp MCP tool is the third way, and not every operation has one — each row below names the surfaces its own operation reaches, and a row with no MCP tool says so and says why. That is a deliberate asymmetry rather than a gap to close: the reason each CLI-only lane has no twin is recorded per lane in docs/contracts/surface-parity.json (_cliOnlyLanes), which a meta-test (crates/engine/tests/rule_contracts/surface_parity.rs) reads to hold the MCP reply to what that registry says it carries.
analyze_envelope_jsonzzop analyze-envelope · analyze_envelopeMode A: an adapter's envelope replaces native parsing.¶query_io_jsonzzop endpoint · check_endpointIs one io key provided, consumed or joined? One sealed verdict.¶validate_envelope_only_jsonzzop validate-envelope · validate_envelopeOffline “is my envelope well-formed?” — it never fails.¶validate_rule_pack_jsonzzop validate-rule-pack · validate_rule_packOffline “does this pack load, and can every rule in it fire?”¶explain_with_configzzop explain --config · no MCP toolThe same lookup over the packs a config's trees really load.¶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.
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, ioChannels (extracted — one row per io kind the rules read, present even at zero, so a filled channel can no longer vouch for an empty one the way the kind-agnostic joinContributionZero does; zeroExtraction — the CAPABILITY×MEASURED cross naming each (channel, extension) this build has a recognizer for whose extraction came back 0, restricted to filetypes that are a principal share of what the run read structurally (one under that floor is absent from the list, not cleared by it), a coverage fact keyed on the tree rather than on recognizing a framework by name), and joinVisibility as counts (provides, consumesKeyed, consumesUnresolved) plus a meaning — no derived rate, since a quotient reads the same at 1-of-1 as at 400-of-440. 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.
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 and the validate_envelope MCP tool.
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 and the validate_rule_pack MCP tool.
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. No MCP tool twin, for the same reason as version_string.
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. No MCP tool twin: the zzop-mcp version --verbose named above is a subcommand of that binary, not a tool an MCP host can call.
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), and for an unknown id it names explain_with_config as the wider corpus. Backs zzop explain, which has no MCP tool twin — an agent reads the rule-catalog contract resource instead.
explain_with_config
(config_path: &str, query: &str) -> Result<String, String>
The same lookup over a wider corpus: the packs that config’s trees actually load — the compiled-in ones plus every zzop/rules/ and packs.extraDirs directory those trees name. This is the only surface that reaches a rule which LEFT the bundled set: recovered into a tree, such a rule runs and reports findings under its id while the compiled-in-only lookup calls that id unknown. Reads the config file (and its pack directories) — it does not analyze anything. Backs zzop explain <rule-id> --config <path>, which like plain zzop explain has no MCP tool twin.
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.
rootrequiredstringTree root to analyze. An empty string is rejected with Err.sourceIdstring — default ""Free-form label carried through into cross-tree output.packsDirstring | string[] — optionalDirectory (or directories) of *.json DSL rule packs to load.contract
packsDirstring | string[] — optionalDirectory (or directories) of *.json DSL rule packs to load.contractMultiple 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.
packDefsobject[] — default []Inline rule-pack definitions handed to the engine as data.contract
packDefsobject[] — default []Inline rule-pack definitions handed to the engine as data.contractThe 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.
disabledRulesstring[]Rule / native-analysis ids to disable entirely (exact match).packsOnlystring[] — default []DSL pack allowlist. Config-file dialect: packs.only.contract
packsOnlystring[] — default []DSL pack allowlist. Config-file dialect: packs.only.contractWhen non-empty, a pack whose id is absent does not run. The opt-in twin of disabledRules, which can only say "everything except". Empty means no allowlist (every loaded pack runs), never allow-nothing. Scoped to packs: native analyses keep running and stay disabledRules' business. Composes with disabledRules (the allowlist selects, disabledRules still subtracts).
severityOverridesobjectRule id → "critical" | "warning" | "info". Promote or demote a specific id without forking its pack.suppressionsobject[]Finding accept-list, per rule, by path substring or glob.contract
suppressionsobject[]Finding accept-list, per rule, by path substring or glob.contractEach { 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).
globalExcludesobject[] — default []Config-wide, rule-agnostic report filter — the top-level "exclude" key.contract
globalExcludesobject[] — default []Config-wide, rule-agnostic report filter — the top-level "exclude" key.contractSame 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.
cacheDirstring — optionalPer-file IR/rule-result cache directory — omit and this wire runs uncached.contract
cacheDirstring — optionalPer-file IR/rule-result cache directory — omit and this wire runs uncached.contractKeyed by content hash + parser/ruleset fingerprint. 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.
gitobject — optionalTurns git collection on — the gate every history-derived key sits behind.contract
gitobject — optionalTurns git collection on — the gate every history-derived key sits behind.contractShape: { since?: string, recentDays?: number, commitTypePatterns?: { pattern, tag }[], commitSubjectPatterns?: { pattern, label }[] }. 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.
sizeCapnumber — optionalDefault 1,500,000 bytes (~1.5 MB). Files larger than this skip structural parsing and are listed under degraded.vocabularyobject — default {}Convention vocabulary — the names a project picks, declared instead of guessed.contract
vocabularyobject — default {}Convention vocabulary — the names a project picks, declared instead of guessed.contractAn object of optional convention-vocabulary keys — the authoritative list is zzop contract config-surface, and the engine type is zzop_engine::VocabularyConfig.
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.
profileRulesboolean — default falseRule timing instrumentation — populates the output's ruleTimings.contract
profileRulesboolean — default falseRule timing instrumentation — populates the output's ruleTimings.contractThe ESLint TIMING=1 / oxlint rule-timing equivalent. true times each DSL rule and each whole-graph native analysis that runs; false leaves ruleTimings null at zero added cost. Never changes findings/ir, and deliberately takes no part in the cache key — a profiled and an unprofiled run of the same tree are the same analysis and reuse each other's cache entries.
The one request field here with no zzop.config.jsonc key: a config declares what is true about the project and gets committed, while a timing report is a question about one invocation on one machine. CLI dialect: --profile-rules on analyze/analyze-envelope/cross. EnvelopeAnalyzeRequest carries the same field with the identical contract. Note a file served whole from cache never re-runs its per-file rules and contributes no timing, so a warm run reports only the whole-graph native analyses — the emitted report discloses this and carries the cache counts that prove it.
mountedAtstring — optionalWhole-tree gateway/ingress mount prefix, http provides only.contract
mountedAtstring — optionalWhole-tree gateway/ingress mount prefix, http provides only.contractShorthand for a mounts entry with dir: "", folded in last so an explicit equal-length mounts entry wins a tie. Stacks on top of any code-extracted prefix (e.g. NestJS's setGlobalPrefix).
mountsobject[]Per-directory mounts — longest matching dir wins per provide.contract
mountsobject[]Per-directory mounts — longest matching dir wins per provide.contractShape: { dir: string, at: string }[]. Deployment-topology per-directory mounts: prepends at to an http provide's key when its file path falls under dir.
clientBasestring — optionalThe calling side's mirror of mountedAt.contract
clientBasestring — optionalThe calling side's mirror of mountedAt.contractThe 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.
hostsstring[]Hosts this tree owns.contract
hostsstring[]Hosts this tree owns.contractAn 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.
routesobject[] — default []Inject one route zzop could not resolve from source.contract
routesobject[] — default []Inject one route zzop could not resolve from source.contractShape: { key: string, role?: "provide" | "consume" }[]. Lightweight route-fact injection for the common 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.
adapterOverlaysobject[]Mode B: partial envelopes merged on top of native analysis.contract
adapterOverlaysobject[]Mode B: partial envelopes merged on top of native analysis.contractPartial Normalized-AST envelopes (typically just io + fragment channels for a handful of files) — 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.
parsersobject — default {}Parser routing — force paths matching a glob to a named language.contract
parsersobject — default {}Parser routing — force paths matching a glob to a named language.contractShape: { globOverrides?: { glob: string, language: string }[] }. 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 — a subset of the fields above, whose authoritative list is the EnvelopeAnalyzeRequest struct itself (crates/facade/src/request.rs); the fields it shares with AnalyzeRequest behave identically to their counterparts there. 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."
This index is the wire shape, which is one layer wider than the type it is named for: the reply root flattens AnalyzeOutputView and adds the run-global disclosure registry as a sibling. Both halves are enumerated once, in docs/contracts/surface-parity.json — every top-level key must have a row there, and a build test fails otherwise.
irobjectLanguage-neutral common IR: symbols, dep (import graph), loc, io (IoFacts).fileCountnumberFiles walked.degradedstring[]Paths that hit sizeCap or otherwise failed to parse structurally.buildScriptPathsstring[]Files this tree's own package.json scripts names, sorted — build surface, not shipped code. Always present; [] means the manifests declared none.coverageobjectStructural coverage census — always present.contract
coverageobjectStructural coverage census — always present.contractVocab-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. Every cell is defined in the coverage census below.
nodesobject[]Per-file churn/fan-in/fan-out/risk metrics — fully populated only when git is set.foldersobjectFolder-granularity rollup of nodes and the dependency graph.contract
foldersobjectFolder-granularity rollup of nodes and the dependency graph.contractNot 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.
findingsobject[]Sorted (severity, file, line, ruleId) ascending, critical first.contract
findingsobject[]Sorted (severity, file, line, ruleId) ascending, critical first.contractA finding suppressed by an inline marker comment never appears at all. The shape every finding shares — and what its severity does and does not assert — is defined below the index.
scoresobject | nullStructural sub-scores, 0–100. null unless git is set.contract
scoresobject | nullStructural sub-scores, 0–100. null unless git is set.contractEvery score ships the population it scored over — featureSlicedDesign.layerClassifiedImports, cohesion.sliceCount, coupling.importerCount, godFile.total, busFactor.total, diamond.rootsExamined, mainSequence.classifiedFiles and their siblings. A population of 0 is the never measured signal, not a clean bill of health: every formula returns 100 on an empty population, so the denominator is the only thing separating "judged everything and all passed" from "found nothing it could judge".
Each is a judged-population denominator, never a tree total: the top-level exclude removes a path from both the violation list and the denominator, and fileSizeCompliance/godFile judge source files only. mainSequence.classifiedFiles is 0 on every current build (nothing classifies a file abstract vs concrete), so read only its instability/fileCount. What each key means rides beside it in scoreMeanings.
scoreMeaningsobjectOne sentence per scores key, keyed identically.contract
scoreMeaningsobjectOne sentence per scores key, keyed identically.contractPresent exactly when scores is, absent (not null) otherwise. One score key is a bare acronym and the legend is where it is expanded: sdp (Stable Dependencies Principle). Two more used to be and were renamed on the wire instead: sfc → fileSizeCompliance, fsd → featureSlicedDesign. A fourth, lod (Law of Demeter), was removed along with its score in 2026-08 — it had never measured anything. Each sentence says what a low number means; all scores are 0–100, higher is healthier.
healthobject | nullOne composite index rolled up from scores — and it carries no rule findings.contract
healthobject | nullOne composite index rolled up from scores — and it carries no rule findings.contractShape: {pain, axisPain[], measuredWeight, totalWeight, contributors[]}. pain carries no rule findings — a tree full of SQL injection scores exactly what the same tree scores with none — and most of what it does carry is a structural opinion rather than a defect claim.
axisPain[] says so on the wire: it splits pain into defect (import cycles, the only one), opinion (barrel discipline, FSD layering, SDP/Main Sequence, Newman modularity, LOC ceilings — a project that deliberately does the opposite is not wrong, it scores low) and history (rename churn, bus factor), each on pain’s own scale and summing to it.
pain renormalizes over the metrics that had a population, so an axis this tree could not measure leaves the weighting entirely instead of quietly scoring 100 and making the repo look healthier. Read pain against measuredWeight / totalWeight, which says how much of the metric table was measurable here; pain is null (never 0) when none of it was. contributors[] keeps the unmeasured metrics as rows with population: 0 and a null gap, so a dark axis is stated rather than absent.
recommendationsobject[]ROI-ranked refactor candidates.contract
recommendationsobject[]ROI-ranked refactor candidates.contractAn 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 size-weighted blast radius.contract
criticalobject[]Files ranked by size-weighted blast radius.contractblastRadius * 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.
seamsobject[]Folders that are good first-extraction candidates (low boundary-crossing coupling).contract
seamsobject[]Folders that are good first-extraction candidates (low boundary-crossing coupling).contractfiles 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.
layerCoChurnobject[] | nullCross-layer commit co-churn pairs.contract
layerCoChurnobject[] | nullCross-layer commit co-churn pairs.contractnull 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".
coChangeobject[] | nullFile-pair co-change edges — the substrate graph --domain cochange draws.contract
coChangeobject[] | nullFile-pair co-change edges — the substrate graph --domain cochange draws.contractThe same evidence the dep-graph descriptions draw on. null = git inactive or collection failed, so nothing was measured; [] = measured and nothing co-changed — the two must not be folded together. Carries the same two noise filters as layerCoChurn, so it is a sample rather than a total. Not gated by disabledRules: it is measured evidence, not a rule verdict. Paths are relative to the analyzed tree, like nodes and dep: history is collected per repository and then rebased onto each tree root, so a package inside a monorepo sees its own co-change and never its sibling's.
gitWindowobject | nullEchoes the resolved git-history window — always serialized.contract
gitWindowobject | nullEchoes the resolved git-history window — always serialized.contractShape: { recentDays, since } | null. 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.
packsLoadedobject[]Positive pack-load confirmation: one entry per loaded DSL pack; a pack that did not run is marked didNotRun.contract
packsLoadedobject[]Positive pack-load confirmation: one entry per loaded DSL pack; a pack that did not run is marked didNotRun.contractShape: { id, rules, ruleIds, source, filesInScope, zeroAdmissionRules? }[], sorted by id, with each pack's rule count and provenance (source: "dir" = read from a packsDir directory, "inline" = packDefs). ruleIds is the LIST behind the rules COUNT — every rule id this run could have reported from that pack, so "does this run carry a rule named X" is answered from the reply instead of guessed from the pack prefix. Always present: an omitted list would read as "this build declines to say", which is exactly the state a validator has to tell apart from "no such rule".
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." zeroAdmissionRules is the same census per rule: the ids of this pack's rules whose own path gates (file_pattern plus that rule's file_exclude_pattern) admit zero files here — their zero findings are scope, not "checked and clean". Present only when non-empty; omitted on a filesInScope: 0 pack, whose pack-level zero already covers every rule.
Always present — [] is the honest "zero DSL packs loaded" state. Verifies a custom pack actually loaded without inferring it from findings deltas.
packsLoadedMeaningobjectThe legend for the array above; absent entirely when no pack loaded.contract
packsLoadedMeaningobjectThe legend for the array above; absent entirely when no pack loaded.contractOne sentence per key: row (an entry is one loaded pack — loading is not running), filesInScope (path candidacy, never a "matched" count, and the presence of the key is itself the claim that the pack ran), and zeroAdmissionRules (those rules admitted no analyzed file at all — not "files reached them and nothing fired"). Those two readings imply opposite things; it has always meant the first, and now says so. A fourth key, didNotRun, appears only when a pack really was gated off — a legend entry for a state this run is not in is noise, and noise is what teaches readers to skip disclosures. It sits beside the array rather than inside it because packsLoaded is an array: inside, the same sentences would repeat once per loaded pack (same shape as scoreMeanings).
nativeAnalysesobjectThe same question as packsLoaded, asked of the built-in analyses: which of them could not have keyed this reply's findings.contract
nativeAnalysesobjectThe same question as packsLoaded, asked of the built-in analyses: which of them could not have keyed this reply's findings.contractShape: { registered, disabled, reportedInCrossLayerFindings }. registered is how many native analyses this build carries — the denominator the two lists are read against. It counts the gate id space (what rules/disabledRules name), not the ids a finding can carry: some gate score computations that emit no finding, and some are umbrella ids whose findings carry finer schema/<label> names.
The two lists are kept apart because the remedies are opposite. disabled = your config switched these off, so their absence from findings means not analyzed; turn one back on to get a verdict. reportedInCrossLayerFindings = these are switched on and still cannot appear here, because they judge the cross-tree join and report into its own crossLayerFindings channel, which a per-tree output does not have; run the cross-layer join over the same config to see them. Anything registered and in neither list ran — except for the kinds the paragraph above named. A score-gating id emits no finding at all, and an umbrella id's findings arrive under the finer schema/<label> names, so neither can key findings under the name registered counted: a blank there is how those ids always look, not a verdict (for an umbrella, look under schema/<label> instead). For everything else in neither list, absence from findings is a measured zero.
Both lists are always serialized, [] included — unlike zeroAdmissionRules, deliberately. A list that appears only when it has entries reports nothing on a clean run in bytes indistinguishable from a build that does not report at all, which is the confusion this object exists to end.
nativeAnalysesMeaningobjectThe legend for the object above — always present, because its subject always is.contract
nativeAnalysesMeaningobjectThe legend for the object above — always present, because its subject always is.contractOne sentence per key: registered, disabled, reportedInCrossLayerFindings, plus everythingElse — the residual class that has no field of its own precisely because it is the case where absence is the verdict. Unconditional, unlike packsLoadedMeaning: a run can legitimately load no pack, but every build registers native analyses.
ruleOverridesAppliedobjectPositive confirmation that the three rule knobs were applied.contract
ruleOverridesAppliedobjectPositive confirmation that the three rule knobs were applied.contractShape: { disabled, severityRemapped, only } — that disabledRules/severityOverrides/packsOnly were applied, listing the affected rule ids, with 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.
warningsstring[]Non-fatal issues plus capability self-report notes — see Honest output.configWarningsstring[]Config-authoring problems, kept OUT of warnings. Always present.contract
configWarningsstring[]Config-authoring problems, kept OUT of warnings. Always present.contractComputed at analysis time: a disabledRules/severityOverrides entry matching no known rule id is reported here (only analysis time has the full known-id set). [] means neither knob had a matching-nothing entry.
cache{ hits, misses } | nullSet only when cacheDir was given.ruleTimingsobject[] | nullPer-rule id + elapsed time + finding count, when profiling is enabled.contract
ruleTimingsobject[] | nullPer-rule id + elapsed time + finding count, when profiling is enabled.contractSet 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.
disclosureobject[]Run-global silent-failure-class registry — static and identical every run.contract
disclosureobject[]Run-global silent-failure-class registry — static and identical every run.contractzzop's honest list of which classes of blindness it does and does not yet detect. On a multi-tree analyzeTrees call it sits once beside trees, never per tree. Its three fields are defined in the registry legend below.
This index 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 — unless messageRef is present, in which case this is a short pointer and the text itself is the entry it names.
messageRefPresent only when the text was folded. A key into ruleMessages, the object sitting beside the shown list this finding came from — a sibling, not a fixed path, because one shaper feeds both findings and crossLayerFindings. A text carried by more than one finding is stored there once and pointed at, but only where doing so removes more bytes than the pointer and the table cost, so a repeated text can also be absent. Read message directly when messageRef is absent. The stored text is byte-identical to the inline one — nothing is shortened or dropped, and no second request is needed. ruleMessagesMeaning states the same contract on the wire.
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.