feat: finish register receiving and authority routing
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
parent
6d134425df
commit
d103955217
28 changed files with 970 additions and 48 deletions
74
src/repo_manager/authority.py
Normal file
74
src/repo_manager/authority.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Resolve exactly one authoritative owner for a State Hub record type."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
AUTHORITY_SCHEMA = "repo-manager.hub-record-authority.v1"
|
||||
VALID_CLASSES = frozenset(
|
||||
{"file-derived", "hub-native-append", "hub-native-control", "derived-cache", "retired"}
|
||||
)
|
||||
DEFAULT_CONTRACT = Path(__file__).resolve().parents[2] / "config" / "hub-record-authority.yaml"
|
||||
|
||||
|
||||
class AuthorityError(ValueError):
|
||||
"""The requested route is missing or conflicts with the authority contract."""
|
||||
|
||||
|
||||
def load_authority_contract(path: Path = DEFAULT_CONTRACT) -> dict[str, Any]:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict) or data.get("schema") != AUTHORITY_SCHEMA:
|
||||
raise AuthorityError(f"unsupported authority contract: {path}")
|
||||
records = data.get("records")
|
||||
if not isinstance(records, dict) or not records:
|
||||
raise AuthorityError("authority contract has no records")
|
||||
for record_type, rule in records.items():
|
||||
if not isinstance(rule, dict):
|
||||
raise AuthorityError(f"invalid rule for {record_type}")
|
||||
if rule.get("class") not in VALID_CLASSES or not rule.get("owner"):
|
||||
raise AuthorityError(f"incomplete authority rule for {record_type}")
|
||||
return data
|
||||
|
||||
|
||||
def resolve_record_authority(
|
||||
record_type: str,
|
||||
*,
|
||||
repo_slug: str | None = None,
|
||||
domain_slug: str | None = None,
|
||||
claimed_owner: str | None = None,
|
||||
contract_path: Path = DEFAULT_CONTRACT,
|
||||
) -> dict[str, str]:
|
||||
"""Return the one authority route or reject an ambiguous/conflicting write."""
|
||||
contract = load_authority_contract(contract_path)
|
||||
rule = contract["records"].get(record_type)
|
||||
if not isinstance(rule, dict):
|
||||
raise AuthorityError(f"unknown record type: {record_type}")
|
||||
|
||||
record_class = str(rule["class"])
|
||||
owner = str(rule["owner"])
|
||||
if claimed_owner is not None and claimed_owner != owner:
|
||||
raise AuthorityError(
|
||||
f"authority mismatch for {record_type}: contract={owner}, claimed={claimed_owner}"
|
||||
)
|
||||
|
||||
if record_class == "file-derived":
|
||||
if not repo_slug or not domain_slug:
|
||||
raise AuthorityError(
|
||||
f"file-derived {record_type} requires repo_slug and domain_slug"
|
||||
)
|
||||
authority_key = f"repository:{domain_slug}/{repo_slug}"
|
||||
elif record_class == "retired":
|
||||
authority_key = f"archive:{record_type}"
|
||||
else:
|
||||
authority_key = f"hub:{owner}"
|
||||
|
||||
return {
|
||||
"schema": AUTHORITY_SCHEMA,
|
||||
"record_type": record_type,
|
||||
"record_class": record_class,
|
||||
"owner": owner,
|
||||
"authority_key": authority_key,
|
||||
}
|
||||
|
|
@ -295,6 +295,25 @@ def main(argv: list[str] | None = None) -> int:
|
|||
p_id_plan.add_argument("--output", default=None, help="Write the provenance mapping as JSON")
|
||||
p_id_plan.add_argument("--force", action="store_true", help="Replace an existing --output file")
|
||||
|
||||
p_sbom = sub.add_parser("sbom", help="Derive SBOM snapshots and licence reports from repository files")
|
||||
sbom_sub = p_sbom.add_subparsers(dest="sbom_command")
|
||||
p_sbom_scan = sbom_sub.add_parser("scan", help="Scan recognised lockfiles and tool manifests")
|
||||
p_sbom_scan.add_argument("--path", default=".")
|
||||
p_sbom_scan.add_argument("--slug", default=None)
|
||||
p_sbom_scan.add_argument("--output", default=None, help="Write the derived snapshot as JSON")
|
||||
p_sbom_scan.add_argument("--force", action="store_true", help="Replace an existing --output file")
|
||||
p_sbom_report = sbom_sub.add_parser("licence-report", help="Report licences from a fresh file scan")
|
||||
p_sbom_report.add_argument("--path", default=".")
|
||||
p_sbom_report.add_argument("--slug", default=None)
|
||||
|
||||
p_authority = sub.add_parser("authority", help="Resolve the one authoritative record owner")
|
||||
authority_sub = p_authority.add_subparsers(dest="authority_command")
|
||||
p_authority_route = authority_sub.add_parser("route", help="Resolve or verify an authority route")
|
||||
p_authority_route.add_argument("--record-type", required=True)
|
||||
p_authority_route.add_argument("--repo-slug", default=None)
|
||||
p_authority_route.add_argument("--domain-slug", default=None)
|
||||
p_authority_route.add_argument("--claimed-owner", default=None)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.version or args.command in (None, "version"):
|
||||
|
|
@ -618,6 +637,56 @@ def main(argv: list[str] | None = None) -> int:
|
|||
print(json.dumps(result, indent=2))
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
if args.command == "sbom":
|
||||
if not args.sbom_command:
|
||||
p_sbom.print_help()
|
||||
return 2
|
||||
from repo_manager.sbom import scan_repository
|
||||
|
||||
snapshot = scan_repository(Path(args.path), slug=args.slug)
|
||||
result = snapshot if args.sbom_command == "scan" else {
|
||||
"ok": snapshot["ok"],
|
||||
"repo_slug": snapshot["repo_slug"],
|
||||
"source_revision": snapshot["source_revision"],
|
||||
"generated_at": snapshot["generated_at"],
|
||||
"entry_count": snapshot["entry_count"],
|
||||
"licence_report": snapshot["licence_report"],
|
||||
"errors": snapshot["errors"],
|
||||
}
|
||||
if args.sbom_command == "scan" and args.output:
|
||||
output = Path(args.output)
|
||||
if output.exists() and not args.force:
|
||||
print(
|
||||
json.dumps(
|
||||
{"ok": False, "error": f"output exists: {output}; use --force to replace"},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 1
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
if args.command == "authority":
|
||||
if not args.authority_command:
|
||||
p_authority.print_help()
|
||||
return 2
|
||||
from repo_manager.authority import AuthorityError, resolve_record_authority
|
||||
|
||||
try:
|
||||
result = resolve_record_authority(
|
||||
args.record_type,
|
||||
repo_slug=args.repo_slug,
|
||||
domain_slug=args.domain_slug,
|
||||
claimed_owner=args.claimed_owner,
|
||||
)
|
||||
except AuthorityError as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
||||
return 1
|
||||
print(json.dumps({"ok": True, **result}, indent=2))
|
||||
return 0
|
||||
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
|
|
|
|||
|
|
@ -448,10 +448,20 @@ def place(path: Path, *, reef: str, family_root: Path | None = None) -> dict[str
|
|||
if reef not in COMPUTE_REEFS and not (search / reef).is_dir():
|
||||
return _refuse(f"reef {reef!r} is not a known compute reef")
|
||||
text = declaration.read_text()
|
||||
if re.search(r"^bound_reefs:\n - ", text, re.M):
|
||||
text = re.sub(r"^bound_reefs:\n(?: - .+\n)+", f"bound_reefs:\n - {reef}\n", text, flags=re.M)
|
||||
if re.search(r"^bound_reefs:\n - ", text, re.MULTILINE):
|
||||
text = re.sub(
|
||||
r"^bound_reefs:\n(?: - .+\n)+",
|
||||
f"bound_reefs:\n - {reef}\n",
|
||||
text,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
else:
|
||||
text = re.sub(r"^bound_reefs:\s*\[\]\s*$", f"bound_reefs:\n - {reef}", text, flags=re.M)
|
||||
text = re.sub(
|
||||
r"^bound_reefs:\s*\[\]\s*$",
|
||||
f"bound_reefs:\n - {reef}",
|
||||
text,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if "exposure:" in text and "posture: public" in text:
|
||||
return _refuse("place does not grant public exposure; edit exposure separately")
|
||||
declaration.write_text(text)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
|||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -16,6 +15,7 @@ from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
|||
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
from repo_manager.parse.record import iter_record_files, parse_record_file
|
||||
from repo_manager.time import utc_now_text
|
||||
|
||||
_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_PROTECTED = frozenset(
|
||||
|
|
@ -39,7 +39,7 @@ _OPERATIONS = {
|
|||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
return utc_now_text()
|
||||
|
||||
|
||||
def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
|||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -16,6 +15,7 @@ from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
|||
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
from repo_manager.parse.register import REGISTER_SCHEMA, SUPPORTED_REGISTER_KINDS, register_path
|
||||
from repo_manager.time import utc_now_text
|
||||
|
||||
_ENTRY_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_PROTECTED_FIELDS = frozenset({"id", "title", "status", "notes", "created", "updated"})
|
||||
|
|
@ -30,7 +30,7 @@ _REQUIRED_DATA = {
|
|||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
return utc_now_text()
|
||||
|
||||
|
||||
def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult:
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from repo_manager.gitops import GitError, commit_paths, is_git_repo
|
||||
from repo_manager.standards import FLAVOR_MARKER_PREFIX, expected_workplan_prefix
|
||||
from repo_manager.time import utc_today
|
||||
|
||||
DURABLE_FLAVORS = ("experimental", "research", "tooling", "product", "business")
|
||||
FLAVORS = (*DURABLE_FLAVORS, "project")
|
||||
|
|
@ -124,7 +124,7 @@ def scaffold_repository(
|
|||
f"Workplan prefix: `{prefix}-`.\n",
|
||||
)
|
||||
if prj:
|
||||
today = date.today().isoformat()
|
||||
today = utc_today().isoformat()
|
||||
put(
|
||||
"GOAL.md",
|
||||
"---\n"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import json
|
|||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ from repo_manager import dual_run, idempotency
|
|||
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
||||
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
from repo_manager.time import utc_today
|
||||
|
||||
VALID_WORKPLAN_STATUSES = frozenset(
|
||||
{"proposed", "ready", "active", "blocked", "backlog", "finished", "archived"}
|
||||
|
|
@ -80,7 +81,7 @@ def _quoted(value: str) -> str:
|
|||
|
||||
|
||||
def _today() -> date:
|
||||
return datetime.now(UTC).date()
|
||||
return utc_today()
|
||||
|
||||
|
||||
def _patch_frontmatter(text: str, updates: dict[str, str]) -> str | None:
|
||||
|
|
|
|||
|
|
@ -16,13 +16,14 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
|
||||
from repo_manager.time import utc_now_text
|
||||
|
||||
Source = Literal["state-hub", "repo-manager"]
|
||||
|
||||
_TRUE = frozenset({"1", "true", "yes", "on"})
|
||||
|
|
@ -157,7 +158,7 @@ def record_mutation(
|
|||
path = meter_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
row = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"ts": utc_now_text(),
|
||||
"source": source,
|
||||
"kind": kind,
|
||||
"repo_slug": repo_slug,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from repo_manager.time import utc_now_text
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
return utc_now_text()
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
326
src/repo_manager/sbom.py
Normal file
326
src/repo_manager/sbom.py
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
"""Repository-derived SBOM scanning and licence reporting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from repo_manager.gitops import head_sha
|
||||
from repo_manager.time import utc_now_text
|
||||
|
||||
SBOM_SCHEMA = "repo-manager.sbom-snapshot.v1"
|
||||
COPYLEFT_MARKERS = frozenset({"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"})
|
||||
VALID_ECOSYSTEMS = frozenset(
|
||||
{"python", "node", "rust", "go", "java", "terraform", "ansible", "tool", "other"}
|
||||
)
|
||||
SKIP_DIRECTORIES = frozenset(
|
||||
{
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
".venv",
|
||||
"venv",
|
||||
".env",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"dist",
|
||||
"build",
|
||||
".build",
|
||||
"target",
|
||||
".tox",
|
||||
".nox",
|
||||
}
|
||||
)
|
||||
|
||||
Entry = dict[str, Any]
|
||||
Parser = Callable[[Path], list[Entry]]
|
||||
|
||||
|
||||
def _entry(
|
||||
name: str,
|
||||
version: str | None,
|
||||
ecosystem: str,
|
||||
*,
|
||||
license_spdx: str | None = None,
|
||||
is_direct: bool = False,
|
||||
is_dev: bool = False,
|
||||
) -> Entry:
|
||||
return {
|
||||
"package_name": name,
|
||||
"package_version": version,
|
||||
"ecosystem": ecosystem,
|
||||
"license_spdx": license_spdx,
|
||||
"is_direct": is_direct,
|
||||
"is_dev": is_dev,
|
||||
}
|
||||
|
||||
|
||||
def _parse_toml_packages(path: Path, ecosystem: str) -> list[Entry]:
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
return [
|
||||
_entry(str(item["name"]), str(item["version"]) if item.get("version") else None, ecosystem)
|
||||
for item in data.get("package", [])
|
||||
if isinstance(item, dict) and item.get("name")
|
||||
]
|
||||
|
||||
|
||||
def parse_uv_lock(path: Path) -> list[Entry]:
|
||||
return _parse_toml_packages(path, "python")
|
||||
|
||||
|
||||
def parse_cargo_lock(path: Path) -> list[Entry]:
|
||||
return _parse_toml_packages(path, "rust")
|
||||
|
||||
|
||||
def parse_requirements(path: Path) -> list[Entry]:
|
||||
entries: list[Entry] = []
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith(("#", "-")):
|
||||
continue
|
||||
match = re.match(r"^([A-Za-z0-9_.-]+)(?:[>=<!~^]+([^\s;]+))?", line)
|
||||
if match:
|
||||
entries.append(_entry(match.group(1), match.group(2), "python", is_direct=True))
|
||||
return entries
|
||||
|
||||
|
||||
def parse_package_lock(path: Path) -> list[Entry]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
entries: list[Entry] = []
|
||||
for package_path, item in (data.get("packages") or {}).items():
|
||||
if not package_path or not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("name") or package_path.rsplit("node_modules/", 1)[-1]
|
||||
entries.append(
|
||||
_entry(
|
||||
str(name),
|
||||
str(item["version"]) if item.get("version") else None,
|
||||
"node",
|
||||
license_spdx=str(item["license"]) if item.get("license") else None,
|
||||
is_direct=not bool(item.get("indirect", False)),
|
||||
is_dev=bool(item.get("dev", False)),
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def parse_yarn_lock(path: Path) -> list[Entry]:
|
||||
entries: list[Entry] = []
|
||||
names: list[str] = []
|
||||
for raw in [*path.read_text(encoding="utf-8").splitlines(), ""]:
|
||||
stripped = raw.strip()
|
||||
if raw and not raw.startswith((" ", "\t")) and stripped.endswith(":"):
|
||||
names = []
|
||||
for specifier in stripped.rstrip(":").split(","):
|
||||
match = re.match(r'"?((?:@[^/" ]+/)?[^@" ]+)@', specifier.strip())
|
||||
if match:
|
||||
names.append(match.group(1))
|
||||
elif stripped.startswith("version ") and names:
|
||||
version_match = re.search(r'"([^"]+)"', stripped)
|
||||
version = version_match.group(1) if version_match else None
|
||||
entries.extend(_entry(name, version, "node") for name in names)
|
||||
names = []
|
||||
return entries
|
||||
|
||||
|
||||
def _go_direct_modules(directory: Path) -> set[str]:
|
||||
path = directory / "go.mod"
|
||||
if not path.is_file():
|
||||
return set()
|
||||
direct: set[str] = set()
|
||||
in_block = False
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if line == "require (":
|
||||
in_block = True
|
||||
continue
|
||||
if in_block and line == ")":
|
||||
in_block = False
|
||||
continue
|
||||
candidate = line if in_block else line.removeprefix("require ") if line.startswith("require ") else ""
|
||||
if candidate and "// indirect" not in candidate:
|
||||
direct.add(candidate.split()[0])
|
||||
return direct
|
||||
|
||||
|
||||
def parse_go_sum(path: Path) -> list[Entry]:
|
||||
direct = _go_direct_modules(path.parent)
|
||||
seen: set[tuple[str, str]] = set()
|
||||
entries: list[Entry] = []
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
parts = raw.split()
|
||||
if len(parts) < 2 or parts[1].endswith("/go.mod"):
|
||||
continue
|
||||
key = (parts[0], parts[1])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
entries.append(_entry(key[0], key[1], "go", is_direct=parts[0] in direct))
|
||||
return entries
|
||||
|
||||
|
||||
def parse_terraform_lock(path: Path) -> list[Entry]:
|
||||
entries: list[Entry] = []
|
||||
provider: str | None = None
|
||||
version: str | None = None
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
match = re.match(r'^provider\s+"([^"]+)"\s*\{', line)
|
||||
if match:
|
||||
provider, version = match.group(1), None
|
||||
elif provider:
|
||||
version_match = re.match(r'version\s*=\s*"([^"]+)"', line)
|
||||
if version_match:
|
||||
version = version_match.group(1)
|
||||
elif line == "}":
|
||||
entries.append(_entry(provider, version, "terraform", is_direct=True))
|
||||
provider, version = None, None
|
||||
return entries
|
||||
|
||||
|
||||
def parse_ansible_requirements(path: Path) -> list[Entry]:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
entries: list[Entry] = []
|
||||
for kind in ("collections", "roles"):
|
||||
for item in data.get(kind, []) or []:
|
||||
if isinstance(item, str):
|
||||
name, version = item, None
|
||||
elif isinstance(item, dict):
|
||||
name = item.get("name") or item.get("src")
|
||||
version = str(item["version"]) if item.get("version") else None
|
||||
else:
|
||||
continue
|
||||
if name:
|
||||
entries.append(_entry(str(name), version, "ansible", is_direct=True))
|
||||
return entries
|
||||
|
||||
|
||||
def parse_tools_manifest(path: Path) -> list[Entry]:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
entries: list[Entry] = []
|
||||
for item in data.get("tools", []) or []:
|
||||
if not isinstance(item, dict) or not item.get("name"):
|
||||
continue
|
||||
ecosystem = str(item.get("ecosystem") or "tool")
|
||||
if ecosystem not in VALID_ECOSYSTEMS:
|
||||
ecosystem = "tool"
|
||||
version = str(item["version"]) if item.get("version") not in {None, "unknown"} else None
|
||||
entries.append(
|
||||
_entry(
|
||||
str(item["name"]),
|
||||
version,
|
||||
ecosystem,
|
||||
license_spdx=str(item["license_spdx"]) if item.get("license_spdx") else None,
|
||||
is_direct=bool(item.get("is_direct", True)),
|
||||
is_dev=bool(item.get("is_dev", False)),
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
LOCKFILE_PARSERS: dict[str, Parser] = {
|
||||
"uv.lock": parse_uv_lock,
|
||||
"requirements.txt": parse_requirements,
|
||||
"package-lock.json": parse_package_lock,
|
||||
"yarn.lock": parse_yarn_lock,
|
||||
"Cargo.lock": parse_cargo_lock,
|
||||
".terraform.lock.hcl": parse_terraform_lock,
|
||||
"go.sum": parse_go_sum,
|
||||
}
|
||||
|
||||
|
||||
def detect_sources(repo_root: Path) -> list[tuple[Path, Parser]]:
|
||||
found: list[tuple[Path, Parser]] = []
|
||||
seen: set[Path] = set()
|
||||
for directory, directories, filenames in os.walk(repo_root):
|
||||
directories[:] = sorted(name for name in directories if name not in SKIP_DIRECTORIES)
|
||||
current = Path(directory)
|
||||
for filename, parser in LOCKFILE_PARSERS.items():
|
||||
if filename in filenames:
|
||||
path = current / filename
|
||||
found.append((path, parser))
|
||||
seen.add(path)
|
||||
if current.name == "ansible":
|
||||
for filename in ("requirements.yml", "requirements.yaml"):
|
||||
if filename in filenames:
|
||||
path = current / filename
|
||||
found.append((path, parse_ansible_requirements))
|
||||
seen.add(path)
|
||||
tools = repo_root / "sbom-tools.yaml"
|
||||
if tools.is_file() and tools not in seen:
|
||||
found.append((tools, parse_tools_manifest))
|
||||
return sorted(found, key=lambda item: str(item[0]))
|
||||
|
||||
|
||||
def is_copyleft(spdx: str | None) -> bool:
|
||||
upper = (spdx or "").upper()
|
||||
return any(marker in upper for marker in COPYLEFT_MARKERS)
|
||||
|
||||
|
||||
def licence_report(entries: list[Entry]) -> dict[str, Any]:
|
||||
counts = Counter(entry.get("license_spdx") for entry in entries)
|
||||
groups = [
|
||||
{"license_spdx": license_id, "count": count, "is_copyleft": is_copyleft(license_id)}
|
||||
for license_id, count in sorted(counts.items(), key=lambda item: (-item[1], item[0] or ""))
|
||||
]
|
||||
risks = [
|
||||
{
|
||||
"package_name": entry["package_name"],
|
||||
"package_version": entry.get("package_version"),
|
||||
"license_spdx": entry.get("license_spdx"),
|
||||
"source_path": entry["source_path"],
|
||||
}
|
||||
for entry in entries
|
||||
if is_copyleft(entry.get("license_spdx"))
|
||||
and entry.get("is_direct")
|
||||
and not entry.get("is_dev")
|
||||
]
|
||||
return {"groups": groups, "copyleft_direct_prod": risks, "copyleft_direct_count": len(risks)}
|
||||
|
||||
|
||||
def scan_repository(repo_root: Path, *, slug: str | None = None) -> dict[str, Any]:
|
||||
repo_root = repo_root.resolve()
|
||||
sources: list[dict[str, Any]] = []
|
||||
entries: list[Entry] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for path, parser in detect_sources(repo_root):
|
||||
relative = str(path.relative_to(repo_root))
|
||||
try:
|
||||
parsed = parser(path)
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError, tomllib.TOMLDecodeError, yaml.YAMLError) as exc:
|
||||
errors.append({"source_path": relative, "error": str(exc)})
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
sources.append({"path": relative, "sha256": digest, "entry_count": len(parsed)})
|
||||
for item in parsed:
|
||||
entries.append({**item, "source_path": relative})
|
||||
return {
|
||||
"schema": SBOM_SCHEMA,
|
||||
"ok": not errors,
|
||||
"repo_slug": slug or repo_root.name,
|
||||
"repo_path": str(repo_root),
|
||||
"source_revision": head_sha(repo_root),
|
||||
"generated_at": utc_now_text(),
|
||||
"authority": "detected lockfiles and reviewed sbom-tools.yaml",
|
||||
"sources": sources,
|
||||
"entry_count": len(entries),
|
||||
"entries": entries,
|
||||
"licence_report": licence_report(entries),
|
||||
"errors": errors,
|
||||
}
|
||||
|
|
@ -16,8 +16,6 @@ from dataclasses import dataclass, field
|
|||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
|
||||
from repo_manager.observe import load_classification
|
||||
from repo_manager.parse.workplan import parse_frontmatter
|
||||
|
||||
|
|
|
|||
26
src/repo_manager/time.py
Normal file
26
src/repo_manager/time.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""Canonical UTC time helpers (RMGR-ADR-002)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return the current timezone-aware UTC instant."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def format_utc(value: datetime) -> str:
|
||||
"""Serialize an aware instant as canonical RFC 3339 UTC with ``Z``."""
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError("timestamp must be timezone-aware")
|
||||
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def utc_now_text() -> str:
|
||||
return format_utc(utc_now())
|
||||
|
||||
|
||||
def utc_today() -> date:
|
||||
"""Return the calendar date derived from the current UTC instant."""
|
||||
return utc_now().date()
|
||||
Loading…
Add table
Add a link
Reference in a new issue