pgc-inspector 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
inspector/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """snapshot_inspector — read-only inspection over assembled PGC snapshots.
2
+
3
+ A first-class toolchain component, symmetric with the compiler / assembler / runtime — each
4
+ owns one phase of the snapshot lifecycle:
5
+
6
+ protocol_compiler source → compiled projections
7
+ snapshot_assembler projections → assembled snapshot
8
+ protocol_runtime snapshot → execution
9
+ snapshot_inspector snapshot → inspection (this repo)
10
+
11
+ CONSUMER-NEUTRAL: the transport SNAPSHOT_READ/SNAPSHOT_QUERY handlers are one consumer; a `si`
12
+ CLI, CI gates (validate/violations), and change-management dossiers are others. The API makes
13
+ no web/transport assumptions.
14
+
15
+ Inspection never mutates and never executes. Phase 2a projects PUBLISHED snapshot material;
16
+ Phase 2b derives governed queries (refs/deps/impact/validation) over it.
17
+ """
inspector/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """`python -m inspector` → the `si` CLI, so the tool runs without being installed."""
2
+ import sys
3
+
4
+ from inspector.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
inspector/api.py ADDED
@@ -0,0 +1,94 @@
1
+ """inspector.api — the single programmatic entry point for snapshot inspection.
2
+
3
+ query(operation, params, snapshot_root) -> (status, payload)
4
+ operations(snapshot_root) -> the published catalog
5
+ operation_kind(operation, snapshot_root) -> the handler kind a boundary routes it by
6
+
7
+ `status` ∈ {"SUCCESS", "NOT_FOUND"}; raises on internal error. This mirrors
8
+ `runtime.api.run_workflow`'s `(status, surface)` so any boundary (e.g. the transport
9
+ SNAPSHOT_READ handler) can treat execution and inspection uniformly.
10
+
11
+ **The snapshot declares the operation set.** Every operation, its accepted parameters, its handler
12
+ kind and the implementation that answers it are read from the snapshot's compiled `inspection::` TI
13
+ artifacts (`inspector.catalog`); `inspector.registry` supplies implementations and declares no
14
+ metadata. So an inspector pointed at a snapshot answers exactly what that snapshot declares — two
15
+ snapshots may legitimately offer different operations, and neither is what the code privately
16
+ believes.
17
+
18
+ A missing REQUIRED parameter is NOT_FOUND, not a raise: the caller asked a well-formed question the
19
+ snapshot cannot answer as posed. An operation the snapshot does not declare raises, because no
20
+ amount of parameters would make it answerable here — that is a caller defect, not a result.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from inspector.catalog import load_catalog
28
+ from inspector.kinds import CATEGORIES
29
+ from inspector.snapshot import Snapshot
30
+
31
+
32
+ def _resolve(operation: str, snapshot: Snapshot):
33
+ catalog = load_catalog(snapshot)
34
+ op = catalog.get(operation)
35
+ if op is None:
36
+ raise KeyError(
37
+ f"snapshot at {snapshot.root} declares no inspection operation {operation!r}"
38
+ )
39
+ return op
40
+
41
+
42
+ def query(
43
+ operation: str,
44
+ params: dict[str, Any],
45
+ snapshot_root: str | Path,
46
+ ) -> tuple[str, dict[str, Any]]:
47
+ """Answer an inspection Operation Identity over the assembled snapshot at `snapshot_root`."""
48
+ snapshot = Snapshot(Path(snapshot_root))
49
+ op = _resolve(operation, snapshot)
50
+
51
+ params = params or {}
52
+ missing = [p for p in op.required if params.get(p) in (None, "")]
53
+ if missing:
54
+ return "NOT_FOUND", {
55
+ "operation": operation,
56
+ "reason": f"missing required parameter(s): {', '.join(missing)}",
57
+ "params": list(op.params),
58
+ }
59
+
60
+ return op.handler(snapshot, params)
61
+
62
+
63
+ def operation_kind(operation: str, snapshot_root: str | Path) -> str:
64
+ """The handler kind (`SNAPSHOT_READ` / `SNAPSHOT_QUERY`) a boundary routes this operation by."""
65
+ return _resolve(operation, Snapshot(Path(snapshot_root))).kind
66
+
67
+
68
+ def operations(snapshot_root: str | Path) -> list[dict[str, Any]]:
69
+ """The inspection catalog — every operation the snapshot declares, in category then identity order.
70
+
71
+ Published so a client's menu is what the SNAPSHOT offers. There is no second list: the CLI's
72
+ commands and the surface's launcher are both built from this, and this is built from the
73
+ compiled contracts.
74
+ """
75
+ snapshot = Snapshot(Path(snapshot_root))
76
+ return [
77
+ {
78
+ "operation": op.identity,
79
+ "kind": op.kind,
80
+ "category": op.category,
81
+ "label": op.label,
82
+ "params": list(op.params),
83
+ "required": list(op.required),
84
+ "flags": list(op.flags),
85
+ "summary": op.summary,
86
+ "implementation": op.implementation,
87
+ }
88
+ for op in load_catalog(snapshot).values()
89
+ ]
90
+
91
+
92
+ def categories() -> tuple[str, ...]:
93
+ """The catalog groupings, in presentation order."""
94
+ return CATEGORIES
inspector/catalog.py ADDED
@@ -0,0 +1,135 @@
1
+ """The operation catalog, read from the snapshot's own `inspection::` TI artifacts.
2
+
3
+ **The protocol declares which operations exist; the code only obeys.** An operation's identity,
4
+ handler kind, accepted parameters and their types, which are required, how it is presented, and
5
+ which implementation answers it are all declared by a compiled TI artifact. Nothing in Python
6
+ enumerates the operation set — `inspector.registry` holds implementations and no metadata at all.
7
+
8
+ Adding, removing, renaming or re-pointing an operation is therefore an authoring act on an
9
+ artifact that is compiled, sealed into the snapshot and attested. It is visible in a snapshot
10
+ diff, and it cannot happen silently in code.
11
+
12
+ TI frontmatter Operation field
13
+ ───────────────────────────────────── ───────────────
14
+ operation identity
15
+ handler.kind kind
16
+ handler.implementation {module,callable} implementation → registry lookup
17
+ input_contract {name: {type, required}} params / required / flags
18
+ catalog {category, label, summary} presentation
19
+
20
+ `flags` is derived, not declared twice: a parameter is a flag exactly when its declared type is
21
+ boolean. A client renders those as switches.
22
+
23
+ Read once per `Snapshot` instance. If the snapshot carries no inspection contracts this raises —
24
+ an inspector pointed at a snapshot that declares no operations has nothing to offer, and saying so
25
+ is more useful than answering from a private list the snapshot does not know about.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+ from typing import Any
31
+
32
+ from inspector.kinds import CATEGORIES, INSPECTION_KINDS as _INSPECTION_KINDS
33
+ from inspector.registry import Projection, resolve_implementation
34
+ from inspector.snapshot import Snapshot, SnapshotError
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Operation:
39
+ identity: str
40
+ kind: str # SNAPSHOT_READ | SNAPSHOT_QUERY
41
+ category: str # catalog grouping
42
+ label: str # short human name for a menu entry
43
+ params: tuple[str, ...] # accepted parameter names
44
+ required: tuple[str, ...] # those without which the operation cannot answer
45
+ flags: tuple[str, ...] # of `params`, the ones declared boolean
46
+ summary: str
47
+ implementation: str # "<module>:<callable>", as the artifact declares it
48
+ handler: Projection # what that reference resolves to
49
+
50
+
51
+ def _operation_from_ti(frontmatter: dict[str, Any], source: str) -> Operation:
52
+ identity = frontmatter.get("operation")
53
+ handler = frontmatter.get("handler") or {}
54
+ kind = handler.get("kind")
55
+ if not identity:
56
+ raise SnapshotError(f"inspection TI declares no operation identity: {source}")
57
+ if kind not in _INSPECTION_KINDS:
58
+ raise SnapshotError(
59
+ f"inspection TI {identity!r} declares handler kind {kind!r}; expected one of "
60
+ f"{list(_INSPECTION_KINDS)} ({source})"
61
+ )
62
+
63
+ declared_impl = handler.get("implementation") or {}
64
+ module = declared_impl.get("module")
65
+ callable_name = declared_impl.get("callable")
66
+ if not module or not callable_name:
67
+ raise SnapshotError(
68
+ f"inspection TI {identity!r} declares no handler.implementation {{module, callable}} "
69
+ f"({source}) — an operation whose implementation is not declared is bound by code, "
70
+ "not by protocol"
71
+ )
72
+
73
+ contract = frontmatter.get("input_contract") or {}
74
+ params = tuple(contract)
75
+ required = tuple(p for p in params if (contract[p] or {}).get("required") is True)
76
+ flags = tuple(p for p in params if (contract[p] or {}).get("type") == "boolean")
77
+
78
+ presentation = frontmatter.get("catalog") or {}
79
+ category = presentation.get("category") or "SNAPSHOT"
80
+ if category not in CATEGORIES:
81
+ raise SnapshotError(
82
+ f"inspection TI {identity!r} declares category {category!r}, which is not a known "
83
+ f"catalog category {list(CATEGORIES)} ({source})"
84
+ )
85
+
86
+ return Operation(
87
+ identity=identity,
88
+ kind=kind,
89
+ category=category,
90
+ label=presentation.get("label") or identity,
91
+ params=params,
92
+ required=required,
93
+ flags=flags,
94
+ summary=presentation.get("summary") or "",
95
+ implementation=f"{module}:{callable_name}",
96
+ handler=resolve_implementation(module, callable_name),
97
+ )
98
+
99
+
100
+ def load_catalog(snapshot: Snapshot) -> dict[str, Operation]:
101
+ """Every inspection operation the snapshot declares, keyed by Operation Identity."""
102
+ cached = snapshot.cached("__catalog__")
103
+ if cached is not None:
104
+ return cached
105
+
106
+ operations: dict[str, Operation] = {}
107
+ for fqdn, entry in snapshot.entries().items():
108
+ if entry.get("kind") != "TI":
109
+ continue
110
+ canonical = snapshot.canonical(fqdn) or {}
111
+ frontmatter = canonical.get("frontmatter") or {}
112
+ handler = frontmatter.get("handler") or {}
113
+ if handler.get("kind") not in _INSPECTION_KINDS:
114
+ continue # an execution TI belongs to a workload, not to this catalog
115
+ operation = _operation_from_ti(frontmatter, fqdn)
116
+ if operation.identity in operations:
117
+ raise SnapshotError(
118
+ f"two inspection TIs declare operation {operation.identity!r} — an Operation "
119
+ "Identity must resolve to exactly one contract"
120
+ )
121
+ operations[operation.identity] = operation
122
+
123
+ if not operations:
124
+ raise SnapshotError(
125
+ f"snapshot at {snapshot.root} declares no inspection operations "
126
+ "(no TI artifact with a SNAPSHOT_READ/SNAPSHOT_QUERY handler kind) — "
127
+ "compile and assemble the inspection domain before inspecting"
128
+ )
129
+
130
+ ordered = dict(sorted(
131
+ operations.items(),
132
+ key=lambda kv: (CATEGORIES.index(kv[1].category), kv[0]),
133
+ ))
134
+ snapshot.cache("__catalog__", ordered)
135
+ return ordered
inspector/cli.py ADDED
@@ -0,0 +1,265 @@
1
+ """`si` — the command-line client of `inspector.api`.
2
+
3
+ **Generated from the SNAPSHOT, not hand-written.** Every command, its arguments and its help text
4
+ come from the inspection contracts the snapshot declares; there is no per-operation code here.
5
+ That is deliberate twice over: a CLI with hand-written commands accretes capability the API does
6
+ not have and becomes a second inspection engine, and a CLI built from a private list would offer
7
+ commands the snapshot in front of it never declared. Authoring a TI artifact is the only way to
8
+ add a command, and it is then automatically present.
9
+
10
+ si <group> <verb> [args] one command per Operation Identity: si.artifact.show → si artifact show
11
+ si operations the catalog itself
12
+ si --snapshot PATH … which snapshot to read
13
+
14
+ Snapshot resolution, in order: `--snapshot`, `$PGC_SNAPSHOT_ROOT`, `./snapshot`. The only
15
+ requirement is that the directory carry a `manifest.json` — an ASSEMBLED snapshot is the input
16
+ contract, and nothing else is gated on.
17
+
18
+ Exit codes: 0 on SUCCESS, 1 on NOT_FOUND, 2 on usage or snapshot error. `si snapshot validate
19
+ --strict` additionally exits 1 when the snapshot is invalid, so it works as a CI gate.
20
+
21
+ Stdlib only (argparse) — this repo installs nothing.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import sys
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from inspector.api import query
33
+ from inspector.catalog import Operation, load_catalog
34
+ from inspector.kinds import CATEGORIES
35
+ from inspector.snapshot import Snapshot, SnapshotError
36
+
37
+ _DEFAULT_SNAPSHOT = "snapshot"
38
+
39
+
40
+ def _snapshot_root(argv: list[str]) -> Path:
41
+ """Resolve the snapshot BEFORE the parser exists.
42
+
43
+ The commands are generated from the snapshot's declared operations, so the snapshot has to be
44
+ located first — `--snapshot` is read by a deliberately minimal pre-pass rather than by the
45
+ parser it is needed to build. This is the one place the ordering is inverted, and it is
46
+ inverted because the protocol, not the code, decides what commands exist.
47
+ """
48
+ pre = argparse.ArgumentParser(add_help=False)
49
+ pre.add_argument("--snapshot", default=None)
50
+ known, _ = pre.parse_known_args(argv)
51
+ return Path(known.snapshot or os.environ.get("PGC_SNAPSHOT_ROOT") or _DEFAULT_SNAPSHOT)
52
+
53
+
54
+ def _group_and_verb(identity: str) -> tuple[str, str]:
55
+ """`si.artifact.show` → ('artifact', 'show'); `si.behavior_logic.show` → ('behavior_logic', 'show')."""
56
+ parts = identity.split(".")
57
+ return parts[1], ".".join(parts[2:])
58
+
59
+
60
+ def _dest(param: str) -> str:
61
+ return f"param_{param}"
62
+
63
+
64
+ def _build_parser(operations: dict[str, Operation]
65
+ ) -> tuple[argparse.ArgumentParser, dict[tuple[str, str], Operation]]:
66
+ parser = argparse.ArgumentParser(
67
+ prog="si",
68
+ description="Read-only inspection of an assembled PGC snapshot.",
69
+ )
70
+ parser.add_argument("--snapshot", default=None, metavar="PATH",
71
+ help="snapshot root (default: $PGC_SNAPSHOT_ROOT, else ./snapshot)")
72
+ parser.add_argument("--json", action="store_true", help="emit the raw payload as JSON")
73
+ groups = parser.add_subparsers(dest="group", metavar="<group>")
74
+
75
+ # The same two options after the verb, where a reader naturally reaches for them
76
+ # (`si artifact list --json`). SUPPRESS so an unused subcommand copy never overwrites the
77
+ # value already parsed at the top level.
78
+ common = argparse.ArgumentParser(add_help=False)
79
+ common.add_argument("--snapshot", metavar="PATH", default=argparse.SUPPRESS)
80
+ common.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
81
+
82
+ by_group: dict[str, list[Operation]] = {}
83
+ for op in operations.values():
84
+ by_group.setdefault(_group_and_verb(op.identity)[0], []).append(op)
85
+
86
+ def bind(parser: argparse.ArgumentParser, op: Operation) -> None:
87
+ """Give a parser this operation's declared parameters and bind it to the identity."""
88
+ for param in op.params:
89
+ if param in op.flags:
90
+ parser.add_argument(f"--{param}", dest=_dest(param), action="store_true")
91
+ elif param in op.required:
92
+ parser.add_argument(_dest(param), nargs="?", default=None, metavar=param.upper())
93
+ else:
94
+ parser.add_argument(f"--{param}", dest=_dest(param), default=None)
95
+ parser.set_defaults(_operation=op.identity)
96
+
97
+ routes: dict[tuple[str, str], Operation] = {}
98
+ order = {c: i for i, c in enumerate(CATEGORIES)}
99
+ for group_name in sorted(by_group, key=lambda g: (order.get(by_group[g][0].category, 99), g)):
100
+ ops = sorted(by_group[group_name], key=lambda o: o.identity)
101
+
102
+ # A two-segment identity (`si.catalog`) is a command in its own right; a three-segment one
103
+ # (`si.artifact.show`) is a verb under a group. The shape of the identity decides, so an
104
+ # operation named either way gets the command a reader would expect.
105
+ flat = [op for op in ops if not _group_and_verb(op.identity)[1]]
106
+ if flat:
107
+ if len(ops) > 1:
108
+ raise ValueError(
109
+ f"identity group {group_name!r} mixes a bare command with verbs: "
110
+ f"{[o.identity for o in ops]}"
111
+ )
112
+ op = flat[0]
113
+ command = groups.add_parser(group_name, help=op.summary, parents=[common],
114
+ description=(f"{op.summary}\n\n"
115
+ f"Operation Identity: {op.identity} ({op.kind})"),
116
+ formatter_class=argparse.RawDescriptionHelpFormatter)
117
+ bind(command, op)
118
+ routes[(group_name, "")] = op
119
+ continue
120
+
121
+ group_parser = groups.add_parser(group_name, help=f"{ops[0].category.lower()} operations")
122
+ verbs = group_parser.add_subparsers(dest="verb", metavar="<verb>")
123
+ for op in ops:
124
+ _, verb = _group_and_verb(op.identity)
125
+ verb_parser = verbs.add_parser(verb, help=op.summary, parents=[common], description=(
126
+ f"{op.summary}\n\nOperation Identity: {op.identity} ({op.kind})"
127
+ ), formatter_class=argparse.RawDescriptionHelpFormatter)
128
+ bind(verb_parser, op)
129
+ routes[(group_name, verb)] = op
130
+
131
+ operations_parser = groups.add_parser(
132
+ "operations", help="list every inspection operation this snapshot inspector answers")
133
+ operations_parser.set_defaults(_operation="__catalog__")
134
+
135
+ return parser, routes
136
+
137
+
138
+ # ── rendering ────────────────────────────────────────────────────
139
+ #
140
+ # One generic renderer, for the same reason there is one generic command builder: a per-operation
141
+ # formatter is per-operation code, and per-operation code drifts from the payload it formats.
142
+
143
+ def _render(value: Any, indent: int = 0) -> list[str]:
144
+ pad = " " * indent
145
+ lines: list[str] = []
146
+ if isinstance(value, dict):
147
+ scalars = {k: v for k, v in value.items() if not isinstance(v, (dict, list))}
148
+ nested = {k: v for k, v in value.items() if isinstance(v, (dict, list))}
149
+ width = max((len(k) for k in scalars), default=0)
150
+ for key, scalar in scalars.items():
151
+ lines.append(f"{pad}{key.ljust(width)} {scalar}")
152
+ for key, child in nested.items():
153
+ if not child:
154
+ lines.append(f"{pad}{key}: —")
155
+ continue
156
+ lines.append(f"{pad}{key}:")
157
+ lines.extend(_render(child, indent + 1))
158
+ elif isinstance(value, list):
159
+ if all(isinstance(item, dict) for item in value) and value:
160
+ lines.extend(_render_table(value, pad))
161
+ else:
162
+ lines.extend(f"{pad}- {item}" for item in value)
163
+ else:
164
+ lines.append(f"{pad}{value}")
165
+ return lines
166
+
167
+
168
+ def _render_table(rows: list[dict[str, Any]], pad: str) -> list[str]:
169
+ """List-of-dicts as a table when the rows are flat and share a shape; itemised otherwise."""
170
+ columns = list(dict.fromkeys(k for row in rows for k in row))
171
+ flat = all(not isinstance(row.get(c), (dict, list)) for row in rows for c in columns)
172
+ if not flat or len(columns) > 8:
173
+ lines = []
174
+ for position, row in enumerate(rows):
175
+ lines.append(f"{pad}—" if position else f"{pad}—")
176
+ lines.extend(_render(row, len(pad) // 2 + 1))
177
+ return lines
178
+ widths = {
179
+ c: max(len(c), max((len(str(row.get(c, ""))) for row in rows), default=0))
180
+ for c in columns
181
+ }
182
+ header = " ".join(c.ljust(widths[c]) for c in columns)
183
+ lines = [f"{pad}{header}", f"{pad}{' '.join('-' * widths[c] for c in columns)}"]
184
+ lines.extend(
185
+ f"{pad}{' '.join(str(row.get(c, '')).ljust(widths[c]) for c in columns)}"
186
+ for row in rows
187
+ )
188
+ return lines
189
+
190
+
191
+ def _render_catalog(operations: dict[str, Operation]) -> list[str]:
192
+ lines = ["Protocol Inspection — read-only inspection of the PGC snapshot", ""]
193
+ by_category: dict[str, list[Operation]] = {}
194
+ for op in operations.values():
195
+ by_category.setdefault(op.category, []).append(op)
196
+ for category in CATEGORIES:
197
+ ops = by_category.get(category)
198
+ if not ops:
199
+ continue
200
+ lines.append(f" {category}")
201
+ for op in ops:
202
+ group, verb = _group_and_verb(op.identity)
203
+ params = " ".join(
204
+ f"<{p}>" if p in op.required else f"[--{p}]" for p in op.params
205
+ )
206
+ lines.append(f" si {group} {verb} {params}".rstrip())
207
+ lines.append(f" {op.summary} [{op.kind}]")
208
+ lines.append("")
209
+ return lines
210
+
211
+
212
+ # ── entry point ──────────────────────────────────────────────────
213
+
214
+ def main(argv: list[str] | None = None) -> int:
215
+ argv = argv if argv is not None else sys.argv[1:]
216
+
217
+ # The snapshot declares the commands, so it is located first and read before the parser is
218
+ # built. A snapshot that declares no inspection contracts yields a usage error, not a partial
219
+ # CLI answering from a list the snapshot never agreed to.
220
+ root = _snapshot_root(argv)
221
+ try:
222
+ operations = load_catalog(Snapshot(root))
223
+ except SnapshotError as exc:
224
+ print(f"si: {exc}", file=sys.stderr)
225
+ return 2
226
+
227
+ parser, _ = _build_parser(operations)
228
+ args = parser.parse_args(argv)
229
+
230
+ operation = getattr(args, "_operation", None)
231
+ if operation is None:
232
+ parser.print_help()
233
+ return 2
234
+
235
+ if operation == "__catalog__":
236
+ print("\n".join(_render_catalog(operations)))
237
+ return 0
238
+
239
+ op = operations[operation]
240
+ params = {p: getattr(args, _dest(p)) for p in op.params}
241
+ params = {k: v for k, v in params.items() if v not in (None, False)}
242
+
243
+ try:
244
+ status, payload = query(operation, params, root)
245
+ except SnapshotError as exc:
246
+ print(f"si: {exc}", file=sys.stderr)
247
+ return 2
248
+
249
+ if args.json:
250
+ print(json.dumps(payload, indent=2, sort_keys=True))
251
+ else:
252
+ print(f"{operation} [{status}]")
253
+ print("\n".join(_render(payload, 1)))
254
+
255
+ if status != "SUCCESS":
256
+ return 1
257
+ # `validate --strict` is a CI gate: an invalid snapshot must fail the command, not merely
258
+ # report inside a successful one.
259
+ if payload.get("valid") is False:
260
+ return 1
261
+ return 0
262
+
263
+
264
+ if __name__ == "__main__":
265
+ sys.exit(main())
inspector/graph.py ADDED
@@ -0,0 +1,138 @@
1
+ """Semantic graph — the traversal substrate behind every SNAPSHOT_QUERY operation.
2
+
3
+ The graph is the union of every domain's `evidence/<domain>/evidence.json` (artifact-level nodes
4
+ + typed edges), deduplicated: an artifact visible to three domain builds contributes one node and
5
+ its edges once. Operations here are STRUCTURAL only — neighbourhood, closure, grouping. No domain
6
+ knowledge and no PGC semantics live here; that is what keeps inspection from becoming a second
7
+ governance engine.
8
+
9
+ Edge direction, as the compiler materializes it:
10
+
11
+ source --kind--> target means "source depends on / contains / routes to target"
12
+
13
+ So the CONSUMERS of X are the sources of edges INTO X (`refs`), and X's DEPENDENCIES are the
14
+ targets of edges out of X (`deps`). Impact analysis is the transitive consumer closure.
15
+
16
+ Ported from `protocol_compiler/compiler/inspection/traversal.py`, which this repo supersedes.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+ from inspector.snapshot import Snapshot
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Edge:
29
+ source: str
30
+ target: str
31
+ kind: str
32
+ metadata_json: str # canonical JSON — keeps Edge hashable, so dedup is by value
33
+
34
+ @property
35
+ def metadata(self) -> dict[str, Any]:
36
+ return json.loads(self.metadata_json)
37
+
38
+
39
+ class SemanticGraph:
40
+ """Federated, immutable artifact graph over every composed domain."""
41
+
42
+ def __init__(self, snapshot: Snapshot) -> None:
43
+ nodes: dict[str, str] = {}
44
+ edges: set[Edge] = set()
45
+
46
+ for domain in snapshot.evidence_domains():
47
+ evidence = snapshot.evidence(domain)
48
+ for node in evidence.get("nodes", []):
49
+ nodes.setdefault(node["fqdn"], node["kind"])
50
+ for edge in evidence.get("edges", []):
51
+ edges.add(Edge(
52
+ source=edge["source_fqdn"],
53
+ target=edge["target_fqdn"],
54
+ kind=edge["kind"],
55
+ metadata_json=json.dumps(edge.get("metadata", {}), sort_keys=True),
56
+ ))
57
+
58
+ self.nodes = nodes
59
+ self.edges = sorted(edges, key=lambda e: (e.source, e.target, e.kind, e.metadata_json))
60
+
61
+ self._out: dict[str, list[Edge]] = {}
62
+ self._in: dict[str, list[Edge]] = {}
63
+ for edge in self.edges:
64
+ self._out.setdefault(edge.source, []).append(edge)
65
+ self._in.setdefault(edge.target, []).append(edge)
66
+
67
+ # ── direct neighbourhood ─────────────────────────────────────
68
+
69
+ def out_edges(self, fqdn: str) -> list[Edge]:
70
+ return self._out.get(fqdn, [])
71
+
72
+ def in_edges(self, fqdn: str) -> list[Edge]:
73
+ return self._in.get(fqdn, [])
74
+
75
+ def node_kind(self, fqdn: str) -> str:
76
+ return self.nodes.get(fqdn, "UNKNOWN")
77
+
78
+ def known(self, fqdn: str) -> bool:
79
+ """True iff the graph carries this artifact as a node.
80
+
81
+ An artifact can be indexed but carry no evidence node — it is then not traversable, which
82
+ a caller must be able to distinguish from "traversable but unreferenced".
83
+ """
84
+ return fqdn in self.nodes
85
+
86
+ # ── walks ────────────────────────────────────────────────────
87
+
88
+ def refs(self, fqdn: str, transitive: bool = False) -> list[dict[str, Any]]:
89
+ """Who references this artifact — incoming edges; its consumers."""
90
+ return self._walk(fqdn, self._in, "source", transitive)
91
+
92
+ def deps(self, fqdn: str, transitive: bool = False) -> list[dict[str, Any]]:
93
+ """What this artifact depends on — outgoing edges."""
94
+ return self._walk(fqdn, self._out, "target", transitive)
95
+
96
+ def _walk(
97
+ self,
98
+ start: str,
99
+ adjacency: dict[str, list[Edge]],
100
+ neighbor_attr: str,
101
+ transitive: bool,
102
+ ) -> list[dict[str, Any]]:
103
+ results: list[dict[str, Any]] = []
104
+ visited: set[str] = {start}
105
+ frontier = [start]
106
+ depth = 0
107
+ while frontier:
108
+ depth += 1
109
+ next_frontier: list[str] = []
110
+ for fqdn in frontier:
111
+ for edge in adjacency.get(fqdn, []):
112
+ neighbor = getattr(edge, neighbor_attr)
113
+ if neighbor in visited:
114
+ continue
115
+ visited.add(neighbor)
116
+ results.append({
117
+ "fqdn": neighbor,
118
+ "kind": self.node_kind(neighbor),
119
+ "edge_kind": edge.kind,
120
+ "depth": depth,
121
+ })
122
+ next_frontier.append(neighbor)
123
+ if not transitive:
124
+ break
125
+ frontier = next_frontier
126
+ results.sort(key=lambda r: (r["depth"], r["fqdn"]))
127
+ return results
128
+
129
+ def impact(self, fqdn: str) -> dict[str, dict[str, list[str]]]:
130
+ """Transitive consumer closure, grouped by artifact kind then namespace."""
131
+ grouped: dict[str, dict[str, list[str]]] = {}
132
+ for record in self.refs(fqdn, transitive=True):
133
+ namespace = record["fqdn"].split("::", 1)[0]
134
+ grouped.setdefault(record["kind"], {}).setdefault(namespace, []).append(record["fqdn"])
135
+ return {
136
+ kind: {ns: sorted(set(fqdns)) for ns, fqdns in sorted(by_ns.items())}
137
+ for kind, by_ns in sorted(grouped.items())
138
+ }
inspector/kinds.py ADDED
@@ -0,0 +1,19 @@
1
+ """Inspection vocabulary — handler kinds and catalog categories.
2
+
3
+ A leaf module that imports nothing, so the registry, the catalog reader, and the projections can
4
+ all name these without importing each other. They are the fixed words of the inspection surface:
5
+
6
+ SNAPSHOT_READ project PUBLISHED snapshot material — no traversal, no evaluation
7
+ SNAPSHOT_QUERY DERIVE a result by traversing or evaluating snapshot state
8
+
9
+ The handler kinds are also transport handler kinds: the boundary routes by them, and the pair is
10
+ part of the Transport Standard rather than of this repo. `CATEGORIES` is presentation order for a
11
+ catalog — the only thing here that is purely this surface's own.
12
+ """
13
+
14
+ SNAPSHOT_READ = "SNAPSHOT_READ"
15
+ SNAPSHOT_QUERY = "SNAPSHOT_QUERY"
16
+
17
+ INSPECTION_KINDS = (SNAPSHOT_READ, SNAPSHOT_QUERY)
18
+
19
+ CATEGORIES = ("SNAPSHOT", "ARTIFACTS", "STORES", "VOCABULARY", "BEHAVIOR")