Add codespaces skill for belief-map architecture discovery

Initial import of the codespaces skill: query .belief_map.sexp for module
boundaries, dependencies, blast radius, and the minimal files to read before
non-trivial code changes.

Includes the belief-map and infra-topology builders, the belief-search query
engine and its tests, and reference docs for the S-expression notation and
query patterns.
This commit is contained in:
Aleksei Kudriashov
2026-06-20 17:24:22 +02:00
commit f340ff941f
11 changed files with 7393 additions and 0 deletions

View File

@@ -0,0 +1,288 @@
Infrastructure Topology Builders for Belief Map
=================================================
Context and motivation
----------------------
The belief map currently models **source-code** relationships: imports, entity references, data flows, and interface implementations. It has no visibility into **infrastructure topology** -- which services exist, how they connect at the network level, what databases/queues/caches they depend on, and how they are deployed.
This creates a blind spot: when an agent investigates a cross-service issue or plans a deployment change, the belief map shows code dependencies but not the runtime service graph. The data exists in three IaC formats already present in the codebase:
- **Kustomize** (`.k8s/base/kustomization.yaml`): 20+ services in the mono workspace, each with deployment manifests declaring env vars, service endpoints, and resource dependencies (postgres, rabbitmq, minio, vault)
- **Helm charts** (`charts/*/Chart.yaml`): avionica streaming services (sara-mock, transformer, ergoss-push, nats, prometheus, dashboard)
- **Terraform** (`infra/terraform/*.tf`): infrastructure prerequisites (namespaces, secrets, PVCs, configmaps) with module dependencies
Goals:
- Extract service topology from all three IaC formats and emit sexp edges into the belief map
- Detect service-to-service dependencies: env var references, service DNS names, port mappings
- Detect service-to-infrastructure dependencies: databases, message queues, object stores, vaults
- Output is the same `.belief_map.sexp` format, appended or merged by the main builder
Non-goals for first implementation (v1):
- No runtime service discovery (no kubectl, no cluster queries)
- No Tilt/docker-compose parsing (separate effort)
- No ingress/route topology (just service-to-service and service-to-infra)
- No multi-cluster or multi-namespace topology
- No cost or resource quota analysis
Implementation considerations
------------------------------
**Pure static analysis.** All three parsers read YAML/HCL files from disk. No cluster access needed.
**Additive to existing graph.** Infrastructure edges are a new layer on top of the source-code graph. They use new edge types (`k8s-service`, `k8s-depends`, `infra-resource`) that don't conflict with existing `imports`/`calls-api`/`data-flow` edges.
**Node identity.** Infrastructure nodes use the service name from k8s metadata (e.g., `app-service`, `drive-postgres`, `nats`) prefixed with `infra/` to distinguish from source-code module IDs. Example: `infra/app-service`, `infra/drive-postgres`.
**Minimal dependencies.** Kustomize and Helm parsers use only PyYAML (already available). Terraform parser uses `hcl2` Python library or falls back to regex if unavailable.
**Invocation.** Each script is standalone but designed to be called from `build_belief_map.py` as an optional post-pass:
```bash
python3 scripts/build_infra_topology.py [--kustomize PATH] [--helm PATH] [--terraform PATH]
```
Or integrated into the main builder via `--infra` flag.
High-level behavior
-------------------
### Discovery
1. Scanner walks the workspace looking for:
- `kustomization.yaml` / `kustomization.yml` files (kustomize)
- `Chart.yaml` files (helm)
- `*.tf` files in directories containing `main.tf` (terraform)
2. For each discovered IaC root, the appropriate parser runs.
3. All parsers emit the same intermediate format: a list of `InfraNode` and `InfraEdge` dataclasses.
4. A merge step deduplicates nodes by service name and writes sexp output.
### Kustomize parser
Reads each kustomization.yaml and its referenced resources:
1. Parse `kustomization.yaml` -> extract `resources` list
2. For each resource directory, parse `deployment.yaml` / `statefulset.yaml`:
- Extract service name from `metadata.name` or `app.kubernetes.io/name` label
- Extract container image -> maps service to codebase repo
- Extract `env` and `envFrom` -> detect service endpoint references:
- `*_HOST`, `*_URL`, `*_SERVICE_HOST` -> depends-on target service
- `DATABASE_URL`, `POSTGRES_*` -> depends-on database
- `RABBITMQ_*`, `AMQP_*`, `NATS_*` -> depends-on message queue
- `REDIS_*` -> depends-on cache
- `S3_*`, `MINIO_*` -> depends-on object store
3. Parse `service.yaml` -> extract ports and service type
Output edges:
```scheme
(infra-node infra/app-service :kind deployment :image upgraide/app-service :port 3001 :component api)
(infra-node infra/app-postgres :kind deployment :image postgres:16 :port 5432 :component database)
(k8s-depends infra/app-service infra/app-postgres :via env :transport tcp)
(k8s-depends infra/app-service infra/drive-service :via env :transport http)
(k8s-service infra/app-service app-service :namespace upgraide :port 3001)
```
### Helm parser
Reads Chart.yaml and values.yaml:
1. Parse `Chart.yaml` -> extract chart name, version, dependencies
2. Parse `values.yaml` -> extract:
- `image.repository` / `image.tag` -> container image
- `env.*` -> service endpoint references (same heuristics as kustomize)
- `service.port` -> exposed port
3. Parse `Chart.yaml` `dependencies` -> direct chart-to-chart edges
Output edges:
```scheme
(infra-node infra/sara-mock :kind helm-chart :image sara-mock :port 8080 :chart sara-mock)
(helm-depends infra/sara-mock infra/nats :via chart-dependency)
```
### Terraform parser
Reads `*.tf` files:
1. Find all `module` blocks -> extract module name and source
2. Find all `resource` blocks -> extract resource type and name
3. Build dependency graph from:
- `module.X.output_name` references in other modules
- `depends_on` explicit declarations
- Variable references across modules
Output edges:
```scheme
(infra-node infra/tf-namespace :kind terraform :resource kubernetes_namespace)
(infra-node infra/tf-secrets :kind terraform :resource module)
(tf-depends infra/tf-secrets infra/tf-namespace :via module-reference)
```
S-expression output format
---------------------------
New fact types (appended to `.belief_map.sexp`):
```scheme
; Infrastructure nodes
(infra-node <id> :kind <deployment|statefulset|helm-chart|terraform> :image <image> :port <port> :component <label>)
; Kustomize service dependencies
(k8s-depends <src-service> <tgt-service> :via <env|dns|volume> :transport <http|tcp|amqp|grpc>)
; Kustomize service declarations
(k8s-service <service-id> <dns-name> :namespace <ns> :port <port>)
; Helm chart dependencies
(helm-depends <src-chart> <tgt-chart> :via <chart-dependency|values-ref>)
; Terraform module dependencies
(tf-depends <src-module> <tgt-module> :via <module-reference|depends-on|variable>)
; Cross-layer: maps infra service to source-code repo
(infra-maps infra/app-service app-service :via image-name)
```
The `infra-maps` edge connects infrastructure nodes to source-code nodes, enabling queries like:
```scheme
(deps "infra/app-service" 2) ; what infra does app-service depend on?
(rdeps "infra/drive-postgres" 1) ; which services use this database?
```
Error handling and UX
---------------------
- Missing YAML files: skip with `; warning: skipped <path>: file not found` comment in output
- Malformed YAML: skip resource, emit `; error: parse failed <path>: <reason>`
- Missing PyYAML: exit with clear error message and install instructions
- Missing hcl2 (for terraform): fall back to regex-based parsing, emit `; warning: terraform parsed with regex fallback`
- No IaC files found: emit `; info: no infrastructure files found` and exit cleanly
Update cadence / Lifecycle
--------------------------
Infrastructure topology changes rarely compared to source code. The scripts should:
- Run only when `--infra` flag is passed to `build_belief_map.py`
- OR when the user explicitly runs `build_infra_topology.py`
- Cache results alongside `.belief_map_cache.json` using mtime-based invalidation on IaC files
Future-proofing
---------------
- **Tilt / docker-compose**: same intermediate format, different parser. Add `--tilt` and `--compose` flags.
- **Ingress topology**: parse `ingress.yaml` / `Ingress` resources for external routing
- **Multi-namespace**: extend `infra-node` with `:namespace` field
- **Service mesh**: parse Istio/Linkerd VirtualService/DestinationRule for traffic routing edges
- **GitOps**: parse ArgoCD Application manifests for deployment pipeline edges
- **Runtime enrichment**: optional `--live` flag to query kubectl for actual pod status
Implementation outline
----------------------
### Phase 1: Core infrastructure (shared)
1. Define `InfraNode` and `InfraEdge` dataclasses in `build_infra_topology.py`
2. Define env-var heuristic patterns for service/infra detection
3. Implement sexp writer for new fact types
4. Add `--infra` flag to `build_belief_map.py` that calls the topology builder post-pass
### Phase 2: Kustomize parser
1. Walk directories for `kustomization.yaml`
2. Parse resource references -> find deployment/service YAML
3. Extract service names, images, ports, env vars
4. Apply env-var heuristics to detect dependencies
5. Emit `infra-node`, `k8s-depends`, `k8s-service` facts
### Phase 3: Helm parser
1. Walk directories for `Chart.yaml`
2. Parse Chart.yaml dependencies
3. Parse values.yaml for image/port/env configuration
4. Parse templates/*.yaml for env var references (handle Go template syntax)
5. Emit `infra-node`, `helm-depends` facts
### Phase 4: Terraform parser
1. Walk directories for `main.tf`
2. Parse module blocks and resource blocks (via hcl2 or regex)
3. Build dependency graph from cross-module references
4. Emit `infra-node`, `tf-depends` facts
### Phase 5: Cross-layer mapping
1. For each `infra-node` with `:image`, match image name against known repo names
2. Emit `infra-maps` edges connecting infrastructure to source-code graph
3. Update `belief_search.py` to parse new edge types
### Phase 6: Integration
1. Add new edge types to `belief_search.py` edge parser
2. Update `sexp-notation.md` reference with new fact types
3. Update SKILL.md with infrastructure query examples
Testing approach
----------------
**Unit tests:**
- Kustomize parser: fixture kustomization.yaml + deployment.yaml -> expected InfraNode/InfraEdge list
- Helm parser: fixture Chart.yaml + values.yaml -> expected output
- Terraform parser: fixture main.tf with modules -> expected dependency graph
- Env-var heuristics: input env var name/value -> expected dependency type
**Integration tests:**
- Run kustomize parser against `mono/app-service/.k8s/base/` -> verify app-service depends on postgres
- Run helm parser against `nextnet-avsync-streaming/charts/` -> verify chart dependencies
- Run terraform parser against `avsync-stream-lab/infra/terraform/` -> verify module dependencies
**Manual verification:**
- Run `build_infra_topology.py` on mono workspace
- Query `belief_search.py rdeps infra/drive-postgres 1` -> verify all drive consumers listed
- Query `belief_search.py deps infra/app-service 2` -> verify database + queue dependencies shown
Acceptance criteria
-------------------
1. Given a workspace with `.k8s/base/` directories, when running `build_infra_topology.py --kustomize .`, then `infra-node` facts are emitted for each deployment with correct service name, image, and port.
2. Given a deployment.yaml with `DATABASE_URL` env var, when parsing, then a `k8s-depends` edge is emitted to the postgres service with `:transport tcp`.
3. Given a kustomization.yaml with `resources: [app-service, app-web, postgres]`, when parsing, then all three services appear as `infra-node` facts.
4. Given a Helm Chart.yaml with `dependencies`, when parsing, then `helm-depends` edges are emitted for each dependency.
5. Given Terraform modules with cross-references (`module.namespace.name`), when parsing, then `tf-depends` edges reflect the dependency order.
6. Given an `infra-node` with `:image upgraide/app-service`, when cross-layer mapping runs, then an `infra-maps` edge connects `infra/app-service` to the source-code `app-service` node.
7. Given `--infra` flag passed to `build_belief_map.py`, when building completes, then infrastructure facts appear in `.belief_map.sexp` alongside source-code facts.
8. Given no IaC files in the workspace, when running the topology builder, then it exits cleanly with an informational message and no errors.
9. `belief_search.py` correctly loads and queries `infra-node`, `k8s-depends`, `helm-depends`, `tf-depends`, and `infra-maps` edges.
Estimated scope
---------------
| Component | Lines | Effort |
|-----------|-------|--------|
| Shared infra types + sexp writer | ~80 | Small |
| Kustomize parser | ~200 | Medium |
| Helm parser | ~150 | Medium |
| Terraform parser | ~120 | Medium |
| Cross-layer mapping | ~40 | Small |
| belief_search.py integration | ~10 | Trivial |
| Tests | ~150 | Medium |
| **Total** | **~750** | ~2 days |

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# macOS
.DS_Store
# Python
__pycache__/
*.py[cod]
.ruff_cache/
# Generated belief-map artifacts (produced by scripts/build_belief_map.py)
.belief_map.sexp
.belief_map_cache.json

9
LOG.md Normal file
View File

@@ -0,0 +1,9 @@
## 2026-06-19
### Changes
- Updated the codespaces skill workflow so agents are directed to query the belief map before reading source and to use `quick` as a first-pass `analyze` shortcut.
- Changed `belief_search.py quick` to run full `analyze` output instead of boundary-only output, and made it resolve legacy path-map aliases found in raw belief-map hits.
- Added focused unit coverage for quick analyze output, alias resolution from raw hits, boundary files-only output, and no-match suggestions.
### Motivation
- Session-log review showed that agents usually used `search` and `analyze` after activation, but often queried too late and did not discover shortcut or boundary commands.

254
SKILL.md Normal file
View File

@@ -0,0 +1,254 @@
---
name: codespaces
description: >
Query `.belief_map.sexp` before non-trivial code changes to find module boundaries,
dependencies, blast radius, and the minimal files to read. Use for architecture
discovery, impact analysis, and boundary checks.
---
# Codespaces -- Belief Map Architecture Discovery
Query `.belief_map.sexp` to discover modules, boundaries, and dependencies before reading source files. Never read code blindly -- query the graph first.
## Scripts
This skill bundles Python scripts. Run them from the skill's base directory:
- `scripts/belief_search.py` -- query the belief map graph
- `scripts/build_belief_map.py` -- generate the belief map from source
- `scripts/build_infra_topology.py` -- extract Kustomize, Helm, and Terraform topology facts
- `scripts/git_descendants.py` -- find all commits descending from HEAD (or a given ref)
Never use project-local copies of these scripts -- they may be outdated and output JSON instead of sexp.
## Prerequisite
Locate the belief map. If absent, build it:
```bash
ls .belief_map.sexp belief_map.sexp 2>/dev/null
# Build: pip3 install tree-sitter tree-sitter-typescript tree-sitter-python
python3 scripts/build_belief_map.py # incremental ~5s
python3 scripts/build_belief_map.py --full # full rebuild ~30s
```
## Mandatory Workflow: Query Before Code
For non-trivial code work, run a belief-map query before `rg`, `find`, `sed`,
`cat`, `nl`, opening directories, or reading source files. The skill only helps
when it is used as the first scoping step, not as confirmation after source has
already been inspected.
**Fast first pass when the task has a clear keyword:**
```bash
python3 scripts/belief_search.py quick "keyword"
```
`quick` searches for the first matching module and immediately runs `analyze`.
Use it to get boundary files, dependencies, reverse dependencies, layers, and
violations in one step. If `quick` returns the wrong module or no match, use the
explicit `search` -> `analyze` flow below.
**Step 1 -- Find the module ID:**
```bash
python3 scripts/belief_search.py search "keyword"
python3 scripts/belief_search.py entity EntityName
```
Use `search` or `entity` to locate the module ID. Module IDs are **full relative paths** like `pki-service/src/modules/ca/ca.service`, not short codes like `ps34`. The belief map may contain short path-map IDs internally, but they are resolved automatically -- always pass human-readable path-like strings. Do NOT guess or invent module IDs -- always confirm with `search` first.
**Step 2 -- Analyze the module (do this IMMEDIATELY, not optionally):**
```bash
python3 scripts/belief_search.py analyze <module-id>
```
`analyze` is the PRIMARY command. It returns the full context: boundary files, dependencies, reverse dependencies, architecture layer, and violations. Always run it before reading any source.
**Step 3 -- Read only the boundary files listed in the analyze output.**
**Step 4 -- Do NOT read entire directories.** If the boundary shows 2-3 relevant files, read only those files. Ignore everything else in the directory.
## Command Reference
| Command | Purpose | Example |
|---|---|---|
| `search "<pattern>"` | Regex grep on graph (find module IDs) | `search "operative.*service"` |
| `analyze <id>` | Full module analysis (PRIMARY command) | `analyze drive/modules/drive/drive_schema` |
| `quick <keyword>` | Search + analyze first match (shortcut) | `quick operatives` |
| `boundary <id>` | Files to read for a change | `boundary operatives.service` |
| `boundary <id> --files-only` | File paths only (compact) | `boundary operatives.service --files-only` |
| `entity <Name>` | Find definitions + references | `entity DrivePath` |
| `deps <id> [depth]` | Outgoing dependency tree | `deps operatives.service 3` |
| `rdeps <id> [depth]` | Blast radius (who depends on this) | `rdeps drive_schema 2` |
| `flow <id> <fn>` | Trace call/data flow | `flow drive_api fetchFiles` |
| `module <id>` | Single module with edges | `module operatives.controller` |
| `boundaries [id\|all]` | Check architecture violations | `boundaries all` |
| `invariants [id\|all]` | Check naming convention violations | `invariants all` |
| `layers` | Show all modules by layer | `layers` |
| `find_function <name>` | Find function/method definitions | `find_function createOrder` |
| `find_type <name>` | Find type/class/ifc/enum definitions | `find_type DriveDbCommitResponse` |
| `find_callchain <src> <tgt> [depth]` | Trace call path between functions | `find_callchain createOrder processPayment` |
| `find_callers <fn> [depth]` | Who calls this function | `find_callers handleRequest 3` |
| `find_calls <fn> [depth]` | What does this function call | `find_calls createOrder 2` |
| `grep_functions <pattern>` | Search function bodies in source | `grep_functions "validate.*input"` |
| `diff_functions [ref]` | Changed functions in git diff | `diff_functions HEAD~3` |
| `query '<sexp>'` | Composable Scheme query | `query '(count (boundary "X"))'` |
| `repl` | Interactive Scheme REPL | `repl` |
## Quick Reference: rg Queries
When you need fast, targeted lookups without invoking the Python script, query the belief map directly with `rg`. The bundled builder writes `.belief_map.sexp`; if a repo still uses the legacy `belief_map.sexp`, substitute that filename in the commands below:
```bash
# All facts about a module
rg 'drive/modules/drive_db/api ' .belief_map.sexp
# Who imports a module
rg 'imports .* drive/modules/commons/result' .belief_map.sexp
# What does a module import
rg 'imports drive/modules/drive_db/api' .belief_map.sexp
# All references to an entity
rg 'DriveDbCommitResponse' .belief_map.sexp
# All classes in a repo
rg '^\(cls drive/' .belief_map.sexp
# Data flow edges
rg 'data-flow' .belief_map.sexp
```
These one-liners are useful for quick checks. For structured output and full analysis, use the Python script commands.
## Output Notation
All output is S-expression facts, one per line. See [references/sexp-notation.md](references/sexp-notation.md) for the full format.
Key patterns:
```scheme
(boundary <mod> :lang py :file <path> :purpose "<desc>")
(boundary-dep <mod> <dep> :lang ts :file <path> :relation imports)
(boundary-rdep <mod> <rdep> :lang tsx :file <path> :relation imports)
(boundary-summary <mod> :total 10 :deps 7 :rdeps 2)
(entity-def cls <mod> <Name> <line> :lang py :file <path>)
(violation <src> <tgt> :src-layer domain :tgt-layer api :via imports :lang ts "<reason>")
(result <module-or-path>)
(result-count 42)
```
**`:lang` values**: `py` = Python (snake_case), `ts` = TypeScript (camelCase), `tsx` = TSX (PascalCase).
## Scheme Query Language
Compose queries for complex analysis. See [references/scheme-queries.md](references/scheme-queries.md) for full reference.
**Essentials:**
```scheme
(boundary "id") ; target + deps + rdeps (change boundary)
(entity "Name") ; modules defining entity
(deps "id" depth) ; outgoing dependencies
(rdeps "id" depth) ; reverse dependencies
(find-function "name") ; modules with matching function defs
(find-type "name") ; modules with matching type defs
(find-callers "fn" depth) ; modules calling a function
(find-calls "fn" depth) ; modules a function calls
(intersect A B) ; set intersection
(filter SET "pattern") ; regex filter
(files SET) ; resolve to file paths
(count SET) ; count members
(violations) ; modules with boundary violations
(layer "id") ; architecture layer of a module
```
## Analysis Plan Pattern
Before modifying code, compose a plan from queries:
```scheme
; 1. Scope
(boundary "operatives.service")
(count (boundary "operatives.service"))
; 2. Architecture constraints
(violations "operatives.service")
(layer "operatives.service")
; 3. Cross-language boundaries
(filter (refs-to "DrivePath") "app-service")
; 4. Narrow to actionable files
(filter (boundary "operatives.service") "dto")
(intersect (deps "controller" 2) (deps "service" 2))
; 5. After changes: verify
(violations "operatives.service")
```
## Trigger Examples
Use this skill before reading source for:
- Root-cause investigations that mention services, APIs, jobs, events, UI flows, or failing behavior.
- Cross-service or cross-module questions, including "how does X call Y?" and "where is this stored?"
- Redmine clarification when the answer depends on code behavior.
- Architecture reviews, impact analysis, public contract changes, or blast-radius checks.
- Reviews of non-trivial staged changes when dependency direction or module ownership matters.
Do not force it for commit-only tasks, git-log summaries, manual demo/server runs, pure browser testing, or session-log analysis.
## Error Recovery
If a command returns `(error no-match ...)`:
1. Do NOT retry with the same ID or guess another ID
2. Run `search` with a broader keyword to find the correct module ID
3. Use the `:suggestions` field if present -- it lists similar module IDs
```bash
# Wrong: guessing IDs
python3 scripts/belief_search.py analyze ps34 # fails: ps34 is a short path-map ID
python3 scripts/belief_search.py analyze ca.service # fails: incomplete path
# Right: search first, then use the full path from results
python3 scripts/belief_search.py search "ca.*service"
# Output shows: pki-service/src/modules/ca/ca.service
python3 scripts/belief_search.py analyze pki-service/src/modules/ca/ca.service
```
## Anti-patterns
- **Guessing module IDs.** Module IDs are full relative paths (e.g., `pki-service/src/modules/ca/ca.service`). Short codes like `ps34` are internal path-map IDs and must not be used directly. Always `search` first.
- **Build but never query.** Building the belief map and then never running `search`, `analyze`, or `boundary` provides zero value.
- **Querying after reading source.** The first source-code read should come from `boundary-file` lines in `analyze` output.
- **Reading `.belief_map.sexp` as a whole file.** Use `search`, `analyze`, or `rg` to query specific facts.
- **Skipping the belief map for cross-module tasks.** Always query the graph to understand boundaries before reading source files.
- **Reading all files in a directory.** When the boundary shows 2-3 files, read only those.
- **Using project-local scripts.** Always use this skill's bundled scripts. Project copies may output JSON instead of sexp.
## Rebuilding the Graph
```bash
python3 scripts/build_belief_map.py # incremental (~5s)
python3 scripts/build_belief_map.py --full # full rebuild (~30s)
python3 scripts/build_belief_map.py ./drive # single sub-repo
python3 scripts/build_belief_map.py --lsp # with LSP call hierarchy (~1-5min)
```
Rebuild after structural changes (add/remove files, change imports, add classes).
## Infrastructure Topology
Extract service topology from Kustomize, Helm, and Terraform files. This script requires `PyYAML` (`pip3 install pyyaml`).
```bash
python3 scripts/build_infra_topology.py # auto-detect all IaC
python3 scripts/build_infra_topology.py --kustomize . # kustomize only
python3 scripts/build_infra_topology.py --helm ./charts # helm charts only
python3 scripts/build_infra_topology.py --terraform ./infra # terraform only
python3 scripts/build_infra_topology.py --append .belief_map.sexp # merge into graph
```
Emits `infra-node`, `k8s-depends`, `k8s-service`, `helm-depends`, `tf-depends`, and `infra-maps` edges. Query with:
```bash
rg 'infra-node' .belief_map.sexp # all services
rg 'k8s-depends' .belief_map.sexp # service dependencies
rg 'infra-node.*:component database' .belief_map.sexp # all databases
rg 'infra-maps.*app-service' .belief_map.sexp # infra -> source code mapping
```

View File

@@ -0,0 +1,88 @@
# Scheme Query Language Reference
Use `query '<expr>'` for one-shot or `repl` for interactive mode.
## Primitives (return module sets)
```scheme
(resolve "pattern") ; find module IDs matching pattern (suffix > substring)
(boundary "id") ; target + imports + dependents (change boundary)
(deps "id" depth) ; outgoing dependencies (default depth 2)
(rdeps "id" depth) ; reverse dependencies (default depth 1)
(imports "id") ; direct import targets only
(dependents "id") ; direct dependents only
(entity "Name") ; modules defining an entity
(refs-to "Name") ; modules referencing an entity
```
## Set Operations
```scheme
(intersect A B) ; set intersection
(union A B ...) ; set union (variadic)
(diff A B) ; A minus B
(filter SET "pattern") ; keep members matching regex (case-insensitive)
```
## Boundary Analysis
```scheme
(layer "id") ; architecture layer: domain/api/service/infra/shared/test/ui/config/other
(layer-of SET "layer") ; filter set to modules in a specific layer
(violations) ; all modules with boundary violations
(violations "id") ; violations for a specific module's scope
```
## Function & Type Search (return module-set)
```scheme
(find-function "name") ; modules containing matching function definitions
(find-type "name") ; modules containing matching type/class/ifc/enum defs
(find-callers "fn" depth) ; modules that call a function (default depth 2)
(find-calls "fn" depth) ; modules that a function calls (default depth 2)
(diff-functions "ref") ; print changed functions (side-effect, returns empty)
```
## Output Transforms
```scheme
(files SET) ; resolve module IDs to file paths
(count SET) ; count set members (returns int)
(analyze "id") ; run full analysis (prints sexp, returns empty set)
(search "pattern") ; grep .belief_map.sexp (prints matches, returns empty set)
```
## Composition Examples
```scheme
; Files shared between two modules
(intersect (deps "controller" 2) (deps "service" 2))
; Only DTOs in a boundary
(filter (boundary "operatives.service") "dto")
; How many files in the blast radius?
(count (deps "drive_schema" 3))
; Which TS modules reference a Python entity?
(filter (refs-to "DrivePath") "app-service")
; Domain modules that violate boundaries
(violations "all")
; Infra files in the boundary
(layer-of (boundary "operatives.service") "infra")
; Files to read (resolved paths)
(files (filter (boundary "X") "service"))
```
## Result Format
```scheme
(result <module-or-path>) ; one per set member
(result-empty) ; empty set
(result-count 42) ; from (count ...)
```
String results (from `layer`) print as `(result <value>)`.

176
references/sexp-notation.md Normal file
View File

@@ -0,0 +1,176 @@
# S-expression Output Notation
Every output line is a self-contained fact. Each fact that references a module carries `:lang` (py/ts/tsx).
**Module ID**: relative path from workspace root, no extension, no `/index`. Example: `drive/modules/drive_db/api/drive_db_api.ts` -> `drive/modules/drive_db/api/drive_db_api`.
## Raw Graph Facts (in .belief_map.sexp)
```scheme
(node <id> <lang> "<purpose>" :naming <convention> :pkg <repo>)
(fn <mod> <name> <line> :eid <uuid>)
(cls <mod> <name> <line> :eid <uuid> (:bases A) (:deco D) (:methods m1 m2))
(ifc <mod> <name> <line> :eid <uuid> (:methods m1 m2))
(typ <mod> <name> <line> :eid <uuid>)
(enm <mod> <name> <line> :eid <uuid>)
(imports <src> <tgt> [:via-base] [:lsp])
(calls-api <src> <tgt> :via-ifc [:lsp])
(data-flow <src> <tgt> [:validated])
(refs <src> <tgt>::<Entity> [:lsp])
(calls <src>::<fn> <tgt>::<fn> :lines L1 L2 :lsp)
```
## boundary Output
```scheme
(boundary <mod> :lang py :file <path> :purpose "<desc>")
(cls <mod> <Name> <line> :lang py (:bases A) (:deco D) (:methods m1 m2))
(boundary-dep <mod> <dep> :lang ts :file <path> :relation imports)
(boundary-rdep <mod> <rdep> :lang py :file <path> :relation imports)
(boundary-summary <mod> :total 10 :deps 7 :rdeps 2)
```
## analyze Output
```scheme
(analyze <mod> :lang py "<purpose>" :file <path>)
(cls <mod> <Name> <line> :lang py (:bases A) (:deco D) (:methods m1 m2))
(analyze-import <mod> <tgt> :lang ts :file <path>)
(analyze-dataflow <mod> <tgt> :lang py :validated)
(refs <src> <tgt>::<Entity>)
(analyze-dependent <mod> <src> :lang tsx :via imports refs :file <path>)
(dep <root> <tgt> :lang py :via imports :depth 1)
(rdep <root> <src> :lang tsx :via imports :depth 1)
(key-entity <mod> <Name> :lang py :kind cls :line 32 :refs 7 :modules 4)
(key-entity-ref <Name> <ref-mod> :lang ts)
(boundary-file <mod> <file-path>)
(layer <mod> <dep-name> :layer domain)
(analyze-summary <mod> :entities 31 :imports 1 :dataflows 0 :refs 1 :dependents 50 :boundary-files 52)
```
## entity Output
```scheme
(entity-def cls <mod> <Name> <line> :lang py :file <path>)
(entity-def ifc <mod> <Name> <line> :lang ts :file <path>)
(refs <src-mod> <tgt-mod>::<Name>)
```
## deps / rdeps Output
```scheme
(deps-root <mod> :lang py)
(dep <root> <tgt> :lang ts :via imports :depth 1)
(dep <root> <tgt> :lang py :via data-flow :depth 2 :cycle)
(rdeps-root <mod> :lang ts)
(rdep <root> <src> :lang tsx :via imports :depth 1)
```
## flow Output
```scheme
(flow <src>::<fn> <tgt>::<fn> :lang py :via calls :depth 0)
(flow <src>::<fn> <tgt> :lang ts :via imports :depth 1 :module-level)
```
## boundaries Output
```scheme
(violation <src> <tgt> :src-layer domain :tgt-layer api :via imports :lang ts "<reason>")
(boundaries-summary :violations 2 :checked 3587)
(boundaries-ok :checked 3587)
```
## invariants Output
```scheme
(invariant-violation <mod> :expected snake_case :actual PascalCase :lang py :pkg <repo>)
(invariants-summary :violations 12 :checked 3587)
(invariants-ok :checked 3587)
```
## layers Output
```scheme
(layer-group api :count 144)
(layer-member api <mod> :lang ts)
```
## find_function / find_type Output
```scheme
(fn-def <mod>::<name> :line 42 :lang ts :file <path> :kind function)
(fn-def <mod>::<cls>.<method> :line 42 :lang py :file <path> :kind method)
(type-def cls <mod>::<Name> :line 10 :lang ts :file <path> (:bases A) (:methods m1 m2))
(type-def ifc <mod>::<Name> :line 10 :lang ts :file <path>)
```
## find_callers / find_calls Output
```scheme
(callers-target <mod>::<fn> :line 42 :lang ts :file <path>)
(caller <src>::<fn> -> <tgt>::<fn> :lang ts :file <path> :lines L1 L2 :lsp)
(caller-module <src> -> <tgt> :via imports :depth 1 :lang ts :file <path>)
(calls-source <mod>::<fn> :line 42 :lang ts :file <path>)
(calls-target <src>::<fn> -> <tgt>::<fn> :lang ts :file <path>)
(calls-module <src> -> <tgt> :via imports :depth 1 :lang ts :file <path>)
```
## find_callchain Output
```scheme
(callchain-step 0 <mod>::<fn> :lang ts :file <path>)
(callchain-step 1 <mod>::<fn> :lang py :file <path>)
(callchain-summary :length 3 :hops 2)
(callchain-module-step 0 <mod> :lang ts :file <path>)
(callchain-module-summary :length 2 :hops 1)
(callchain-none "srcFn" -> "tgtFn" :max-depth 5)
```
## grep_functions Output
```scheme
(grep-fn <mod>::<fn> :line 55 :lang ts :file <path> :match "matched line text")
(grep-functions-summary :matches 12 :pattern "pattern")
(grep-functions-empty "pattern")
```
## diff_functions Output
```scheme
(diff-fn <mod>::<name> :kind fn :line 42 :lang ts :file <path>)
(diff-functions-summary :changed 5 :files 3 :ref "HEAD")
(diff-file <path> :hunks 2)
(diff-functions-no-match :files 3 :ref "HEAD")
(diff-functions-empty "HEAD")
```
## query / repl Output
```scheme
(result <module-or-path>)
(result-empty)
(result-count 42)
```
## :lang Values
| Value | Language | Naming | Example |
|---|---|---|---|
| `py` | Python | snake_case fns, PascalCase classes | `drive_path_to_entry`, `DrivePath` |
| `ts` | TypeScript | camelCase fns, PascalCase classes | `calculateTotal`, `OperativesService` |
| `tsx` | TSX/React | PascalCase components, camelCase hooks | `FileBrowser`, `useQuery` |
## Architecture Layers
| Layer | Path patterns | Role |
|---|---|---|
| `domain` | `/domain/`, `/entities/`, `_schema` | Pure business logic |
| `api` | `/api/`, `/routes/`, `/controllers/` | HTTP/RPC handlers |
| `service` | `/services/`, `/application/` | Business orchestration |
| `infra` | `/repositories/`, `/db/`, `/infra/` | Database, storage, messaging |
| `shared` | `/commons/`, `/utils/`, `/types/` | Cross-cutting utilities |
| `test` | `/tests/`, `.test.`, `.spec.` | Test files |
| `ui` | `/components/`, `/pages/`, `/hooks/` | Frontend UI |
| `config` | `/config/`, `/env/` | Configuration |
| `other` | (no match) | Unclassified |

2256
scripts/belief_search.py Normal file

File diff suppressed because it is too large Load Diff

3182
scripts/build_belief_map.py Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,853 @@
from __future__ import annotations
"""build_infra_topology.py -- scan Kustomize, Helm, and Terraform files and
emit infra topology facts in sexp format.
Usage:
python3 build_infra_topology.py [ROOT] [OPTIONS]
Options:
ROOT Workspace root (default: .)
--kustomize Scan for kustomize files (default: auto-detect)
--helm Scan for helm charts (default: auto-detect)
--terraform Scan for terraform files (default: auto-detect)
--append FILE Append output to existing sexp file
--stdout Print to stdout (default if no --append)
"""
import os
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
# ---------------------------------------------------------------------------
# PyYAML guard
# ---------------------------------------------------------------------------
try:
import yaml # type: ignore[import-untyped]
except ImportError:
print(
"Error: PyYAML is required. Install it with: pip install pyyaml",
file=sys.stderr,
)
sys.exit(1)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SKIP_DIRS: frozenset[str] = frozenset(
{"node_modules", ".git", ".venv", "dist", "build", "coverage", "target", "__pycache__"}
)
KUSTOMIZE_FILENAMES: tuple[str, ...] = ("kustomization.yaml", "kustomization.yml")
HELM_CHART_FILENAME = "Chart.yaml"
TERRAFORM_MAIN_FILENAME = "main.tf"
# Env-var heuristic patterns: (compiled_regex, inferred_dep_kind, transport)
# The dep_kind is either a generic service hint or a named infrastructure type.
_ENV_HEURISTICS: list[tuple[re.Pattern[str], str, str]] = [
(re.compile(r"DATABASE_URL|POSTGRES_HOST|POSTGRES_URL|PG_HOST|PG_URL|PGHOST"), "postgres", "tcp"),
(re.compile(r"RABBITMQ_HOST|RABBITMQ_URL|AMQP_HOST|AMQP_URL|AMQP_URI"), "rabbitmq", "amqp"),
(re.compile(r"NATS_URL|NATS_HOST|NATS_URI"), "nats", "tcp"),
(re.compile(r"REDIS_HOST|REDIS_URL|REDIS_URI"), "redis", "tcp"),
(re.compile(r"S3_ENDPOINT|S3_HOST|MINIO_HOST|MINIO_URL|AWS_S3_ENDPOINT"), "minio", "http"),
(re.compile(r"VAULT_ADDR|VAULT_HOST|VAULT_URL"), "vault", "http"),
# Generic service references come last to avoid shadowing the above
(re.compile(r"(.+?)_HOST$|(.+?)_URL$|(.+?)_SERVICE_HOST$|(.+?)_ENDPOINT$"), "service", "http"),
]
# Module-reference pattern inside Terraform: module.<name>.<attr>
_TF_MODULE_REF_RE = re.compile(r"module\.(\w+)\.")
# Terraform block parsers (no hcl2 dependency)
_TF_MODULE_BLOCK_RE = re.compile(r'module\s+"(\w+)"\s*\{([^}]*)\}', re.DOTALL)
_TF_MODULE_SOURCE_RE = re.compile(r'source\s*=\s*"([^"]+)"')
_TF_RESOURCE_BLOCK_RE = re.compile(r'resource\s+"(\w+)"\s+"(\w+)"\s*\{')
# ---------------------------------------------------------------------------
# Domain types
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class InfraNode:
id: str # infra/<name>
kind: str # deployment | statefulset | helm-chart | terraform
image: str # container image or ""
port: int # primary port or 0
component: str # k8s app.kubernetes.io/component label or ""
source_file: str # path to the manifest
@dataclass(frozen=True)
class InfraEdge:
source: str # infra/<name>
target: str # infra/<name>
edge_type: str # k8s-depends | helm-depends | tf-depends | infra-maps | k8s-service
via: str # env | dns | chart-dependency | module-reference | image-name
transport: str # http | tcp | amqp | grpc | ""
# For k8s-service facts the target holds the dns-name and extra is namespace
extra: str = ""
@dataclass
class TopologyResult:
nodes: list[InfraNode] = field(default_factory=list)
edges: list[InfraEdge] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Pure utilities
# ---------------------------------------------------------------------------
def _infra_id(name: str) -> str:
"""Return the canonical infra/ prefixed node id for a service name."""
return f"infra/{name}"
def _safe_load_yaml(path: Path, warnings: list[str]) -> Optional[dict]:
"""Load a YAML file, appending a warning on failure and returning None."""
try:
with path.open("r", encoding="utf-8", errors="replace") as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict):
return None
return data
except FileNotFoundError:
warnings.append(f"; warning: skipped {path}: file not found")
return None
except yaml.YAMLError as exc:
warnings.append(f"; error: parse failed {path}: {exc}")
return None
def _walk_dirs(root: Path) -> list[Path]:
"""Yield all sub-directories under root, skipping SKIP_DIRS."""
results: list[Path] = []
for dirpath, dirnames, _ in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
results.append(Path(dirpath))
return results
def _infer_dep_from_env(env_name: str) -> Optional[tuple[str, str]]:
"""Return (dep_kind, transport) inferred from an env var name, or None."""
for pattern, dep_kind, transport in _ENV_HEURISTICS:
m = pattern.match(env_name)
if m:
if dep_kind == "service":
# Extract the service name from the prefix captured group
prefix = next((g for g in m.groups() if g), None)
if prefix:
# Convert SCREAMING_SNAKE to kebab-case service name
service_name = prefix.lower().replace("_", "-")
return (service_name, transport)
return None
return (dep_kind, transport)
return None
def _extract_env_deps(
env_list: list[dict],
source_id: str,
known_node_names: frozenset[str],
) -> list[InfraEdge]:
"""Return dependency edges inferred from a list of k8s env var dicts."""
edges: list[InfraEdge] = []
seen: set[str] = set()
for env in env_list:
name = env.get("name", "")
if not name:
continue
result = _infer_dep_from_env(name)
if result is None:
continue
dep_kind, transport = result
# Resolve target: prefer a known infra node by name, else use the dep_kind
target_name = dep_kind
# Check if any known node name contains the dep_kind as a substring
for node_name in known_node_names:
if dep_kind in node_name or node_name.endswith(dep_kind):
target_name = node_name
break
target_id = _infra_id(target_name)
key = f"{source_id}->{target_id}"
if key in seen:
continue
seen.add(key)
edges.append(
InfraEdge(
source=source_id,
target=target_id,
edge_type="k8s-depends",
via="env",
transport=transport,
)
)
return edges
# ---------------------------------------------------------------------------
# Kustomize parser
# ---------------------------------------------------------------------------
def _parse_kustomization_resources(
kustom_path: Path,
warnings: list[str],
) -> list[str]:
"""Return the list of resource paths declared in a kustomization.yaml."""
data = _safe_load_yaml(kustom_path, warnings)
if data is None:
return []
resources = data.get("resources", []) or []
return [str(r) for r in resources if r]
def _find_workload_manifest(resource_dir: Path) -> Optional[Path]:
"""Find the primary workload manifest inside a resource directory."""
candidates = (
"deployment.yaml",
"deployment.yml",
"statefulset.yaml",
"statefulset.yml",
)
for name in candidates:
p = resource_dir / name
if p.is_file():
return p
return None
def _find_service_manifest(resource_dir: Path) -> Optional[Path]:
"""Find the service manifest inside a resource directory."""
for name in ("service.yaml", "service.yml"):
p = resource_dir / name
if p.is_file():
return p
return None
def _parse_service_port(service_path: Path, warnings: list[str]) -> tuple[int, str]:
"""Return (port, namespace) from a Service manifest."""
data = _safe_load_yaml(service_path, warnings)
if data is None:
return (0, "")
namespace: str = (data.get("metadata") or {}).get("namespace", "")
ports: list[dict] = ((data.get("spec") or {}).get("ports") or [])
port: int = 0
if ports:
port = int(ports[0].get("port", 0))
return (port, namespace)
def _extract_container_env(container: dict) -> list[dict]:
"""Return the flat env list from a container spec (env only, not envFrom)."""
return container.get("env", []) or []
def parse_kustomize(root: Path) -> TopologyResult:
"""Scan root for kustomization.yaml files and extract infra topology."""
result = TopologyResult()
kustom_files: list[Path] = []
for d in _walk_dirs(root):
for fname in KUSTOMIZE_FILENAMES:
p = d / fname
if p.is_file():
kustom_files.append(p)
if not kustom_files:
return result
# First pass: collect all node names for dependency resolution
workload_data: list[tuple[Path, Path, dict]] = [] # (kustom_path, manifest, data)
for kustom_path in kustom_files:
kustom_dir = kustom_path.parent
for resource_str in _parse_kustomization_resources(kustom_path, result.warnings):
resource_dir = kustom_dir / resource_str
if not resource_dir.is_dir():
# Could be a direct file reference
continue
manifest = _find_workload_manifest(resource_dir)
if manifest is None:
continue
data = _safe_load_yaml(manifest, result.warnings)
if data and data.get("kind") in ("Deployment", "StatefulSet"):
workload_data.append((kustom_path, manifest, data))
# Collect known names for env heuristic resolution
known_names: set[str] = set()
for _, _, data in workload_data:
name = (data.get("metadata") or {}).get("name", "")
if name:
known_names.add(name)
known_names_frozen = frozenset(known_names)
# Second pass: emit nodes and edges
for kustom_path, manifest, data in workload_data:
kustom_dir = kustom_path.parent
k8s_kind = data.get("kind", "Deployment").lower()
metadata = data.get("metadata", {}) or {}
name: str = metadata.get("name", "")
if not name:
continue
labels = metadata.get("labels", {}) or {}
component: str = labels.get("app.kubernetes.io/component", "")
spec = data.get("spec", {}) or {}
template = spec.get("template", {}) or {}
pod_spec = template.get("spec", {}) or {}
containers: list[dict] = pod_spec.get("containers", []) or []
if not containers:
continue
first_container = containers[0]
image: str = first_container.get("image", "")
ports: list[dict] = first_container.get("ports", []) or []
port: int = int(ports[0].get("containerPort", 0)) if ports else 0
node_id = _infra_id(name)
result.nodes.append(
InfraNode(
id=node_id,
kind=k8s_kind,
image=image,
port=port,
component=component,
source_file=str(manifest.relative_to(root) if manifest.is_relative_to(root) else manifest),
)
)
# Service manifest for k8s-service fact
# Determine the resource dir from the manifest's parent
resource_dir = manifest.parent
svc_manifest = _find_service_manifest(resource_dir)
if svc_manifest:
svc_port, namespace = _parse_service_port(svc_manifest, result.warnings)
if svc_port == 0:
svc_port = port
result.edges.append(
InfraEdge(
source=node_id,
target=name,
edge_type="k8s-service",
via="service-manifest",
transport="",
extra=namespace,
)
)
# Env-var dependency edges
env_list = _extract_container_env(first_container)
dep_edges = _extract_env_deps(env_list, node_id, known_names_frozen)
result.edges.extend(dep_edges)
# infra-maps cross-layer edge (image name -> source code node)
if image:
image_base = image.split(":")[0].split("/")[-1]
if image_base:
result.edges.append(
InfraEdge(
source=node_id,
target=image_base,
edge_type="infra-maps",
via="image-name",
transport="",
)
)
return result
# ---------------------------------------------------------------------------
# Helm parser
# ---------------------------------------------------------------------------
def _parse_chart_yaml(chart_path: Path, warnings: list[str]) -> Optional[dict]:
"""Load and return Chart.yaml contents."""
return _safe_load_yaml(chart_path, warnings)
def _parse_values_yaml(values_path: Path, warnings: list[str]) -> dict:
"""Load values.yaml; return empty dict on failure."""
if not values_path.is_file():
return {}
data = _safe_load_yaml(values_path, warnings)
return data if isinstance(data, dict) else {}
def _extract_helm_image(values: dict) -> str:
"""Extract image string from values.yaml using common conventions."""
image_section = values.get("image", {}) or {}
if isinstance(image_section, dict):
repo = image_section.get("repository", "")
tag = image_section.get("tag", "")
if repo:
return f"{repo}:{tag}" if tag else repo
return ""
def _extract_helm_port(values: dict) -> int:
"""Extract primary service port from values.yaml."""
svc = values.get("service", {}) or {}
if isinstance(svc, dict):
return int(svc.get("port", 0))
return 0
def _extract_helm_env_deps(
values: dict,
source_id: str,
known_chart_names: frozenset[str],
) -> list[InfraEdge]:
"""Infer service dependencies from env entries in values.yaml."""
env_section = values.get("env", {}) or {}
if not isinstance(env_section, dict):
return []
env_list = [{"name": k, "value": str(v)} for k, v in env_section.items()]
return _extract_env_deps(env_list, source_id, known_chart_names)
def parse_helm(root: Path) -> TopologyResult:
"""Scan root for Chart.yaml files and extract Helm chart topology."""
result = TopologyResult()
chart_files: list[Path] = []
for d in _walk_dirs(root):
p = d / HELM_CHART_FILENAME
if p.is_file():
chart_files.append(p)
if not chart_files:
return result
# Collect chart names for cross-reference resolution
chart_names: dict[str, Path] = {}
chart_data_map: dict[Path, dict] = {}
for chart_path in chart_files:
data = _parse_chart_yaml(chart_path, result.warnings)
if data is None:
continue
chart_name: str = data.get("name", "")
if chart_name:
chart_names[chart_name] = chart_path
chart_data_map[chart_path] = data
known_chart_names = frozenset(chart_names.keys())
for chart_path, data in chart_data_map.items():
chart_dir = chart_path.parent
chart_name = data.get("name", "")
if not chart_name:
continue
values = _parse_values_yaml(chart_dir / "values.yaml", result.warnings)
image = _extract_helm_image(values)
port = _extract_helm_port(values)
node_id = _infra_id(chart_name)
result.nodes.append(
InfraNode(
id=node_id,
kind="helm-chart",
image=image,
port=port,
component="",
source_file=str(chart_path.relative_to(root) if chart_path.is_relative_to(root) else chart_path),
)
)
# Chart dependency edges
dependencies: list[dict] = data.get("dependencies", []) or []
for dep in dependencies:
dep_name: str = dep.get("name", "")
if not dep_name:
continue
target_id = _infra_id(dep_name)
result.edges.append(
InfraEdge(
source=node_id,
target=target_id,
edge_type="helm-depends",
via="chart-dependency",
transport="",
)
)
# Env-var dependency edges from values
env_edges = _extract_helm_env_deps(values, node_id, known_chart_names)
result.edges.extend(env_edges)
# infra-maps cross-layer edge
if image:
image_base = image.split(":")[0].split("/")[-1]
if image_base:
result.edges.append(
InfraEdge(
source=node_id,
target=image_base,
edge_type="infra-maps",
via="image-name",
transport="",
)
)
return result
# ---------------------------------------------------------------------------
# Terraform parser (regex-based, no hcl2 dependency)
# ---------------------------------------------------------------------------
def _read_tf_files(tf_dir: Path) -> str:
"""Concatenate all .tf files in a directory into one string."""
parts: list[str] = []
for p in sorted(tf_dir.glob("*.tf")):
try:
parts.append(p.read_text(encoding="utf-8", errors="replace"))
except OSError:
pass
return "\n".join(parts)
def _parse_tf_modules(content: str) -> dict[str, str]:
"""Return {module_name: source_path} from HCL content."""
modules: dict[str, str] = {}
for m in _TF_MODULE_BLOCK_RE.finditer(content):
mod_name = m.group(1)
body = m.group(2)
src_match = _TF_MODULE_SOURCE_RE.search(body)
source = src_match.group(1) if src_match else ""
modules[mod_name] = source
return modules
def _parse_tf_resources(content: str) -> list[tuple[str, str]]:
"""Return [(resource_type, resource_name)] from HCL content."""
return [
(m.group(1), m.group(2))
for m in _TF_RESOURCE_BLOCK_RE.finditer(content)
]
def _find_cross_module_refs(
content: str,
all_module_names: frozenset[str],
) -> set[str]:
"""Return set of module names referenced via module.<name>. syntax."""
refs: set[str] = set()
for m in _TF_MODULE_REF_RE.finditer(content):
ref_name = m.group(1)
if ref_name in all_module_names:
refs.add(ref_name)
return refs
def parse_terraform(root: Path) -> TopologyResult:
"""Scan root for Terraform directories and extract module topology."""
result = TopologyResult()
# Find all directories containing main.tf
tf_dirs: list[Path] = []
for d in _walk_dirs(root):
if (d / TERRAFORM_MAIN_FILENAME).is_file():
tf_dirs.append(d)
if not tf_dirs:
return result
result.warnings.append(
"; warning: terraform parsed with regex fallback (no hcl2 dependency)"
)
# Each tf_dir is an independent Terraform root; we treat module blocks as nodes
# and cross-module references as edges within the same root.
for tf_dir in tf_dirs:
content = _read_tf_files(tf_dir)
if not content.strip():
continue
modules = _parse_tf_modules(content)
resources = _parse_tf_resources(content)
all_module_names = frozenset(modules.keys())
rel_dir = str(tf_dir.relative_to(root) if tf_dir.is_relative_to(root) else tf_dir)
# If there are resource blocks, emit per-resource nodes
for res_type, res_name in resources:
node_name = f"tf-{res_name}"
node_id = _infra_id(node_name)
result.nodes.append(
InfraNode(
id=node_id,
kind="terraform",
image="",
port=0,
component=res_type,
source_file=rel_dir,
)
)
# Emit a node for each module block
for mod_name in modules:
node_name = f"tf-{mod_name}"
node_id = _infra_id(node_name)
result.nodes.append(
InfraNode(
id=node_id,
kind="terraform",
image="",
port=0,
component="module",
source_file=rel_dir,
)
)
# Find cross-module dependencies: for each module block, scan its body
# for references to other modules
for m in _TF_MODULE_BLOCK_RE.finditer(content):
src_mod_name = m.group(1)
body = m.group(2)
refs = _find_cross_module_refs(body, all_module_names - {src_mod_name})
src_id = _infra_id(f"tf-{src_mod_name}")
for ref in sorted(refs):
tgt_id = _infra_id(f"tf-{ref}")
result.edges.append(
InfraEdge(
source=src_id,
target=tgt_id,
edge_type="tf-depends",
via="module-reference",
transport="",
)
)
return result
# ---------------------------------------------------------------------------
# Sexp writer
# ---------------------------------------------------------------------------
def _quote(s: str) -> str:
"""Wrap s in double quotes, escaping inner quotes and backslashes."""
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def _needs_quoting(s: str) -> bool:
"""Return True if s contains whitespace or sexp-special characters."""
return bool(re.search(r'[\s"()\\]', s))
def _atom(s: str) -> str:
"""Return s as a sexp atom, quoting if necessary."""
if not s:
return '""'
return _quote(s) if _needs_quoting(s) else s
def render_sexp(nodes: list[InfraNode], edges: list[InfraEdge]) -> list[str]:
"""Return a list of sexp lines representing the infra topology."""
lines: list[str] = ["", "; --- infrastructure topology ---"]
# Deduplicate nodes by id, keeping first occurrence
seen_nodes: set[str] = set()
unique_nodes: list[InfraNode] = []
for node in nodes:
if node.id not in seen_nodes:
seen_nodes.add(node.id)
unique_nodes.append(node)
for node in sorted(unique_nodes, key=lambda n: n.id):
parts: list[str] = [f"(infra-node {node.id}"]
parts.append(f":kind {node.kind}")
if node.image:
parts.append(f":image {_atom(node.image)}")
if node.port:
parts.append(f":port {node.port}")
if node.component:
parts.append(f":component {_atom(node.component)}")
lines.append(" ".join(parts) + ")")
if unique_nodes:
lines.append("")
# Deduplicate edges by (source, target, edge_type, via)
seen_edges: set[tuple[str, str, str, str]] = set()
unique_edges: list[InfraEdge] = []
for edge in edges:
key = (edge.source, edge.target, edge.edge_type, edge.via)
if key not in seen_edges:
seen_edges.add(key)
unique_edges.append(edge)
for edge in sorted(unique_edges, key=lambda e: (e.edge_type, e.source, e.target)):
if edge.edge_type == "k8s-depends":
transport_str = f" :transport {edge.transport}" if edge.transport else ""
lines.append(
f"({edge.edge_type} {edge.source} {edge.target}"
f" :via {edge.via}{transport_str})"
)
elif edge.edge_type == "k8s-service":
ns_str = f" :namespace {_atom(edge.extra)}" if edge.extra else ""
# target holds the dns service name for k8s-service facts
lines.append(
f"(k8s-service {edge.source} {_atom(edge.target)}{ns_str})"
)
elif edge.edge_type == "helm-depends":
lines.append(
f"(helm-depends {edge.source} {edge.target} :via {edge.via})"
)
elif edge.edge_type == "tf-depends":
lines.append(
f"(tf-depends {edge.source} {edge.target} :via {edge.via})"
)
elif edge.edge_type == "infra-maps":
lines.append(
f"(infra-maps {edge.source} {_atom(edge.target)} :via {edge.via})"
)
return lines
# ---------------------------------------------------------------------------
# Merge results
# ---------------------------------------------------------------------------
def merge_results(results: list[TopologyResult]) -> TopologyResult:
"""Combine multiple TopologyResult instances into one."""
merged = TopologyResult()
for r in results:
merged.nodes.extend(r.nodes)
merged.edges.extend(r.edges)
merged.warnings.extend(r.warnings)
return merged
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _parse_args(argv: list[str]) -> tuple[Path, bool, bool, bool, Optional[Path], bool]:
"""Parse CLI arguments.
Returns (root, do_kustomize, do_helm, do_terraform, append_file, stdout_mode).
"""
root = Path(".")
do_kustomize: Optional[bool] = None
do_helm: Optional[bool] = None
do_terraform: Optional[bool] = None
append_file: Optional[Path] = None
stdout_mode = False
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--kustomize":
do_kustomize = True
elif arg == "--helm":
do_helm = True
elif arg == "--terraform":
do_terraform = True
elif arg == "--stdout":
stdout_mode = True
elif arg == "--append":
i += 1
if i < len(argv):
append_file = Path(argv[i])
elif not arg.startswith("--"):
root = Path(arg)
i += 1
# Auto-detect mode: run all parsers unless explicitly requested
if do_kustomize is None and do_helm is None and do_terraform is None:
do_kustomize = True
do_helm = True
do_terraform = True
return (
root,
bool(do_kustomize),
bool(do_helm),
bool(do_terraform),
append_file,
stdout_mode or (append_file is None),
)
def main() -> None:
root, do_kustomize, do_helm, do_terraform, append_file, stdout_mode = _parse_args(
sys.argv[1:]
)
if not root.is_dir():
print(f"Error: root directory not found: {root}", file=sys.stderr)
sys.exit(1)
results: list[TopologyResult] = []
if do_kustomize:
results.append(parse_kustomize(root))
if do_helm:
results.append(parse_helm(root))
if do_terraform:
results.append(parse_terraform(root))
merged = merge_results(results)
# Emit warnings to stderr
for w in merged.warnings:
print(w, file=sys.stderr)
if not merged.nodes and not merged.edges:
print("; info: no infrastructure files found", file=sys.stderr)
if not stdout_mode and append_file is None:
return
# Still write the comment so callers know the script ran
output_lines = ["", "; --- infrastructure topology ---", "; info: no infrastructure files found"]
else:
output_lines = render_sexp(merged.nodes, merged.edges)
output_text = "\n".join(output_lines) + "\n"
if stdout_mode:
sys.stdout.write(output_text)
if append_file is not None:
try:
with append_file.open("a", encoding="utf-8") as fh:
fh.write(output_text)
except OSError as exc:
print(f"Error: could not append to {append_file}: {exc}", file=sys.stderr)
sys.exit(1)
node_count = len({n.id for n in merged.nodes})
edge_count = len(merged.edges)
print(
f"[infra-topology] Done: {node_count} nodes, {edge_count} edges",
file=sys.stderr,
)
if __name__ == "__main__":
main()

124
scripts/git_descendants.py Executable file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Git Descendants Finder -- find all commits that descend from HEAD.
Builds a parent-child graph from git rev-list, then BFS-walks forward
from HEAD to find all descendant commits. Useful for understanding
what work was built on top of a given commit.
Usage::
git_descendants.py # descendants of HEAD
git_descendants.py <ref> # descendants of a specific commit/ref
"""
from __future__ import annotations
import subprocess
import sys
def run_git(cmd: str, cwd: str | None = None) -> list[str]:
"""Run a git command and return output lines."""
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=cwd)
if result.returncode != 0:
print(f"Error running: {cmd}", file=sys.stderr)
print(result.stderr, file=sys.stderr)
sys.exit(1)
return result.stdout.strip().split("\n") if result.stdout.strip() else []
def get_commit_sha(ref: str = "HEAD", cwd: str | None = None) -> str:
"""Resolve a ref to a full SHA."""
result = subprocess.run(
["git", "rev-parse", ref], capture_output=True, text=True, cwd=cwd,
)
if result.returncode != 0:
print(f"Error: cannot resolve ref '{ref}'", file=sys.stderr)
sys.exit(1)
return result.stdout.strip()
def build_parent_child_map(
cwd: str | None = None,
) -> tuple[dict[str, str], dict[str, list[str]]]:
"""Build a map of commit -> leftmost parent and parent -> children."""
print("Building commit graph...", file=sys.stderr)
commits = run_git("git rev-list --all --reflog --parents", cwd=cwd)
parent_map: dict[str, str] = {}
children_map: dict[str, list[str]] = {}
for line in commits:
parts = line.split()
if not parts:
continue
commit = parts[0]
parents = parts[1:]
if parents:
leftmost_parent = parents[0]
parent_map[commit] = leftmost_parent
if leftmost_parent not in children_map:
children_map[leftmost_parent] = []
children_map[leftmost_parent].append(commit)
print(f"Found {len(parent_map)} commits with parents", file=sys.stderr)
return parent_map, children_map
def find_all_descendants(
commit: str, children_map: dict[str, list[str]],
) -> set[str]:
"""Find all descendants of a commit using BFS."""
descendants: set[str] = set()
queue = [commit]
while queue:
current = queue.pop(0)
if current in children_map:
for child in children_map[current]:
if child not in descendants:
descendants.add(child)
queue.append(child)
return descendants
def main() -> None:
ref = sys.argv[1] if len(sys.argv) > 1 else "HEAD"
full_sha = get_commit_sha(ref)
short_sha = subprocess.run(
["git", "rev-parse", "--short", full_sha],
capture_output=True, text=True,
).stdout.strip()
print(f"Finding descendants of {ref}: {short_sha}", file=sys.stderr)
print(f"Full SHA: {full_sha}", file=sys.stderr)
print(file=sys.stderr)
_parent_map, children_map = build_parent_child_map()
descendants = find_all_descendants(full_sha, children_map)
print(f"Found {len(descendants)} descendant commits", file=sys.stderr)
print(file=sys.stderr)
if descendants:
for commit in sorted(descendants):
commit_info = subprocess.run(
["git", "log", "-1", "--format=%h %s", commit],
capture_output=True, text=True,
).stdout.strip()
print(commit_info)
else:
print("No descendants found", file=sys.stderr)
if __name__ == "__main__":
main()

152
tests/test_belief_search.py Normal file
View File

@@ -0,0 +1,152 @@
import importlib.util
import io
import sys
import tempfile
import textwrap
import unittest
from contextlib import redirect_stdout
from pathlib import Path
SCRIPT_PATH = Path("/Users/alexeus/.codex/skills/codespaces/scripts/belief_search.py")
SPEC = importlib.util.spec_from_file_location("belief_search", SCRIPT_PATH)
belief_search = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = belief_search
SPEC.loader.exec_module(belief_search)
class BeliefSearchAliasTest(unittest.TestCase):
def _load_graph(self) -> tuple[object, str]:
tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(tmpdir.cleanup)
sexp_path = Path(tmpdir.name) / ".belief_map.sexp"
sexp_path.write_text(
textwrap.dedent(
"""
(paths
(app-service
(app-service
(src
(workspace-operatives
(asp543 workspace-operative.service)
(asp544 workspace-operative.service.spec)
)
)
)
)
)
(node asp543 ts service)
(node asp544 ts test)
(cls asp543 WorkspaceOperativeService 16)
(imports asp544 asp543)
"""
).strip()
+ "\n",
encoding="utf-8",
)
graph = belief_search.BeliefGraph()
graph.load(str(sexp_path))
return graph, str(sexp_path)
def test_resolve_module_accepts_path_aliases(self) -> None:
"""/* REQ-CS-001: analyze must accept internal path-map aliases returned by older belief maps. */"""
graph, _sexp_path = self._load_graph()
self.assertEqual(
graph.resolve_module("asp543"),
["app-service/app-service/src/workspace-operatives/workspace-operative.service"],
)
output = io.StringIO()
with redirect_stdout(output):
belief_search.cmd_analyze(graph, "asp543")
self.assertIn(
"(analyze app-service/app-service/src/workspace-operatives/workspace-operative.service",
output.getvalue(),
)
def test_search_returns_resolved_module_ids(self) -> None:
"""/* REQ-CS-002: search must emit resolved module IDs instead of raw path-map aliases. */"""
graph, sexp_path = self._load_graph()
output = io.StringIO()
with redirect_stdout(output):
belief_search.cmd_search(graph, "workspace-operative.service", sexp_path)
lines = output.getvalue().strip().splitlines()
self.assertEqual(
lines,
[
"(result app-service/app-service/src/workspace-operatives/workspace-operative.service)",
"(result app-service/app-service/src/workspace-operatives/workspace-operative.service.spec)",
"(result-count 2)",
],
)
def test_quick_runs_analyze_for_first_resolved_module(self) -> None:
"""/* REQ-CS-003: quick must provide full analyze output for the first matching module. */"""
graph, sexp_path = self._load_graph()
output = io.StringIO()
with redirect_stdout(output):
belief_search.cmd_quick(graph, "workspace-operative.service", sexp_path)
text = output.getvalue()
self.assertIn(
"(analyze app-service/app-service/src/workspace-operatives/workspace-operative.service",
text,
)
self.assertIn(
"(boundary-file app-service/app-service/src/workspace-operatives/workspace-operative.service",
text,
)
self.assertNotIn("(boundary app-service/app-service/src/workspace-operatives/workspace-operative.service", text)
def test_quick_resolves_alias_from_raw_search_hits(self) -> None:
"""/* REQ-CS-004: quick must resolve legacy path-map aliases found in raw belief-map facts. */"""
graph, sexp_path = self._load_graph()
output = io.StringIO()
with redirect_stdout(output):
belief_search.cmd_quick(graph, "WorkspaceOperativeService", sexp_path)
self.assertIn(
"(analyze app-service/app-service/src/workspace-operatives/workspace-operative.service",
output.getvalue(),
)
def test_boundary_files_only_emits_minimal_file_list(self) -> None:
"""/* REQ-CS-005: boundary --files-only must emit the target and directly related files only. */"""
graph, _sexp_path = self._load_graph()
output = io.StringIO()
with redirect_stdout(output):
belief_search.cmd_boundary(graph, "workspace-operative.service", files_only=True)
self.assertEqual(
output.getvalue().strip().splitlines(),
[
"app-service/app-service/src/workspace-operatives/workspace-operative.service",
"app-service/app-service/src/workspace-operatives/workspace-operative.service.spec",
],
)
def test_no_match_includes_suggestions(self) -> None:
"""/* REQ-CS-006: no-match errors must surface actionable module suggestions. */"""
graph, _sexp_path = self._load_graph()
output = io.StringIO()
with redirect_stdout(output):
belief_search.cmd_analyze(graph, "workspace-operative.controller")
text = output.getvalue()
self.assertIn("(error no-match workspace-operative.controller :suggestions", text)
self.assertIn("workspace-operative.service", text)
if __name__ == "__main__":
unittest.main()