Add language-neutral project onboarding
This commit is contained in:
parent
73165c9f51
commit
09c09300b1
8 changed files with 924 additions and 9 deletions
|
|
@ -12,6 +12,7 @@ from .application import CanonicalApplicationService, GenericCanonicalApplier
|
|||
from .context import compile_context
|
||||
from .errors import DocForgeError
|
||||
from .index import ProjectIndex
|
||||
from .onboarding import assess_project, scaffold_project
|
||||
from .project import Project, project_root_fingerprint
|
||||
from .rendering import RenderService
|
||||
from .viewer_manager import ViewerManagerClient
|
||||
|
|
@ -21,6 +22,12 @@ def _parser() -> argparse.ArgumentParser:
|
|||
parser = argparse.ArgumentParser(prog="docforge")
|
||||
parser.add_argument("--project-root", type=Path, required=True)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
onboard = commands.add_parser("onboard")
|
||||
onboard.add_argument("--language", action="append", default=[])
|
||||
onboard.add_argument("--scaffold", action="store_true")
|
||||
onboard.add_argument("--project-id")
|
||||
onboard.add_argument("--title")
|
||||
onboard.add_argument("--content-root", default="docs/docforge/content")
|
||||
commands.add_parser("info")
|
||||
commands.add_parser("validate")
|
||||
commands.add_parser("build")
|
||||
|
|
@ -72,6 +79,21 @@ def _parser() -> argparse.ArgumentParser:
|
|||
|
||||
|
||||
def _run(arguments: argparse.Namespace) -> dict[str, object]:
|
||||
if arguments.command == "onboard":
|
||||
languages = tuple(arguments.language)
|
||||
if arguments.scaffold:
|
||||
scaffold = scaffold_project(
|
||||
arguments.project_root,
|
||||
requested_languages=languages,
|
||||
project_id=arguments.project_id,
|
||||
title=arguments.title,
|
||||
content_root=arguments.content_root,
|
||||
)
|
||||
project = Project.open(arguments.project_root)
|
||||
build = ProjectIndex(project).build()
|
||||
render = RenderService(project).render("manual")
|
||||
return {**scaffold, "build": build, "render": render}
|
||||
return assess_project(arguments.project_root, requested_languages=languages)
|
||||
project = Project.open(arguments.project_root)
|
||||
index = ProjectIndex(project)
|
||||
if arguments.command == "info":
|
||||
|
|
|
|||
450
src/docforge/onboarding.py
Normal file
450
src/docforge/onboarding.py
Normal 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",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue