428 lines
14 KiB
Python
428 lines
14 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from collections import defaultdict
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import yaml
|
||
|
|
|
||
|
|
|
||
|
|
GENERATED_NOTICE = "<!-- GENERATED by info_tech_canon; do not edit by hand. -->"
|
||
|
|
|
||
|
|
|
||
|
|
def generate_indexes(context: Any) -> dict[str, Any]:
|
||
|
|
assets: list[dict[str, Any]] = []
|
||
|
|
ownership = concept_ownership(context)
|
||
|
|
import_matrix = relationship_matrix(context)
|
||
|
|
|
||
|
|
assets.append(
|
||
|
|
_write_yaml(
|
||
|
|
context.infospace_root / "indexes" / "concept-ownership.yaml",
|
||
|
|
ownership,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
assets.append(
|
||
|
|
_write_yaml(
|
||
|
|
context.infospace_root / "indexes" / "import-matrix.yaml",
|
||
|
|
import_matrix,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
assets.append(
|
||
|
|
_write_yaml(
|
||
|
|
context.infospace_root / "indexes" / "artifact-tree.yaml",
|
||
|
|
artifact_tree(context),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
assets.extend(generate_views(context, ownership, import_matrix)["files"])
|
||
|
|
return _result("index", assets)
|
||
|
|
|
||
|
|
|
||
|
|
def generate_views(
|
||
|
|
context: Any,
|
||
|
|
ownership: dict[str, Any] | None = None,
|
||
|
|
import_matrix: dict[str, Any] | None = None,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
ownership = ownership or concept_ownership(context)
|
||
|
|
import_matrix = import_matrix or relationship_matrix(context)
|
||
|
|
files = [
|
||
|
|
_write_text(
|
||
|
|
context.infospace_root / "views" / "by-standard.md",
|
||
|
|
_render_by_standard(context),
|
||
|
|
),
|
||
|
|
_write_text(
|
||
|
|
context.infospace_root / "views" / "by-concept.md",
|
||
|
|
_render_by_concept(ownership),
|
||
|
|
),
|
||
|
|
_write_text(
|
||
|
|
context.infospace_root / "views" / "by-profile.md",
|
||
|
|
_render_by_profile(context),
|
||
|
|
),
|
||
|
|
_write_text(
|
||
|
|
context.infospace_root / "views" / "by-mapping-target.md",
|
||
|
|
_render_by_mapping_target(context),
|
||
|
|
),
|
||
|
|
_write_text(
|
||
|
|
context.infospace_root / "views" / "kernel-overview.md",
|
||
|
|
_render_kernel_overview(context),
|
||
|
|
),
|
||
|
|
_write_text(
|
||
|
|
context.infospace_root / "views" / "import-matrix.md",
|
||
|
|
_render_import_matrix(import_matrix),
|
||
|
|
),
|
||
|
|
]
|
||
|
|
return _result("views", files)
|
||
|
|
|
||
|
|
|
||
|
|
def generate_tree(context: Any) -> dict[str, Any]:
|
||
|
|
tree = artifact_tree(context)
|
||
|
|
files = [
|
||
|
|
_write_yaml(context.infospace_root / "indexes" / "artifact-tree.yaml", tree),
|
||
|
|
_write_text(
|
||
|
|
context.infospace_root / "views" / "repository-tree.md",
|
||
|
|
_render_repository_tree(tree),
|
||
|
|
),
|
||
|
|
]
|
||
|
|
return _result("tree", files)
|
||
|
|
|
||
|
|
|
||
|
|
def generate_agent_briefs(context: Any) -> dict[str, Any]:
|
||
|
|
files = [
|
||
|
|
_write_text(
|
||
|
|
context.infospace_root / "agent" / "global-agent-brief.md",
|
||
|
|
_render_global_agent_brief(context),
|
||
|
|
)
|
||
|
|
]
|
||
|
|
return _result("agent-briefs", files)
|
||
|
|
|
||
|
|
|
||
|
|
def list_generated_views(context: Any) -> dict[str, Any]:
|
||
|
|
views = []
|
||
|
|
for path in sorted((context.infospace_root / "views").glob("*.md")):
|
||
|
|
views.append(
|
||
|
|
{
|
||
|
|
"name": path.name,
|
||
|
|
"path": str(path.relative_to(context.infospace_root)),
|
||
|
|
"generated": _is_generated(path),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return {"ok": True, "count": len(views), "views": views}
|
||
|
|
|
||
|
|
|
||
|
|
def read_generated_view(context: Any, name: str) -> dict[str, Any]:
|
||
|
|
if "/" in name or "\\" in name:
|
||
|
|
raise ValueError("View name must be a single file name.")
|
||
|
|
path = context.infospace_root / "views" / name
|
||
|
|
if not path.is_file():
|
||
|
|
raise FileNotFoundError(name)
|
||
|
|
return {
|
||
|
|
"ok": True,
|
||
|
|
"name": name,
|
||
|
|
"path": str(path.relative_to(context.infospace_root)),
|
||
|
|
"generated": _is_generated(path),
|
||
|
|
"content": path.read_text(encoding="utf-8"),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def concept_ownership(context: Any) -> dict[str, Any]:
|
||
|
|
concepts: list[dict[str, Any]] = []
|
||
|
|
for artifact in sorted(context.infospace.artifacts, key=lambda item: item.id):
|
||
|
|
concepts.append(
|
||
|
|
{
|
||
|
|
"concept": artifact.title,
|
||
|
|
"owner": artifact.id,
|
||
|
|
"path": artifact.path,
|
||
|
|
"source": "artifact_title",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
frontmatter = _frontmatter(context.infospace_root / artifact.path)
|
||
|
|
owned = frontmatter.get("owned_concepts") or []
|
||
|
|
if isinstance(owned, list):
|
||
|
|
for concept in owned:
|
||
|
|
concepts.append(
|
||
|
|
{
|
||
|
|
"concept": str(concept),
|
||
|
|
"owner": artifact.id,
|
||
|
|
"path": artifact.path,
|
||
|
|
"source": "frontmatter.owned_concepts",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
by_key: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||
|
|
for item in concepts:
|
||
|
|
by_key[_normalize_concept(item["concept"])].append(item)
|
||
|
|
|
||
|
|
duplicates = [
|
||
|
|
{"normalized": key, "candidates": items}
|
||
|
|
for key, items in sorted(by_key.items())
|
||
|
|
if len(items) > 1
|
||
|
|
]
|
||
|
|
conflicts = [
|
||
|
|
{
|
||
|
|
"normalized": item["normalized"],
|
||
|
|
"owners": sorted({candidate["owner"] for candidate in item["candidates"]}),
|
||
|
|
"candidates": item["candidates"],
|
||
|
|
}
|
||
|
|
for item in duplicates
|
||
|
|
if len({candidate["owner"] for candidate in item["candidates"]}) > 1
|
||
|
|
]
|
||
|
|
return {
|
||
|
|
"concept_count": len(concepts),
|
||
|
|
"concepts": concepts,
|
||
|
|
"duplicate_candidates": duplicates,
|
||
|
|
"ownership_conflicts": conflicts,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def relationship_matrix(context: Any) -> dict[str, Any]:
|
||
|
|
artifact_ids = sorted(artifact.id for artifact in context.infospace.artifacts)
|
||
|
|
rows: list[dict[str, Any]] = []
|
||
|
|
for artifact in sorted(context.infospace.artifacts, key=lambda item: item.id):
|
||
|
|
targets: dict[str, list[str]] = {target: [] for target in artifact_ids}
|
||
|
|
for relationship in artifact.relationships:
|
||
|
|
target = relationship.get("target")
|
||
|
|
relation_type = str(relationship.get("type") or "related")
|
||
|
|
if isinstance(target, str) and target in targets:
|
||
|
|
targets[target].append(relation_type)
|
||
|
|
rows.append(
|
||
|
|
{
|
||
|
|
"artifact": artifact.id,
|
||
|
|
"targets": {
|
||
|
|
target: sorted(types)
|
||
|
|
for target, types in targets.items()
|
||
|
|
if types
|
||
|
|
},
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return {"artifacts": artifact_ids, "rows": rows}
|
||
|
|
|
||
|
|
|
||
|
|
def artifact_tree(context: Any) -> dict[str, Any]:
|
||
|
|
files: list[dict[str, Any]] = []
|
||
|
|
for path in sorted(context.infospace_root.rglob("*")):
|
||
|
|
if path.is_file():
|
||
|
|
relative = path.relative_to(context.infospace_root)
|
||
|
|
files.append(
|
||
|
|
{
|
||
|
|
"path": str(relative),
|
||
|
|
"directory": str(relative.parent),
|
||
|
|
"name": path.name,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return {"root": "infospace", "file_count": len(files), "files": files}
|
||
|
|
|
||
|
|
|
||
|
|
def _render_by_standard(context: Any) -> str:
|
||
|
|
lines = _heading("By Standard")
|
||
|
|
standards = [
|
||
|
|
artifact
|
||
|
|
for artifact in context.infospace.artifacts
|
||
|
|
if artifact.kind in {"kernel", "standard"}
|
||
|
|
]
|
||
|
|
for artifact in sorted(standards, key=lambda item: item.id):
|
||
|
|
lines.extend(
|
||
|
|
[
|
||
|
|
f"## {artifact.title}",
|
||
|
|
"",
|
||
|
|
f"- ID: `{artifact.id}`",
|
||
|
|
f"- Kind: `{artifact.kind}`",
|
||
|
|
f"- Path: `{artifact.path}`",
|
||
|
|
f"- Relationships: {len(artifact.relationships)}",
|
||
|
|
"",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
return "\n".join(lines).rstrip() + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def _render_by_concept(ownership: dict[str, Any]) -> str:
|
||
|
|
lines = _heading("By Concept")
|
||
|
|
lines.extend(
|
||
|
|
[
|
||
|
|
f"Concept count: **{ownership['concept_count']}**",
|
||
|
|
"",
|
||
|
|
"| Concept | Owner | Source |",
|
||
|
|
"| --- | --- | --- |",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
for concept in ownership["concepts"]:
|
||
|
|
lines.append(
|
||
|
|
f"| {concept['concept']} | `{concept['owner']}` | `{concept['source']}` |"
|
||
|
|
)
|
||
|
|
lines.extend(["", "## Duplicate Candidates", ""])
|
||
|
|
duplicates = ownership["duplicate_candidates"]
|
||
|
|
if not duplicates:
|
||
|
|
lines.append("No duplicate concept candidates detected.")
|
||
|
|
else:
|
||
|
|
for duplicate in duplicates:
|
||
|
|
lines.append(f"- `{duplicate['normalized']}`")
|
||
|
|
lines.extend(["", "## Ownership Conflicts", ""])
|
||
|
|
conflicts = ownership["ownership_conflicts"]
|
||
|
|
if not conflicts:
|
||
|
|
lines.append("No ownership conflicts detected.")
|
||
|
|
else:
|
||
|
|
for conflict in conflicts:
|
||
|
|
owners = ", ".join(f"`{owner}`" for owner in conflict["owners"])
|
||
|
|
lines.append(f"- `{conflict['normalized']}` owned by {owners}")
|
||
|
|
return "\n".join(lines).rstrip() + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def _render_by_profile(context: Any) -> str:
|
||
|
|
lines = _heading("By Profile")
|
||
|
|
profiles = sorted((context.infospace_root / "profiles").glob("*/profile.yaml"))
|
||
|
|
if not profiles:
|
||
|
|
lines.append("No profiles have been registered yet.")
|
||
|
|
for path in profiles:
|
||
|
|
lines.extend(
|
||
|
|
[
|
||
|
|
f"## {path.parent.name}",
|
||
|
|
"",
|
||
|
|
f"- Path: `{path.relative_to(context.infospace_root)}`",
|
||
|
|
"",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
return "\n".join(lines).rstrip() + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def _render_by_mapping_target(context: Any) -> str:
|
||
|
|
incoming: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||
|
|
for artifact in context.infospace.artifacts:
|
||
|
|
for relationship in artifact.relationships:
|
||
|
|
target = relationship.get("target")
|
||
|
|
relation_type = str(relationship.get("type") or "related")
|
||
|
|
if isinstance(target, str):
|
||
|
|
incoming[target].append((artifact.id, relation_type))
|
||
|
|
lines = _heading("By Mapping Target")
|
||
|
|
for target in sorted(incoming):
|
||
|
|
lines.extend([f"## `{target}`", ""])
|
||
|
|
for source, relation_type in sorted(incoming[target]):
|
||
|
|
lines.append(f"- `{source}` via `{relation_type}`")
|
||
|
|
lines.append("")
|
||
|
|
return "\n".join(lines).rstrip() + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def _render_kernel_overview(context: Any) -> str:
|
||
|
|
kind_counts: dict[str, int] = defaultdict(int)
|
||
|
|
relationship_counts: dict[str, int] = defaultdict(int)
|
||
|
|
for artifact in context.infospace.artifacts:
|
||
|
|
kind_counts[artifact.kind] += 1
|
||
|
|
for relationship in artifact.relationships:
|
||
|
|
relationship_counts[str(relationship.get("type") or "related")] += 1
|
||
|
|
lines = _heading("Kernel Overview")
|
||
|
|
lines.extend(
|
||
|
|
[
|
||
|
|
f"- Infospace: `{context.infospace.config.slug}`",
|
||
|
|
f"- Artifacts: {len(context.infospace.artifacts)}",
|
||
|
|
"",
|
||
|
|
"## Artifact Kinds",
|
||
|
|
"",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
for kind, count in sorted(kind_counts.items()):
|
||
|
|
lines.append(f"- `{kind}`: {count}")
|
||
|
|
lines.extend(["", "## Relationship Types", ""])
|
||
|
|
for relation_type, count in sorted(relationship_counts.items()):
|
||
|
|
lines.append(f"- `{relation_type}`: {count}")
|
||
|
|
return "\n".join(lines).rstrip() + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def _render_import_matrix(matrix: dict[str, Any]) -> str:
|
||
|
|
artifacts = matrix["artifacts"]
|
||
|
|
lines = _heading("Import Matrix")
|
||
|
|
header = "| Artifact | " + " | ".join(f"`{artifact}`" for artifact in artifacts) + " |"
|
||
|
|
divider = "| --- | " + " | ".join("---" for _ in artifacts) + " |"
|
||
|
|
lines.extend([header, divider])
|
||
|
|
for row in matrix["rows"]:
|
||
|
|
cells = []
|
||
|
|
targets = row["targets"]
|
||
|
|
for artifact in artifacts:
|
||
|
|
cells.append(", ".join(f"`{item}`" for item in targets.get(artifact, [])))
|
||
|
|
lines.append(f"| `{row['artifact']}` | " + " | ".join(cells) + " |")
|
||
|
|
return "\n".join(lines).rstrip() + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def _render_repository_tree(tree: dict[str, Any]) -> str:
|
||
|
|
lines = _heading("Repository Tree")
|
||
|
|
lines.append(f"File count: **{tree['file_count']}**")
|
||
|
|
lines.append("")
|
||
|
|
for file_info in tree["files"]:
|
||
|
|
lines.append(f"- `{file_info['path']}`")
|
||
|
|
return "\n".join(lines).rstrip() + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def _render_global_agent_brief(context: Any) -> str:
|
||
|
|
lines = _heading("Global Agent Brief")
|
||
|
|
lines.extend(
|
||
|
|
[
|
||
|
|
"This brief summarizes the current canon service surface for agents.",
|
||
|
|
"",
|
||
|
|
f"- Infospace slug: `{context.infospace.config.slug}`",
|
||
|
|
f"- Artifact count: {len(context.infospace.artifacts)}",
|
||
|
|
"- Primary confidence command: `make validate`",
|
||
|
|
"- Refresh generated indexes and views with: `make index`",
|
||
|
|
"",
|
||
|
|
"## Useful Commands",
|
||
|
|
"",
|
||
|
|
"- `PYTHONPATH=src python3 -m info_tech_canon inspect`",
|
||
|
|
"- `PYTHONPATH=src python3 -m info_tech_canon validate`",
|
||
|
|
"- `PYTHONPATH=src python3 -m info_tech_canon graph`",
|
||
|
|
"- `PYTHONPATH=src python3 -m info_tech_canon index`",
|
||
|
|
"",
|
||
|
|
"## Consumption Notes",
|
||
|
|
"",
|
||
|
|
"- Treat `seeds/` as provenance.",
|
||
|
|
"- Treat `infospace/` as the service-consumable canon root.",
|
||
|
|
"- Generated files are marked and can be refreshed deterministically.",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
return "\n".join(lines).rstrip() + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def _heading(title: str) -> list[str]:
|
||
|
|
return [GENERATED_NOTICE, "", f"# {title}", ""]
|
||
|
|
|
||
|
|
|
||
|
|
def _write_text(path: Path, content: str) -> dict[str, Any]:
|
||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
old = path.read_text(encoding="utf-8") if path.exists() else None
|
||
|
|
changed = old != content
|
||
|
|
if changed:
|
||
|
|
path.write_text(content, encoding="utf-8")
|
||
|
|
return {"path": str(path), "changed": changed}
|
||
|
|
|
||
|
|
|
||
|
|
def _write_yaml(path: Path, data: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
content = yaml.safe_dump(data, sort_keys=False)
|
||
|
|
return _write_text(path, content)
|
||
|
|
|
||
|
|
|
||
|
|
def _result(kind: str, files: list[dict[str, Any]]) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"ok": True,
|
||
|
|
"kind": kind,
|
||
|
|
"count": len(files),
|
||
|
|
"changed": [item for item in files if item["changed"]],
|
||
|
|
"files": files,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _frontmatter(path: Path) -> dict[str, Any]:
|
||
|
|
text = path.read_text(encoding="utf-8")
|
||
|
|
if not text.startswith("---\n"):
|
||
|
|
return {}
|
||
|
|
end = text.find("\n---\n", 4)
|
||
|
|
if end == -1:
|
||
|
|
return {}
|
||
|
|
data = yaml.safe_load(text[4:end]) or {}
|
||
|
|
return data if isinstance(data, dict) else {}
|
||
|
|
|
||
|
|
|
||
|
|
def _normalize_concept(value: str) -> str:
|
||
|
|
return "-".join(value.lower().replace("_", "-").split())
|
||
|
|
|
||
|
|
|
||
|
|
def _is_generated(path: Path) -> bool:
|
||
|
|
try:
|
||
|
|
return path.read_text(encoding="utf-8").startswith(GENERATED_NOTICE)
|
||
|
|
except FileNotFoundError:
|
||
|
|
return False
|