JSON 계약

zzop 은 JSON 계약을 하나만 노출하고, 모든 표면이 그 하나를 말한다 — zzop CLI 서브커맨드, zzop-mcp MCP 도구, 인프로세스 호출이 전부 같은 요청 모양을 보내고 같은 출력 모양을 돌려받는다. 이 페이지가 그 계약이다: 입력(AnalyzeRequest), 출력(AnalyzeOutputView), 그리고 그 사이의 어휘. zzop 을 그냥 돌리는 법은 Usage 를 보라. 임베드하려면 바이너리의 JSON 서브커맨드로 셸아웃한다 — JSON 넣고 JSON 받고, 링크는 없다. 이것이 빌드된 zzop-facade / zzop-summary 크레이트는 워크스페이스 내부용이라(crates.io 에 발행하지 않는다) 인프로세스 Rust 의존이란 cargo add 가 아니라 워크스페이스를 벤더링한다는 뜻이다.

Operations

공유되는 오퍼레이션

아래의 모든 오퍼레이션은 최소 두 갈래로 닿는다 — zzop CLI 서브커맨드와 인프로세스 호출 — 그리고 그 둘은 하나의 구현을 공유한다(crates/facade, 그리고 CLI 와 MCP 도구가 받는 설정 자동탐색·결과 정형을 맡는 crates/summary). 어느 표면을 쓰든 같은 요청을 넣으면 같은 JSON 이 나오고, version 을 빼면 전부 JSON 문자열 입력 / JSON 문자열 출력이다. zzop-mcp MCP 도구가 세 번째 갈래이고, 모든 오퍼레이션에 하나씩 있는 것은 아니다 — 아래 각 행은 자기 오퍼레이션이 닿는 표면을 스스로 밝히고, MCP 도구가 없는 행은 없다는 사실과 그 이유를 함께 적는다. 메워야 할 구멍이 아니라 의도된 비대칭이다: CLI 전용 레인마다 짝이 없는 이유가 docs/contracts/surface-parity.json(_cliOnlyLanes)에 레인 단위로 기록돼 있고, 메타 테스트(crates/engine/tests/rule_contracts/surface_parity.rs)가 그것을 읽어 MCP 응답을 그 등기부가 말하는 것에 붙들어 둔다.

Operation · CLI · MCP toolWhat it answers
analyze
analyze_jsonzzop analyze · analyze_repoOne tree in, one AnalyzeOutputView out.
analyze_trees_jsonzzop cross · cross_repoSeveral trees, joined across the layer boundary.
analyze_envelope_jsonzzop analyze-envelope · analyze_envelopeMode A: an adapter's envelope replaces native parsing.
query
query_io_jsonzzop endpoint · check_endpointIs one io key provided, consumed or joined? One sealed verdict.
query_file_jsonzzop file · check_fileEverything zzop knows about ONE file — uncapped.
query_coverage_jsonzzop coverage · no MCP toolHow much of this tree does zzop actually see?
validate
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?”
meta
versionzzop version · no MCP toolThe bare release number — one token a script can parse.
version_stringzzop version --verbose · no MCP toolEngine + parser fingerprint string. Cannot fail.
explainzzop explain · no MCP toolOne bundled rule id → that rule's own compiled-in data.
explain_with_configzzop explain --config · no MCP toolThe same lookup over the packs a config's trees really load.
Details — analyze

analyze_json

(config_json: &str) -> Result<String, String>

AnalyzeRequestAnalyzeOutputView. 트리 하나를 분석한다. zzop analyze <path> / analyze --config <path> CLI 서브커맨드와 analyze_repo MCP 도구(zzop-summary 경유)를 떠받친다.

analyze_trees_json

(config_json: &str) -> Result<String, String>

AnalyzeTreesRequest({ trees: AnalyzeRequest[] }) → MultiAnalyzeOutputView. 여러 트리를 분석하고 레이어를 가로질러 잇는다. zzop cross / cross_repo MCP 도구를 떠받친다.

analyze_envelope_json

(envelope_json: &str, config_json: &str) -> Result<String, String>

NormalizedEnvelope + EnvelopeAnalyzeRequestAnalyzeOutputView. 외부 파서 어댑터가 만든 Normalized AST 엔벨로프를 분석한다. zzop analyze-envelope / analyze_envelope MCP 도구를 떠받친다.

Details — query

query_io_json

(analysis_json: &str, query_json: &str) -> Result<String, String>

analyze_trees_json 출력 + { pattern: string } → 엔드포인트/io 키에 대한 확정 답. 이미 만들어진 다중 트리 분석 위의 순수 후처리이고 재분석은 없다: pattern 은 모든 크로스레이어 io 키(HTTP 라우트, env 키, DB 테이블, 토픽, 그리고 풀리지 않은 consume 을 위한 raw)에 대소문자 무시 부분 문자열로 맞춰 보고, 결과는 봉인된 verdict 어휘(linked | provided-only | consumed-unprovided | external | unresolved-only | ambiguous | mixed | not-found)를 그 뒤의 매치·개수·관련 발견과 함께 싣는다. 질의가 망가졌을 때와 단일 트리 analyze_json 출력이 들어왔을 때는 에러를 낸다 — 안내가 붙은 에러다. verdict 는 조인 사실이기 때문이다(트리가 하나여도 조인하는 analyze_trees_json 을 돌려라). zzop endpoint / check_endpoint MCP 도구를 떠받친다.

query_file_json

(analysis_json: &str, query_json: &str) -> Result<String, String>

analyze_trees_json 출력 + { path: string, sourceId?: string } → zzop 이 파일 하나에 대해 아는 전부. query_io_json 과 같은 순수 후처리 계약이고 — 재분석은 없다 — 대상이 io 키 대신 파일 경로다: 그 파일이 속한 트리, 심볼, io 사실, 양방향 의존 간선, 그리고 그 파일에 앵커된 모든 발견. 상한이 없다, 의도적으로: 파일 하나는 유계이므로 버리는 것이 없고, 따라서 밝힐 절단도 없다.

이쪽의 봉인된 verdict 어휘(analyzed | lexical-only | degraded | not-found)가 답하는 것은 그 파일이 분석됐는가이지 건강한가가 아니다 — 빈 발견 목록은 첫 번째에서는 "깨끗하다"이고 다음 둘에서는 "구조 분석이 애초에 돌지 않았다"이다. query_io_json 과 마찬가지로 응답 자신의 verdictMeaning 필드가 돌려준 토큰의 정의를 싣고 다니므로, 어떤 문서도 그 정의의 두 번째 주인이 되지 않는다. sourceId 가 없으면 모든 트리를 뒤지고, 응답은 매치가 나온 트리를 이름 대며 나머지는 otherTrees 에 늘어놓는다 — 말없이 하나를 고르지 않는다. not-found 응답에는 워크된 경로 중 가장 가까운 것들이 suggestions 로 실린다. path 가 없는 질의와, 보고할 트리 정체성이 없는 단일 트리 analyze_json 출력에는 에러를 낸다. zzop file / check_file MCP 도구를 떠받친다.

query_coverage_json

(analysis_json: &str) -> Result<String, String>

analyze_trees_json 출력 → 총량 가시성 뷰: “zzop 이 이 트리를 실제로 얼마나 보고 있나?” 트리마다 디스패치별 확장자 표(구조 / lexical-only / degraded, 그리고 inDepGraph), blindSpots — 컴파일된 각 룰의 시선을 그 트리의 구조 확장자 구성과 교차시킨 능력 축 — 그 트리 자신의 엔진 경고를 그대로 전달한 것(프레임워크 침묵 자기보고가 여기 실린다), 커버리지 센서스, ioChannels(extracted — 룰이 읽는 io 종류마다 한 행씩, 0 이어도 반드시 실린다: 종류를 구분하지 않는 joinContributionZero 처럼 채워진 채널이 빈 채널을 대신 보증하지 못하게 한다. zeroExtraction — 이 빌드가 인식기를 가진 (채널, 확장자) 중 추출이 0 으로 돌아온 것을 이름 붙인 능력×실측 교차: 이 실행이 구조적으로 읽은 것 중 주요 비중인 확장자만 실리고(그 아래는 목록에서 빠지는 것이지 통과한 것이 아니다), 프레임워크 이름을 알아보는 게 아니라 트리 자체를 모집단으로 삼는 커버리지 사실이다), 그리고 joinVisibility 를 개수로(provides, consumesKeyed, consumesUnresolved) 의미와 함께 낸다 — 파생 비율은 내지 않는다. 몫은 1 중 1 에서나 440 중 400 에서나 똑같이 읽히기 때문이다. 단일 점수는 일부러 없다: zzop 이 당신 트리에서 한 번도 재지 못한 축은, 그 사실 없이 인용될 숫자에 접혀 들어가는 대신 재지 못했다는 필드에 실려 간다. MCP 도구 짝이 없는 zzop coverage 를 떠받친다.

