AI 코딩 에이전트는 저장소 전체를 컨텍스트에 못 담는다. 안 읽은 것은 추측한다. zzop 은 저장소를 읽어 한 장의 JSON 지도로 답한다 — 어떤 호출이 어떤 라우트에 닿고, 어떤 호출은 아무 데도 안 닿는지. 그리고 같은 입력이면 매번 같은 답을 준다.
코드는 쓰지 않는다. 에이전트가 딛고 일하는 이해를 정확하게 만든다.
독립적으로 만들어진 두 앱 — React 프론트엔드와 Express 백엔드. 코드도 타입도 공유하지 않는다. 백엔드 라우트 이름을 하나 정리한다.
- router.put('/user', auth.required, …) + router.put('/users/me', auth.required, …)
프론트엔드 빌드는 깨끗하다. 라우트가 문자열 리터럴이라 대조할 타입이 없다. 자체 목킹 테스트도 초록이다. 계약은 이미 깨졌는데, 한 저장소만 보는 린터·타입체커·테스트는 구조적으로 못 본다 — 증거가 두 저장소에 나뉘어 있고 컴파일러 경계를 넘지 않기 때문이다.
=== unprovided consumes === "PUT /api/user" @ fe-vite src/pages/Settings.jsx:19 ← 호출은 살아 있는데 받는 라우트가 없다 === unconsumed provides === "PUT /api/users/me" @ be-express auth.controller.ts:61 ← 라우트는 있는데 부르는 쪽이 없다
깨진 양쪽을 파일과 줄까지. 디스크에서 아무것도 공유하지 않는 두 저장소를 가로질러서.
프론트 호출과 백엔드 라우트를 잇는다. 아무도 안 부르는 엔드포인트, 메서드 불일치, 경로 드리프트 — 저장소를 넘어서도.
SQL 인젝션, 약한 해시, SSRF, 하드코딩된 시크릿. DSL 룰과 네이티브 분석이 언어를 가로질러 본다.
순환 의존, 죽은 코드, 리팩터 우선순위. 구조적 부채를 파일 단위로 셈한다.
네이티브로 읽는 언어는 여덟이다 — TypeScript · Python · Java · C# · Rust · Go · Prisma · SQL. 그 밖의 언어는 어댑터로 주입한다.
정적 분석의 진짜 위험은 틀린 답이 아니라 침묵이다. 아무것도 안 나온 것과 못 본 것을 구별할 수 없으면, 초록은 아무 뜻도 없다.
warnings 에 스스로 적힌다. 대충 채워 넣지 않는다.그래서 zzop 은 목록을 주는 도구가 아니라 방향을 주는 도구다. 결과는 리팩터 ROI 로 정렬되고, 두 저장소가 조용히 어긋난 것은 일급 발견이 된다.
X(구 트위터)와 xAI 가 공개한 저장소 12개 — For You 피드부터 Grok 의 빌드 시스템까지 — 를 한 번에 걸었다. 아래 수는 2026-08-15 에 zzop 0.31.0 으로 잰 것이고, 수마다 무엇을 센 것인지가 다르다: walked 는 트리에서 걸은 파일, 파서 수신은 그중 네이티브 파서 8종이 실제로 받은 것, 심볼은 그 파서들이 추출한 선언이다.
facts 한 번이 콜드 73초, 캐시 뒤 30초.두 비율을 섞지 않는 것이 이 표의 요점이다: 96% 는 한 트리(x-algorithm)의 수이고
37% 가 세트 전체다. 낮은 쪽을 숨기면 높은 쪽도 못 믿게 된다.
재는 법: 저장소들을 클론하고 zzop facts --config 한 번 — 트리마다 coverage 블록이
자기 파일·수신·심볼 수를 내고, 열두 블록을 더하면 위 합계가 나온다.
초 단위는 그 실행의 벽시계 시간이지 출력 필드가 아니다.
같은 저장소 12개에 룰을 걸었다(2026-08-15, zzop 0.32.0). 결함이 나온 곳은 4개뿐이고 8개는 0건이다 — 0건도 결과라, 숨기지 않고 공시한다. 합 171건: 심각도로 나누면 critical 5 · warning 122 · info 44. 여기서 핵심은 심각도가 취약점 판정이 아니라 렉시컬 판정이라는 것이다.
conn-string-credentials(scheme://user:pass@host 를
소스에 박은 URL)이고, 다섯 건 모두 #[test] 함수 안이다 —
자격증명을 지우는 코드의 테스트 입력(strip_url_credentials_removes_token)이다.
zzop 은 이걸 취약점이라 부르지 않는다. 렉시컬로 보이는 것을 보고하고, 잠재우는 config 키를 문장에 담고,
사람이 5초 만에 픽스처임을 읽게 둔다.command-and-interpolation 33 · reqwest-no-timeout 24 ·
hardcoded-secret 19 · high-entropy-secret 13 · fs-check-then-use 9.
크로스-레인은 별도로, 소비되지 않는 엔드포인트·제공자 없는 호출 같은 계약 틈 20건을 냈다 — 파일 하나를 보는 룰이
못 보는 층이다.이게 제품의 논지다: zzop 은 결함을 자랑하지 않는다 — 자기가 무엇을 봤고 무엇을 못 봤는지를
정직하게 말한다. critical 다섯이 전부 테스트 픽스처인 것이 약점이 아니라, 그걸 취약점이라 우기지 않은 것이
강점이다. 재현: 저장소 12개를 클론하고 zzop cross --config 한 번 — 트리마다 심각도·룰별 카운트가 나오고,
each finding 은 rule id·file:line·잠재우는 config 키를 함께 낸다. 이 코퍼스의 그래프 다섯 장 —
dep 전량 4,457파일을 한 캔버스에 그린 것 포함 — 은 영어판 쇼케이스 페이지에 모여 있다(상단 EN 토글).
zzop init
설정 파일을 쓴다 · 트리당 한 번
zzop analyze .
이 트리를 분석 · JSON 출력
zzop cross ./web ./api
두 저장소를 잇는다
Node.js 도, npm 도, 컴파일할 것도 없다. GitHub Releases 에서 바이너리를 받으면 끝이다.
zzopzzop-mcp설정은 선택이 아니라 필수다. zzop 이 당신의 프로젝트에 대해 추측했을 이름들이 거기 산다 — 선언하지 않은 키는 zzop 이 판정하지 않는다.
zzop 은 저장소를 훑어 파일마다 같은 모양의 사실을 뽑는다. 언어가 무엇이든 결과는 하나의 중립 표현이다. 그 표현들을 합쳐 그래프를 만들고, 판정은 전부 그 그래프 위에서 한다.
이 페이지는 개념만 다룬다. 필드 하나하나의 모양은 계약이 주인이다.
한 파일에 필요한 일은 한 번에 끝난다. 파싱하고, 중립 표현으로 접고, 룰을 돌리는 것이 한 패스다. 파일끼리는 병렬로 돈다. 파서가 만든 원본 AST 는 이 단계 밖으로 나가지 않는다.
Walk
파일을 모은다 · gitignore 를 따른다
Parse → IR → rules
파일마다 한 패스
Assemble
트리 전체를 한 그래프로
Envelope
한 장의 JSON
파일들이 처음 만나는 곳은 세 번째 단계다. 순환 의존, 죽은 코드, 구조 점수가 여기서 나온다. 여러 트리를 함께 분석하면 교차 계층 조인도 여기서 돈다.
언어마다 다른 문법은 두 번째 단계에서 사라진다. 어떤 파일이든 CommonIr 의 같은 네 칸으로 접힌다.
뒤에 오는 분석은 원본 문법이 아니라 이 네 칸만 본다.
depsymbolslocio걷는 순서부터 고정돼 있다. 그래서 같은 입력이면 출력이 바이트까지 같다.
언어 지원을 되냐 안 되냐로 말하지 않는다. 각 언어를 무엇이 읽는지를 등급으로 밝힌다 — 그리고 거기서 그 파서가 뒤에 설 수 있는 정밀도가 나온다.
Full AST — 각 언어의 정식 파서를 라이브러리로 링크했다(swc · ruff · syn 2). 심볼, 임포트, 라우트, 외부 호출, ORM 테이블까지.Full CST — tree-sitter 문법으로 읽는다. gin · Spring MVC · ASP.NET Core 라우트와 GORM · JPA · EF Core 테이블이 같은 채널로 들어온다.Lexical — 스키마만 읽는다. Prisma 모델과 CREATE TABLE 이 테이블을 제공하고, 코드 쪽 쿼리가 그것을 소비한다.External adapter — 정규화 AST 봉투로 같은 모양을 직접 넣는다. 한 트리를 통째로 대신하거나(Mode A), 네이티브 분석 위에 얹는다(Mode B).Full AST 와 Full CST 를 가르는 것은 누가 그 파일을 읽느냐 — 그리고 그래서 실패의 결이 다르다.
Full AST 는 그 언어 자신의 파서를 링크한 것이라 그 언어의 도구체인이 보는 트리를 그대로 보고,
파싱이 실패하면 파일 단위로 어휘 폴백으로 강등된다.
Full CST 는 tree-sitter 문법 — 버전이 핀된 독립 재구현이라 오류에 관대하다:
한 멤버가 깨져도 파일의 나머지는 계속 추출된다.
그 대가로 아주 새로운 문법은 일반 CST 로 파싱은 되지만 전용 추출이 아직 없을 수 있다(Java 21 의 sealed-permits 와 패턴 스위치가 그 자리다).
등급은 능력 순위가 아니다 — 어떤 채널이 실제로 나오는지는 등급이 아니라 언어마다 다르고,
그 목록의 정본은 레포의 docs/ARCHITECTURE.md 언어별 표다.
파서는 전부 Rust 안에 있다. Python 을 읽는 데 Python 런타임이 필요 없다.
네이티브 파서가 없는 파일도 버리지 않는다. 줄 수는 세고, 텍스트를 훑는 룰은 그대로 돈다. 빠지는 것은 심볼 · 임포트 · IO 이고, 빠졌다는 사실이 경고로 적힌다.
어떤 언어를 네이티브로 읽을지는 탐지가 되느냐가 아니라 얼마나 흔한 환경이냐로 정한다. 흔하지 않은 것은 어댑터가 넣는다 — 그래서 목록이 짧은 것이 한계가 아니다.
파서는 파일마다 두 가지를 적어 둔다 — 여기가 제공하는 것과 소비하는 것. 여러 트리를 함께 분석하면 이 둘을 정규화된 키로 맞춘다. AST 를 맞추는 것이 아니라, 키가 정확히 같은지만 본다.
consume @ fe-vite fetch("/users/:id") → http GET /users/:id
provide @ be-express router.get('/users/:id') → http GET /users/:id
^^^^^^^^^^^^^^
키가 정확히 같으면 엣지 하나
그래서 조잡한 외부 어댑터도 낄 수 있다. 키만 제대로 만들면 네이티브 파서와 동등한 참가자다. 두 저장소가 디스크에서 아무것도 공유하지 않아도 상관없다.
/login 처럼 아무 서비스나 가질 법한 경로로 맞은 엣지는 신뢰도 낮음으로 표시된다.게이트웨이가 붙이는 접두사, 그리고 어떤 트리가 어떤 호스트를 소유하는지는 어느 저장소의 코드에도 없다.
그건 설정(mounts · hosts)으로 선언한다. 선언이 아무 호출도 옮기지 못하면 그 사실도 경고로 나온다.
분석이 완전할 수 없다는 것은 전제다. 그래서 zzop 은 못 한 일을 결과 안에 적는다. 침묵과 무결과를 구별할 수 있어야 초록이 뜻을 가진다.
degradedwarningscoveragedisclosure번들 산출물처럼 한 줄이 지나치게 긴 파일은 또 다른 경우다. 텍스트 룰은 전부 건너뛰지만 구조 추출은 정상으로 돈다. 거대한 한 줄에는 룰이 기댈 문맥이 없기 때문이다.
여기까지가 개념이다. 실제 필드 이름과 모양은 계약, 룰 하나하나는 룰, 이 저장소를 실제로 분석한 결과는 그래프가 주인이다.
바이너리는 둘인데 엔진은 하나다. 그래서 고르는 기준은 능력이 아니라 누가 실행하느냐다. 먼저 이걸 정하고 설치를 시작한다.
zzop-mcp.mcpb 번들을 깔면 당신은 커맨드를 치지 않는다.zzop둘은 같은 핸들러로 디스패치한다 — 같은 경로면 같은 판정이다. 다만
manifest · diff · facts · coverage ·
graph · explain · init 은 CLI 에만 있다.
어느 쪽도 네트워크 요청을 하지 않는다.
Node.js 도 npm 도 없어도 된다. 네 갈래 중 하나를 고르면 되는데, 앞의 둘은 에이전트 레인이고 뒤의 둘은 CLI 레인이다.
/plugin marketplace add eezz4/zzop 다음 /plugin install zzop@zzop. 첫 세션에는 도구가 아직 안 보인다 — 한 번 재시작하면 나온다..mcpb 번들을 끌어다 놓는다. 플랫폼 바이너리가 그 안에 들어 있다.zzop-cli-<platform>(CLI) 또는 zzop-mcp-<platform>(MCP) 을 받아 PATH 에 둔다.npm i -g @zzop/cli — 같은 네이티브 바이너리를 플랫폼별로 받아 띄우는 얇은 런처다.zzop init
설정 파일을 쓴다 · 트리당 한 번
zzop analyze .
이 트리를 분석 · JSON 출력
zzop cross ./web ./api
두 트리를 잇는다 · 각 트리에 설정이 있어야 한다
MCP 레인이면 이 셋을 칠 일이 없다. 클라이언트가 zzop-mcp mcp 를 대신 띄우고, 도구는 에이전트가 부른다.
zzop 은 설정 파일이 없는 트리를 분석하지 않는다. 당신이 본 적 없는 가정으로 당신 코드를 판정하지 않기 위해서다.
vocabulary 블록이 그 가정의 자리다 — 무엇을 auth 가드라 부르는지, 어떤 URL 조각이 API 를 뜻하는지.
선언하지 않은 키는 zzop 이 아예 묻지 않는 질문이 된다.
{
"roots": ["."], // 분석할 트리
"packs": { "only": ["security", "sql"] }, // 주제 단위 스위치
"rules": { "sql/nplus1": "off" }, // 룰 하나씩
"exclude": ["legacy/"], // 경로로 버리기
"vocabulary": {
"skipDirs": ["node_modules", "dist", "build", ".git"]
// 나머지 이름들은 zzop init 이 채워 준다
}
}
직접 쓸 필요는 없다. zzop init 이 주석 달린 시작 파일을 써 주는데,
값이 전부 zzop 자신의 제안이라 그 파일은 기본값을 바꾸는 게 아니라 보여준다.
이미 있는 파일은 --force 없이 덮지 않는다.
키 전부를 여기 늘어놓지 않는다. zzop contract config-surface 가 기계로 검증된 전체 키 목록을,
zzop contract config-template 가 그 시작 파일을 그대로 찍는다 — 소스 체크아웃 없이 바이너리 하나로.
기본 값들도 마찬가지다 — 예컨대 무엇이 auth 가드로 인정되는지는 authGuardPattern 을 필두로
여러 어휘 키가 함께 정한다(메서드 이름이 안 맞아도 authGuardQualifierTokens 의
클래스 이름이 가드를 증명할 수 있다). 그 전문들은 이 페이지가 아니라 template 출력이 정본이다:
잘린 채 복사된 정규식은 조용히 좁게 탐지한다.
입출력 JSON 계약은 계약 페이지에 있다.
층은 셋이고 넓은 쪽부터 좁아진다 — 팩 통째(packs) ·
룰 하나(rules) · 그 한 줄(인라인 마커).
앞의 둘은 위 설정 블록 그대로다.
마커는 찾아볼 필요가 없다. 룰 id 에서 팩 접두사를 떼고 zzop-…-ok 로 감싼 것이다.
저장된 값이 아니라 유도된 값이라 룰 이름이 바뀌면 마커도 같이 바뀌고, 발견의 메시지가 매번 정확한 마커를 적어 준다.
그 줄이나 바로 윗줄에 쓴다.
sql/nplus1 → zzop-nplus1-ok const items = list.map(x => db.find(x.id)); // zzop-nplus1-ok: 아래에서 배치한다
네이티브 분석 — dead-candidates, cross-layer/unconsumed-endpoint 같은 것들 — 에는 마커가 없다.
설정으로만 끈다: "dead-candidates": "off". 심각도만 낮추거나 경로만 빼려면 객체로 쓴다 —
{ "severity": "warn", "exclude": ["legacy/"] }.
예외는 둘뿐이다. non-idempotent-write / unsafe-read-endpoint 는 손으로 쓴
// idempotent-ok: <이유> 를 읽고(콜론이 필수다), dead-candidates /
unimported-export 는 첫 8줄에 생성 파일 배너(@generated 등)가 있으면 그 파일을 건너뛴다.
zzop analyze <path>zzop cross <path>...zzop file <path> <tree>...zzop endpoint <pattern> <path>...zzop coverage <path>...zzop explain <rule-id>analyze 와 cross 는 --severity · --rule · --limit 로 목록을 좁힌다.
좁아지는 건 목록뿐이고 카운트는 언제나 전부를 덮으며, 잘렸다는 사실은 출력에 적힌다.
종료 코드는 0 성공 · 1 실행 실패 · 2 인자 모양 오류 —
심각도로 갈리는 종료 코드는 없으니 CI 게이트는 JSON 을 직접 읽어 만든다.
전체 목록은 zzop help 가 답한다 — 페이지가 아니라 바이너리가 정본이다.
그림은 zzop graph(그래프), 룰 목록은 룰 페이지에 있다.
zzop 은 실행마다 같은 키 집합을 가진 JSON 객체 하나로 답한다. 값이 없어도 남는 자리가 있고, 그 능력이 안 돌면 키 자체가 사라지는 자리가 있다 — 그 둘이 서로 다른 말이기 때문이다.
이 페이지는 그 자리들이 무엇을 약속하는지만 본다. 오퍼레이션마다의 필드 표는 여기 없다 — 마지막 밴드에 왜 없는지가 있다.
분석은 거부된다 — 조용히 작아진 답이 대신 오지 않는다. 그러면서도
zzop init 이 쓰는 스타터 설정은 아무것도 끄지 않는다: packs.disabled 는 빈 배열,
rules 는 빈 객체, exclude 는 빈 배열이다.
못 본 것 · 못 한 것 · 잘린 것이 전부 이름 있는 자리에 적힌다. 그리고 세는 수는 필터를 걸어도 줄지 않는다 — 줄어드는 건 보여주는 목록뿐이다.
파일 하나가 파싱에 실패해도 실행은 계속되고, 그 파일은 degraded 에 이름으로 남는다.
MCP 툴 실패도 프로토콜 오류가 아니라 isError 를 단 보통의 결과라 서버는 살아 있다.
CLI 는 그 마지막 항목이 다르다. 실패하면 stderr 로 zzop: <메시지> 한 줄이 나가고 종료 코드는
1 이다 — JSON 이 아니다. stdout 은 성공했을 때만 쓰이니, 파이프라인은 stdout 만 파싱하면 된다.
zzop analyze 가 찍는 것과 MCP analyze_repo 가 돌려주는 것은
같은 셰이퍼를 지난 같은 객체다. 호스트가 자기 모양으로 다시 빚지 않는다.
{
"path": "/repo/api",
"config": "/repo/api/zzop.config.jsonc",
"fileCount": 1284,
"degraded": [], // 구조 추출이 빠진 파일
"packsLoaded": [
{ "id": "security", "rules": 49, "source": "inline", "filesInScope": 912 }
],
"findings": {
"total": 137, // 필터와 무관한 전체
"bySeverity": { "critical": 3, "warning": 61, "info": 73 },
"byRule": { "security/hardcoded-secret": 2 },
"shown": [ ], // 필터·상한이 걸린 목록
"truncated": { "shown": 50, "totalMatching": 137, "hint": "..." }
},
"warnings": [ ],
"coverage": {
"files": 1284, "parserDispatched": 1102, "symbols": 8431,
"resolvedImportEdges": 3126,
"ioProvides": 84, "ioConsumesKeyed": 57, "ioConsumesUnresolved": 12,
"degraded": 0, "joinContributionZero": false
},
"configWarnings": [ ],
"disclosure": { "classes": 18, "asserted": 6, "partial": 10, "notYetDetected": 2 },
"gitWindow": { "recentDays": 30, "since": null }
}
위 열한 자리는 언제나 있다. 조건이 맞을 때만 붙는 자리는 따로다 —
ruleOverridesApplied · architecture · ruleTimings · degradedTruncated
값이 없을 때 무엇을 하느냐가 이 봉투의 진짜 계약이다. 셋이 서로 다른 뜻을 갖는다.
warnings · configWarnings · packsLoaded 는 비어도 항상 나온다. 빈 배열이 곧 “할 말이 없었다”는 답이다.ruleOverridesApplied · architecture 는 그 능력이 안 돌면 키 자체가 없다. null 을 찍어 “쟀는데 아무것도 없었다”처럼 보이게 하지 않는다.nullgitWindow: null 은 git 신호가 안 돌았다는 뜻이고, architecture.pain: null 은 잴 모집단이 없었다는 뜻이다 — 0 이 아니다.정적 분석에서 제일 위험한 건 틀린 답이 아니라 조용히 작아진 답이다. 그래서 스코프를 줄이는 것마다 그 사실이 나가는 자리가 따로 있다.
findings.totalbySeverity · byRule 도 같다. --severity · --rule · --limit 이 줄이는 것은 shown 뿐이라, 인용한 수가 필터 때문에 작아질 일이 없다.findings.truncated{shown, totalMatching, hint} 셋을 같이 준다. hint 에는 이 목록에 실제로 먹는 방법만 적힌다 — 고정 상한인 목록에는 “limit 을 올려라”라고 쓰지 않는다.packsLoaded[].filesInScope0 이면 그 팩은 로드됐지만 이번 트리에 대상 파일이 하나도 없었다. 발견 0 이 “깨끗하다”가 아니라 “범위 밖”이라는 뜻이다.ruleOverridesApplied{disabled, severityRemapped, only}. 오타 난 룰 id 는 여기 안 들어오고 configWarnings 로 간다.coverage.joinContributionZerowarningsdisclosure 는 이번 실행이 아니라 zzop 자신에 대한 자리다 — 아직 못 잡는 침묵의 종류를 센다.
지금 18종이고 그중 12종은 부분 탐지이거나 아예 탐지 못 한다. 매 실행 같은 글이라
전문은 응답에서 빼고 zzop contract disclosure-classes 로 옮겼다 — 숫자는 남는다.
zzop cross 는 트리를 둘 이상 받는다 — 하나로는 부를 수 없다.
돌아오는 것은 단일 트리 응답을 여러 개 담은 배열이 아니라 다른 모양의 객체다:
트리별 요약 sources[] 와, 조인 자체의 결과가 나란히 있다.
{
"config": null, // 루트를 직접 넘긴 모드
"sources": [
{ "sourceId": "web", "path": "/repo/web", "fileCount": 812, "findingCount": 44, "coverage": { } },
{ "sourceId": "api", "path": "/repo/api", "fileCount": 1284, "findingCount": 137, "coverage": { } }
],
"buckets": {
"edges": 61, // 이어진 쌍
"unconsumedProvides": 9, // 부르는 데가 없는 라우트
"unprovidedConsumes": 23, // 받는 데가 없는 호출
"unresolvedConsumes": 7, "externalConsumes": 4, "ambiguousConsumes": 0
},
"bucketMeaning": "...", // 위 여섯 수의 산술을 응답이 직접 적는다
"distinctBucketKeys": { "unprovidedConsumes": ["PUT /api/user"] },
"distinctBucketKeyFirstSites": { },
"edges": [ ],
"crossLayerFindings": { "total": 5 },
"configWarnings": [ ], "warnings": [ ], "disclosure": { }
}
buckets 는 행을 세고 distinctBucketKeys 는 그 행들이 접히는
키를 나열한다. 그래서 둘의 길이가 다른 게 정상이고 — 같은 라우트를 세 군데서 부르면 행 셋, 키 하나다 —
그 관계를 응답 자신이 bucketMeaning 에 적어 둔다. 읽는 쪽이 문서를 찾아가 확인할 일이 없다.
그리고 조인은 넘겨주지 않은 것도 말한다. 분석한 루트들이 한 부모 디렉터리 밑에 모여 있으면,
그 부모의 분석되지 않은 형제 디렉터리 이름이 configWarnings 에 나열된다 —
조인이 “당신이 마침 넘긴 트리들”로 조용히 좁아지지 않는다.
오퍼레이션 하나하나의 요청 필드·출력 필드 전체 표는 원문 레퍼런스에 있다. 여기 옮겨 적지 않는 이유는 분량이 아니다 — 요청 필드 표는 디시리얼라이저가 실제로 받는 필드 목록과, 출력 필드 표는 아래 레지스트리와 Rust 메타테스트가 대조한다. 사본을 여기 만들면 그 사본만 테스트 밖에 있게 된다.
응답이 무엇을 떨어뜨렸는지도 등록돼 있다. 엔진이 계산한 최상위 필드는 전부 레지스트리에 한 행씩 갖고, 전달 표면이 그것을 그대로 싣는지 · 조건부로 싣는지 · 아예 안 싣는지를 적는다. 지금 28행이고 그중 9행이 “안 실음”이다 — 그리고 안 싣는 행은 그 값을 어디서 얻는지를 같이 적어야 통과한다.
오퍼레이션 열두 개 각각의 필드 표는 원문 레퍼런스에 있다. 이 페이지가 링크만 거는 이유가 그것이다 — 표에는 주인이 하나여야 하고, 그 주인은 테스트가 읽는 쪽이다.
기본으로 로드되는 것은 팩 11벌 · DSL 룰 116개와 네이티브 분석 60개(단일 트리 33 + 저장소 간 27)다. 원문 사이트는 이걸 176행짜리 표로 싣는다 — 찾을 것이 있을 때는 정확하지만, 무엇을 잡는 도구인지는 알려주지 않는다. 이 페이지는 표가 아니라 지도다.
룰 id·팩 이름·억제 마커는 당신이 설정에 그대로 적는 문자열이다. 이 페이지의 것은 전부 원문 철자 그대로다.
security · 49hardcoded-secret · sql-string-concat · weak-password-hash ·
jwt-none-algorithm · cors-credentials-wildcard.db · 21update-delete-no-where · multi-write-no-tx · connection-no-release.reliability · 16fetch-no-timeout · async-route-no-catch · sync-fs-in-handler ·
interval-no-clear.sql · 8nplus1 · delete-no-where · destructive-migration.browser · 8unsafe-html-sink · postmessage-wildcard · vue-v-html.redis · 6flushall-in-code · keys-command-in-code · lock-no-ttl.egress · 3http-url-literal · ws-no-auth · get-and-body.http · 2protected-path-no-auth-evidence · dev-path-no-guard-hint.go · perf · reactgoroutine-in-loop ·
api-in-loop · setstate-after-async-unguarded. 팩은 분류이지 분량이 아니다.어떤 룰이 어느 언어에 닿는지는 팩이 아니라 룰마다 정해진다 — 룰 자신의
file_pattern 하나가 답이고 팩 수준에는 그런 설정이 없다. 그래서 한 팩이 어떤 언어에는 빽빽하고
다른 언어에는 텅 빌 수 있다. "이 언어에 룰이 몇 개냐"에는 답이 없다 — 이 경로에 몇 개가 도는지를 물어야 한다.
파일 한 장 안에서 끝난다. 룰마다 matcher 모양을 정확히 하나 고르고, 두 번째 파일의 내용은 볼 수 없다. 그 대신 JSON 이라 당신이 직접 쓸 수 있다.
트리 하나 전체를 본다 — 의존 그래프, 죽은 코드, 스키마, 라우트. 이 중 다섯(seams ·
criticality · scores · health · recommendations)은 발견이
아니라 점수 계산이라 심각도 자체가 없다.
저장소 여럿을 조인해야만 존재하는 발견이다. zzop cross 로만 돈다 —
cross-layer/method-mismatch · cross-layer/body-field-drift ·
cross-layer/sensitive-response-field.
경계는 취향이 아니라 표현 가능성이다. 네 가지는 줄 단위 정규식으로 정직하게 쓸 수 없다 — 선언과 사용을 잇는 추적("선언됐는데 아무도 안 읽는다"), 파일을 가로지르는 조인(상수나 핸들러가 다른 파일에 있다), 콜그래프 탐색("핸들러 X가, 혹은 X가 부르는 무언가가 Y를 한다"), 그리고 텍스트 동시출현이 아닌 진짜 AST·JSX 모양. 그래서 그것들만 네이티브다.
끄는 법도 여기서 갈린다. DSL 룰은 인라인 주석 하나로 한 건만 조용히 시킬 수 있고, 네이티브 분석은
설정으로만 끈다 — 예외 둘: non-idempotent-write · unsafe-read-endpoint
는 손으로 쓴 // idempotent-ok: <reason> 를 존중하고(끝의 콜론 필수),
dead-candidates · unimported-export 는 생성 파일 배너가 붙은 파일을 건너뛴다.
룰은 컴파일된 코드가 아니라 <id>.json 한 장이다. 트리 안 zzop/rules/ 에 넣으면
설정 키 하나 없이 다음 실행부터 로드된다. 1급/3급 구분 같은 것은 애초에 없다.
{
"id": "house-rules",
"schema_version": 1,
"rules": [
{
"id": "hardcoded-debug-token",
"severity": "warning",
"message": "X-Debug-Token header set to a string literal — read it from env/config instead.",
"matcher": {
"type": "line-scan",
"file_pattern": "(?i)\\.(ts|tsx)$",
"require_file": "X-Debug-Token",
"skip_comment_lines": true,
"line_pattern": "[\"']X-Debug-Token[\"']\\s*:\\s*[\"'][^\"'`]+[\"']",
"snippet_max": 160
}
}
]
}
두 id 가 곧 계약이다 → 발견은 house-rules/hardcoded-debug-token,
마커는 // zzop-hardcoded-debug-token-ok
고를 수 있는 matcher 는 여섯이다 — line-scan(한 줄의 모양) · method-scan(한 함수 안의
동시출현) · symbol-scan(선언된 심볼) · io-scan(라우트·테이블 같은 IO 사실) ·
call-scan(파서가 목격한 호출) · literal-scan(문자열의 이름·해시·엔트로피 — 값 자체는 절대 아니다).
zzop/rules/packs.extraDirszzop.config.jsonc 에서 쓰는 철자. 디렉터리 하나 또는 배열.packsDir디렉터리는 각각 따로 로드된 뒤 팩 id 로 병합된다. 같은 id 가 두 곳에 있으면 뒤쪽 디렉터리의 팩이
앞쪽을 통째로 대체한다 — 룰 단위 병합이 아니다. 엔진을 포크하지 않고 번들 팩을 통째로 덮는 길이 이것이다.
번들에 안 들어간 완성 팩 넷은 examples/packs/ 에 있고, 각각 계약 문서로도 제공된다.
db/float-money-compare info src/billing/invoice.ts:212 A money-named identifier (`price`/`amount`/`balance`/`fee`/`cost`) compared with `==`/`===`/`!=`/`!==` against a float literal (e.g. `price === 19.99`) — floating-point rounding error makes strict equality on monetary values unreliable. Represent money as integer minor units (cents) or a decimal library. 여기까지가 룰 저자가 쓴 문장 Suppress a vetted case with `// zzop-float-money-compare-ok`. Disable via config `rules: { "db/float-money-compare": "off" }` (embedders: `disabledRules`) 이 두 줄은 엔진이 붙인다
원인과 고치는 법, 이 한 건만 조용히 시키는 주석, 그리고 이 룰을 실행 단위로 끄는 설정 키 — 셋이 한 덩어리로 온다. "이건 왜 뜨지"와 "어떻게 끄지"를 문서에서 찾을 일이 없다.
zzop-<rule id>-ok 로 계산된다 — 어디에도 저장되지 않으니 낡을 수가 없다.
팩 접두사는 붙지 않는다: security/hardcoded-secret →
// zzop-hardcoded-secret-ok.symbol-scan 발견은 주석을 걸 소스 줄 개념이 없어서 인라인 마커를 갖지 않는다.
그런 것도 rules: { "<id>": "off" } 로는 언제나 끌 수 있다.message 에 마커나 끄는 법을 적으면 두 번 찍힌다. 게다가
matcher 종류를 바꾸는 순간, 손으로 쓴 문장은 엔진이 더는 인정하지 않는 주석 기호를 가리키게 된다.176행 전부 — 룰마다의 심각도·matcher·정확히 무엇을 잡는지, 그리고 네이티브 60개의 표 — 는
원문 카탈로그에 있다.
그 페이지는 레포의 docs/rules/catalog.md 에서 생성되고, 거기 적힌 모든 id 가 엔진이 실제로 로드하는 것과
같은지는 Rust 메타테스트가 기계로 확인한다 — 카탈로그는 코드와 조용히 어긋날 수 없다.
zzop graph 는 분석 결과를 표준 그래프 포맷으로 직렬화해 stdout 으로 흘려보내고 끝난다.
픽셀을 그리는 코드는 이 저장소에 한 줄도 없다. 뷰어는 당신이 고른다.
그 표를 읽은 그림 — 이 저장소 자신의 import 그래프 — 은 이 페이지 맨 아래에 있다. 좌표와 통계는 이 페이지가 소유하지 않는다. 빌드할 때마다 원문 그래프 페이지에서 그대로 가져온다.
뷰어가 반드시 필요로 하는 것은 링크 표이고 노드 표는 스타일용 선택지다. 표는 둘인데 stdout 은 하나이고 zzop 은 파일을 쓰지 않는다 — 그래서 포맷 이름이 곧 선택기다.
zzop graph --domain dep --format cosmograph-links > links.ndjson
엣지 표 · 필수
zzop graph --domain dep --format cosmograph-nodes > nodes.ndjson
점 표 · 스타일 축
links.ndjson — 한 줄이 import 하나 {"endpointsInCycle":false,"source":"src/app.ts","target":"src/db.ts"} nodes.ndjson — 한 줄이 파일 하나 {"degree":7,"fanIn":6,"fanOut":1,"folder":"src","id":"src/db.ts","inCycle":false,"label":"db.ts","loc":214,"path":"src/db.ts","source":"web"} git 수집이 돌았으면 changeCount · churn · authorCount · lastModified 가 더 붙는다
위 샘플은 어떤 한 번의 실행이 낸 줄이라, 어느 열이 늘 있고 어느 열이 그 실행이 무엇을 쟀느냐에 달렸는지는 거기서 읽히지 않는다. 그 갈래가 곧 스키마다.
source/target/endpointsInCycleid/source/path/label/folder/fanIn/fanOut/degree/inCyclelocauthorCount/changeCount/churn/lastModifiedsource · target 은 뷰어의 매핑 단계가 이미 짐작하는 철자라 흔한 경우엔 매핑할 것이 없다.
화살표 방향은 import 하는 쪽 → import 되는 쪽이다.
churn: 0 은 “한 번도 안 바뀌었다”와 “아무도 안 봤다”를 같은 바이트로 쓰기 때문이다.endpointsInCycle> 로 그대로 받을 수 있다.렌더러를 우리가 가지면 좌표계와 라이브러리와 뷰어를 같이 갖게 된다 — 그리고 그건 분석 엔진이 할 일이 아니다. 표는 그 반대다. 같은 두 파일이 Cosmograph 든 Gephi 든 아무 force-graph 라이브러리든 변환 없이 들어간다.
dep 를 기본 40 노드에서 자른다. 수천 개를 다 그리면 검은 사각형이 되고, 그건 아무것도 안 그린 것보다 나쁘다(정보처럼 보이니까). 이 레인은 줌이 그 일을 하므로 전부 낸다.--top 이나 --fold 를 이 포맷에 주면 오류로 멈춘다. 받아 놓고 무시하는 플래그를 두지 않는다. --format cosmograph-* 는 --domain dep 에서만 쓴다.--scope <prefix> 뿐이다. 한쪽 끝이 밖으로 밀려난 엣지는 같이 버린다 — 표에 없는 노드를 가리키는 행을 남기지 않으려고.--domain joinedges 목록이 다른 버킷을 그림 밖으로 밀어내지 못하게 하려는 것이다.--domain dep--domain risk--domain posture--domain cochangedep 와 같은 노드 위의 다른 관계라 겹치지 않고 따로 선다 — import 는 소스에서 읽고, 동시 변경은 이력의 표본이다. 기본 캡 30 으로 dep 보다 낮다: 여기 엣지는 독자가 비교해야 하는 가중치를 달고 있어서, 40개면 이미 그림이 아니라 목록으로 읽힌다.--format 은 mermaid(기본) · cosmograph-nodes · cosmograph-links 셋이다.
mermaid 는 다섯 도메인 전부를 플로차트 텍스트로 내고, cosmograph 표는 dep 하나에만 있다.
이 레인은 CLI 전용이다 — MCP 도구 쌍이 없다.
--top 의 기본값이 도메인마다 다른 것은 밀도가 다르기 때문이다 — 조인은 관계가 수십인데 import 그래프는 수천이다.
다섯 값은 전부 zzop graph --help 가 직접 찍는다(이 페이지가 아니라 그쪽이 정본이다).
그리고 잘린 만큼은 문서 안에 공시된다 — 센서스 한 줄과 눈에 보이는 노트 노드로.
아래는 원문 페이지의 뷰어가 그 표를 어떻게 배치했는지다 — 그 규칙은 zzop 의 출력에 들어 있지 않다. 같은 두 표로 다른 배치를 만들 수 있다는 것, 그게 이 페이지 전체의 논지다.
bin 루트, 스크립트, <script> 가 불러오는 파일)은 정의상 아무도 import 하지 않는다.그 그림과 거기 적힌 수치는 스냅숏이다 — 페이지를 다시 생성한 시점의 트리를 서술하고, CI 에 물려 있는 것은 없다. 위의 두 커맨드를 지금 체크아웃에 다시 돌리는 것이 재계수다. 데이터를 여기 옮겨 담지 않은 이유도 그것이다.
끌면 움직이고 굴리면 확대된다. 점 위에 올리면 그 파일의 경로와 fanIn·fanOut·차수가 왼쪽 패널에 나온다.
색과 크기의 축은 그 패널 위쪽에서 바꾼다 — 바뀌는 것은 보이는 방식뿐이고, 표는 그대로다.
위 줄은 그 커맨드가 stderr 로 찍는 통계다 — stdout 은 파싱 가능한 표로 남는다. 노드와 엣지가 몇 개인지는 이 산문 어디에도 박혀 있지 않다 — 뷰어가 실린 표를 그 자리에서 센다. 숫자의 주인이 하나여야 그래프를 다시 재도 이 페이지가 낡지 않는다. 배치는 미리 계산되어 데이터에 실려 있어, 이 그림은 누가 언제 열어도 같다.