"""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 = """