Details — validate

validate_envelope_only_json

(envelope_json: &str) -> String

엔벨로프 JSON → { valid: boolean, issues: string[], hints: string[] }hints 는 언제나 있고, 구조적으로 유효한 엔벨로프가 그럼에도 아무것도 조인하지 못할 이유를 듣는 자리다. analyze_envelope_json 이 자기 엔벨로프에 적용하는 구조·의미 검사를 똑같이 돌리고 거기서 멈춘다 — 설정도, 팩 로딩도, 엔진 실행도 없다 — 어댑터 작성자를 위한 빠르고 오프라인인 "내 엔벨로프가 잘 생겼나" 피드백이다. 실패하지 않는다: 유효하지 않은 엔벨로프도 평범한 { valid: false, issues: [...] } 결과로 돌아온다. zzop validate-envelopevalidate_envelope MCP 도구를 떠받친다.

validate_rule_pack_json

(pack_json: &str) -> String

룰팩 JSON → { valid: boolean, issues: string[] }. 엔진의 팩 로더가 적재 시점에 적용하는 구조 판정을 그대로 돌리고(망가진 JSON, 빠진 필드, 틀린 타입, 너무 새로운 schema_version), 여기에 적재는 되지만 말없이 영영 발화하지 못할 룰까지 잡는다 — 컴파일되지 않는 matcher 정규식, line_patternany 도 선언하지 않은 line-scan, trigger 가 어떤 patterns 항목도 선언하지 않은 라벨을 가리키는 method-scan. 모양만 보고 룰 품질의 의미는 절대 보지 않는다. 팩 작성자를 위한 배포 전 피드백이다. 실패하지 않는다: 유효하지 않은 팩도 평범한 { valid: false, issues: [...] } 결과로 돌아온다. zzop validate-rule-packvalidate_rule_pack MCP 도구를 떠받친다.

Details — meta

version

() -> String

핑거프린트 없는 맨 릴리스 번호 — 그냥 zzop version / zzop-mcp version 이 찍는 것. version_string 과 일부러 갈라 뒀다: 이쪽은 스크립트가 파싱하는 토큰 하나여서, 길어지면 그렇게 하는 호출자가 전부 깨진다. MCP 도구 짝은 없고, 이유는 version_string 과 같다.

version_string

() -> String

엔진 + 파서 핑거프린트 버전 문자열. Result 가 없다 — 실패할 수 없다. 사용자 표면에는 zzop manifestzzop factstool 필드로, zzop graph%% tool: 센서스 줄로, 그리고 zzop version --verbose / zzop-mcp version --verbose 가 찍는 것으로 닿는다. 그냥 zzop version 은 맨 릴리스 번호로 남고 핑거프린트를 싣지 않는다. MCP 도구 짝은 없다: 위에 이름 댄 zzop-mcp version --verbose 는 그 바이너리의 서브커맨드이지 MCP 호스트가 호출할 수 있는 도구가 아니다.

explain

(query: &str) -> Result<String, String>

룰 id 하나 → 그 번들 DSL 룰의 컴파일된 데이터를 사람이 읽는 줄로. 실행에서 읽는 것은 없다 — 팩 데이터는 바이너리 안에 컴파일돼 있다. Err 에는 안내가 붙는다: 그 id 가 실제로 무엇인지 이름을 대고(네이티브 분석 id, 팩 전체 id, 출력 필드 id, 모호한 맨 id, 아니면 미상), 미상인 id 에 대해서는 더 넓은 코퍼스로 explain_with_config 를 가리킨다. MCP 도구 짝이 없는 zzop explain 을 떠받친다 — 에이전트는 대신 rule-catalog 계약 리소스를 읽는다.

explain_with_config

(config_path: &str, query: &str) -> Result<String, String>

더 넓은 코퍼스 위의 같은 조회: 그 설정의 트리들이 실제로 적재하는 팩 — 컴파일된 것에 더해 그 트리들이 이름 대는 모든 zzop/rules/packs.extraDirs 디렉터리. 번들 집합을 떠난 룰에 닿는 유일한 표면이다: 트리로 회수된 그런 룰은 자기 id 로 돌면서 발견을 내는데, 컴파일된 것만 보는 조회는 그 id 를 미상이라 부른다. 설정 파일(과 그 팩 디렉터리)을 읽는다 — 무엇도 분석하지 않는다. zzop explain <rule-id> --config <path> 를 떠받치고, 그냥 zzop explain 과 마찬가지로 MCP 도구 짝은 없다.

Defaults

설정은 필수다; 시작용 설정만으로도 전체 분석이 돈다

모든 분석 레인은 zzop.config.jsonc 가 없는 트리를 거부한다 — 두 배포 바이너리 모두에서, 같은 거부를 같은 문서 이름을 대며 한다. 의례가 아니다: vocabulary 블록은 그것이 없으면 zzop 이 당신 프로젝트에 대해 추측해야 할 이름들을 담고 있고, 선언되지 않은 키는 zzop 이 내리지 않는 판정이다 — 이름 기반 인증 면제, 생성 파일 탐지, 쓰기 지점 판정이 그것과 함께 전부 꺼진다. zzop init 은 zzop 자신의 값이 이미 들어 있는 그 파일을 써 준다.

나머지는 전부 기본값이 선다. roots 만 선언한 시작용 설정으로도 두 바이너리 모두에서 똑같이 전체 분석이 돈다 — 둘 다 공유 zzop-summary 층과 그 zzop-config 크레이트를 지나가고, 그 층이 번들 DSL 룰팩(바이너리에 컴파일돼 있다), 엔진의 recentDays: 30 git 기본값, 그리고 .zzop/cache 라는 cacheDir 을 주입한다:

시작용 설정이 줄 수 없는 단 하나는 두 번째 트리다: 루트가 하나면 크로스레이어 조인은 이을 것이 없다. pnpm-workspace.yaml(또는 package.jsonworkspaces)이 2개 이상의 패키지로 풀리는 워크스페이스 루트에서는, 실행의 첫 configWarnings 항목이 그 매니페스트와 정확한 패키지 개수, 그리고 {"trees": "auto"} 라는 처방을 이름 댄다.

zzop CLI
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

zzop_facade::analyze_json 을 직접 부르면 그 앞에 그런 래퍼가 없다 — 파사드는 암묵적 기본값을 하나도 적용하지 않는다. 맨 {"root": "."} 설정 JSON 은 DSL 팩을 0개 적재하고(네이티브 분석만) git 수집을 끈 채 돈다(scores/health/recommendationsnull 로 남는다) — packsDir/packDefsgit 을 직접 세우지 않는 한:

