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 +17 -0
- inspector/__main__.py +7 -0
- inspector/api.py +94 -0
- inspector/catalog.py +135 -0
- inspector/cli.py +265 -0
- inspector/graph.py +138 -0
- inspector/kinds.py +19 -0
- inspector/queries/__init__.py +8 -0
- inspector/queries/artifact_refs.py +40 -0
- inspector/queries/snapshot_validate.py +221 -0
- inspector/queries/topology_impact.py +39 -0
- inspector/reads/__init__.py +5 -0
- inspector/reads/artifact_indexed.py +22 -0
- inspector/reads/artifact_list.py +53 -0
- inspector/reads/artifact_show.py +30 -0
- inspector/reads/behavior_logic_list.py +29 -0
- inspector/reads/behavior_logic_show.py +36 -0
- inspector/reads/capability_surface.py +151 -0
- inspector/reads/catalog.py +42 -0
- inspector/reads/rule_set_list.py +84 -0
- inspector/reads/snapshot_summary.py +71 -0
- inspector/reads/snapshot_topology.py +37 -0
- inspector/reads/store_reads.py +112 -0
- inspector/reads/vocab_resolve.py +66 -0
- inspector/reads/vocab_search.py +41 -0
- inspector/registry.py +97 -0
- inspector/snapshot.py +175 -0
- pgc_inspector-2.0.0.dist-info/METADATA +223 -0
- pgc_inspector-2.0.0.dist-info/RECORD +34 -0
- pgc_inspector-2.0.0.dist-info/WHEEL +5 -0
- pgc_inspector-2.0.0.dist-info/entry_points.txt +2 -0
- pgc_inspector-2.0.0.dist-info/licenses/LICENSE +67 -0
- pgc_inspector-2.0.0.dist-info/licenses/NOTICE +11 -0
- pgc_inspector-2.0.0.dist-info/top_level.txt +1 -0
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
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")
|