
Scrutineer Servicemap
- 1 installs
- 1 repo stars
- Updated July 27, 2026
- cyrus-is/scrutineer
Visualize and debug microservice dependencies and network topology.
About
Scrutineer-servicemap provides service mapping and debugging for microservice architectures. Developers use it to understand service dependencies, identify bottlenecks, and troubleshoot distributed system issues.
- Microservice dependency visualization
- Service communication and topology analysis
Scrutineer Servicemap by the numbers
- 1 all-time installs (skills.sh)
- Ranked #489 of 596 Debugging skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cyrus-is/scrutineer --skill scrutineer-servicemapAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 1 |
| Last updated | July 27, 2026 |
| Repository | cyrus-is/scrutineer ↗ |
What it does
Visualize and debug microservice dependencies and network topology.
Files
/scrutineer-servicemap
Generate a comprehensive, machine-readable servicemap.json from a repository by performing a deep, phased agentic crawl. The map captures services, apps, libraries, infrastructure, data stores, external dependencies, inter-service communication, security posture, observability surface, and ownership — with confidence scores on every discovery.
Invocation
/scrutineer-servicemap --path <output-path>--path(optional): Where to write the output file. Defaults to./servicemap.jsonin the current
working directory. Use a specific path to control where the map lands (e.g., /scrutineer-servicemap --path tools/servicemap.json).
Before You Start
1. Locate the schema: Look for references/schema.md relative to the directory where servicemap.json will be written (i.e., sibling references/ folder next to the output path). For example:
- Output path
tools/servicemap.json→ readreferences/schema.md - Output path
./servicemap.json→ read./references/schema.md
Read the schema to internalize the full JSON structure. Every field matters — the schema is the contract that downstream skills and apps depend on. 2. Check if a servicemap.json already exists at the target path. If it does, this is an incremental update — read the Incremental Update Strategy section below.
Crawl Philosophy
This is a DEEP crawl. The goal is to trace every meaningful connection, not to produce a quick summary. The reason depth matters is that this map will be consumed by other Claude instances and automated tooling that need to reason about blast radius, security boundaries, deployment dependencies, and operational risk. A shallow map that misses a database connection or an unauthenticated endpoint is worse than no map, because it creates false confidence.
That said, depth must be managed against context constraints. The phased approach below is how you do that.
Phase 1: Discovery — Identify All Components
Goal: Build a manifest of every discrete component in the repo without deep-diving any of them yet.
Scan the repository structure to identify:
- Services: Anything that runs independently. Heuristics: has its own Dockerfile, has a
package.json
/ go.mod / Cargo.toml / pyproject.toml / build.gradle / pom.xml / *.csproj / *.sln with an entrypoint, has its own Terraform module that provisions compute (ECS, Lambda, EC2, Cloud Run, App Service, etc.), or has its own Kubernetes Deployment manifest.
- Apps: Frontend applications, mobile apps, CLI tools. Distinguished from services by being
user-facing rather than API-facing.
- Libraries: Shared internal packages without their own entrypoint. Look for workspace members,
internal package references, monorepo package directories.
- Infrastructure: Terraform/OpenTofu/Pulumi/CloudFormation directories. Each module or stack is a
component.
- CI/CD Pipelines: GitHub Actions workflows, GitLab CI, CircleCI, Jenkins, etc. Scan ALL
workflow files, not just the obvious ones — repos often have 10-20+ workflows for linting, security scanning, ephemeral environments, dependency updates, etc.
- Data stores: Database migration directories, schema files, seed data.
- Utility containers: Dockerfiles that don't fit the service/app pattern — migration runners,
reverse proxies, setup/initialization containers, database seed tools, static file servers. Check util/, tools/, scripts/, deploy/ directories and CI build matrices for Docker images that are built and shipped but aren't traditional services. These are components too.
For each component discovered, record:
name,type,path(relative to repo root),language,framework,platformconfidence: How certain you are this is correctly classified (0.0–1.0)discovery_method: What heuristic identified it (e.g., "Dockerfile present", "Kubernetes Deployment manifest")
Write the Phase 1 manifest to memory before proceeding. This is your roadmap for subsequent phases.
Phase 1b: Fallback Discovery — Unknown or Sparse Stacks
If Phase 1 discovers fewer components than the repository structure suggests (e.g., a large repo with many directories but only 1-2 matched heuristics), or the repo uses a stack not covered by the heuristics above, run a fallback discovery pass:
1. Scan for generic service signals:
- Entrypoint files:
main.*,app.*,server.*,Program.cs,Startup.cs,index.* - Build system files not already matched:
Makefile,CMakeLists.txt,*.csproj,*.sln,
*.fsproj, mix.exs, build.zig, dune-project, *.cabal, stack.yaml
- Port exposure:
EXPOSEin any Dockerfile,ports:in docker-compose,listencalls in source - HTTP handler patterns: any file registering routes, handlers, or controllers
- Database connection patterns: connection strings, ORM config, migration directories
2. Reason from directory structure: If a subdirectory has its own build file, its own entrypoint, and its own source tree — it's likely a component even if you don't recognize the stack. Classify it with lower confidence (0.4–0.6) and note the discovery method as "inferred from project structure."
3. Flag unknown stacks: For any component discovered via fallback, add a note in the component's discovery_method field:
"discovery_method": "Fallback: .csproj with Program.cs entrypoint — stack not in primary heuristics"This helps downstream consumers know where the map is less certain.
4. Self-heal suggestion: At the end of the crawl, if fallback discovery found components, include a message to the user:
⚠️ ENRICHMENT AVAILABLE: N components were discovered via fallback heuristics rather than
primary detection. Consider adding explicit heuristics for [stack] to improve future crawl
accuracy. Affected components: [list]Phase 2: Deep Dive — Analyze Each Component
Goal: For each component from Phase 1, extract detailed metadata.
Work through components one at a time (or in small batches if they're lightweight). For each:
Services and Apps
- Endpoints: Trace route definitions by **reading the actual route attributes and registrations
in the source code**. Do NOT guess or infer route prefixes — read them. Common patterns:
- Express/Koa/Hono:
app.get('/path', ...),router.post('/path', ...) - FastAPI/Flask/Django:
@app.get("/path"),urlpatterns,@api_view - Spring:
@RequestMapping("/path"),@GetMapping,@PostMapping - Go chi/mux/gin:
r.Get("/path", ...),r.Route("/path", ...) - Rails:
routes.rb—resources,get,post - ASP.NET:
[Route("path")],[ApiController],[HttpGet],[HttpPost]— note that
the [Route] attribute on the controller IS the prefix, do not add /api/v1/ or other prefixes unless they are explicitly in the attribute. Also check MapGet/MapPost minimal APIs and UseEndpoints / MapControllers in Program.cs/Startup.cs.
- Phoenix:
scope "/api",get "/path",resources "/path" - For each endpoint: method, path (exactly as defined in code — include route constraints like
{id:int} and path parameters like {organizationId}), whether it's public or private (behind auth middleware), authentication mechanism (JWT, API key, OAuth, session, mTLS, none), authorization requirements (roles, scopes, policies).
- Dependencies — other services: Trace HTTP client calls, gRPC stubs, SDK imports that point to
other internal services. Look for base URLs, service names in env vars, Kubernetes service DNS names, HttpClient / IHttpClientFactory registrations (.NET), Refit interface definitions (.NET).
- Dependencies — data stores: Database connection strings/configs, ORM model definitions (Entity
Framework DbContext, Dapper, ActiveRecord, SQLAlchemy, GORM, Prisma, etc.), cache client instantiation, object storage client usage, message queue producer/consumer setup. For .NET, check appsettings.json / appsettings.*.json for ConnectionStrings sections.
- Use the actual cloud service name for `engine` types. Azure Blob Storage is
azure-blob-storage, not s3. Azure Service Bus is azure-service-bus, not sqs. Cosmos DB is cosmosdb, not mongodb. Never map one cloud provider's service to another's equivalent. Secret stores (vault-kv, aws-secrets-manager, azure-key-vault, google-secret-manager) and container registries (aws-ecr, azure-container-registry, google-artifact-registry, docker-hub, ghcr) are first-class datastore engines — do not collapse them under s3 or redis. See the schema for the full list of valid engine values.
- Dependencies — external APIs: Third-party SDK imports and API calls (Stripe, Twilio, SendGrid,
Auth0, Datadog, PagerDuty, etc.). For .NET, check NuGet package references in *.csproj files.
- Environment and config: How config is loaded — env vars, config files (
appsettings.json,
application.yml, .env, config.toml, etc.), Vault references, AWS Secrets Manager, Kubernetes ConfigMaps/Secrets. Catalog every env var referenced.
- Observability: Health check endpoints, logging framework, tracing instrumentation (OpenTelemetry,
Datadog APM, Jaeger, Zipkin), metrics endpoints, alerting rules.
- Container config: Dockerfile analysis — base image, exposed ports, build stages, runtime user.
Docker Compose service definitions if present.
Infrastructure
- Terraform / IaC: For each module, catalog:
- Resources provisioned (with types:
aws_ecs_service,aws_rds_instance, etc.) - Variables and their defaults
- Outputs (these are the interface other modules/services consume)
- Remote state references (how modules connect to each other)
- Provider and backend configuration
- Workspaces or environment parameterization
- Kubernetes manifests: Deployments, Services, Ingresses, NetworkPolicies, HPA, PDB, ServiceAccounts,
RBAC roles. Map port relationships between Deployment containers and Service/Ingress definitions.
CI/CD Pipelines
- Trigger conditions (push, PR, schedule, manual)
- Steps and their purposes
- Which services/apps they build, test, and deploy
- Environment targets (dev, staging, prod)
- Secret references
- Deployment strategy (rolling, blue-green, canary)
Libraries
- What exports they provide
- Which services/apps import them
- Version pinning strategy
Phase 3: Trace Connections
Goal: Build the relationship graph between all components.
This phase is where the map becomes genuinely valuable. Using the data from Phase 2:
1. Service-to-service: Match outbound HTTP/gRPC/queue calls in one service to inbound endpoint definitions in another. Record the protocol, whether it's sync or async, and the specific endpoints involved. 2. Service-to-data-store: Match database connection configs to Terraform/IaC resources that provision those stores. Flag shared databases (multiple services connecting to the same store). 3. Service-to-external: Catalog all third-party API dependencies. 4. Infrastructure-to-service: Map Terraform resources to the services they support (e.g., aws_ecs_service → the service that runs on it). 5. Pipeline-to-service: Map CI/CD workflows to the services they deploy. 6. Library-to-consumer: Map internal library usage across all services. Every library relationship must be represented in two places: (a) the library component's consumers array AND (b) a corresponding entry in connections[] with "type": "library". Do not list a consumer without creating the connection, or vice versa. Verify consistency.
For each connection:
source,target,type(http, grpc, graphql, queue, database, library, infrastructure)async: booleanprotocol_details: method, path, queue name, topic, etc.auth_required: what auth the connection usesconfidence: how certain you are this connection exists
Verify that every service listed in a datastore's `consumers` array actually connects to that datastore. Read the service's startup/config code to confirm — do not assume a service uses a database just because a shared library provides database access. Only list services that directly establish a connection.
Phase 4: Assemble and Validate
Goal: Produce the final servicemap.json.
1. Assemble all phase outputs into the schema defined in references/schema.md. 2. Validate completeness: Every service discovered in Phase 1 should have a deep-dive entry from Phase 2 and connections from Phase 3. If any are missing, go back and fill them. 3. Identify stubs: Any service, data store, or dependency referenced but NOT found in this repo (and not already present from another repo in an existing map) gets a stub entry with "stub": true and a "stub_reason" explaining what's missing. 4. Set `source_repo` on every component discovered in this crawl to the current repo name. 5. Set timestamps: last_crawled on every component and generated_at on the root. 6. Merge with existing map if one exists at the target path (see Multi-Repo and Incremental Update Strategy below). 7. Write to the --path location.
Multi-Repo and Incremental Update Strategy
The servicemap supports multiple repositories in a single map. Each component tracks which repo it came from via source_repo. When an existing servicemap.json is found at the target path, the crawler adds to it — it never deletes components from other repos.
Core Principle: Never Delete Unless Explicitly Asked
Running the crawler against repo B does not touch repo A's components. Components are never removed from the map automatically. They can only be:
- Updated (re-crawled from their source repo)
- Marked stale (not found in their own source repo during a re-crawl)
- Explicitly deleted by the user (e.g.,
/scrutineer-servicemap --remove-repo my-old-repo)
When an existing servicemap.json is found:
1. Read and parse the existing map. 2. Check schema_version compatibility. If the major version differs, warn the user and offer to regenerate from scratch. If repository (singular, 1.0 format) exists, migrate to repositories[] array format. 3. Identify the current repo (from git remote or working directory name). 4. Crawl the current repo as normal (all four phases). 5. Merge strategy:
- Components from the current repo: crawl wins for discovered data. New components are
added. Components previously from this repo but not found in this crawl get marked "stale": true with "stale_since": "<timestamp>". They are NOT removed.
- Components from other repos: left completely untouched. Not updated, not marked stale,
not removed. They belong to their source repo's crawl cycle.
- Manual overrides: any field with
"manual_override": trueis preserved, not overwritten. - Stub resolution: if a component discovered in this crawl matches a stub (by ID or name),
the stub is replaced with the full entry and source_repo is set to this repo.
- Connections: connections where the
sourcebelongs to the current repo are rebuilt from
the crawl. Connections where the source belongs to another repo are preserved. Cross-repo connections (source in one repo, target in another) are rebuilt if the source repo is being crawled. 6. Update last_crawled on all components from this repo. 7. Update the repo's entry in repositories[] (add it if this is the first crawl for this repo). 8. Recompute metadata.repo_staleness for all repos.
Staleness reporting
The metadata.repo_staleness array shows how fresh each repo's data is, sorted stalest-first:
"repo_staleness": [
{"repo": "notification-service", "last_crawled": "2026-03-01T10:00:00Z", "components": 5, "age_days": 13},
{"repo": "my-platform", "last_crawled": "2026-03-14T12:00:00Z", "components": 18, "age_days": 0}
]After every crawl, report the staleness table to the user so they can see which repos need a refresh. If any repo is more than 30 days stale, flag it:
⚠️ STALE REPOS: notification-service was last crawled 45 days ago (5 components).
Consider re-running /scrutineer-servicemap from that repo to refresh.First-time crawl (no existing map)
If no servicemap.json exists at the target path, this is a fresh map. Create the repositories[] array with a single entry for the current repo and proceed normally.
Confidence Scoring Guide
Every discovery should include a confidence score. Use this calibration:
- 1.0: Definitive evidence. A Dockerfile with an ENTRYPOINT, a Kubernetes Deployment manifest, an
explicit route definition.
- 0.8–0.9: Strong evidence. A database connection string in config pointing to a named resource,
an import of another internal package.
- 0.5–0.7: Inferential. An env var that looks like a service URL but isn't confirmed, a comment
referencing another service, a TODO mentioning a dependency.
- 0.3–0.4: Speculative. Naming conventions suggest a relationship, a file structure implies a
service but no entrypoint found.
- < 0.3: Weak signal. Include only if it fills a gap that would otherwise be a stub.
Output
The output is a single JSON file conforming to the schema in references/schema.md. The JSON should be pretty-printed with 2-space indentation for version control friendliness, even though it's machine-targeted.
After writing the file, report to the user:
- Total components discovered in this crawl (by type)
- Total connections traced
- Number of stubs remaining (unresolved cross-repo references)
- Stubs resolved in this crawl (if merging into existing map)
- Any components with confidence below 0.5 (these need human review)
- If incremental: what changed since last crawl of this repo
- Repo staleness table: for every repo in the map, show name, last crawled date, component
count, and age in days. Flag any repo > 30 days stale.
Context Management
Large repos will challenge context limits. Strategies:
- In Phase 1, use directory listings and file existence checks rather than reading file contents.
You're just building the manifest.
- In Phase 2, process one component at a time. Read only the files relevant to that component,
extract what you need, then move on. Don't try to hold the entire repo in context.
- In Phase 3, work from the structured data you already extracted in Phase 2, not from raw files.
You should rarely need to re-read source files in this phase.
- If the repo is exceptionally large (50+ services), consider batching Phase 2 into groups of
5–10 services, writing intermediate results to a temp file between batches.
generate-servicemap
A Claude Code skill that performs a deep, phased crawl of a repository and produces a machine-readable servicemap.json — mapping every service, app, library, data store, external dependency, infrastructure resource, CI/CD pipeline, and inter-service connection.
Most users don't copy this manually. The top-level installer places the skill for you —
scrutineer install <repo>, or the/scrutineer-setupskill which also runs the crawl; see the
main README. The setup below is for installing the skill by hand. The default
output is./servicemap.jsonwith./references/schema.mdalongside; thetools/paths below are
just an example of pointing --path elsewhere.Setup
1. Copy SKILL.md into your repo's .claude/commands/ directory (rename to scrutineer-servicemap.md) 2. Place references/schema.md as a sibling references/ folder next to where you want the output:
your-repo/
├── .claude/commands/
│ └── scrutineer-servicemap.md ← copy of SKILL.md
├── tools/
│ ├── references/
│ │ └── schema.md ← schema reference
│ └── servicemap.json ← generated output (after running)The schema must be at references/schema.md relative to the output directory. The skill looks for it there automatically.
Usage
From Claude Code in your repo:
# Default: writes to ./servicemap.json, looks for ./references/schema.md
/scrutineer-servicemap
# Specify output path: writes to tools/servicemap.json, looks for tools/references/schema.md
/scrutineer-servicemap --path tools/servicemap.jsonWhat it produces
A servicemap.json (schema v1.0.0) containing:
- Components: services, apps, libraries, infrastructure modules, CI/CD pipelines, data stores, external dependencies — each with language, framework, endpoints, auth/authz detail, env vars, container config, observability surface, and confidence scores
- Connections: every inter-component relationship (HTTP, gRPC, database, queue, pub/sub, etc.) with protocol details, auth requirements, and confidence
- Metadata: summary statistics, shared datastores, unauthenticated endpoints, unmonitored services, low-confidence detections
Downstream consumers
The service map is consumed by:
- generate-security-review — uses component paths and languages for more precise platform detection
- generate-peer-review — uses connections, auth, observability, and shared datastore data to inform review lenses
Validation
After generating, validate the output:
python3 validate_servicemap.py path/to/servicemap.jsonIncremental updates
Re-running the skill with an existing servicemap.json at the target path triggers an incremental update:
- New components are added
- Existing components are re-crawled and updated
- Components not found in the latest crawl are marked
stale(not deleted) - Fields with
manual_override: trueare preserved
#!/usr/bin/env python3
"""
Validate a servicemap.json file against the schema requirements.
Reports errors, warnings, and summary statistics.
Usage:
python validate_servicemap.py <path-to-servicemap.json>
"""
import json
import sys
from datetime import datetime
from collections import Counter
VALID_COMPONENT_TYPES = {"service", "app", "library", "infrastructure", "pipeline", "datastore", "external"}
VALID_CONNECTION_TYPES = {"http", "grpc", "graphql", "websocket", "queue", "pubsub", "database", "cache", "storage", "library", "infrastructure", "event"}
VALID_AUTH_MECHANISMS = {"jwt", "api_key", "oauth2", "session", "mtls", "basic", "none", "unknown"}
VALID_AUTHZ_TYPES = {"rbac", "abac", "acl", "scope", "none", "unknown"}
ID_PREFIXES = {"service": "svc-", "app": "app-", "library": "lib-", "infrastructure": "infra-", "pipeline": "pipeline-", "datastore": "datastore-", "external": "ext-"}
errors = []
warnings = []
def error(msg):
errors.append(f"ERROR: {msg}")
def warn(msg):
warnings.append(f"WARN: {msg}")
def check_iso_timestamp(value, field_name):
if not isinstance(value, str):
error(f"{field_name} must be a string, got {type(value).__name__}")
return False
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
return True
except ValueError:
error(f"{field_name} is not a valid ISO 8601 timestamp: {value}")
return False
def check_confidence(value, context):
if not isinstance(value, (int, float)):
error(f"confidence in {context} must be a number, got {type(value).__name__}")
return
if value < 0.0 or value > 1.0:
error(f"confidence in {context} must be 0.0–1.0, got {value}")
def validate_component(comp, idx, all_ids, is_v1_1_plus=False, known_repos=None):
ctx = f"components[{idx}]"
# Required fields
required = ["id", "name", "type", "confidence", "discovery_method", "last_crawled", "stub"]
if is_v1_1_plus:
# source_repo is required in 1.1+ (may be None for unresolved stubs)
required.append("source_repo")
for field in required:
if field not in comp:
error(f"{ctx} missing required field: {field}")
comp_id = comp.get("id", f"<unknown-{idx}>")
ctx = f"component '{comp_id}'"
# ID uniqueness
if comp_id in all_ids:
error(f"{ctx}: duplicate component ID")
all_ids.add(comp_id)
# Type validation
comp_type = comp.get("type")
if comp_type and comp_type not in VALID_COMPONENT_TYPES:
error(f"{ctx}: invalid type '{comp_type}'. Valid: {VALID_COMPONENT_TYPES}")
# ID prefix convention
if comp_type and comp_type in ID_PREFIXES:
expected_prefix = ID_PREFIXES[comp_type]
if not comp_id.startswith(expected_prefix):
warn(f"{ctx}: ID should start with '{expected_prefix}' for type '{comp_type}'")
# Confidence
if "confidence" in comp:
check_confidence(comp["confidence"], ctx)
# Timestamp
if "last_crawled" in comp:
check_iso_timestamp(comp["last_crawled"], f"{ctx}.last_crawled")
# Stub validation
if comp.get("stub"):
if "stub_reason" not in comp:
error(f"{ctx}: stub=true requires stub_reason")
if comp.get("confidence", 1) > 0.3:
warn(f"{ctx}: stub with confidence > 0.3 is unusual")
# Stale validation
if comp.get("stale") and "stale_since" not in comp:
error(f"{ctx}: stale=true requires stale_since")
# Path required for non-stubs (except external)
if not comp.get("stub") and comp_type != "external" and not comp.get("path"):
warn(f"{ctx}: non-stub, non-external component should have a path")
# source_repo must reference a declared repository (v1.1+); null is acceptable for unresolved stubs.
if is_v1_1_plus and known_repos is not None and "source_repo" in comp:
sr = comp["source_repo"]
if sr is not None and sr not in known_repos:
error(f"{ctx}: source_repo '{sr}' does not match any entry in repositories[]")
if sr is None and not comp.get("stub"):
warn(f"{ctx}: source_repo is null but component is not a stub")
# Endpoint validation for services/apps
if comp_type in ("service", "app"):
for i, ep in enumerate(comp.get("endpoints", [])):
ep_ctx = f"{ctx}.endpoints[{i}]"
for field in ["method", "path", "public", "confidence"]:
if field not in ep:
error(f"{ep_ctx} missing required field: {field}")
if "confidence" in ep:
check_confidence(ep["confidence"], ep_ctx)
if "authentication" in ep:
mech = ep["authentication"].get("mechanism")
if mech and mech not in VALID_AUTH_MECHANISMS:
warn(f"{ep_ctx}: unknown auth mechanism '{mech}'")
if "authorization" in ep:
authz_type = ep["authorization"].get("type")
if authz_type and authz_type not in VALID_AUTHZ_TYPES:
warn(f"{ep_ctx}: unknown authz type '{authz_type}'")
# Datastore-specific
if comp_type == "datastore":
if "engine" not in comp:
error(f"{ctx}: datastore missing required field 'engine'")
if "shared" not in comp:
error(f"{ctx}: datastore missing required field 'shared'")
if "consumers" not in comp:
error(f"{ctx}: datastore missing required field 'consumers'")
# External-specific
if comp_type == "external":
for field in ["vendor", "category", "consumers"]:
if field not in comp:
error(f"{ctx}: external component missing required field '{field}'")
def validate_connection(conn, idx, component_ids):
ctx = f"connections[{idx}]"
for field in ["id", "source", "target", "type", "async", "confidence", "discovery_method"]:
if field not in conn:
error(f"{ctx} missing required field: {field}")
conn_id = conn.get("id", f"<unknown-conn-{idx}>")
ctx = f"connection '{conn_id}'"
# Type validation
conn_type = conn.get("type")
if conn_type and conn_type not in VALID_CONNECTION_TYPES:
error(f"{ctx}: invalid type '{conn_type}'. Valid: {VALID_CONNECTION_TYPES}")
# Reference validation
source = conn.get("source")
target = conn.get("target")
if source and source not in component_ids:
error(f"{ctx}: source '{source}' does not match any component ID")
if target and target not in component_ids:
error(f"{ctx}: target '{target}' does not match any component ID")
if "confidence" in conn:
check_confidence(conn["confidence"], ctx)
def validate_metadata(meta, components, connections, is_v1_1_plus=False):
ctx = "metadata"
required = ["total_components", "total_connections", "total_stubs", "component_counts",
"low_confidence_components", "shared_datastores",
"unauthenticated_public_endpoints", "unmonitored_services"]
if is_v1_1_plus:
required.append("repo_staleness")
for field in required:
if field not in meta:
error(f"{ctx} missing required field: {field}")
# repo_staleness shape check (v1.1+)
if is_v1_1_plus and isinstance(meta.get("repo_staleness"), list):
for i, entry in enumerate(meta["repo_staleness"]):
ectx = f"{ctx}.repo_staleness[{i}]"
for f in ["repo", "last_crawled", "components", "age_days"]:
if f not in entry:
error(f"{ectx} missing required field: {f}")
if "last_crawled" in entry:
check_iso_timestamp(entry["last_crawled"], f"{ectx}.last_crawled")
# Cross-check counts
if meta.get("total_components") != len(components):
warn(f"{ctx}: total_components ({meta.get('total_components')}) != actual component count ({len(components)})")
if meta.get("total_connections") != len(connections):
warn(f"{ctx}: total_connections ({meta.get('total_connections')}) != actual connection count ({len(connections)})")
actual_stubs = sum(1 for c in components if c.get("stub"))
if meta.get("total_stubs") != actual_stubs:
warn(f"{ctx}: total_stubs ({meta.get('total_stubs')}) != actual stub count ({actual_stubs})")
# Check component_counts
if "component_counts" in meta:
actual_counts = Counter(c.get("type") for c in components)
for comp_type, count in meta["component_counts"].items():
if actual_counts.get(comp_type, 0) != count:
warn(f"{ctx}: component_counts.{comp_type} ({count}) != actual ({actual_counts.get(comp_type, 0)})")
def _parse_schema_version(sv):
"""Return (major, minor, patch) tuple, or None if unparseable."""
if not sv:
return None
parts = sv.split(".")
if len(parts) != 3 or not all(p.isdigit() for p in parts):
return None
return tuple(int(p) for p in parts)
def validate(data):
# Schema version drives which root shape we expect.
# 1.0.x: singular `repository` object.
# 1.1.0+: plural `repositories[]` array (with per-component `source_repo`).
sv = data.get("schema_version", "")
parsed = _parse_schema_version(sv)
if sv and not parsed:
error(f"schema_version must be semver (e.g., '1.0.0'), got '{sv}'")
is_v1_1_plus = parsed is not None and (parsed[0], parsed[1]) >= (1, 1)
repo_field = "repositories" if is_v1_1_plus else "repository"
# Root fields — repo field name depends on schema version.
for field in ["schema_version", "generated_at", repo_field, "components", "connections", "metadata"]:
if field not in data:
error(f"Missing required root field: {field}")
# Track known repo names for source_repo cross-referencing on components.
known_repos = None
if is_v1_1_plus and isinstance(data.get("repositories"), list):
known_repos = {r["name"] for r in data["repositories"] if isinstance(r, dict) and "name" in r}
if "generated_at" in data:
check_iso_timestamp(data["generated_at"], "generated_at")
# Repository / Repositories
if is_v1_1_plus:
repos = data.get("repositories", [])
if not isinstance(repos, list):
error("repositories must be an array")
elif not repos:
error("repositories must contain at least one entry")
else:
seen_names = set()
for i, repo in enumerate(repos):
rctx = f"repositories[{i}]"
if "name" not in repo:
error(f"{rctx}.name is required")
else:
if repo["name"] in seen_names:
error(f"{rctx}.name '{repo['name']}' is duplicated")
seen_names.add(repo["name"])
if "monorepo" not in repo:
error(f"{rctx}.monorepo is required")
if "last_crawled" not in repo:
error(f"{rctx}.last_crawled is required")
else:
check_iso_timestamp(repo["last_crawled"], f"{rctx}.last_crawled")
else:
# 1.0.x singular form (or version missing — fall back to 1.0 shape).
repo = data.get("repository", {})
if "name" not in repo:
error("repository.name is required")
if "monorepo" not in repo:
error("repository.monorepo is required")
# Components
component_ids = set()
for i, comp in enumerate(data.get("components", [])):
validate_component(comp, i, component_ids, is_v1_1_plus=is_v1_1_plus, known_repos=known_repos)
# Connections
for i, conn in enumerate(data.get("connections", [])):
validate_connection(conn, i, component_ids)
# Metadata
if "metadata" in data:
validate_metadata(data["metadata"], data.get("components", []), data.get("connections", []),
is_v1_1_plus=is_v1_1_plus)
def main():
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} <path-to-servicemap.json>")
sys.exit(1)
path = sys.argv[1]
try:
with open(path, "r") as f:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"FATAL: Invalid JSON: {e}")
sys.exit(2)
except FileNotFoundError:
print(f"FATAL: File not found: {path}")
sys.exit(2)
validate(data)
# Summary
components = data.get("components", [])
connections = data.get("connections", [])
stubs = [c for c in components if c.get("stub")]
low_conf = [c for c in components if c.get("confidence", 1) < 0.5]
type_counts = Counter(c.get("type") for c in components)
sv_parsed = _parse_schema_version(data.get("schema_version", ""))
is_v1_1 = sv_parsed is not None and (sv_parsed[0], sv_parsed[1]) >= (1, 1)
if is_v1_1:
repos = data.get("repositories", [])
repo_label = ", ".join(r.get("name", "?") for r in repos) if repos else "MISSING"
else:
repo_label = data.get("repository", {}).get("name", "MISSING")
print(f"\n{'='*60}")
print(f" servicemap.json Validation Report")
print(f"{'='*60}")
print(f" Schema version: {data.get('schema_version', 'MISSING')}")
print(f" Generated at: {data.get('generated_at', 'MISSING')}")
print(f" Repository: {repo_label}")
print(f"{'='*60}")
print(f"\n Components: {len(components)}")
for t, count in sorted(type_counts.items()):
print(f" {t}: {count}")
print(f" Connections: {len(connections)}")
print(f" Stubs (TODOs): {len(stubs)}")
print(f" Low confidence (<0.5): {len(low_conf)}")
if errors:
print(f"\n ERRORS: {len(errors)}")
for e in errors:
print(f" {e}")
if warnings:
print(f"\n WARNINGS: {len(warnings)}")
for w in warnings:
print(f" {w}")
if not errors and not warnings:
print(f"\n ✓ All checks passed!")
print()
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()