Direct zzop-facade call (Rust)
let config = serde_json::json!({ "root": ".", "git": {} }).to_string();
let result: serde_json::Value =
    serde_json::from_str(&zzop_facade::analyze_json(&config)?)?;
  • packsDir 생략(파사드) — DSL 팩이 아예 적재되지 않는다. packsDir(*.json 팩들이 든 디렉터리)이나 packDefs(인라인 정의 — 아래 참고)를 명시적으로 넘겨라.
  • packsDir 지정, 디렉터리 여럿 — 전부 적재해 병합한다. 두 디렉터리가 같은 id 의 팩을 실으면 뒤쪽 디렉터리가 그 팩을 통째로 대체한다(룰 단위 병합이 아니다).
  • packDefs 지정 — 어떤 packsDir 항목보다 먼저 적재되므로, 같은 id 의 디렉터리 팩이 충돌을 통째로 이긴다. 디스크에 팩 디렉터리가 없는 호스트(예: zzop-mcp — 그 zzop-config 층이 번들 팩을 이 방식으로 주입한다)가 룰을 갖게 되는 유일한 길이 이것이다.
  • git 생략(파사드) — git 수집이 꺼진다. scores, health, recommendations, critical, seams, layerCoChurnnull 로 남는다. 엔진 자신의 recentDays: 30 기본값으로 켜려면 git: {} 를, 그 값을 덮으려면 git: { "recentDays": N } 을 넘겨라. root 가 git 레포지토리가 아니면 엔진은 곱게 성능을 낮추고 그 사실을 warnings 에 보고한다.
  • cacheDir 생략 — 디스크에 쓰는 유일한 기본값이고, 그래서 지금 어느 방언에 있는지 알아 둘 값어치가 있는 유일한 기본값이다. zzop/zzop-mcp/설정 파일 아래에서는 .zzop/cache 로 기본값이 서고, 설정 파일의 디렉터리(설정 파일이 없으면 분석 대상 루트)를 기준으로 풀리며, 첫 실행이 당신이 분석한 트리 안에 그것을 만든다 — 그 레포의 .gitignore 에는 zzop* 글롭이 아니라 앵커된 **/.zzop/ 를 넣어라. 전자는 버전 관리에 있어야 할, 사람이 쓴 zzop/ 디렉터리(커스텀 룰팩, 어댑터 오버레이)까지 삼킨다. 키를 null 로 두면 캐시가 꺼지고 아무것도 쓰지 않는다. 파사드를 직접 부르면 기본값이 아예 주입되지 않는다: 필드를 생략하면 캐시 없이 돌고 쓰는 것이 없다.
  • vocabulary 생략 — 주입되는 것도 없고 폴백되는 것도 없다 — 모든 경로에서, 당신이 선언하지 않은 키는 그냥 판정되지 않는다(내장 폴백 갈래는 2026-07-27 에 제거됐다). 내장 값들은 zzop init 이 당신의 시작용 파일에 써 주는 것으로만 살아남는다 — 즉 그것들이 실행에 닿는 이유는 당신의 설정이 그렇게 말하기 때문이다. 그래서 이 블록을 쓰는 것은 아주 많은 것을 바꾼다: 빼 두면 룰은 이 아니라 발화한다. 선언되지 않은 가드 어휘는 어떤 가드도 증명하지 못하고, 선언되지 않은 면제는 어떤 면제도 주지 못하기 때문이다.
Config reference

AnalyzeRequest

#[serde(rename_all = "camelCase", default)]root 를 빼면 모든 필드가 선택이고, 모르는 필드는 거부가 아니라 무시된다.

Field · typeWhat it is
identity
rootrequiredstringTree root to analyze. An empty string is rejected with Err.
sourceIdstring — default ""Free-form label carried through into cross-tree output.
rules & findings
packsDirstring | string[] — 선택적재할 *.json DSL 룰팩 디렉터리(또는 디렉터리들).계약

디렉터리 여럿은 적재해 병합한다 — 여러 디렉터리에 걸쳐 되풀이된 팩 id 는 뒤쪽 디렉터리 것을 통째로 취한다. 없거나 읽을 수 없는 디렉터리는 치명적이지 않은 warnings 항목이지 실패가 아니다.

packDefsobject[] — 기본값 []엔진에 데이터로 건네는 인라인 룰팩 정의.계약

디스크에 팩 디렉터리가 없는 호스트를 위한, packsDir 의 자기완결 바이너리 대안이다(예: zzop-mcp 의 컴파일 시점 내장 팩). packsDir 디렉터리보다 먼저 적재되므로 같은 id 의 디렉터리 팩이 충돌을 통째로 이긴다. 더하기만 한 변경이고(v0.16.0), 은퇴한 JS 래퍼는 이것을 보낸 적이 없다. analyzeEnvelope 의 설정에서도 동일한 계약으로 받는다.

disabledRulesstring[]Rule / native-analysis ids to disable entirely (exact match).
packsOnlystring[] — 기본값 []DSL 팩 허용 목록. 설정 파일 방언: packs.only.계약

비어 있지 않으면, id 가 여기 없는 팩은 돌지 않는다. "이것만 빼고"밖에 말하지 못하는 disabledRules 의 옵트인 쌍둥이다. 비어 있다는 것은 허용 목록이 없다는 뜻(적재된 모든 팩이 돈다)이지 아무것도 허용하지 않는다는 뜻이 아니다. 범위는 팩까지다: 네이티브 분석은 계속 돌고 disabledRules 의 소관으로 남는다. disabledRules 와 함께 쓰인다(허용 목록이 고르고, disabledRules 가 여전히 빼낸다).

severityOverridesobjectRule id → "critical" | "warning" | "info". Promote or demote a specific id without forking its pack.
suppressionsobject[]룰별 발견 수용 목록. 경로 부분 문자열이나 글롭으로.계약

{ rule, path?, glob? } 하나하나가 rule 의 발견을 버린다 — 어디서나(필터 없음), 경로에 path 가 든 파일에서(부분 문자열), 또는 glob 에 맞는 파일에서(전체 경로 글롭. globpath 를 이긴다).

globalExcludesobject[] — 기본값 []설정 전체에 걸친, 룰을 가리지 않는 보고 필터 — 최상위 "exclude" 키.계약

suppressions 와 같은 path/glob 매칭이지만, 맞는 경로를 모든 룰에서 한꺼번에, 그리고 다른 모든 보고 채널에서도 버린다 — recommendations, crossLayerFindings, critical, 그리고 scores.* 아래의 모든 지표별 위반 목록.

0.27 부터는 그 파일을 채점에서도 뺀다: 제외된 파일은 판정 대상이기를 그만두므로, 파일 단위 점수 뒤의 위반 목록과 분모가 함께 사라지고 health.pain 도 그에 따라 움직인다. 그래프 자체는 건드리지 않는다 — 그 파일은 여전히 분석되고, 여전히 의존 그래프에 있고, 여전히 실재하는 import 대상이므로 다른 파일의 결합도·팬아웃·폭발 반경은 바뀌지 않는다. 방향이 예측 가능하지 않다는 점에 주의하라: 평균보다 깨끗한 코드를 제외하면 pain 은 올라간다. 그 수치는 트리 전체가 아니라 이 설정이 판정하는 모집단을 서술하고, 같은 exclude 를 쓴 실행끼리만 비교 가능하기 때문이다. exclude 가 파일을 하나라도 걷어낸 실행은 그 사실을 warnings 에 적는다.

필터를 아예 받지 않는 채널이 둘 있다: warnings 자신(문제가 없어 보이게만 만들 만큼 넓은 exclude 를 보고하는 곳 — 여기를 거르면 필터가 자기 경고를 지울 수 있게 된다), 그리고 파일이 아니라 슬라이스나 모듈로 키가 잡힌 행들(cohesion.slices, sdp.violations, mainSequence.modules, modularity) — 이쪽의 주어는 파일이 아니라 디렉터리다.

analysis
cacheDirstring — 선택파일 단위 IR/룰 결과 캐시 디렉터리 — 생략하면 이 와이어는 캐시 없이 돈다.계약

내용 해시 + 파서/룰셋 핑거프린트로 키를 잡는다. 생략하면 캐시 없이 돈다 — 기본값이 결코 주입되지 않는 이 와이어에서의 답이 그렇다는 뜻이다. zzop/zzop-mcp/zzop.config.jsonc 실행은 다른 방언이다: 그쪽 설정 프런트엔드는 이 키의 기본값을 .zzop/cache 로 세우고 첫 실행에서 그 디렉터리를 만든다 — 설정은 필수다 를 보라.

gitobject — 선택git 수집을 켠다 — 이력에서 나온 모든 키가 그 뒤에 앉아 있는 관문.계약

모양: { since?: string, recentDays?: number, commitTypePatterns?: { pattern, tag }[], commitSubjectPatterns?: { pattern, label }[] }. git 에서 나오는 scores/health/recommendations/critical/seams 를 켠다.

recentDays 의 기본값은 30 이다. commitTypePatterns 는 비어 있지 않으면 기본 FIX/FEAT/REVERT/... 분류표를 통째로 대체한다.

