oct
Loading...
Searching...
No Matches
oct_scaffold.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/scaffold/oct_scaffold.py
4
5"""
6Purpose
7-------
8OI-512 / FS-518 - project scaffolding. Lifts the scaffold body out of
9``oct/cli.py`` so the CLI becomes a thin dispatcher and so the venv
10integration (FS-503 / FS-504) can layer on a single ``include_venv``
11parameter rather than a second 135-line rewrite.
12
13The public entry point is :func:`run_scaffold`, which returns a
14:class:`ScaffoldResult` describing what was created, skipped, or warned
15about. Callers are expected to render the result themselves - this
16module never touches ``click.echo`` directly.
17
18Responsibilities
19----------------
20- Materialise the standard Option C directory layout under a target path.
21- Write bundled templates: ``.octrc.json``, Sphinx ``conf.py`` /
22 ``index.rst``, the debug-config skeleton, stub READMEs.
23- Optionally create a ``.venv/`` via :mod:`venv` (``include_venv``).
24- Run in dry-run mode where no filesystem state changes.
25
26Diagnostics
27-----------
28Domain: SCAFFOLD
29Levels:
30 L2 - lifecycle: start / end / target path
31 L3 - semantic details: per-template decisions
32 L4 - deep tracing: per-file / per-dir outcomes
33
34Contracts
35---------
36- ``run_scaffold`` must be side-effect-free when ``dry_run=True``.
37- Venv creation must never shell out - uses :class:`venv.EnvBuilder`.
38- The Sphinx template directory is resolved at import time to keep
39 ``run_scaffold`` self-contained.
40
41Dependencies
42------------
43- oct.core.diagnostics (_dbg structured logger)
44- oct.core.option_c_dir (OcStatus, write_oc_status)
45- oct.docs.doxyfile_init (lazy; stamps the Doxyfile from the bundled template)
46"""
47
48from __future__ import annotations
49
50import datetime
51import json
52import os
53import platform
54import shutil
55import venv
56from dataclasses import dataclass, field
57from pathlib import Path
58
59from oct.core.diagnostics import _dbg
60from oct.core.option_c_dir import OcStatus, write_oc_status
61
62
63# ---------------------------------------------------------------------
64# Template bundle - resolved relative to the installed package.
65# ---------------------------------------------------------------------
66
67_SPHINX_TEMPLATES_DIR: Path = Path(__file__).resolve().parents[1] / "templates" / "sphinx"
68
69
70# ---------------------------------------------------------------------
71# Debug-config skeleton (v2 schema).
72# ---------------------------------------------------------------------
73
74_DEBUG_CONFIG_TEMPLATE: dict = {
75 "_schema_version": "2.0",
76 "DEBUG_LEVELS": {
77 "GENERAL": {"level": 2, "color": "default"}
78 },
79 "global_settings": {
80 "silent_mode": False,
81 "enable_timestamps": True,
82 "enable_colors": True,
83 "include_filename": True,
84 "output_mode": "both",
85 "log_to_file": True,
86 "log_file_path": None,
87 "log_format": "text",
88 "log_rotation": {
89 "max_files": 10,
90 "max_size_mb": 5.0,
91 },
92 "global_debug_level": None,
93 "watch_config": False,
94 "ai_trace_level": False,
95 "redaction_patterns": [],
96 "mode": "dev",
97 },
98 "instrumentation_enabled": False,
99 "http_redact_headers": ["authorization", "cookie", "set-cookie", "x-api-key"],
100}
101
102
103# ---------------------------------------------------------------------
104# Result dataclass
105# ---------------------------------------------------------------------
106
107
108@dataclass
110 """Structured output from :func:`run_scaffold`.
111
112 Attributes
113 ----------
114 created
115 Paths (directories + files) that were created.
116 skipped
117 Paths that already existed and were not overwritten.
118 messages
119 Informational strings the caller may render - warnings, tips,
120 or dry-run descriptions.
121 venv_path
122 The ``.venv/`` that was created, or ``None`` when
123 ``include_venv=False``.
124 """
125
126 created: list[Path] = field(default_factory=list)
127 skipped: list[Path] = field(default_factory=list)
128 messages: list[str] = field(default_factory=list)
129 venv_path: Path | None = None
130
131
132# ---------------------------------------------------------------------
133# File-content helpers
134# ---------------------------------------------------------------------
135
136
137def _architecture_md(name: str) -> str:
138 """Seed ``docs/ARCHITECTURE.md`` with the workspace's overview-chart slot.
139
140 Every subproject's architecture document carries a section headed
141 exactly ``Architecture overview``: one Mermaid flowchart of intent,
142 then how to read it and what it leaves out (plan B of
143 ``docs/DOCS_OVERVIEW_CHARTS_PLAN_2026-09-27.md``). Seeding the slot is
144 what makes a new project arrive with the convention rather than
145 acquire it later. The placeholder chart is valid Mermaid, so the
146 first Sphinx build renders it instead of failing on it.
147 """
148 return (
149 f"# {name} - Architecture\n\n"
150 "## Architecture overview\n\n"
151 f"What {name} is for, in one paragraph.\n\n"
152 "```mermaid\n"
153 "flowchart TB\n"
154 ' user(["Who or what uses it"]) --> entry["Entry point"]\n'
155 ' entry --> core["The layer everything else stands on"]\n'
156 "```\n\n"
157 "**How to read it.** The direction of the chart, what solid and "
158 "dotted arrows mean, and the one property the picture exists to "
159 "show.\n\n"
160 "**What it leaves out.** Name the deliberate omissions. The "
161 "module-level truth is the generated `dependencies` page, derived "
162 "from the imports on every docs build.\n\n"
163 "## Components\n\nList major components and their responsibilities.\n\n"
164 "## Data Flow\n\nDescribe how data flows through the system.\n"
165 )
166
167
168def _run_tests_py(name: str) -> str:
169 return (
170 "#!/usr/bin/env python3\n"
171 "# -*- coding: utf-8 -*-\n"
172 f"# {name}/tests/run_tests.py\n\n"
173 "import subprocess\nimport sys\n\n"
174 "sys.exit(subprocess.call([sys.executable, '-m', 'pytest', '--tb=short', '-q']))\n"
175 )
176
177
178def _tests_readme(name: str) -> str:
179 return (
180 f"# {name} - Tests\n\n"
181 "## Running Tests\n\n"
182 "```bash\npython -m pytest tests/ -v\n```\n\n"
183 "## Test Structure\n\nDescribe the test organization.\n"
184 )
185
186
187def _docs_readme(name: str) -> str:
188 return (
189 f"# {name} - Documentation\n\n"
190 "This directory contains project documentation.\n\n"
191 "## Building\n\n"
192 "```bash\noct docs --sphinx\n```\n\n"
193 "Output will be generated in ``docs/_build/html/``.\n"
194 )
195
196
197def _octrc_json() -> str:
198 """OI-530: build the scaffolded ``.octrc.json``.
199
200 Ships with a live ``linter.profile`` default plus an underscore-prefixed
201 ``_git_example`` stanza. JSON has no comments, so we use the same
202 ``_<name>`` convention that ``_schema_version`` and other sentinels
203 already use elsewhere: :func:`oct.core.octrc.load_octrc_safe` ignores
204 underscore-prefixed top-level keys, so the example is discoverable but
205 inert until the operator renames it.
206 """
207 return json.dumps(
208 {
209 "linter": {"profile": "compact"},
210 # sphinx_theme / sphinx_output are deliberately absent: nothing
211 # reads them (removed from oct.docs.oct_docs on 2026-09-25). The
212 # theme lives in the generated docs/_sphinx/conf.py, which is the
213 # file to edit, and the output path is fixed by the build.
214 "docs": {
215 "include_dependency_graphs": True,
216 "include_mermaid_diagrams": True,
217 "autodoc_modules": [],
218 },
219 "_git_example": {
220 "protected_branches": ["main", "master"],
221 "branch_profile_map": {"main": "strict", "dev": "compact"},
222 "require_signed_commits": False,
223 },
224 },
225 indent=2,
226 )
227
228
229# FS-503 / FS-504: default ignore lines for scaffolded projects. ``.venv/``
230# ships in both ``.gitignore`` and ``.octignore`` so a freshly-scaffolded
231# project never commits its venv and the linter never walks into it.
232# FS-539: logs and lint/linter caches live under ``.option_c/`` - those
233# subdirs are ignored so only the canonical config files stay tracked.
234_GITIGNORE_LINES: tuple[str, ...] = (
235 "# Python",
236 "__pycache__/",
237 "*.py[cod]",
238 "*.egg-info/",
239 "",
240 "# Virtual environments",
241 ".venv/",
242 "venv/",
243 "env/",
244 "",
245 "# IDE / OS",
246 ".vscode/",
247 ".idea/",
248 ".DS_Store",
249 "",
250 "# Build output",
251 "build/",
252 "dist/",
253 "docs/_build/",
254 "",
255 "# Generated documentation - everything oct docs writes. Sphinx lands in",
256 "# html/ and doctrees/ (source and output are the same directory), the",
257 "# API stubs in _api/, Doxygen in doxygen_output/. docs/doxygen_work_dir/",
258 "# holds the Doxyfile and IS tracked.",
259 "docs/html/",
260 "docs/doctrees/",
261 "docs/_api/",
262 "docs/_autosummary/",
263 "docs/doxygen_output/",
264 "",
265 "# Option C runtime data (FS-539) - config files stay tracked",
266 ".option_c/logs/",
267 ".option_c/cache/",
268 "",
269 "# Logs (legacy location pre-v0.19.0; kept for projects mid-migration)",
270 "logs/",
271 "*.log",
272 "",
273 "# Secrets (mirror NEVER_EXPORT_FILE_PATTERNS)",
274 ".env",
275 ".env.*",
276 "*.pem",
277 "*.key",
278 "credentials.json",
279 "secrets.json",
280 "secrets.yaml",
281)
282
283
284_OCTIGNORE_LINES: tuple[str, ...] = (
285 "# oct-specific ignores - lint/format/test/deps all consult this file.",
286 ".venv/",
287 "venv/",
288 "env/",
289 "docs/_build/",
290 "docs/html/",
291 "docs/doxygen_output/",
292 "build/",
293 "dist/",
294 ".formatter_archive/",
295)
296
297
298# ---------------------------------------------------------------------
299# Public entry point
300# ---------------------------------------------------------------------
301
302
304 name: str,
305 base_dir: Path,
306 *,
307 dry_run: bool = False,
308 include_venv: bool = True,
309) -> ScaffoldResult:
310 """Create a new Option C project under ``base_dir / name``.
311
312 Parameters
313 ----------
314 name
315 Project name (becomes the top-level directory).
316 base_dir
317 Parent directory - the project is created at ``base_dir / name``.
318 dry_run
319 When True, describe what would happen without touching the
320 filesystem.
321 include_venv
322 When True, create a ``.venv/`` at the project root using the
323 stdlib :mod:`venv` module.
324
325 Returns
326 -------
327 ScaffoldResult
328 Structured result. Always returns; never raises for existing
329 files (they are recorded in ``skipped``).
330 """
331 func = "run_scaffold"
332 project_dir = base_dir / name
333 _dbg("SCAFFOLD", func, f"start name={name} dir={project_dir} dry_run={dry_run}", 2)
334
335 result = ScaffoldResult()
336 config_text = json.dumps(_DEBUG_CONFIG_TEMPLATE, indent=2)
337
338 # FS-539: canonical project layout. ``.option_c/`` holds the OCT
339 # runtime state (config, logs, cache, migration status). ``docs/``
340 # and ``tests/`` stay at the project root because they're the
341 # developer-facing surface. ``oc_diagnostics/`` is no longer a
342 # scaffolded dir - its former contents (``debug_config.json``) live
343 # under ``.option_c/`` now.
344 dirs_to_create: list[Path] = [
345 project_dir,
346 project_dir / ".option_c",
347 project_dir / ".option_c" / "logs",
348 project_dir / ".option_c" / "cache",
349 project_dir / "docs",
350 project_dir / "docs" / "_static",
351 project_dir / "docs" / "_build",
352 project_dir / "docs" / "_templates",
353 project_dir / "tests",
354 ]
355
356 files_to_write: list[tuple[Path, str]] = [
357 (project_dir / "docs" / "ARCHITECTURE.md", _architecture_md(name)),
358 (project_dir / "tests" / "run_tests.py", _run_tests_py(name)),
359 (project_dir / "tests" / "Tests_README.md", _tests_readme(name)),
360 # FS-539: debug_config.json moved from oc_diagnostics/ to .option_c/
361 (project_dir / ".option_c" / "debug_config.json", config_text),
362 (project_dir / "docs" / "README.md", _docs_readme(name)),
363 # FS-539: .octrc.json moved from root to .option_c/octrc.json
364 (project_dir / ".option_c" / "octrc.json", _octrc_json()),
365 (project_dir / ".gitignore", "\n".join(_GITIGNORE_LINES) + "\n"),
366 (project_dir / ".octignore", "\n".join(_OCTIGNORE_LINES) + "\n"),
367 ]
368
369 if dry_run:
370 for d in dirs_to_create:
371 result.messages.append(f"[DRY RUN] Would create directory: {d}")
372 for path, _ in files_to_write:
373 result.messages.append(f"[DRY RUN] Would write file: {path}")
374 result.messages.append(
375 f"[DRY RUN] Would write file: {project_dir / '.option_c' / 'oc_status.json'}"
376 )
377 result.messages.append("[DRY RUN] Would copy Sphinx templates to docs/")
378 if include_venv:
379 result.messages.append(
380 f"[DRY RUN] Would create virtualenv: {project_dir / '.venv'}"
381 )
382 result.messages.append("[DRY RUN] Scaffold complete (no files written)")
383 _dbg("SCAFFOLD", func, "dry-run end", 2)
384 return result
385
386 # Create directories (parents first).
387 for d in dirs_to_create:
388 if d.exists():
389 result.skipped.append(d)
390 else:
391 d.mkdir(parents=True, exist_ok=True)
392 result.created.append(d)
393
394 # Write bundled files, never overwriting.
395 for path, content in files_to_write:
396 if path.exists():
397 result.skipped.append(path)
398 continue
399 path.write_text(content, encoding="utf-8")
400 result.created.append(path)
401
402 # FS-539: oc_status.json declares this project compliant at birth.
403 _write_fresh_oc_status(project_dir, result)
404
405 # Executable bit on run_tests.py (POSIX only).
406 if platform.system() != "Windows":
407 run_tests_path = project_dir / "tests" / "run_tests.py"
408 if run_tests_path.exists():
409 os.chmod(str(run_tests_path), 0o755)
410
411 # Sphinx template pack.
412 _copy_sphinx_templates(project_dir, name, result)
413
414 # Doxyfile: its presence is what declares the project for Doxygen
415 # builds, so a scaffolded project is Doxygen-ready from birth without
416 # anyone copying a 3,000-line file by hand. Never overwrites.
417 _write_doxyfile(project_dir, name, result)
418
419 # FS-503 / FS-504: optional venv.
420 if include_venv:
421 _create_venv(project_dir, result)
422
423 _dbg(
424 "SCAFFOLD", func,
425 f"end created={len(result.created)} skipped={len(result.skipped)} "
426 f"venv={'yes' if result.venv_path else 'no'}",
427 2,
428 )
429 return result
430
431
432# ---------------------------------------------------------------------
433# Internal helpers
434# ---------------------------------------------------------------------
435
436
438 project_dir: Path, name: str, result: ScaffoldResult,
439) -> None:
440 """Stamp ``docs/doxygen_work_dir/Doxyfile`` via :mod:`oct.docs.doxyfile_init`.
441
442 A missing bundled template is reported in ``messages`` rather than
443 raised, matching how the Sphinx pack is handled: the scaffold as a
444 whole must still succeed on a checkout that omits templates.
445 """
446 func = "_write_doxyfile"
447 from oct.docs.doxyfile_init import DoxyfileTemplateMissing, write_doxyfile
448 try:
449 path, written = write_doxyfile(project_dir, name)
450 except DoxyfileTemplateMissing as exc:
451 result.messages.append(f" Warning: {exc}; skipping Doxyfile.")
452 _dbg("SCAFFOLD", func, "template missing; skipped", 3)
453 return
454 (result.created if written else result.skipped).append(path)
455 _dbg("SCAFFOLD", func, f"{path} {'written' if written else 'kept'}", 3)
456
457
459 project_dir: Path, name: str, result: ScaffoldResult,
460) -> None:
461 """Render and copy the bundled Sphinx template pack into ``docs/``.
462
463 Silently skips (with a ``messages`` note) when the bundled template
464 directory is missing - typical for source checkouts that omit the
465 templates pack.
466 """
467 func = "_copy_sphinx_templates"
468 if not _SPHINX_TEMPLATES_DIR.is_dir():
469 result.messages.append(
470 f" Warning: Sphinx templates not found at {_SPHINX_TEMPLATES_DIR}; skipping."
471 )
472 _dbg("SCAFFOLD", func, "templates missing; skipped", 3)
473 return
474
475 docs_dir = project_dir / "docs"
476 static_dir = docs_dir / "_static"
477
478 # conf.py
479 conf_template = (_SPHINX_TEMPLATES_DIR / "conf.py.template").read_text(
480 encoding="utf-8",
481 )
482 year = datetime.datetime.now().year
483 conf_content = (
484 conf_template
485 .replace("{{PROJECT_NAME}}", name)
486 .replace("{{YEAR}}", str(year))
487 .replace("{{AUTHOR}}", "Option C Dev Team")
488 .replace("{{VERSION}}", "0.1.0")
489 )
490 (docs_dir / "conf.py").write_text(conf_content, encoding="utf-8")
491 result.created.append(docs_dir / "conf.py")
492
493 # index.rst
494 idx_template = (_SPHINX_TEMPLATES_DIR / "index.rst.template").read_text(
495 encoding="utf-8",
496 )
497 title = f"{name} Documentation"
498 underline = "=" * len(title)
499 idx_content = (
500 idx_template
501 .replace("{{PROJECT_NAME}}", name)
502 .replace("{{TITLE_UNDERLINE}}", underline)
503 )
504 (docs_dir / "index.rst").write_text(idx_content, encoding="utf-8")
505 result.created.append(docs_dir / "index.rst")
506
507 # Static files.
508 for static_file in ("custom.css", "Option_C_logo.svg"):
509 src = _SPHINX_TEMPLATES_DIR / "_static" / static_file
510 if src.exists():
511 shutil.copy2(src, static_dir / static_file)
512 result.created.append(static_dir / static_file)
513
514 # Build files.
515 for build_file in ("Makefile", "make.bat"):
516 src = _SPHINX_TEMPLATES_DIR / build_file
517 if src.exists():
518 shutil.copy2(src, docs_dir / build_file)
519 result.created.append(docs_dir / build_file)
520
521 result.messages.append(" Copied Sphinx docs template -> docs/")
522 _dbg("SCAFFOLD", func, "templates copied", 3)
523
524
525def _write_fresh_oc_status(project_dir: Path, result: ScaffoldResult) -> None:
526 """FS-539: emit ``oc_status.json`` for a newly-scaffolded project.
527
528 A fresh scaffold is ``stage="compliant"`` with every pillar enabled -
529 the scaffolder writes the full Option C layout, so every pillar is
530 satisfied on day zero. ``migrated_from`` is an empty dict because the
531 project was born in the ``.option_c/`` layout rather than migrated.
532 """
533 func = "_write_fresh_oc_status"
534 status_path = project_dir / ".option_c" / "oc_status.json"
535 if status_path.exists():
536 result.skipped.append(status_path)
537 return
538
539 try:
540 from importlib.metadata import PackageNotFoundError, version
541 try:
542 oct_version = version("oct")
543 except PackageNotFoundError:
544 oct_version = "unknown"
545 except ImportError:
546 oct_version = "unknown"
547
548 today = datetime.date.today().isoformat()
549 pillars = {
550 name: {"enabled": True, "since": today}
551 for name in ("linter", "diagnostics", "git", "docs", "tests", "scaffold")
552 }
553 status = OcStatus(
554 stage="compliant",
555 pillars=pillars,
556 migrated_from={},
557 oct_version_at_migration=oct_version,
558 )
559 write_oc_status(project_dir, status)
560 result.created.append(status_path)
561 _dbg("SCAFFOLD", func, f"oc_status written stage=compliant version={oct_version}", 3)
562
563
564def _create_venv(project_dir: Path, result: ScaffoldResult) -> None:
565 """Create a ``.venv/`` at the project root using :mod:`venv`.
566
567 Any OS-level failure is downgraded to a ``messages`` warning so the
568 caller can continue - a failed venv should not abort scaffolding.
569 """
570 func = "_create_venv"
571 venv_dir = project_dir / ".venv"
572 if venv_dir.exists():
573 result.skipped.append(venv_dir)
574 result.messages.append(f" .venv/ already exists at {venv_dir}")
575 _dbg("SCAFFOLD", func, "venv already exists; skipped", 3)
576 return
577 try:
578 builder = venv.EnvBuilder(with_pip=True, clear=False, upgrade_deps=False)
579 builder.create(str(venv_dir))
580 except (OSError, RuntimeError) as exc:
581 result.messages.append(f" Warning: venv creation failed: {exc}")
582 _dbg("SCAFFOLD", func, f"venv create failed: {exc}", 1)
583 return
584 result.created.append(venv_dir)
585 result.venv_path = venv_dir
586 result.messages.append(f" Created virtualenv -> {venv_dir}")
587 _dbg("SCAFFOLD", func, f"venv created at {venv_dir}", 3)
588
589
590_CLAUDE_SKILLS_TEMPLATE_DIR: Path = (
591 Path(__file__).resolve().parents[1] / "templates" / "claude_skills"
592)
593
594
596 project_dir: Path, result: ScaffoldResult, *, dry_run: bool = False,
597) -> None:
598 """FS-541: copy the bundled Claude Code skills into the project."""
599 func = "_copy_claude_skills"
600 skills_dir = project_dir / ".claude" / "skills"
601
602 if not _CLAUDE_SKILLS_TEMPLATE_DIR.is_dir():
603 result.messages.append(
604 " Warning: Claude skills templates not found; skipping --with-claude."
605 )
606 _dbg("SCAFFOLD", func, "skills templates missing", 3)
607 return
608
609 if dry_run:
610 result.messages.append(
611 f"[DRY RUN] Would copy Claude skills to {skills_dir}"
612 )
613 return
614
615 skills_dir.mkdir(parents=True, exist_ok=True)
616 for skill_subdir in _CLAUDE_SKILLS_TEMPLATE_DIR.iterdir():
617 if not skill_subdir.is_dir():
618 continue
619 dest_skill = skills_dir / skill_subdir.name
620 dest_skill.mkdir(exist_ok=True)
621 for f in skill_subdir.iterdir():
622 dest_file = dest_skill / f.name
623 if dest_file.exists():
624 result.skipped.append(dest_file)
625 continue
626 shutil.copy2(f, dest_file)
627 result.created.append(dest_file)
628
629 result.messages.append(f" Copied Claude skills -> {skills_dir}")
630 _dbg("SCAFFOLD", func, f"skills copied to {skills_dir}", 3)
None _write_doxyfile(Path project_dir, str name, ScaffoldResult result)
None _write_fresh_oc_status(Path project_dir, ScaffoldResult result)
None _copy_claude_skills(Path project_dir, ScaffoldResult result, *, bool dry_run=False)
None _copy_sphinx_templates(Path project_dir, str name, ScaffoldResult result)
str _architecture_md(str name)
ScaffoldResult run_scaffold(str name, Path base_dir, *, bool dry_run=False, bool include_venv=True)
None _create_venv(Path project_dir, ScaffoldResult result)