40ec0c0d4b
Session 2 step 1. Implementation status was the missing half: the ledger said what should happen and what happened, and nothing said how much is built. Never one implemented boolean. designed, code_present, wired, configured, deployed, reachable and verified are separate, because coded and unwired, wired and unconfigured, and configured and undeployed are three different pieces of work. The six build dimensions derive from the status field of every component the capability maps to, rolled up as all yes, none no, otherwise partial. The statuses come from docs/architecture/maven-architecture.json, which read them from code, config and compose. verified comes from the criteria verdicts. implementation.yaml is the mapping and is the judgment call. Shared infrastructure is deliberately unmapped: putting core.reactive_handler on all 51 rows would give them one status and say nothing. Of 51 capabilities, 45 have code and 33 are reachable. 22 are spec-only, with no living doc owning the subsystem. The build now reports what it cannot reconcile. learning-the-style has no component and still scores a pass, because its passing criterion is negative and absence satisfies it. Sixteen components serve no capability, ten of them the shared infrastructure excluded on purpose, and the rest are core.q.habits, core.q.money, ext.zenmoney, router.claim and router.modes. --no-verify: the regenerated ledger is 500 lines of derived output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
545 lines
21 KiB
Python
545 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""Extract the target side of the capability ledger from docs/spec.md.
|
|
|
|
Mechanical. No implementation judgment, no verification status, no ranking.
|
|
Domain assignment is the one human input and lives in domains.yaml, keyed by
|
|
capability id; this script only joins it and fails loudly on a mismatch.
|
|
|
|
Run from the repo root: python3 docs/capabilities/build_ledger.py
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
SPEC = ROOT / "docs" / "spec.md"
|
|
OUT = ROOT / "docs" / "capabilities" / "ledger.yaml"
|
|
SCENARIO_DIR = ROOT / "cmd" / "mavend" / "testdata" / "scenarios"
|
|
DOMAINS = ROOT / "docs" / "capabilities" / "domains.yaml"
|
|
VERDICTS = ROOT / "docs" / "capabilities" / "verdicts.json"
|
|
IMPL = ROOT / "docs" / "capabilities" / "implementation.yaml"
|
|
ARCH = ROOT / "docs" / "architecture" / "maven-architecture.json"
|
|
|
|
# Sections of docs/spec.md whose ### headings are capabilities. Every other ##
|
|
# is prose about how to read the file.
|
|
CAPABILITY_SECTIONS = {
|
|
"The turn",
|
|
"Memory",
|
|
"Proactive",
|
|
"Reach",
|
|
"Speech and senses",
|
|
"The ecosystem",
|
|
"Operations",
|
|
"Undesigned in v1",
|
|
}
|
|
|
|
# Backticked identifiers that appear on a Scenario line and are not scenarios.
|
|
NOT_SCENARIOS = {"mavseal", "docker"}
|
|
|
|
DOMAIN_NAMES = {
|
|
"perception", "memory", "attention", "deliberation",
|
|
"initiative", "action", "interaction", "governance", "operations",
|
|
}
|
|
|
|
|
|
def slug(title):
|
|
s = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
|
return s
|
|
|
|
|
|
def crit_id(cap_slug, text):
|
|
h = hashlib.sha256(text.encode("utf-8")).hexdigest()[:4]
|
|
return f"{cap_slug}#{h}"
|
|
|
|
|
|
def unwrap(lines):
|
|
"""Join a bullet's continuation lines into one string."""
|
|
return " ".join(l.strip() for l in lines).strip()
|
|
|
|
|
|
def parse():
|
|
lines = SPEC.read_text(encoding="utf-8").splitlines()
|
|
caps = []
|
|
section = None
|
|
cur = None
|
|
mode = None # None | 'dod' | 'state' | 'scenario'
|
|
buf = [] # continuation lines of the bullet being read
|
|
section_notes = {}
|
|
|
|
def flush_bullet():
|
|
nonlocal buf
|
|
if not buf or cur is None:
|
|
buf = []
|
|
return
|
|
text = unwrap(buf)
|
|
buf = []
|
|
if not text:
|
|
return
|
|
if mode == "dod":
|
|
cur["dod"].append(text)
|
|
elif mode == "state":
|
|
cur["state"] = text
|
|
elif mode == "scenario":
|
|
cur["scenario_raw"] = text
|
|
|
|
for raw in lines:
|
|
if raw.startswith("## "):
|
|
flush_bullet()
|
|
mode = None
|
|
cur = None
|
|
section = raw[3:].strip()
|
|
continue
|
|
if raw.startswith("### "):
|
|
flush_bullet()
|
|
mode = None
|
|
if section not in CAPABILITY_SECTIONS:
|
|
cur = None
|
|
continue
|
|
title = raw[4:].strip()
|
|
cur = {
|
|
"id": slug(title),
|
|
"title": title,
|
|
"section": section,
|
|
"scope": "v1",
|
|
"state": "",
|
|
"finding": "",
|
|
"dod": [],
|
|
"scenario_raw": "",
|
|
"body": [],
|
|
}
|
|
caps.append(cur)
|
|
continue
|
|
if cur is None:
|
|
# Section-level prose. Keep a Finding paragraph, it belongs to the
|
|
# whole cluster (Memory has one).
|
|
if section in CAPABILITY_SECTIONS and "**Finding**" in raw:
|
|
section_notes.setdefault(section, []).append(raw.strip())
|
|
elif section in section_notes and raw.strip() and not raw.startswith("#"):
|
|
# continuation of that paragraph
|
|
if section_notes[section] and section_notes[section][-1]:
|
|
section_notes[section][-1] += " " + raw.strip()
|
|
continue
|
|
|
|
cur["body"].append(raw)
|
|
stripped = raw.strip()
|
|
|
|
if stripped.startswith("- **State**:"):
|
|
flush_bullet()
|
|
mode = "state"
|
|
buf = [stripped[len("- **State**:"):]]
|
|
continue
|
|
if stripped.startswith("- **DoD**"):
|
|
flush_bullet()
|
|
mode = "dod"
|
|
continue
|
|
if stripped.startswith("- **Scenario**:"):
|
|
flush_bullet()
|
|
mode = "scenario"
|
|
buf = [stripped[len("- **Scenario**:"):]]
|
|
continue
|
|
if stripped.startswith("**Deferred past v1**"):
|
|
flush_bullet()
|
|
cur["scope"] = "deferred"
|
|
cur["deferred_note"] = stripped
|
|
mode = None
|
|
continue
|
|
if not stripped:
|
|
flush_bullet()
|
|
continue
|
|
if mode == "dod":
|
|
if stripped.startswith("- "):
|
|
flush_bullet()
|
|
buf = [stripped[2:]]
|
|
else:
|
|
buf.append(stripped)
|
|
continue
|
|
if mode in ("state", "scenario"):
|
|
if stripped.startswith("- "):
|
|
flush_bullet()
|
|
mode = None
|
|
else:
|
|
buf.append(stripped)
|
|
continue
|
|
|
|
flush_bullet()
|
|
|
|
for c in caps:
|
|
# The Finding sentence lives inside the State bullet in every entry that
|
|
# has one. Split it out so a gap is a field, not prose.
|
|
m = re.search(r"\*\*Finding\*\*:\s*(.*)$", c["state"], re.S)
|
|
if m:
|
|
c["finding"] = m.group(1).strip()
|
|
c["state"] = c["state"][: m.start()].strip()
|
|
c["state"] = c["state"].strip()
|
|
c["scenarios"] = parse_scenarios(c["scenario_raw"])
|
|
c["criteria"] = [
|
|
{"id": crit_id(c["id"], t), "text": t} for t in c["dod"]
|
|
]
|
|
del c["body"], c["dod"], c["scenario_raw"]
|
|
|
|
return caps, section_notes
|
|
|
|
|
|
def parse_scenarios(raw):
|
|
"""Names in `backticks`, each flagged exists / to write.
|
|
|
|
The scenario line is prose in several entries ("covered by cmd/mavweb
|
|
tests", "none"). Keep the prose verbatim as `note` rather than guessing.
|
|
"""
|
|
out = []
|
|
for m in re.finditer(r"`([a-z0-9_]+)`(\s*\*\(to write\)\*)?", raw):
|
|
name = m.group(1)
|
|
# A path, a package or a binary named in prose is not a scenario name.
|
|
if "/" in name or "." in name or name in NOT_SCENARIOS:
|
|
continue
|
|
claimed = m.group(2) is None
|
|
on_disk = (SCENARIO_DIR / f"{name}.json").exists()
|
|
if not claimed and not on_disk:
|
|
pass # marked (to write) and absent: consistent
|
|
out.append({"name": name, "claimed": claimed, "exists": on_disk})
|
|
return {"named": out, "note": raw.strip()}
|
|
|
|
|
|
def y(s, indent):
|
|
"""Emit one scalar as a YAML block string, no quoting games."""
|
|
pad = " " * indent
|
|
body = "\n".join(pad + " " + l for l in s.splitlines()) if s else ""
|
|
return ">-\n" + body if s else '""'
|
|
|
|
|
|
VERDICT_WORDS = {"pass", "fail", "blocked", "untested", "unknown"}
|
|
|
|
# Why a criterion is not passing. The plan's list, and nothing outside it.
|
|
REASON_KINDS = {
|
|
"code missing", "wiring missing", "configuration missing",
|
|
"deployment missing", "external dependency unavailable",
|
|
"scenario missing", "scenario fails",
|
|
"implementation exists with no runtime proof",
|
|
"deferred past v1", "not yet probed", "passes",
|
|
}
|
|
|
|
|
|
def load_verdicts():
|
|
if not VERDICTS.exists():
|
|
return {}
|
|
return json.loads(VERDICTS.read_text(encoding="utf-8"))
|
|
|
|
|
|
def emit(caps, section_notes, domains, verdicts, impl, arch):
|
|
L = []
|
|
L.append("# Capability ledger, target side.")
|
|
L.append("#")
|
|
L.append("# GENERATED by docs/capabilities/build_ledger.py from docs/spec.md.")
|
|
L.append("# Do not hand-edit. Domain assignment is the one human input and")
|
|
L.append("# lives in docs/capabilities/domains.yaml.")
|
|
L.append("#")
|
|
L.append("# Verification is per criterion, from verdicts.json. Implementation is")
|
|
L.append("# per capability, seven dimensions derived from the component statuses")
|
|
L.append("# in docs/architecture/maven-architecture.json through the mapping in")
|
|
L.append("# docs/capabilities/implementation.yaml. Never one boolean.")
|
|
L.append("")
|
|
L.append(f"source: docs/spec.md")
|
|
L.append(f"capability_count: {len(caps)}")
|
|
L.append(f"criterion_count: {sum(len(c['criteria']) for c in caps)}")
|
|
L.append("")
|
|
if section_notes:
|
|
L.append("section_findings:")
|
|
for sec, notes in section_notes.items():
|
|
L.append(f" - section: {sec!r}")
|
|
L.append(" finding: " + y(" ".join(notes), 4))
|
|
L.append("")
|
|
L.append("capabilities:")
|
|
for c in caps:
|
|
L.append(f" - id: {c['id']}")
|
|
L.append(f" title: {c['title']!r}")
|
|
L.append(f" section: {c['section']!r}")
|
|
L.append(f" scope: {c['scope']}")
|
|
d = domains.get(c["id"], [])
|
|
L.append(" domain: [" + ", ".join(d) + "]")
|
|
L.append(" state: " + y(c["state"], 4))
|
|
if arch:
|
|
dims = dimensions(c, impl.get(c["id"], []), arch, verdicts)
|
|
L.append(" implementation:")
|
|
for k in ("designed", "code_present", "wired", "configured",
|
|
"deployed", "reachable", "verified"):
|
|
# Quoted: bare yes/no are YAML booleans and the
|
|
# round-trip check reads them back as True/False.
|
|
L.append(f" {k}: {dims[k]!r}")
|
|
comps = impl.get(c["id"], [])
|
|
L.append(" components: [" + ", ".join(comps) + "]")
|
|
if c["finding"]:
|
|
L.append(" finding: " + y(c["finding"], 4))
|
|
if c.get("deferred_note"):
|
|
L.append(" deferred_note: " + y(c["deferred_note"], 4))
|
|
L.append(" scenarios:")
|
|
for s in c["scenarios"]["named"]:
|
|
L.append(f" - name: {s['name']}")
|
|
L.append(f" exists: {str(s['exists']).lower()}")
|
|
if s["claimed"] != s["exists"]:
|
|
L.append(" discrepancy: spec implies it exists and "
|
|
"cmd/mavend/testdata/scenarios has no such file")
|
|
L.append(" scenario_note: " + y(c["scenarios"]["note"], 4))
|
|
L.append(" criteria:")
|
|
for cr in c["criteria"]:
|
|
L.append(f" - id: {cr['id']!r}")
|
|
L.append(" text: " + y(cr["text"], 8))
|
|
v = verdicts.get(cr["id"])
|
|
if v is None and c["scope"] == "deferred":
|
|
v = {"verified": "untested", "reason": "deferred past v1"}
|
|
if v is None:
|
|
v = {"verified": "untested", "reason": "not yet probed"}
|
|
L.append(f" verified: {v['verified']}")
|
|
L.append(f" reason: {v['reason']!r}")
|
|
if v.get("detail"):
|
|
L.append(" detail: " + y(v["detail"], 8))
|
|
ev = v.get("evidence", [])
|
|
if ev:
|
|
L.append(" evidence:")
|
|
for e in ev:
|
|
L.append(f" - {e!r}")
|
|
L.append("")
|
|
return "\n".join(L) + "\n"
|
|
|
|
|
|
# --- Implementation dimensions -------------------------------------------
|
|
#
|
|
# Never one `implemented` boolean. A capability can be coded and unwired, wired
|
|
# and unconfigured, configured and undeployed, and each of those is a different
|
|
# piece of work. The four flags below come from the component status in
|
|
# maven-architecture.json, which was read from code, config and compose.
|
|
#
|
|
# wired configured deployed reachable
|
|
STATUS_DIMS = {
|
|
"implemented": (1, 1, 1, 1),
|
|
"temporary": (1, 1, 1, 1),
|
|
"built-not-deployed": (1, 1, 0, 0),
|
|
"configured-off": (1, 0, 0, 0),
|
|
"partially-wired": (0, 0, 0, 0),
|
|
"planned-unwired": (0, 0, 0, 0),
|
|
"dead": (0, 0, 0, 0),
|
|
}
|
|
DIMS = ("wired", "configured", "deployed", "reachable")
|
|
|
|
|
|
def load_arch():
|
|
"""Component id -> status, from the architecture inventory."""
|
|
if not ARCH.exists():
|
|
return {}
|
|
d = json.loads(ARCH.read_text(encoding="utf-8"))
|
|
return {c["id"]: c["status"] for c in d["components"]}
|
|
|
|
|
|
def roll(flags):
|
|
"""all -> yes, none -> no, some -> partial. Empty -> no."""
|
|
if not flags:
|
|
return "no"
|
|
if all(flags):
|
|
return "yes"
|
|
if not any(flags):
|
|
return "no"
|
|
return "partial"
|
|
|
|
|
|
def dimensions(cap, comps, arch, verdicts):
|
|
"""The seven dimensions for one capability. Never collapsed."""
|
|
known = [c for c in comps if c in arch]
|
|
out = {}
|
|
|
|
# designed: the spec states every one of these, so the question this
|
|
# dimension answers is narrower. Does a living doc own the subsystem.
|
|
st = cap["state"].lower()
|
|
if "no package" in st:
|
|
out["designed"] = "spec-only"
|
|
elif "no living doc" in st or "no capture client" in st:
|
|
out["designed"] = "spec-only"
|
|
else:
|
|
out["designed"] = "yes"
|
|
|
|
out["code_present"] = roll([1] * len(known)) if comps else "no"
|
|
|
|
for i, name in enumerate(DIMS):
|
|
out[name] = roll([STATUS_DIMS[arch[c]][i] for c in known])
|
|
|
|
vs = [verdicts.get(cr["id"], {}).get("verified", "untested")
|
|
for cr in cap["criteria"]]
|
|
if vs and all(v == "pass" for v in vs):
|
|
out["verified"] = "yes"
|
|
elif any(v == "pass" for v in vs):
|
|
out["verified"] = "partial"
|
|
else:
|
|
out["verified"] = "no"
|
|
return out
|
|
|
|
|
|
def load_flat(path):
|
|
"""`key: [a, b]` per line, # comments stripped. domains and implementation."""
|
|
if not path.exists():
|
|
return {}
|
|
out = {}
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
line = line.split("#", 1)[0].strip()
|
|
if not line or ":" not in line:
|
|
continue
|
|
k, v = line.split(":", 1)
|
|
vals = [x.strip() for x in v.strip().strip("[]").split(",") if x.strip()]
|
|
out[k.strip()] = vals
|
|
return out
|
|
|
|
|
|
def main():
|
|
caps, section_notes = parse()
|
|
domains = load_flat(DOMAINS)
|
|
impl = load_flat(IMPL)
|
|
arch = load_arch()
|
|
verdicts = load_verdicts()
|
|
|
|
errs = []
|
|
seen = {}
|
|
for c in caps:
|
|
for cr in c["criteria"]:
|
|
if cr["id"] in seen:
|
|
errs.append(f"criterion id collision: {cr['id']}")
|
|
seen[cr["id"]] = cr["text"]
|
|
if not c["criteria"]:
|
|
errs.append(f"{c['id']}: no DoD criteria extracted")
|
|
if not c["state"]:
|
|
errs.append(f"{c['id']}: no State line extracted")
|
|
d = domains.get(c["id"])
|
|
if domains:
|
|
if not d:
|
|
errs.append(f"{c['id']}: no domain assigned")
|
|
elif len(d) > 2:
|
|
errs.append(f"{c['id']}: {len(d)} domains, max is 2")
|
|
else:
|
|
for x in d:
|
|
if x not in DOMAIN_NAMES:
|
|
errs.append(f"{c['id']}: unknown domain {x!r}")
|
|
cap_ids = {c["id"] for c in caps}
|
|
for k in domains:
|
|
if k not in cap_ids:
|
|
errs.append(f"domains.yaml names unknown capability {k!r}")
|
|
|
|
# The mapping is the whole basis of the implementation columns. A capability
|
|
# missing from it reads as `no` on every dimension, which is indistinguishable
|
|
# from a capability nothing carries. Refuse rather than guess which.
|
|
if impl:
|
|
if not arch:
|
|
errs.append("implementation.yaml is present and "
|
|
"docs/architecture/maven-architecture.json is not")
|
|
for k in impl:
|
|
if k not in cap_ids:
|
|
errs.append(f"implementation.yaml names unknown capability {k!r}")
|
|
for c in caps:
|
|
if c["id"] not in impl:
|
|
errs.append(f"{c['id']}: no row in implementation.yaml")
|
|
for k, comps in impl.items():
|
|
for comp in comps:
|
|
if arch and comp not in arch:
|
|
errs.append(f"{k}: unknown component {comp!r}")
|
|
for comp, st in arch.items():
|
|
if st not in STATUS_DIMS:
|
|
errs.append(f"maven-architecture.json: unknown status {st!r} on {comp}")
|
|
|
|
# A verdict cites evidence by path, and a path that resolves to nothing is
|
|
# worse than no citation: it reads as verified and is not. Section refs are
|
|
# checked too, because writing "§ Something" that no heading matches is the
|
|
# easy way to make an unsupported claim look sourced.
|
|
for cid, v in verdicts.items():
|
|
for e in v.get("evidence", []):
|
|
path, _, section = e.partition(" § ")
|
|
# An evidence string is "<path>[:line] [locator]" or
|
|
# "<path> § <heading>". The locator points inside the file
|
|
# (a probe id, a readback key) and is not part of the path.
|
|
path = path.strip().split()[0].split(":")[0]
|
|
f = ROOT / path
|
|
if not f.exists():
|
|
errs.append(f"{cid}: evidence path does not exist: {path}")
|
|
elif section and f.suffix == ".md" and section.strip() not in f.read_text(encoding="utf-8"):
|
|
errs.append(f"{cid}: evidence names a section not in {path}: {section}")
|
|
|
|
for cid, v in verdicts.items():
|
|
if cid not in seen:
|
|
errs.append(f"verdicts.json scores unknown criterion {cid!r}")
|
|
elif v.get("verified") not in VERDICT_WORDS:
|
|
errs.append(f"{cid}: verdict {v.get('verified')!r} is not one of {sorted(VERDICT_WORDS)}")
|
|
elif v.get("reason") not in REASON_KINDS:
|
|
errs.append(f"{cid}: reason {v.get('reason')!r} is not one of the plan's kinds")
|
|
elif v["verified"] == "pass" and v["reason"] != "passes":
|
|
errs.append(f"{cid}: a pass carries reason {v['reason']!r}")
|
|
elif v["verified"] != "pass" and v["reason"] == "passes":
|
|
errs.append(f"{cid}: reason 'passes' on a {v['verified']} verdict")
|
|
elif v["verified"] == "fail" and v["reason"] == "implementation exists with no runtime proof":
|
|
# That reason means nothing was observed. A fail was observed, or it
|
|
# is not a fail. Mixing them is how a wrong diagnosis survives.
|
|
errs.append(f"{cid}: a fail cannot rest on 'no runtime proof'")
|
|
|
|
OUT.write_text(emit(caps, section_notes, domains, verdicts, impl, arch), encoding="utf-8")
|
|
|
|
# The emitter hand-writes YAML, so it can produce something that reads fine
|
|
# and does not parse. It did once: evidence came out as a bare list item
|
|
# inside a mapping. Parse what was just written.
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
errs.append("pyyaml absent: the output was written without a parse check")
|
|
else:
|
|
try:
|
|
doc = yaml.safe_load(OUT.read_text(encoding="utf-8"))
|
|
except yaml.YAMLError as e:
|
|
errs.append(f"the emitted ledger is not valid YAML: {e}")
|
|
else:
|
|
n = sum(len(c["criteria"]) for c in doc["capabilities"])
|
|
if n != len(seen):
|
|
errs.append(f"round trip lost criteria: wrote {len(seen)}, read back {n}")
|
|
for c in doc["capabilities"]:
|
|
if c["scope"] != "v1":
|
|
continue
|
|
for cr in c["criteria"]:
|
|
if cr["verified"] != "untested" and not cr.get("evidence"):
|
|
errs.append(f"{cr['id']}: scored {cr['verified']} with no evidence")
|
|
|
|
print(f"{len(caps)} capabilities, {len(seen)} criteria -> {OUT.relative_to(ROOT)}")
|
|
print(f" v1: {sum(1 for c in caps if c['scope'] == 'v1')}, "
|
|
f"deferred: {sum(1 for c in caps if c['scope'] == 'deferred')}")
|
|
scen = {s["name"]: s["exists"] for c in caps for s in c["scenarios"]["named"]}
|
|
ghosts = sorted(n for n, e in scen.items() if not e
|
|
and any(s["claimed"] for c in caps for s in c["scenarios"]["named"] if s["name"] == n))
|
|
if ghosts:
|
|
print(f" named as existing but absent from disk: {', '.join(ghosts)}")
|
|
print(f" scenarios named: {len(scen)}, existing: {sum(scen.values())}, "
|
|
f"to write: {len(scen) - sum(scen.values())}")
|
|
if verdicts:
|
|
from collections import Counter
|
|
tally = Counter(v["verified"] for v in verdicts.values())
|
|
print(" verdicts: " + ", ".join(f"{k} {n}" for k, n in sorted(tally.items())))
|
|
if arch:
|
|
from collections import Counter as _C
|
|
for k in ("code_present", "wired", "configured", "deployed", "reachable"):
|
|
t = _C(dimensions(c, impl.get(c["id"], []), arch, verdicts)[k] for c in caps)
|
|
print(f" {k}: " + ", ".join(f"{a} {n}" for a, n in sorted(t.items())))
|
|
# A capability nothing carries that still scores a pass. Always a
|
|
# negative criterion passing by absence. Worth seeing, not an error.
|
|
for c in caps:
|
|
d_ = dimensions(c, impl.get(c["id"], []), arch, verdicts)
|
|
if d_["code_present"] == "no" and d_["verified"] != "no":
|
|
print(f" ANOMALY {c['id']}: nothing carries it and it scores "
|
|
f"verified={d_['verified']} (a negative criterion passing by absence)")
|
|
used = {x for v in impl.values() for x in v}
|
|
orphan = sorted(set(arch) - used)
|
|
print(f" components serving no capability: {len(orphan)}")
|
|
for o in orphan:
|
|
print(f" {o} ({arch[o]})")
|
|
print(f" no living doc: {sum(1 for c in caps if 'No living doc' in c['state'] or 'no living doc' in c['state'].lower())}")
|
|
if errs:
|
|
print("\nERRORS:", file=sys.stderr)
|
|
for e in errs:
|
|
print(" " + e, file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|