commitSubjectPatterns 는 선언된 제목 라벨 축이고, 형제와 세 가지가 일부러 다르다: 기본 표가 없고(없거나 비어 있으면 아무것도 라벨링하지 않는다 — "revert"/"ticket"/"hotfix" 제목이 어떻게 생겼는지는 프로젝트마다 다른 관습이고 엔진은 그것을 추측하지 않는다), 첫 매치 승리가 아니며(맞는 선언 전부가 자기 label 을 선언 순서대로 보태고, 되풀이된 라벨은 첫 자리에 한 번만 남는다), pattern 은 쓴 그대로 컴파일돼 — 암묵적 (?i) 는 없다 — 날 제목에 맞춰 본다.

warnings 자기보고가 둘이다: 컴파일되지 않는 pattern(건너뛰고 아무것도 맞히지 않는다), 그리고 수집된 커밋을 하나도 맞히지 못한 선언 표. 오늘 그 경고들이 이 키의 유일한 관측 가능한 효과다 — 보존된 제목과 그 라벨은 엔진 내부의 커밋별 레코드에 머물고 아직 어떤 출력 채널에도 실리지 않는다. 알려진 한계: git 출력은 from_utf8_lossy 로 디코딩되므로, 레거시 인코딩 제목(encoding 헤더가 없는 커밋 객체)은 UTF-8 이 아닌 바이트가 이미 U+FFFD 로 바뀐 상태로 매칭된다 — 그 원래 글자를 적은 패턴은 맞힐 수 없고, U+FFFD 가 관측되면 무매치 경고가 그렇게 말한다.

sizeCapnumber — optionalDefault 1,500,000 bytes (~1.5 MB). Files larger than this skip structural parsing and are listed under degraded.
vocabularyobject — 기본값 {}관습 어휘프로젝트가 고르는 이름들을, 추측 대신 선언한다.계약

선택적인 관습 어휘 키들의 객체다 — 권위 있는 목록은 zzop contract config-surface 이고, 엔진 타입은 zzop_engine::VocabularyConfig 다.

프레임워크가 고정한 이름(@GetMapping, router.post)은 아무도 바꿔 부를 수 없으므로 내장으로 남는다. 프로젝트가 고르는 이름 — 인증 가드를 뭐라 부르는지, 어떤 URL 조각이 자기 API 를 표시하는지, Java 소스가 어디 사는지, 어느 디렉터리가 빌드 산출물을 담는지 — 은 여기서 선언할 수 있다. 그것을 내장 리터럴로 들고 있다는 것은 엔진이 추측한다는 뜻이고, 다르게 이름 붙인 모든 프로젝트를 말없이 오분류한다는 뜻이기 때문이다.

키 단위 통째 교체: 당신이 이름 댄 키는 자기 내장 목록이나 패턴을 통째로 대체하지, 원소 단위로 병합하지 않는다(packs.extraDirsgit.commitTypePatterns 가 말하는 것과 같은 한 출처 규칙이다). 빼 둔 키는 판정되지 않고, 선언했지만 비어 있는 값(null, "", [])도 같은 뜻이다 — 내장 폴백은 없다(2026-07-27 제거. 내장 값들은 zzop init 이 당신 시작용 설정에 써 넣는 기본값으로만 살아남고, 그래서 실행에 닿는 이유는 당신의 설정이 그렇게 말하기 때문이다). 키를 빼 두면 룰은 이 아니라 발화한다: 선언되지 않은 면제는 어떤 면제도 주지 않고, 선언되지 않은 가드 어휘는 어떤 가드도 증명하지 않는다.

"아무것도 선언하지 않기"가 "전부를 가드로 취급하기"로 적힐 수 없게 해 둔 것도 같은 이유다 — 빈 가드 패턴은 모든 이름에 맞는 정규식이 된다(판정을 끄고 싶으면 대신 rules: { "<id>": "off" } 를 써라). 컴파일되지 않는 선언 패턴은 실행을 실패시키는 대신 아무것도 맞히지 않는다 — 내장으로 폴백하지 않는다. 작성자의 패턴을 우리 것으로 갈아 끼우는 일이야말로 이 어휘가 없애려는 그 추측이기 때문이다. skipDirs 는 워커 자신의 스킵 목록에 얹혀서, 목록 하나에 주인이 하나가 되게 한다.

이것은 git.commitTypePatterns/git.commitSubjectPatterns일부러 같은 지붕이 아니다: 그쪽은 git 수집기를 설정하고 커밋 메시지에 맞춰 보지만, 여기의 모든 키는 분석 대상 코드 자신이 적는 것을 이름 댄다. zzop init 은 모든 키를 자기 내장 값과 함께 써 주므로, 시작용 파일이 이 가정들을 숨기는 대신 문서로 남긴다.

profileRulesboolean — 기본값 false룰 타이밍 계측 — 출력의 ruleTimings 를 채운다.계약

ESLint 의 TIMING=1 / oxlint 룰 타이밍에 해당한다. true 는 실행되는 각 DSL 룰과 각 전체 그래프 네이티브 분석의 시간을 잰다. falseruleTimingsnull 로 두고 추가 비용이 0 이다. findings/ir 를 바꾸는 일이 없고, 캐시 키에도 일부러 끼지 않는다 — 같은 트리를 프로파일하며 돈 실행과 그러지 않은 실행은 같은 분석이고 서로의 캐시 항목을 재사용한다.

여기서 zzop.config.jsonc 키가 없는 유일한 요청 필드다: 설정은 프로젝트에 대해 참인 것을 선언하고 커밋되지만, 타이밍 보고는 한 기계에서의 한 번의 호출에 대한 질문이다. CLI 방언: analyze/analyze-envelope/cross--profile-rules. EnvelopeAnalyzeRequest 도 동일한 계약으로 같은 필드를 싣는다. 캐시에서 통째로 나온 파일은 자기 파일 단위 룰을 다시 돌리지 않으므로 타이밍에 기여하지 않는다는 점에 유의하라 — 그래서 따뜻한 실행은 전체 그래프 네이티브 분석만 보고하고, 나온 보고서는 이 사실을 밝히며 그것을 증명하는 캐시 개수를 함께 싣는다.

deployment topology
mountedAtstring — 선택트리 전체의 게이트웨이/인그레스 마운트 접두사. http provide 에만 붙는다.계약

dir: ""mounts 항목의 축약이고, 가장 마지막에 접혀 들어가므로 길이가 같은 명시적 mounts 항목이 동점을 이긴다. 코드에서 뽑아낸 접두사(예: NestJS 의 setGlobalPrefix) 위에 쌓인다.

mountsobject[]디렉터리별 마운트 — provide 마다 가장 긴 dir 매치가 이긴다.계약

모양: { dir: string, at: string }[]. 배포 토폴로지의 디렉터리별 마운트다: http provide 의 파일 경로가 dir 아래에 떨어지면 그 키 앞에 at 을 붙인다.

clientBasestring — 선택mountedAt부르는 쪽 거울.계약

이 트리 자신의 바깥으로 나가는 http 호출이 달고 가는 경로 접두사다. 베이스가 파일을 건너뛴 상수에서 대입되고(axios.defaults.baseURL = settings.baseApiUrl) 그래서 추측하지 않는 추출기가 아무것도 읽지 못할 때를 위한 것이다. 트리의 키가 잡힌 상대 kind=http consume 전부 앞에 붙고, 클라이언트별로 범위가 나뉘지 않는다 — 선언 하나가 트리 전체를 대변한다. 제공하는 쪽에서 mountedAt 이 그러는 것과 같다. 풀리지 않은 consume 과 절대 URL 키는 절대 건드리지 않는다.

mountedAt 과 달리 겹쌓임은 말없이가 아니라 경고와 함께 일어난다: 코드에서 읽어낸 리터럴 베이스가 이미 적용돼 있었다면 선언이 여전히 이기지만 warnings 가 두 접두사를 모두 이름 댄다. 부르는 쪽에서 두 번째 접두사는 대개 진짜 두 번째 층이 아니라 중복이기 때문이다. 아무것도 다시 쓰지 못한 선언도 경고를 낸다.

hostsstring[]이 트리가 소유한 호스트.계약

