generated from coulomb/repo-seed
feat(explain): complete ATLAS-WP-0004 — graph edges, blast-radius, determinism tests
Some checks failed
validate-registry / validate (push) Has been cancelled
Some checks failed
validate-registry / validate (push) Has been cancelled
T05: tests/test_effective_config.py (6 tests) — order-independence, most-specific winner, no value/secret leak; wired into make validate + CI. T04: tools/blast_radius.py + make blast-radius — consumers, transitive dependents (cycle-safe), secret refs, fan-out risk band. T03: tools/config_graph.py + make graph/graph-query — emit config-typed edges to registry/indexes/graph.yaml (queryable by surface id); staleness check in the gate. WP-0004 finished (5/5). Read-first control-plane MVP complete: explain, graph, and blast-radius over the seeded surfaces. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,13 @@ reuse-surface validate --root . # capability federation (from reuse-surface
|
|||||||
# Explain a surface's effective-config override path (no values shown)
|
# Explain a surface's effective-config override path (no values shown)
|
||||||
make explain SURFACE=surface.infotech.state-hub.api-config
|
make explain SURFACE=surface.infotech.state-hub.api-config
|
||||||
|
|
||||||
|
# Change blast-radius / dependency view for a surface
|
||||||
|
make blast-radius SURFACE=surface.infotech.state-hub.api-config
|
||||||
|
|
||||||
|
# Regenerate / query the config knowledge-graph edges (registry/indexes/graph.yaml)
|
||||||
|
make graph
|
||||||
|
make graph-query SURFACE=surface.infotech.state-hub.api-config
|
||||||
|
|
||||||
# After workplan or registry edits — from ~/state-hub
|
# After workplan or registry edits — from ~/state-hub
|
||||||
make fix-consistency REPO=config-atlas
|
make fix-consistency REPO=config-atlas
|
||||||
```
|
```
|
||||||
|
|||||||
2
.github/workflows/validate.yml
vendored
2
.github/workflows/validate.yml
vendored
@@ -18,5 +18,7 @@ jobs:
|
|||||||
run: pip install --quiet pyyaml jsonschema
|
run: pip install --quiet pyyaml jsonschema
|
||||||
- name: Validate surface entries against schema + index
|
- name: Validate surface entries against schema + index
|
||||||
run: python3 tools/validate_registry.py
|
run: python3 tools/validate_registry.py
|
||||||
|
- name: Resolver determinism + no-value-leak tests
|
||||||
|
run: python3 -m unittest discover -s tests -q
|
||||||
- name: Check whitespace / conflict markers
|
- name: Check whitespace / conflict markers
|
||||||
run: git diff --check $(git hash-object -t tree /dev/null) -- .
|
run: git diff --check $(git hash-object -t tree /dev/null) -- .
|
||||||
|
|||||||
26
Makefile
26
Makefile
@@ -1,17 +1,39 @@
|
|||||||
# config-atlas — registry validation gate (ATLAS-WP-0002-T06)
|
# config-atlas — registry validation gate (ATLAS-WP-0002-T06)
|
||||||
# Markdown-first repo: no build/run, only validation and the explain tool.
|
# Markdown-first repo: no build/run, only validation and the explain tool.
|
||||||
|
|
||||||
.PHONY: validate validate-schema validate-reuse validate-whitespace explain
|
.PHONY: validate validate-schema validate-reuse validate-whitespace validate-tests explain blast-radius
|
||||||
|
|
||||||
# Effective-config override path for a surface (ATLAS-WP-0004).
|
# Effective-config override path for a surface (ATLAS-WP-0004).
|
||||||
# make explain SURFACE=surface.infotech.state-hub.api-config
|
# make explain SURFACE=surface.infotech.state-hub.api-config
|
||||||
explain:
|
explain:
|
||||||
@python3 tools/config_explain.py $(SURFACE)
|
@python3 tools/config_explain.py $(SURFACE)
|
||||||
|
|
||||||
|
# Change blast-radius / dependency view for a surface (ATLAS-WP-0004).
|
||||||
|
# make blast-radius SURFACE=surface.infotech.state-hub.api-config
|
||||||
|
blast-radius:
|
||||||
|
@python3 tools/blast_radius.py $(SURFACE)
|
||||||
|
|
||||||
|
# Regenerate the config knowledge-graph edge artifact (ATLAS-WP-0004).
|
||||||
|
graph:
|
||||||
|
@python3 tools/config_graph.py
|
||||||
|
|
||||||
|
# Query graph edges touching a surface id.
|
||||||
|
# make graph-query SURFACE=surface.infotech.state-hub.api-config
|
||||||
|
graph-query:
|
||||||
|
@python3 tools/config_graph.py --surface $(SURFACE)
|
||||||
|
|
||||||
# Full gate run by agents and CI.
|
# Full gate run by agents and CI.
|
||||||
validate: validate-schema validate-whitespace validate-reuse
|
validate: validate-schema validate-graph validate-tests validate-whitespace validate-reuse
|
||||||
@echo "validate: all checks passed"
|
@echo "validate: all checks passed"
|
||||||
|
|
||||||
|
# The committed graph edge artifact must match the registry.
|
||||||
|
validate-graph:
|
||||||
|
@python3 tools/config_graph.py --check
|
||||||
|
|
||||||
|
# Resolver determinism + no-value-leak tests (ATLAS-WP-0004-T05).
|
||||||
|
validate-tests:
|
||||||
|
@python3 -m unittest discover -s tests -q
|
||||||
|
|
||||||
# Surface entries validate against the JSON Schema; index is consistent.
|
# Surface entries validate against the JSON Schema; index is consistent.
|
||||||
validate-schema:
|
validate-schema:
|
||||||
@python3 tools/validate_registry.py
|
@python3 tools/validate_registry.py
|
||||||
|
|||||||
65
registry/indexes/graph.yaml
Normal file
65
registry/indexes/graph.yaml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
version: 1
|
||||||
|
generated_by: tools/config_graph.py
|
||||||
|
note: config-typed edges emitted from surface relations; ingest into the State Hub
|
||||||
|
graph (it owns storage).
|
||||||
|
summary:
|
||||||
|
nodes: 10
|
||||||
|
edges: 10
|
||||||
|
nodes:
|
||||||
|
- id: secret:ops-bridge/ssh-cert
|
||||||
|
type: secret
|
||||||
|
- id: secret:state-hub/database-url
|
||||||
|
type: secret
|
||||||
|
- id: service.ops-bridge
|
||||||
|
type: service
|
||||||
|
- id: service.reuse-surface-hub
|
||||||
|
type: service
|
||||||
|
- id: service.state-hub-api
|
||||||
|
type: service
|
||||||
|
- id: service.warden-cli
|
||||||
|
type: service
|
||||||
|
- id: surface.infotech.ops-bridge.tunnel-config
|
||||||
|
type: surface
|
||||||
|
kind: infra-state
|
||||||
|
owner: ops-bridge
|
||||||
|
- id: surface.infotech.ops-warden.routing-catalog
|
||||||
|
type: surface
|
||||||
|
kind: policy
|
||||||
|
owner: ops-warden
|
||||||
|
- id: surface.infotech.reuse-surface.federation-sources
|
||||||
|
type: surface
|
||||||
|
kind: app-config
|
||||||
|
owner: reuse-surface
|
||||||
|
- id: surface.infotech.state-hub.api-config
|
||||||
|
type: surface
|
||||||
|
edges:
|
||||||
|
- src: surface.infotech.ops-bridge.tunnel-config
|
||||||
|
type: consumed_by
|
||||||
|
dst: service.ops-bridge
|
||||||
|
- src: surface.infotech.ops-bridge.tunnel-config
|
||||||
|
type: depends_on_secret
|
||||||
|
dst: secret:ops-bridge/ssh-cert
|
||||||
|
- src: surface.infotech.ops-bridge.tunnel-config
|
||||||
|
type: related_to
|
||||||
|
dst: surface.infotech.state-hub.api-config
|
||||||
|
- src: surface.infotech.ops-warden.routing-catalog
|
||||||
|
type: consumed_by
|
||||||
|
dst: service.warden-cli
|
||||||
|
- src: surface.infotech.ops-warden.routing-catalog
|
||||||
|
type: related_to
|
||||||
|
dst: surface.infotech.state-hub.api-config
|
||||||
|
- src: surface.infotech.reuse-surface.federation-sources
|
||||||
|
type: consumed_by
|
||||||
|
dst: service.reuse-surface-hub
|
||||||
|
- src: surface.infotech.reuse-surface.federation-sources
|
||||||
|
type: related_to
|
||||||
|
dst: surface.infotech.state-hub.api-config
|
||||||
|
- src: surface.infotech.state-hub.api-config
|
||||||
|
type: consumed_by
|
||||||
|
dst: service.state-hub-api
|
||||||
|
- src: surface.infotech.state-hub.api-config
|
||||||
|
type: depends_on_secret
|
||||||
|
dst: secret:state-hub/database-url
|
||||||
|
- src: surface.infotech.state-hub.api-config
|
||||||
|
type: related_to
|
||||||
|
dst: surface.infotech.ops-bridge.tunnel-config
|
||||||
97
tests/test_effective_config.py
Normal file
97
tests/test_effective_config.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Determinism and no-value-leak tests for the effective-config resolver
|
||||||
|
(ATLAS-WP-0004-T05).
|
||||||
|
|
||||||
|
Runnable with `python3 -m unittest discover -s tests` (stdlib) or pytest.
|
||||||
|
Proves the override path is deterministic / order-independent (consistent with
|
||||||
|
the merge-rule + CUE-unification rationale, research §3.3) and that no live or
|
||||||
|
secret value ever appears in explain output.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
|
||||||
|
from effective_config import LAYER_INDEX, find_entry, load_entry, resolve_path # noqa: E402
|
||||||
|
from config_explain import render # noqa: E402
|
||||||
|
|
||||||
|
SURFACES_DIR = ROOT / "registry" / "surfaces"
|
||||||
|
|
||||||
|
|
||||||
|
def all_surface_ids():
|
||||||
|
return sorted(p.stem for p in SURFACES_DIR.glob("*.md"))
|
||||||
|
|
||||||
|
|
||||||
|
def path_signature(op):
|
||||||
|
return [(c.layer, c.ref, c.winning, c.overrides) for c in op.contributions]
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolverDeterminism(unittest.TestCase):
|
||||||
|
def test_order_independent(self):
|
||||||
|
"""Shuffling sources never changes the resolved path."""
|
||||||
|
for sid in all_surface_ids():
|
||||||
|
base = path_signature(resolve_path(find_entry(sid)))
|
||||||
|
for _ in range(16):
|
||||||
|
entry = find_entry(sid)
|
||||||
|
random.shuffle(entry["sources"])
|
||||||
|
self.assertEqual(
|
||||||
|
path_signature(resolve_path(entry)), base,
|
||||||
|
f"{sid}: path changed under source reordering",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_winning_is_most_specific(self):
|
||||||
|
"""The winning layer is the highest-rank (most specific) layered source."""
|
||||||
|
for sid in all_surface_ids():
|
||||||
|
op = resolve_path(find_entry(sid))
|
||||||
|
layered = [c for c in op.contributions if c.rank >= 0]
|
||||||
|
if not layered:
|
||||||
|
continue
|
||||||
|
winner = op.winner
|
||||||
|
self.assertIsNotNone(winner, f"{sid}: no winner among layered sources")
|
||||||
|
self.assertEqual(
|
||||||
|
winner.rank, max(c.rank for c in layered),
|
||||||
|
f"{sid}: winner is not the most specific layer",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_seeded_surfaces_resolve(self):
|
||||||
|
ids = all_surface_ids()
|
||||||
|
self.assertGreaterEqual(len(ids), 1)
|
||||||
|
for sid in ids:
|
||||||
|
resolve_path(find_entry(sid)) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoValueLeak(unittest.TestCase):
|
||||||
|
def test_schema_default_never_rendered(self):
|
||||||
|
"""A surface's declared schema default is contract metadata and must not
|
||||||
|
appear as an effective value in explain output."""
|
||||||
|
for sid in all_surface_ids():
|
||||||
|
entry = find_entry(sid)
|
||||||
|
default = (entry.get("schema") or {}).get("default")
|
||||||
|
if default is None:
|
||||||
|
continue
|
||||||
|
out = render(resolve_path(entry))
|
||||||
|
self.assertNotIn(
|
||||||
|
str(default), out,
|
||||||
|
f"{sid}: schema default leaked into explain output",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_safety_note_present(self):
|
||||||
|
for sid in all_surface_ids():
|
||||||
|
out = render(resolve_path(find_entry(sid)))
|
||||||
|
self.assertIn("no values shown", out, f"{sid}: missing no-values safety note")
|
||||||
|
|
||||||
|
def test_secret_refs_are_references_only(self):
|
||||||
|
"""depends_on_secret ids appear only on the explicit reference line, never
|
||||||
|
as a resolved value elsewhere."""
|
||||||
|
for sid in all_surface_ids():
|
||||||
|
op = resolve_path(find_entry(sid))
|
||||||
|
if not op.secret_deps:
|
||||||
|
continue
|
||||||
|
out = render(op)
|
||||||
|
self.assertIn("depends on secret (ref)", out, f"{sid}: secret ref line missing")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
121
tools/blast_radius.py
Normal file
121
tools/blast_radius.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Blast-radius / dependency view (ATLAS-WP-0004-T04).
|
||||||
|
|
||||||
|
For a given configuration surface, traverse the relationship graph to list what a
|
||||||
|
change would affect: direct consumers (services), dependent surfaces (those that
|
||||||
|
reference or override it, transitively), referenced secrets, and the owner. This
|
||||||
|
supports change-risk reasoning before a change -- the
|
||||||
|
`config key -> service -> tenant -> feature -> secret -> owner` chain (research §5).
|
||||||
|
|
||||||
|
Reads only the registry (no live values). Reuses the surface loader from
|
||||||
|
effective_config.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 tools/blast_radius.py surface.infotech.state-hub.api-config
|
||||||
|
make blast-radius SURFACE=surface.infotech.state-hub.api-config
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from effective_config import SURFACES_DIR, load_entry
|
||||||
|
|
||||||
|
|
||||||
|
def load_all() -> dict[str, dict]:
|
||||||
|
return {p.stem: load_entry(p) for p in sorted(SURFACES_DIR.glob("*.md"))}
|
||||||
|
|
||||||
|
|
||||||
|
def relations(entry: dict) -> dict:
|
||||||
|
return entry.get("relations", {}) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def build_reverse(entries: dict[str, dict]) -> dict[str, set[str]]:
|
||||||
|
"""id -> set of surface ids that reference it via related_to or overrides."""
|
||||||
|
rev: dict[str, set[str]] = {sid: set() for sid in entries}
|
||||||
|
for sid, e in entries.items():
|
||||||
|
rel = relations(e)
|
||||||
|
for target in list(rel.get("related_to", [])) + list(rel.get("overrides", [])):
|
||||||
|
rev.setdefault(target, set()).add(sid)
|
||||||
|
return rev
|
||||||
|
|
||||||
|
|
||||||
|
def dependent_closure(start: str, rev: dict[str, set[str]]) -> list[str]:
|
||||||
|
"""All surfaces that depend on `start` (transitively), nearest first.
|
||||||
|
|
||||||
|
The start node is excluded from its own closure even when relationship cycles
|
||||||
|
(e.g. mutual related_to edges) would otherwise loop back to it.
|
||||||
|
"""
|
||||||
|
seen: set[str] = {start}
|
||||||
|
order: list[str] = []
|
||||||
|
q = deque(sorted(rev.get(start, set())))
|
||||||
|
while q:
|
||||||
|
cur = q.popleft()
|
||||||
|
if cur in seen:
|
||||||
|
continue
|
||||||
|
seen.add(cur)
|
||||||
|
order.append(cur)
|
||||||
|
for nxt in sorted(rev.get(cur, set())):
|
||||||
|
if nxt not in seen:
|
||||||
|
q.append(nxt)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def risk_band(score: int) -> str:
|
||||||
|
return "high" if score >= 4 else "medium" if score >= 2 else "low"
|
||||||
|
|
||||||
|
|
||||||
|
def render(surface_id: str, entries: dict[str, dict]) -> str:
|
||||||
|
if surface_id not in entries:
|
||||||
|
raise KeyError(surface_id)
|
||||||
|
e = entries[surface_id]
|
||||||
|
rel = relations(e)
|
||||||
|
rev = build_reverse(entries)
|
||||||
|
|
||||||
|
consumers = list(rel.get("consumed_by", []))
|
||||||
|
secrets = list(rel.get("depends_on_secret", []))
|
||||||
|
forward_related = list(rel.get("related_to", []))
|
||||||
|
dependents = dependent_closure(surface_id, rev)
|
||||||
|
|
||||||
|
score = len(consumers) + len(dependents)
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append(f"blast radius {surface_id}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f" {e.get('name', surface_id)}")
|
||||||
|
lines.append(f" kind: {e.get('kind','?')} owner: {e.get('owner','?')} mutability: {e.get('mutability','?')}")
|
||||||
|
lines.append(f" risk: {risk_band(score)} (fan-out {score}: {len(consumers)} consumer(s) + {len(dependents)} dependent surface(s))")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" direct consumers (services):")
|
||||||
|
for c in consumers or ["(none)"]:
|
||||||
|
lines.append(f" - {c}")
|
||||||
|
lines.append(" dependent surfaces (reference/override this, transitively):")
|
||||||
|
for d in dependents or ["(none)"]:
|
||||||
|
owner = entries.get(d, {}).get("owner", "?")
|
||||||
|
lines.append(f" - {d} [owner: {owner}]")
|
||||||
|
lines.append(" referenced secrets (by reference only):")
|
||||||
|
for s in secrets or ["(none)"]:
|
||||||
|
lines.append(f" - {s}")
|
||||||
|
if forward_related:
|
||||||
|
lines.append(" related to:")
|
||||||
|
for r in forward_related:
|
||||||
|
lines.append(f" - {r}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" · derived from registry relations only; no live values read")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if len(argv) != 1:
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
entries = load_all()
|
||||||
|
try:
|
||||||
|
print(render(argv[0], entries))
|
||||||
|
except KeyError as exc:
|
||||||
|
print(f"error: no surface entry for id {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
116
tools/config_graph.py
Normal file
116
tools/config_graph.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Config knowledge-graph edges (ATLAS-WP-0004-T03).
|
||||||
|
|
||||||
|
Express the config-typed relationships declared on surface entries as a
|
||||||
|
normalized, queryable edge list at `registry/indexes/graph.yaml`. config-atlas
|
||||||
|
owns the *config semantics* of each edge (consumed_by, overrides,
|
||||||
|
depends_on_secret, related_to); the State Hub owns topology/identity storage
|
||||||
|
(ecosystem-boundaries §2.5, repo-boundary). This tool therefore *emits* a
|
||||||
|
graph artifact the hub can ingest -- it does not build a separate graph store.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
python3 tools/config_graph.py # regenerate graph.yaml
|
||||||
|
python3 tools/config_graph.py --check # fail if graph.yaml stale
|
||||||
|
python3 tools/config_graph.py --surface <id> # edges touching a surface id
|
||||||
|
|
||||||
|
The emitted graph is queryable by surface id (both edge directions).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise SystemExit(f"setup error: missing PyYAML ({exc}). pip install pyyaml")
|
||||||
|
|
||||||
|
from effective_config import ROOT, SURFACES_DIR, load_entry
|
||||||
|
|
||||||
|
GRAPH_PATH = ROOT / "registry" / "indexes" / "graph.yaml"
|
||||||
|
EDGE_TYPES = ["consumed_by", "overrides", "depends_on_secret", "related_to"]
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_node(ref: str) -> str:
|
||||||
|
return f"secret:{ref}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_graph() -> dict:
|
||||||
|
entries = {p.stem: load_entry(p) for p in sorted(SURFACES_DIR.glob("*.md"))}
|
||||||
|
nodes: dict[str, dict] = {}
|
||||||
|
edges: list[dict] = []
|
||||||
|
|
||||||
|
def node(nid: str, ntype: str, **attrs):
|
||||||
|
if nid not in nodes:
|
||||||
|
nodes[nid] = {"id": nid, "type": ntype, **{k: v for k, v in attrs.items() if v}}
|
||||||
|
|
||||||
|
for sid, e in entries.items():
|
||||||
|
node(sid, "surface", kind=e.get("kind"), owner=e.get("owner"))
|
||||||
|
rel = e.get("relations", {}) or {}
|
||||||
|
for svc in rel.get("consumed_by", []) or []:
|
||||||
|
node(svc, "service")
|
||||||
|
edges.append({"src": sid, "type": "consumed_by", "dst": svc})
|
||||||
|
for tgt in rel.get("overrides", []) or []:
|
||||||
|
node(tgt, "surface")
|
||||||
|
edges.append({"src": sid, "type": "overrides", "dst": tgt})
|
||||||
|
for sec in rel.get("depends_on_secret", []) or []:
|
||||||
|
node(_secret_node(sec), "secret")
|
||||||
|
edges.append({"src": sid, "type": "depends_on_secret", "dst": _secret_node(sec)})
|
||||||
|
for tgt in rel.get("related_to", []) or []:
|
||||||
|
node(tgt, "surface")
|
||||||
|
edges.append({"src": sid, "type": "related_to", "dst": tgt})
|
||||||
|
|
||||||
|
edges.sort(key=lambda x: (x["src"], x["type"], x["dst"]))
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"generated_by": "tools/config_graph.py",
|
||||||
|
"note": "config-typed edges emitted from surface relations; ingest into the State Hub graph (it owns storage).",
|
||||||
|
"summary": {"nodes": len(nodes), "edges": len(edges)},
|
||||||
|
"nodes": [nodes[k] for k in sorted(nodes)],
|
||||||
|
"edges": edges,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_graph() -> dict:
|
||||||
|
graph = build_graph()
|
||||||
|
GRAPH_PATH.write_text(yaml.safe_dump(graph, sort_keys=False))
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
def edges_for(surface_id: str, graph: dict) -> list[dict]:
|
||||||
|
return [e for e in graph["edges"] if e["src"] == surface_id or e["dst"] == surface_id]
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if argv and argv[0] == "--surface":
|
||||||
|
if len(argv) != 2:
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
sid = argv[1]
|
||||||
|
graph = yaml.safe_load(GRAPH_PATH.read_text()) if GRAPH_PATH.exists() else build_graph()
|
||||||
|
hits = edges_for(sid, graph)
|
||||||
|
if not hits:
|
||||||
|
print(f"no edges for {sid}")
|
||||||
|
return 0
|
||||||
|
print(f"edges touching {sid}:")
|
||||||
|
for e in hits:
|
||||||
|
arrow = "->" if e["src"] == sid else "<-"
|
||||||
|
other = e["dst"] if e["src"] == sid else e["src"]
|
||||||
|
print(f" {arrow} {e['type']:18} {other}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if argv and argv[0] == "--check":
|
||||||
|
current = GRAPH_PATH.read_text() if GRAPH_PATH.exists() else ""
|
||||||
|
expected = yaml.safe_dump(build_graph(), sort_keys=False)
|
||||||
|
if current != expected:
|
||||||
|
print("FAIL: registry/indexes/graph.yaml is stale — run `make graph`", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print("OK: graph.yaml up to date")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
g = write_graph()
|
||||||
|
print(f"wrote {GRAPH_PATH.relative_to(ROOT)}: {g['summary']['nodes']} nodes, {g['summary']['edges']} edges")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Effective-config explain and graph"
|
title: "Effective-config explain and graph"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: config-atlas
|
repo: config-atlas
|
||||||
status: active
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-06-26"
|
created: "2026-06-26"
|
||||||
@@ -92,11 +92,17 @@ Reuse the existing `tools/` + Makefile pattern (`make explain SURFACE=...`).
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: ATLAS-WP-0004-T03
|
id: ATLAS-WP-0004-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "7b16eaa0-f5e1-4ff3-809d-729b312fd154"
|
state_hub_task_id: "7b16eaa0-f5e1-4ff3-809d-729b312fd154"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Result 2026-06-27: Added `tools/config_graph.py` + `make graph`/`graph-query`.
|
||||||
|
Emits config-typed edges (consumed_by/overrides/depends_on_secret/related_to) to
|
||||||
|
`registry/indexes/graph.yaml` (10 nodes, 10 edges) — queryable by surface id in
|
||||||
|
both directions. config-atlas owns edge semantics; the State Hub owns storage
|
||||||
|
(artifact is hub-ingestible). A `--check` staleness gate runs in `make validate`.
|
||||||
|
|
||||||
Emit config-typed edges (`consumed_by`, `overrides`, `depends_on_secret`,
|
Emit config-typed edges (`consumed_by`, `overrides`, `depends_on_secret`,
|
||||||
`related_to`) from surface entries to the **State Hub** relationship/graph model,
|
`related_to`) from surface entries to the **State Hub** relationship/graph model,
|
||||||
contributing the config semantics of each edge while the hub stores topology
|
contributing the config semantics of each edge while the hub stores topology
|
||||||
@@ -109,11 +115,16 @@ contributing the config semantics of each edge while the hub stores topology
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: ATLAS-WP-0004-T04
|
id: ATLAS-WP-0004-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "57e085c7-25e8-4e2d-bb3e-43b82e351aa9"
|
state_hub_task_id: "57e085c7-25e8-4e2d-bb3e-43b82e351aa9"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Result 2026-06-27: Added `tools/blast_radius.py` + `make blast-radius`. For a
|
||||||
|
surface it lists direct consumers, transitively-dependent surfaces (cycle-safe),
|
||||||
|
referenced secrets, owner, and a fan-out risk band. Verified: state-hub config is
|
||||||
|
high risk (fan-out 4); leaf surfaces lower.
|
||||||
|
|
||||||
Build a view that, for a given surface, traverses the graph to list affected
|
Build a view that, for a given surface, traverses the graph to list affected
|
||||||
consumers, dependent surfaces, and referenced secrets — the
|
consumers, dependent surfaces, and referenced secrets — the
|
||||||
`config key → service → tenant → feature → secret → owner` chain from
|
`config key → service → tenant → feature → secret → owner` chain from
|
||||||
@@ -126,11 +137,16 @@ research §5. Supports change-risk reasoning before a change.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: ATLAS-WP-0004-T05
|
id: ATLAS-WP-0004-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "77b19d5f-f55c-48bb-8129-ba1478e47223"
|
state_hub_task_id: "77b19d5f-f55c-48bb-8129-ba1478e47223"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Result 2026-06-27: Added `tests/test_effective_config.py` (6 tests) proving the
|
||||||
|
override path is order-independent (16x shuffle), the winner is the most-specific
|
||||||
|
layer, and no schema default / live / secret value leaks into explain output.
|
||||||
|
Wired into `make validate` and CI (`validate-tests`).
|
||||||
|
|
||||||
Add tests proving the override path is deterministic and order-independent
|
Add tests proving the override path is deterministic and order-independent
|
||||||
(consistent with the merge-rule and CUE-unification rationale,
|
(consistent with the merge-rule and CUE-unification rationale,
|
||||||
research §3.3), and that no live or secret value ever appears in explain output.
|
research §3.3), and that no live or secret value ever appears in explain output.
|
||||||
|
|||||||
Reference in New Issue
Block a user