1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Add language-neutral project onboarding

This commit is contained in:
Andraxion 2026-07-27 15:50:33 -04:00
parent 73165c9f51
commit 09c09300b1
8 changed files with 924 additions and 9 deletions

450
src/docforge/onboarding.py Normal file
View file

@ -0,0 +1,450 @@
"""Language-neutral project assessment and safe generic DocForge scaffolding."""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import cast
from .errors import DocForgeError
_EXCLUDED_DIRECTORIES = frozenset(
{
".cache",
".docforge",
".git",
".gradle",
".idea",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".tox",
".venv",
".vscode",
"__pycache__",
"_deps",
"bin",
"build",
"coverage",
"dist",
"external",
"generated",
"node_modules",
"obj",
"out",
"target",
"third_party",
"vendor",
"venv",
}
)
_PROTECTED_PARTS = frozenset({".git", ".ssh", ".gnupg", "secrets", "credentials"})
_PROJECT_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{1,127}")
@dataclass(frozen=True)
class LanguageProfile:
language_id: str
title: str
suffixes: tuple[str, ...]
build_markers: tuple[str, ...]
_LANGUAGE_PROFILES = (
LanguageProfile(
"c",
"C",
(".c",),
("CMakeLists.txt", "meson.build", "Makefile", "configure.ac"),
),
LanguageProfile(
"cpp",
"C++",
(".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx"),
("CMakeLists.txt", "meson.build", "Makefile", "conanfile.py", "vcpkg.json"),
),
LanguageProfile("csharp", "C#", (".cs",), (".sln", ".csproj", "global.json")),
LanguageProfile("go", "Go", (".go",), ("go.mod", "go.work")),
LanguageProfile(
"java",
"Java",
(".java",),
("build.gradle", "build.gradle.kts", "pom.xml", "settings.gradle"),
),
LanguageProfile(
"javascript",
"JavaScript",
(".cjs", ".js", ".jsx", ".mjs"),
("package.json",),
),
LanguageProfile(
"kotlin",
"Kotlin",
(".kt", ".kts"),
("build.gradle", "build.gradle.kts", "settings.gradle.kts"),
),
LanguageProfile("lua", "Lua", (".lua",), (".luacheckrc",)),
LanguageProfile("php", "PHP", (".php",), ("composer.json",)),
LanguageProfile(
"python",
"Python",
(".py",),
("pyproject.toml", "requirements.txt", "setup.py", "setup.cfg"),
),
LanguageProfile("ruby", "Ruby", (".rb",), ("Gemfile", ".ruby-version")),
LanguageProfile(
"rust",
"Rust",
(".rs",),
("Cargo.toml", "Cargo.lock", "rust-toolchain.toml"),
),
LanguageProfile("scala", "Scala", (".scala",), ("build.sbt",)),
LanguageProfile("swift", "Swift", (".swift",), ("Package.swift",)),
LanguageProfile(
"typescript",
"TypeScript",
(".ts", ".tsx"),
("package.json", "tsconfig.json"),
),
)
_PROFILES_BY_ID = {profile.language_id: profile for profile in _LANGUAGE_PROFILES}
def _relative_project_path(root: Path, raw: str, *, field: str) -> Path:
candidate = Path(raw)
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
raise DocForgeError("path_escape", f"{field} must stay inside the project root", path=raw)
if any(part.lower() in _PROTECTED_PARTS for part in candidate.parts):
raise DocForgeError("secret_path", f"{field} may not reference a protected path", path=raw)
resolved = (root / candidate).resolve(strict=False)
if not resolved.is_relative_to(root):
raise DocForgeError("path_escape", f"{field} resolves outside the project root", path=raw)
return candidate
def _walk_project_files(root: Path) -> tuple[Path, ...]:
files: list[Path] = []
for directory, directory_names, file_names in os.walk(root, followlinks=False):
current = Path(directory)
directory_names[:] = sorted(
name
for name in directory_names
if name not in _EXCLUDED_DIRECTORIES and not (current / name).is_symlink()
)
for name in sorted(file_names):
path = current / name
if not path.is_symlink():
files.append(path.relative_to(root))
return tuple(files)
def _normalize_requested_languages(requested: tuple[str, ...]) -> tuple[str, ...]:
if not requested or requested == ("auto",):
return ()
values = tuple(sorted(set(item.strip().lower() for item in requested if item.strip())))
if "auto" in values:
raise DocForgeError(
"invalid_onboarding",
"language auto cannot be combined with explicit language profiles",
)
unknown = tuple(item for item in values if item not in _PROFILES_BY_ID)
if unknown:
raise DocForgeError(
"unsupported_language_profile",
"One or more language profiles are not recognized",
languages=list(unknown),
supported=sorted(_PROFILES_BY_ID),
)
return values
def _language_inventory(
root: Path, files: tuple[Path, ...], requested: tuple[str, ...]
) -> tuple[dict[str, object], ...]:
explicit = _normalize_requested_languages(requested)
profiles = tuple(_PROFILES_BY_ID[item] for item in explicit) if explicit else _LANGUAGE_PROFILES
names = {path.name for path in files}
inventory: list[dict[str, object]] = []
for profile in profiles:
source_count = sum(path.suffix.lower() in profile.suffixes for path in files)
markers = sorted(marker for marker in profile.build_markers if marker in names)
if source_count or explicit:
inventory.append(
{
"id": profile.language_id,
"title": profile.title,
"source_files": source_count,
"build_evidence": markers,
"frontend_status": "adapter_required",
}
)
return tuple(sorted(inventory, key=lambda item: str(item["id"])))
def _documentation_inventory(files: tuple[Path, ...]) -> tuple[str, ...]:
candidates = {
path.as_posix()
for path in files
if path.suffix.lower() in {".md", ".mdx", ".rst", ".toml"}
and (
path.name.lower().startswith(("readme", "architecture", "design", "manual"))
or any(part.lower() in {"doc", "docs", "manual"} for part in path.parts[:-1])
)
}
return tuple(sorted(candidates))
def _default_project_id(root: Path) -> str:
value = re.sub(r"[^a-z0-9._-]+", "-", root.name.lower()).strip("-._")
if len(value) < 2:
value = f"{value or 'project'}-docs"
return value[:128]
def assess_project(root: Path, *, requested_languages: tuple[str, ...] = ()) -> dict[str, object]:
"""Return a deterministic, read-only onboarding assessment."""
resolved = root.resolve(strict=True)
if not resolved.is_dir():
raise DocForgeError("invalid_project_root", "Project root must be a directory")
files = _walk_project_files(resolved)
languages = _language_inventory(resolved, files, requested_languages)
existing_descriptor = resolved / ".docforge" / "project.toml"
documentation = _documentation_inventory(files)
return {
"status": "ok",
"mode": "assessment",
"project_root": str(resolved),
"project_id_suggestion": _default_project_id(resolved),
"file_count": len(files),
"languages": list(languages),
"documentation_candidates": list(documentation),
"configured": existing_descriptor.is_file(),
"capabilities": {
"manual_scaffold": "available" if not existing_descriptor.exists() else "configured",
"source_graph": ("adapter_required" if languages else "no_supported_source_detected"),
"incremental_compilation": "available_after_adapter",
"mcp": "available_after_configuration",
"viewer": "available_after_index",
},
"next_actions": [
"Review detected languages and documentation authority.",
(
"Run onboard with --scaffold to create a generic manual when the project is "
"unconfigured."
),
"Implement or select one language frontend per source language.",
"Prove full and incremental projection equivalence.",
"Generate and register the fixed project MCP command.",
],
}
def _toml_string(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def _descriptor(project_id: str, title: str, content_root: Path) -> str:
content = content_root.as_posix()
return f"""schema_version = 1
project_id = {_toml_string(project_id)}
title = {_toml_string(title)}
adapter = "generic"
[sources]
content_roots = [{_toml_string(content)}]
authority_files = []
[derived]
cache_root = ".docforge/cache"
index = ".docforge/cache/index.sqlite3"
[changesets]
root = ".docforge/changesets"
[[changesets.writers]]
id = "project-editor"
families = ["api", "architecture", "operations", "proof", "roadmap", "system"]
operations = ["create", "update", "move", "delete"]
[render]
template_root = ".docforge/templates"
preview_root = ".docforge/previews"
[[render.views]]
id = "manual"
renderer = "generic_html"
template = "manual.html"
output = ".docforge/rendered/manual.html"
title = {_toml_string(f"{title} Manual")}
families = ["api", "architecture", "operations", "proof", "roadmap", "system"]
[graph]
allowed_relations = [
"calls",
"defines",
"depends_on",
"implements",
"inherits_from",
"owns",
"reads",
"relates_to",
"tested_by",
"writes",
]
[limits]
max_source_bytes = 500000
max_nodes = 10000
max_query_chars = 500
max_results = 100
max_traversal_depth = 6
max_context_tokens = 12000
max_changesets = 100
max_changeset_operations = 100
max_changeset_bytes = 1000000
[[profiles]]
id = "development"
families = ["api", "architecture", "operations", "proof", "roadmap", "system"]
statuses = ["active", "current", "verified"]
required_nodes = ["architecture.overview"]
token_budget = 8000
dependency_depth = 3
"""
def _overview(title: str, languages: tuple[dict[str, object], ...]) -> str:
language_titles = [str(item["title"]) for item in languages]
tags = ["architecture", "onboarding", *[str(item["id"]) for item in languages]]
language_text = ", ".join(language_titles) if language_titles else "No source language selected"
return f"""+++
schema_version = 1
id = "architecture.overview"
title = "Project architecture"
family = "architecture"
authority = "authoritative"
status = "current"
tags = {json.dumps(tags)}
summary = "Introduces the project and its documentation authority."
+++
# {title}
DocForge indexes the canonical documentation under this directory. Derived indexes, rendered
pages, previews, and extraction caches may be deleted and rebuilt.
Detected or selected source languages: {language_text}.
Source-code facts require a language frontend that implements DocForge's adapter contract. Until
that frontend passes full and incremental equivalence checks, this manual remains authoritative
and the source graph remains explicitly unavailable.
"""
_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="docforge-render" content="{{ docforge_render_identity }}">
<title>{{ docforge_title }}</title>
<style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; }
body { margin: 0; background: #0e1518; color: #dce9e6; }
header, main { width: min(70rem, calc(100% - 3rem)); margin: auto; }
header { padding: 3rem 0 1rem; border-bottom: 1px solid #31504c; }
main { padding: 2rem 0 5rem; line-height: 1.65; }
article { margin: 0 0 2rem; padding: 1.5rem; background: #14211f; border: 1px solid #31504c; }
a { color: #78ddd0; }
code { color: #f1c779; }
</style>
</head>
<body data-project="{{ docforge_project_id }}" data-view="{{ docforge_view_id }}">
<header><h1>{{ docforge_title }}</h1></header>
<main>{{ docforge_content }}</main>
</body>
</html>
"""
def _write_new(path: Path, content: str) -> None:
if path.exists() or path.is_symlink():
raise DocForgeError(
"onboarding_conflict",
"Onboarding will not replace an existing path",
path=str(path),
)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.docforge-new")
try:
temporary.write_text(content, encoding="utf-8")
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def scaffold_project(
root: Path,
*,
requested_languages: tuple[str, ...] = (),
project_id: str | None = None,
title: str | None = None,
content_root: str = "docs/docforge/content",
) -> dict[str, object]:
"""Create a minimal generic manual without pretending a source adapter exists."""
resolved = root.resolve(strict=True)
assessment = assess_project(resolved, requested_languages=requested_languages)
language_values = cast(list[object], assessment["languages"])
selected_languages = tuple(
cast(dict[str, object], item) for item in language_values if isinstance(item, dict)
)
resolved_id = project_id or str(assessment["project_id_suggestion"])
if _PROJECT_ID_PATTERN.fullmatch(resolved_id) is None:
raise DocForgeError(
"invalid_onboarding", "project_id is not a stable DocForge ID", project_id=resolved_id
)
resolved_title = title.strip() if title and title.strip() else resolved.name
relative_content = _relative_project_path(resolved, content_root, field="onboard.content_root")
targets = (
resolved / ".docforge" / "project.toml",
resolved / ".docforge" / "templates" / "manual.html",
resolved / relative_content / "project-overview.md",
)
conflicts = [str(path) for path in targets if path.exists() or path.is_symlink()]
if conflicts:
raise DocForgeError(
"onboarding_conflict",
"Onboarding will not replace existing project files",
paths=conflicts,
)
created: list[Path] = []
try:
template = targets[1]
_write_new(template, _TEMPLATE)
created.append(template)
overview = targets[2]
_write_new(overview, _overview(resolved_title, selected_languages))
created.append(overview)
descriptor = targets[0]
_write_new(descriptor, _descriptor(resolved_id, resolved_title, relative_content))
created.append(descriptor)
except Exception:
for path in reversed(created):
path.unlink(missing_ok=True)
raise
return {
**assessment,
"mode": "scaffold",
"configured": True,
"project_id": resolved_id,
"title": resolved_title,
"created": [str(path.relative_to(resolved)) for path in created],
"source_graph_status": "adapter_required",
}