다른 트리에서 이 호스트들 중 하나를 겨눈 절대 URL consume 은, 크로스레이어 링크 시점에 외부로 나가는 트래픽으로 세는 대신 내부의 조인 가능한 키로 다시 키가 잡힌다.

routesobject[] — 기본값 []zzop 이 소스에서 풀지 못한 라우트를 하나 주입한다.계약

모양: { key: string, role?: "provide" | "consume" }[]. 흔한 경우를 위한 가벼운 라우트 사실 주입이다: 리터럴이 아닌 경로, 동적 메서드, 계산된 URL.

key"METHOD PATH" 인터페이스 키이고(예: "GET /api/users"), 추출기가 그쪽 방향에 쓰는 것과 같은 변환을 통해 정규화된다. role 은 그 라우트가 여기서 제공되는지(provide, 기본값) 여기서 호출되는지(consume)를 고른다. 배열 전체는 http provide/consume 으로 이뤄진 합성 어댑터 오버레이 하나로 펼쳐지고, 손으로 쓴 오버레이와 같은 크로스레이어 조인 경로를 지나며 합쳐진다. 망가진 key 는 경고와 함께 부드럽게 건너뛰지, 결코 단단한 에러가 아니다.

extension points
adapterOverlaysobject[]모드 B: 네이티브 분석 위에 병합되는 부분 엔벨로프.계약

부분 Normalized-AST 엔벨로프다(대개 파일 몇 개에 대한 io + 프래그먼트 채널뿐) — 프레임워크/SDK 어댑터가 파서를 다시 구현하지 않고도 엔진이 네이티브로 파싱하지 못하는 IoFact 를 보태는 방법이다. 오버레이마다 다시 검증하고, 유효하지 않으면 경고와 함께 부드럽게 건너뛴다. 전체 엔벨로프가 네이티브 분석을 대체하는 analyzeEnvelope 와 대비된다. NORMALIZED_AST.md 를 보라.

parsersobject — 기본값 {}파서 라우팅 — 글롭에 맞는 경로를 이름 댄 언어로 강제한다.계약

모양: { globOverrides?: { glob: string, language: string }[] }. 확장자 맵보다 앞서 순서대로 적용된다(첫 매치 승리). 확장자가 내용에 대해 거짓말을 하는 파일들을 위한 것이다: SQL 이 든 .txt, 실은 PHP 가 섞이지 않은 Java 인 벤더링된 .inc.

이 빌드에 없는 언어를 이름 댄 항목은 실행을 실패시키는 대신 경고와 함께 건너뛴다 — 모르는 언어는 설정을 쓰다 낸 실수이고, 그 실행의 다른 트리들은 여전히 정직하게 내놓을 답이 있다. vocabulary 와 일부러 지붕을 달리한다: 그쪽의 모든 키는 프로젝트가 자기 것을 부르는 이름을 대지만, 이쪽은 경로→파서 대응을 이름 댄다.

analyzeEnvelope 의 설정(EnvelopeAnalyzeRequest)은 더 작은 모양이다 — 위 필드들의 부분집합이고, 권위 있는 목록은 EnvelopeAnalyzeRequest 구조체 자신(crates/facade/src/request.rs)이다. AnalyzeRequest 와 공유하는 필드는 그쪽 짝과 똑같이 동작한다. mountedAt/mounts 는 위에서 설명한 배포 토폴로지 마운트 의미를 그대로 싣고, 엔벨로프의 http provide 에 균일하게, 같은 접힘 순서로 적용된다(mounts 항목 전부가 먼저, 트리 전체를 뜻하는 암묵적 dir: "" 항목인 mountedAt 이 마지막). 엔벨로프에는 엔진이 다시 읽을 수 있는 파일 시스템 위치가 없으므로 root, cacheDir, git, sizeCap 은 해당되지 않는다 — 소스 텍스트가 없으니 엔벨로프 모드에서 발화하는 DSL 룰은 symbol-scan/io-scan 뿐이다. 네이티브 콜그래프 BFS 룰(mutating-route-no-auth, unsafe-read-endpoint, non-idempotent-write)은 엔벨로프가 자기 calls 채널을 줄 때 추가로 돈다(파일별 호출 간선, files[].calls — NORMALIZED_AST.md 의 calls 절, 최소 version >= 0.29.0). http 라우트는 있는데 calls 가 없는 엔벨로프는 그 룰들을 침묵시키고, 침묵한 룰의 이름을 대며 warnings 에 그렇게 적는다. 배포된 io-scan 룰 둘(http/protected-path-no-auth-evidence, http/dev-path-no-guard-hint)은 여기서도 돌지만, 그 앵커 줄 채널은 돌지 않는다: 소스 텍스트가 없으면 읽을 줄이 없으므로, 파생된 zzop-<rule-id>-ok 억제 마커와 dev-path-no-guard-hintanchor_exclude_pattern 가드 힌트 예외가 둘 다 무력해진다. 둘 다 침묵이 아니라 발화 쪽으로 실패한다 — 맞는 라우트는 등록 줄이 마커나 가드 힌트 인자를 달고 있어도 보고된다 — 그러니 검토를 마친 라우트를 통과시키려면 룰이 읽는 속성을 주입하거나(protected-path-no-auth-evidence 에는 auth-guarded) 설정에서 그 룰을 끄면 된다. 모드 B 의 adapterOverlays 는 영향을 받지 않는다: 그쪽은 소스 텍스트를 읽을 수 있는, 네이티브로 파싱된 트리 위에 병합되므로 두 채널이 그대로 살아 있다.

레퍼런스 모드 B 어댑터가 완성된 예제로 레포에 들어 있다 — 채널 하나짜리 최소 어댑터(빠진 imports 채널을 90 줄 남짓으로 채운다. 가장 낮은 진입로다)와 속성 주입 어댑터(라우터 수준 가드를 파일 속성으로 주입한다), 둘 다 공유 adapter-kit 위에 얹혀 있다. 각각 프레임워크 하나씩을 보여 주는 것이 아니라 계약을 보여 준다: 프레임워크별 맛은 어댑터가 실제로 채워야 하는 채널에 자리를 내주고 제거됐다.

IO 와 의존 사실 너머로, 오버레이는 일반 엔티티 속성을 실을 수 있다: 라우트·심볼·파일·경로 범위에 붙는 열린 어휘의 { target, key, value } 주석이고, 룰이 키로 소비하되 엔진은 그 키가 무슨 뜻인지 끝까지 알지 못한다. 네이티브 패스가 혼자서는 볼 수 없는 횡단 사실 — 속도 제한, 검증 층, 또는 (네이티브 파서가 이제 직접 알아보는 흔한 Express 모양 바깥의 무엇이든) 미들웨어가 적용한 라우터 수준 인증 가드 — 을, 끝없이 자라는 네이티브 모델링 대신 주입으로 채우는 방법이 이것이다. 첫 소비자는 mutating-route-no-auth 다: 라우트의 ioKey(또는 미들웨어가 지키는 pathScope 접두사)에 auth-guarded 속성을 주입하면 룰이 그것을 통과시키고, 자기 네이티브 콜그래프 스캔과도, 알아본 Express 가드에 대해 네이티브 파서 자신이 내보내는 같은 속성과도 함께 어우러진다. 두 번째 소비자는 cross-layer/retrying-write-no-idempotency 다: 제공자 라우트에 붙은 idempotency-guarded 속성 — 핸들러가 Idempotency-Key 헤더를 읽을 때 TypeScript 파서가 네이티브로 세우거나, 다른 어떤 제공자 언어에서든 주입된다 — 이 가드가 목격되는 순간 그 발견에 거부권을 행사한다.

Cross-repo

여러 레포지토리를 함께 분석하기

analyze_trees_json(CLI: zzop cross, MCP 도구: cross_repo)은 트리마다 analyze 를 한 번씩 돌린 다음, 모든 트리가 선언한 IoFact(HTTP/DB/tRPC 의 provide 와 consume)를 전부에 걸쳐 잇는다. 프런트엔드 체크아웃과 백엔드 체크아웃이 디스크에서 아무것도 공유하지 않는 완전히 별개의 git 레포지토리여도 조인된다.

Cross-repo — direct zzop-facade (Rust)
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

결과 모양은 { trees: [{ root, sourceId, output }], crossLayer, crossLayerFindings, disclosure } 다. 트리마다의 output 은 자기 coverage 센서스를 싣고, disclosure(침묵 실패 계열 등기부)는 실행 전역이라 한 번만 나온다. crossLayer 는 날 조인 결과를 싣는다 — 맞은 edges, unconsumedProvides, unprovidedConsumes, unresolvedConsumes, 다중 트리 매치인 ambiguousConsumes, 그리고 절대 URL 인 externalConsumes. 어느 트리든 토폴로지 hosts 를 선언하면 crossLayerhostRekeyCounts 도 싣는다 — 선언된 호스트마다 [host, rekeyedConsumeCount] 짝 하나이고, 어떤 트리도 호스트를 선언하지 않으면 통째로 빠진다. crossLayerFindings 는 그 조인 위에서 도는 cross-layer/* 네이티브 룰의 출력이다(전체 id 목록은 룰 카탈로그를 보라). 크로스레이어 발견은 어느 한 트리의 것이 아니므로, 어느 한 트리에서 disabledRules 로 이 룰 id 중 하나를 끄면 그것이 모든 트리의 합친 배열에서 빠진다 — 트리별 관문이 아니라 합집합이다. coverage.joinContributionZerotrue 인 트리는 이 조인에 IO 를 하나도 보태지 않았다 — 그 트리를 참조하는 크로스레이어 발견은 그만큼 깎아 읽어라.

Output

AnalyzeOutputView

같은 입력이면 바이트까지 같은 출력이다 — 타임스탬프도, 불안정한 맵/배열 순서도 없다. 어떤 실행이 제공할 수 없는 능력은 스키마에서 빠지고 warnings 에 스스로 보고된다. 가짜 빈 값으로 채워 넣는 일은 결코 없다. 반대로 빈 배열은 언제나 "분석했고 아무것도 찾지 못했다"는 뜻이다.

이 색인은 와이어 모양이고, 이름이 가리키는 타입보다 한 겹 넓다: 응답 루트는 AnalyzeOutputView 를 평평하게 펴고 실행 전역인 disclosure 등기부를 형제로 덧붙인다. 두 쪽 다 docs/contracts/surface-parity.json 에 한 번씩 열거돼 있다 — 최상위 키는 전부 거기에 행이 있어야 하고, 없으면 빌드 테스트가 실패한다.

Key · typeWhat it carries
structure
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.
coverageobject구조 커버리지 센서스 — 언제나 있다.계약

이 트리가 어느 채널을 채웠는지를 어휘 없이 세어 놓은 것(files, parserDispatched, symbols, resolvedImportEdges, declaredImportsByExt — 그 간선 개수에 대한 확장자별 선언 지정자 분모이고 해석 이전에 센다. 확장자 키가 없다는 것은 0 이 아니라 잰 적이 없다는 뜻이다 — ioProvides, ioConsumesKeyed, ioConsumesUnresolved, degraded)에 더해, 능동적 실명 사실인 joinContributionZero 가 실린다. 각 칸의 정의는 아래 커버리지 센서스에 있다.

nodesobject[]Per-file churn/fan-in/fan-out/risk metrics — fully populated only when git is set.
foldersobjectnodes 와 의존 그래프를 폴더 단위로 굴려 올린 것.계약

git 이 관문이 아니다 — 빈 트리에서도 언제나 있다. 각 행이 세는 것은 fileCount 가 아니라 nodeCount 다: 노드는 의존 그래프 키이거나 git 이 건드린 경로일 때만 존재하므로, 이 행들을 더해도 최상위 fileCount(워크한 파일 수)가 나오지 않고, git 이 꺼져 있으면 lexical-only 파일은 어느 행에도 없다.

rule verdicts
findingsobject[](severity, file, line, ruleId) 오름차순 정렬, critical 이 먼저.계약

인라인 마커 주석으로 억제된 발견은 아예 나타나지 않는다. 모든 발견이 공유하는 모양과, 그 severity 가 무엇을 주장하고 무엇을 주장하지 않는지는 색인 아래에 정의돼 있다.

git-gated
scoresobject | null구조 세부 점수, 0–100. git 이 서 있지 않으면 null.계약

모든 점수는 자기가 채점한 모집단을 함께 싣는다featureSlicedDesign.layerClassifiedImports, cohesion.sliceCount, coupling.importerCount, godFile.total, busFactor.total, diamond.rootsExamined, mainSequence.classifiedFiles 와 그 형제들. 모집단 0 은 건강 증명서가 아니라 잰 적이 없다는 신호다: 모든 공식이 빈 모집단에서 100 을 돌려주므로, "전부 판정했고 전부 통과했다"와 "판정할 수 있는 것을 하나도 찾지 못했다"를 가르는 것은 분모뿐이다.

각각은 판정된 모집단의 분모이지 트리 총계가 아니다: 최상위 exclude 는 경로를 위반 목록에서도 분모에서도 빼고, fileSizeCompliance/godFile 은 소스 파일만 판정한다. mainSequence.classifiedFiles 는 현재 모든 빌드에서 0 이므로(파일을 추상/구상으로 분류하는 것이 아직 없다) 그 instability/fileCount 만 읽어라. 각 키가 무슨 뜻인지는 scoreMeanings 에 나란히 실려 온다.

scoreMeaningsobjectscores 의 키마다 한 문장씩, 키는 똑같이.계약

scores 가 있을 때 정확히 함께 있고, 아니면 null 이 아니라 아예 없다. 점수 키 하나가 맨 약어이고 그 풀이가 사는 자리가 이 범례다: sdp(Stable Dependencies Principle). 둘이 더 그랬는데 그쪽은 대신 와이어에서 이름을 바꿨다: sfcfileSizeCompliance, fsdfeatureSlicedDesign. 네 번째였던 lod(Law of Demeter)는 2026-08 에 자기 점수와 함께 제거됐다 — 무엇도 잰 적이 없었다. 각 문장은 낮은 수가 무슨 뜻인지를 말한다. 모든 점수는 0–100 이고 높을수록 건강하다.

healthobject | nullscores 를 굴려 올린 하나의 종합 지수 — 그리고 룰 발견은 싣지 않는다.계약

모양: {pain, axisPain[], measuredWeight, totalWeight, contributors[]}. pain 은 룰 발견을 싣지 않는다 — SQL 인젝션으로 가득한 트리와 하나도 없는 같은 트리의 점수가 똑같다 — 그리고 그것이 싣는 것의 대부분은 결함 주장이 아니라 구조에 대한 의견이다.

axisPain[] 이 와이어에서 그렇게 말한다: paindefect(import 순환, 유일한 항목), opinion(배럴 규율, FSD 계층, SDP/Main Sequence, Newman 모듈성, LOC 상한 — 일부러 반대로 하는 프로젝트는 틀린 것이 아니라 점수가 낮을 뿐이다), history(이름 변경 처닝, 버스 팩터)로 쪼개고, 각각을 pain 자신의 척도에 얹으며, 셋을 더하면 그 값이 된다.

pain 은 모집단이 있었던 지표들 위에서 다시 정규화하므로, 이 트리가 재지 못한 축은 조용히 100 점을 받아 레포를 더 건강해 보이게 만드는 대신 가중치에서 통째로 빠진다. pain 은 지표 표의 얼마만큼이 여기서 잴 수 있었는지를 말하는 measuredWeight / totalWeight 와 함께 읽어라. 하나도 잴 수 없었으면 pain 은 (0 이 아니라) null 이다. contributors[] 는 재지 못한 지표를 population: 0null 격차를 가진 행으로 남겨 두므로, 어두운 축은 사라지는 대신 그렇게 말해진다.

recommendationsobject[]ROI 로 순위 매긴 리팩터링 후보.계약

룰이 확인한 critical 발견을 자기 파일에 달고 있는 항목은 합성 그룹 urgent-bug-risk 로 옮겨 간다(복사가 아니라 이동이다). 그 roi 수치는 바뀌지 않는다.

criticalobject[]크기로 가중한 폭발 반경으로 순위 매긴 파일.계약

blastRadius * ln(loc + 2), 동점일 때 폭발 반경으로 가른다 — 폭발 반경이 같은 5 줄짜리 재수출 배럴과 400 줄짜리 코어는 같은 위험이 아니기 때문이다. blastRadius 자체는 이행적 의존자 수이고, 이 배열을 그것만으로 다시 정렬하면 다른 순서가 나온다.

seamsobject[]가장 먼저 떼어내기 좋은 폴더 후보(경계를 넘는 결합이 적은 곳).계약

files 가 세는 것은 그 폴더의 의존 그래프 키이지 워크한 파일이 아니고, 잡음 폴더(테스트, dist, docs, …)는 통째로 건너뛴다. temporalBoundary 는 두 번 걸러진다 — 파일 2–25 개를 건드린 커밋만, 그리고 파일마다 상위 10 개 동시 변경 짝만 — 그래서 그것은 총계가 아니라 측정된 것 중 가장 강한 폴더 간 동시 변경이다.

layerCoChurnobject[] | null레이어를 가로지르는 커밋 동시 처닝 짝.계약

git 이 서 있지 않으면 null. git 은 살아 있는데 동시 변경 문턱을 넘는 짝이 없으면 (null 이 아니라) []. coChanges부분집합 총계다: 파일 2 개 미만이나 25 개 초과를 건드린 커밋은 잡음으로 건너뛰므로, "거른 뒤 동시 변경 N 회"로 읽어야지 "이 레이어들이 N 번 함께 바뀌었다"로 읽으면 안 된다.

coChangeobject[] | null파일 짝 동시 변경 간선 — graph --domain cochange 가 그리는 바탕.계약

의존 그래프 설명이 기대는 것과 같은 증거다. null = git 이 비활성이거나 수집이 실패해 아무것도 재지 못했다. [] = 쟀고 함께 바뀐 것이 없었다 — 이 둘을 하나로 접으면 안 된다. layerCoChurn 과 같은 잡음 필터 둘을 그대로 달고 있으므로 총계가 아니라 표본이다. disabledRules 가 관문이 아니다: 이것은 룰의 판정이 아니라 측정된 증거다. 경로는 nodes·dep 와 마찬가지로 분석 대상 트리 기준이다: 이력은 레포지토리 단위로 모은 다음 각 트리 루트로 다시 얹히므로, 모노레포 안의 패키지는 자기 동시 변경만 보고 형제의 것은 결코 보지 않는다.

gitWindowobject | null해석된 git 이력 창을 되울린다 — 언제나 직렬화된다.계약

모양: { recentDays, since } | null. null 은 "git 이 돌지 않았다"는 신호다(scores 와 같은 관문). recentDays 는 해석된 수(호출자의 값, 아니면 기본값 30)이고, since 는 호출자가 준 날 필터 문자열이거나 이력 전체일 때 null 이다.

what this run itself did
packsLoadedobject[]팩 적재의 긍정 확인: 적재된 DSL 팩마다 한 항목. 끈 팩은 didNotRun 으로 갈린다.계약

모양: { id, rules, ruleIds, source, filesInScope, zeroAdmissionRules? }[], id 로 정렬되며, 팩마다의 룰 개수와 출처를 함께 싣는다(source: "dir" = packsDir 디렉터리에서 읽음, "inline" = packDefs). ruleIdsrules 라는 개수 뒤에 있는 목록 — 이 실행이 그 팩에서 보고할 수 있었던 룰 id 전부라, "이 실행에 X 라는 룰이 있나"를 팩 접두사로 추측하지 않고 답장에서 바로 답할 수 있다. 항상 실려 있다(빠져 있으면 "이 빌드는 말하기를 거부한다"로 읽히는데, 그건 "그런 룰 없다"와 반드시 구분돼야 하는 상태다).

filesInScope 는 그 팩의 룰들이 경로상 스캔할 자격이 있는 파일 수를 센다(file_pattern 후보 자격이고, 내용 검사 이전이다) — filesInScope > 0 인데 발견이 0 이면 "돌았고 아무것도 없었다"로, filesInScope: 0 이면 "범위에 든 것이 없다"로 읽어라. zeroAdmissionRules 는 같은 센서스를 룰 단위로 낸 것이다: 자기 경로 관문(file_pattern 과 그 룰의 file_exclude_pattern)이 여기서 파일을 하나도 들이지 않는 이 팩의 룰 id 들이고, 그들의 발견 0 은 "검사했고 깨끗하다"가 아니라 범위 문제다. 비어 있지 않을 때만 있고, 팩 수준의 0 이 이미 모든 룰을 덮는 filesInScope: 0 팩에서는 빠진다.

언제나 있다 — [] 가 "DSL 팩을 0 개 적재했다"는 정직한 상태다. 커스텀 팩이 실제로 적재됐는지를 발견 개수의 차이로 미루어 짐작하지 않고 확인해 준다.

packsLoadedMeaningobject위 배열의 범례. 팩이 하나도 안 실렸으면 아예 없다.계약

키마다 한 문장씩: row(한 항목 = 적재된 팩 하나 — 적재는 실행이 아니다), filesInScope(경로 후보 수이지 “맞았다”가 아니고, 이 키가 있다는 것 자체가 그 팩이 돌았다는 주장이다), zeroAdmissionRules(그 룰들에는 분석 파일이 한 개도 안 들어왔다 — “파일은 들어왔는데 안 터졌다”가 아니다. 함의가 정반대인 두 읽기 중 언제나 앞쪽이었고, 이제 그렇게 적는다). 네 번째 didNotRun실제로 꺼진 팩이 있을 때만 나온다 — 해당 없는 상태를 설명하는 범례는 소음이고, 소음이 독자에게 공시를 건너뛰게 만든다. 배열 안이 아니라 옆에 두는 이유는 packsLoaded 가 배열이라서다: 안에 넣으면 팩 수만큼 같은 문장이 반복된다(scoreMeanings 와 같은 모양).

nativeAnalysesobjectpacksLoaded 가 DSL 팩에 묻는 것을 내장 분석에 묻는다: 이 응답의 findings 에 키를 만들 수 없었던 것이 무엇인가.계약

모양: { registered, disabled, reportedInCrossLayerFindings }. registered 는 이 빌드가 담은 내장 분석의 수 — 아래 두 목록을 읽을 때의 분모다. 세는 것은 게이트 id 공간(rules/disabledRules 가 부르는 이름)이지 발견이 실을 수 있는 id 가 아니다: 일부는 발견을 안 내는 점수 계산을 게이트하고, 일부는 발견이 더 잘게 schema/<label> 로 나가는 우산 id 다.

두 목록을 가르는 이유는 처방이 정반대라서다. disabled = 당신의 config 가 껐다 — findings 에 없다는 것은 분석 안 됨이고, 판정을 받으려면 다시 켜라. reportedInCrossLayerFindings = 켜져 있는데도 여기 나올 수 없다. 이들은 크로스트리 조인을 판정하고 그 자신의 crossLayerFindings 채널로 보고하는데, 트리별 출력에는 그 채널이 없다 — 같은 config 로 크로스레이어 조인을 돌리면 보인다. 등록됐는데 어느 목록에도 없으면 그것은 돌았다 — 위 문단이 이름 댄 부류만 예외다. 점수 계산을 게이트하는 id 는 발견을 아예 안 내고, 우산 id 의 발견은 더 잘게 schema/<label> 이름으로 나온다. 어느 쪽도 registered 가 세는 자기 이름으로는 findings 에 키를 만들지 않으니, 거기가 비어 있는 것은 그 id 들의 평소 모습이지 판정이 아니다(우산이면 schema/<label> 쪽을 찾아라). 그 밖에 어느 목록에도 없는 것은 findings 에 없는 것이 실측된 0 이다.

두 목록은 비어 있어도 항상 실린다([] 포함) — zeroAdmissionRules 와 달리, 고의로. 항목이 있을 때만 나오는 목록은 깨끗한 실행에서 아무 말도 안 하고, 그 바이트는 보고하지 않는 빌드와 구분되지 않는다. 이 객체가 없애려는 혼동이 바로 그것이다.

nativeAnalysesMeaningobject위 객체의 범례 — 주제가 항상 있으므로 범례도 항상 있다.계약

키마다 한 문장씩: registered, disabled, reportedInCrossLayerFindings, 그리고 everythingElse — 자기 필드가 없는 잔여 부류인데, 없는 것이 판정인 경우가 정확히 그것이라서 그렇다. packsLoadedMeaning 과 달리 무조건 실린다: 팩을 하나도 안 싣는 실행은 정당하게 있지만, 내장 분석을 등록하지 않는 빌드는 없다.

ruleOverridesAppliedobject룰 손잡이 셋이 적용됐다는 긍정 확인.계약

모양: { disabled, severityRemapped, only }disabledRules/severityOverrides/packsOnly 가 적용됐다는 것과 영향을 받은 룰 id 들을 싣고, only 는 존중된 팩 허용 목록(packs.only)으로 그 바깥의 팩은 돌지 않았다는 뜻이다. 셋 중 아무것도 요청되지 않았으면 빠지거나 비어 있다 — 없는 키는 null 이 아니라 "오버라이드 없음"으로 읽어라.

warningsstring[]Non-fatal issues plus capability self-report notes — see Honest output.
configWarningsstring[]설정을 쓰다 난 문제. warnings 바깥에 따로 둔다. 언제나 있다.계약

분석 시점에 계산된다: 알려진 어떤 룰 id 와도 맞지 않는 disabledRules/severityOverrides 항목이 여기 보고된다(알려진 id 전체 집합을 가진 것은 분석 시점뿐이다). [] 는 두 손잡이 어느 쪽에도 아무것도 맞히지 못한 항목이 없었다는 뜻이다.

cache{ hits, misses } | nullSet only when cacheDir was given.
ruleTimingsobject[] | null프로파일링이 켜져 있을 때, 룰 id + 걸린 시간 + 발견 개수.계약

채우려면 요청에 profileRules: true 를 세워라(CLI 방언: zzop analyze --profile-rules / zzop analyze-envelope --profile-rules / zzop cross --profile-rules). 프로파일링이 꺼져 있으면 — 그게 기본값이다 — null 이다. zzop.config.jsonc 키가 없고(타이밍 보고는 프로젝트에 대한 사실이 아니라 한 기계에서의 한 번의 호출에 대한 질문이다), 이것을 켜는 MCP 도구 인자도 없으므로 오늘 analyze_repo 응답은 타이밍을 싣지 않는다.

EnvelopeAnalyzeRequest 도 같은 필드를 싣는다: 모드 A 의 팩 평가(파일별 symbol-scan, 트리 전체 io-scan)와 그 전체 그래프 분석이 네이티브 경로가 쓰는 것과 같은 타이밍 누산기로 흘러든다. 프로파일링은 findings/ir 를 바꾸지 않고 캐시 키에도 끼지 않는다. 캐시에서 통째로 나온 파일은 어떤 파일 단위 룰도 다시 돌리지 않아 타이밍에 기여하지 않으므로, 따뜻한 실행은 전체 그래프 네이티브 분석만 보고한다.

disclosureobject[]실행 전역의 침묵 실패 계열 등기부 — 정적이고 실행마다 동일하다.계약

어떤 계열의 실명을 zzop 이 탐지하고 어떤 것은 아직 탐지하지 못하는지에 대한 정직한 목록이다. 다중 트리 analyzeTrees 호출에서는 트리마다가 아니라 trees 옆에 한 번만 앉는다. 세 필드의 정의는 아래 등기부 범례에 있다.

이 색인은 파사드 와이어이고, 배열 전체를 싣는다 — 파생의 출처다. 정형된 제품 응답(zzop analyze/cross/endpoint 와 그 MCP 쌍둥이)은 2026-07-29 부터 대신 접힌 것을 싣는다: 개수와 포인터(zzop contract disclosure-classes / zzop://contract/disclosure-classes). 그 산문은 실행에 따라 변하지 않으므로 호출마다가 아니라 한 번만 배포된다.

JSON 트리는 위에서 아래까지 전부 camelCase 다 — 최상위 뷰뿐 아니라 중첩된 모든 타입이 자기 표기 규칙을 함께 지닌다. Finding.data 가 의도된 단 하나의 예외다: 균일한 표기 규칙이 없는, 룰 작성자가 쓴 불투명한 JSON 이다.

Finding — the common shape every finding shares
ruleId"{pack}/{rule}" for a DSL rule (e.g. "sql/nplus1"), or a plain id for a native analysis (e.g. "circular"). severity"critical" | "warning" | "info" — the 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.

severity 가 주장하는 것. severity 는 그 발견 하나에 대한 zzop 의 확신을 말하고, 그 확신은 이번 실행이 볼 수 있었던 것에 묶여 있다 — zzop 은 소스를 읽지, 돌아가는 시스템을 찔러 보지 않는다. 크로스레이어 룰 둘이 스스로 등급을 낮춤으로써 그 경계를 눈에 보이게 만든다: cross-layer/unconsumed-mutation-endpointcross-layer/unprovided-mutation-call 은, HTTP 호출이 대부분 풀리지 않은 채 돌아온 소스를(각각, 서버 프레임워크를 import 하면서도 라우트를 거의 내놓지 못한 소스를) 실행이 들고 있을 때 warning 이 아니라 info 로 보고하고 그 소스를 메시지에서 이름 댄다. 어느 쪽이든 발견은 발화하므로, 이것은 억제가 아니라 확신의 눈금 맞추기다. 그 역은 성립하지 않고, warning 갈래의 메시지가 스스로 그렇게 말한다: 실명 검사 하나하나는 좁은 술어 하나이므로, 룰이 warning 에 머물렀다는 것은 실명이 목격되지 않았다는 뜻이지 커버리지가 완전하다고 증명됐다는 뜻이 아니다. 이 추출이 모델링하지 않는 호출 모양이나 언어로 부르는 호출자, 또는 이번 실행 바깥의 레포지토리에 있는 호출자는, 룰에게 그런 것과 똑같이 이 검사에게도 보이지 않는다. severity 를 판결로 대하기 전에, 이번 실행이 자기 한계를 스스로 적은 warnings 와 아래 coverage 센서스를 읽어라.

coverage — structural census, always present
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.
disclosure — what zzop does and doesn't detect
id / groupA stable kebab-case class id and its taxonomy group: extraction-blind, analysis-dark, input-config, or trust-calibration. summaryThe concrete way an agent could silently misread the output for this class. status"asserted" (surfaced from a structural fact every run — cannot be silently missed), "partial" (detected in common cases, a member can still slip past), or "notYetDetected" (a real class zzop does not yet detect — declared so you never assume coverage it lacks).
Honest output

좁아진 범위는 말없이가 아니라 warnings 에 스스로 보고한다

에러는 호출이 실패했다는 뜻이다. 능력이 없는 것은 에러가 아니다 — 분석은 정상적으로 끝나고, 엔진이 무엇을 건너뛰었으며 왜 그랬는지를 정확히 말한다. 이 자기보고는 JS 래퍼가 아니라 엔진 자신 안에서 일어나므로, Rust 엔진을 직접 부르는 JS 아닌 소비자에게도 똑같이 적용된다.

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

DSL 룰팩을 하나도 찾지 못했을 때도 같은 방식이다: 엔진은 네이티브 분석만 돌았다는 사실과 그 개수를 이름 대지, 설명 없이 조용히 더 작아진 findings 배열을 돌려주지 않는다.

Panic safety

프로세스는 죽지 않는다

crates/facade/src/lib.rs(크레이트 zzop-facade)는 계약상 결코 패닉하지 않는다 — 실패할 수 있는 모든 경로(망가진 JSON, 빠진 root, 유효하지 않은 엔벨로프)는 대신 Result<String, String> 을 돌려준다. 엔진은 파일 하나의 파싱/룰 실패를 이미 내부에서 격리하고, 그것이 바깥 경계에 닿기 한참 전에 그렇게 한다. Rust 로 직접 부르는 쪽은 Ok(String) 아니면 Err(String) 만 보고, 프로세스 중단은 결코 보지 않는다. zzop-mcp 바이너리는 사이에 FFI 경계 없이 이 함수들을 부르므로, 따로 따져 볼 애드온 쪽 catch_unwind 층 같은 것은 없다 — 파사드 자신의 계약이 이야기의 전부다. version_string 에는 Result 자체가 없다 — 실패할 수 없다.