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.
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.
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.
30 L2 - lifecycle: start / end / target path
31 L3 - semantic details: per-template decisions
32 L4 - deep tracing: per-file / per-dir outcomes
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.
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)
48from __future__
import annotations
56from dataclasses
import dataclass, field
57from pathlib
import Path
59from oct.core.diagnostics
import _dbg
60from oct.core.option_c_dir
import OcStatus, write_oc_status
67_SPHINX_TEMPLATES_DIR: Path = Path(__file__).resolve().parents[1] /
"templates" /
"sphinx"
74_DEBUG_CONFIG_TEMPLATE: dict = {
75 "_schema_version":
"2.0",
77 "GENERAL": {
"level": 2,
"color":
"default"}
81 "enable_timestamps":
True,
82 "enable_colors":
True,
83 "include_filename":
True,
84 "output_mode":
"both",
86 "log_file_path":
None,
92 "global_debug_level":
None,
93 "watch_config":
False,
94 "ai_trace_level":
False,
95 "redaction_patterns": [],
98 "instrumentation_enabled":
False,
99 "http_redact_headers": [
"authorization",
"cookie",
"set-cookie",
"x-api-key"],
110 """Structured output from :func:`run_scaffold`.
115 Paths (directories + files) that were created.
117 Paths that already existed and were not overwritten.
119 Informational strings the caller may render - warnings, tips,
120 or dry-run descriptions.
122 The ``.venv/`` that was created, or ``None`` when
123 ``include_venv=False``.
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
138 """Seed ``docs/ARCHITECTURE.md`` with the workspace's overview-chart slot.
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.
149 f
"# {name} - Architecture\n\n"
150 "## Architecture overview\n\n"
151 f
"What {name} is for, in one paragraph.\n\n"
154 ' user(["Who or what uses it"]) --> entry["Entry point"]\n'
155 ' entry --> core["The layer everything else stands on"]\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 "
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"
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"
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"
189 f
"# {name} - Documentation\n\n"
190 "This directory contains project documentation.\n\n"
192 "```bash\noct docs --sphinx\n```\n\n"
193 "Output will be generated in ``docs/_build/html/``.\n"
198 """OI-530: build the scaffolded ``.octrc.json``.
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.
209 "linter": {
"profile":
"compact"},
215 "include_dependency_graphs":
True,
216 "include_mermaid_diagrams":
True,
217 "autodoc_modules": [],
220 "protected_branches": [
"main",
"master"],
221 "branch_profile_map": {
"main":
"strict",
"dev":
"compact"},
222 "require_signed_commits":
False,
234_GITIGNORE_LINES: tuple[str, ...] = (
240 "# Virtual environments",
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.",
262 "docs/_autosummary/",
263 "docs/doxygen_output/",
265 "# Option C runtime data (FS-539) - config files stay tracked",
269 "# Logs (legacy location pre-v0.19.0; kept for projects mid-migration)",
273 "# Secrets (mirror NEVER_EXPORT_FILE_PATTERNS)",
284_OCTIGNORE_LINES: tuple[str, ...] = (
285 "# oct-specific ignores - lint/format/test/deps all consult this file.",
291 "docs/doxygen_output/",
294 ".formatter_archive/",
307 dry_run: bool =
False,
308 include_venv: bool =
True,
310 """Create a new Option C project under ``base_dir / name``.
315 Project name (becomes the top-level directory).
317 Parent directory - the project is created at ``base_dir / name``.
319 When True, describe what would happen without touching the
322 When True, create a ``.venv/`` at the project root using the
323 stdlib :mod:`venv` module.
328 Structured result. Always returns; never raises for existing
329 files (they are recorded in ``skipped``).
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)
336 config_text = json.dumps(_DEBUG_CONFIG_TEMPLATE, indent=2)
344 dirs_to_create: list[Path] = [
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",
356 files_to_write: list[tuple[Path, str]] = [
358 (project_dir /
"tests" /
"run_tests.py",
_run_tests_py(name)),
359 (project_dir /
"tests" /
"Tests_README.md",
_tests_readme(name)),
361 (project_dir /
".option_c" /
"debug_config.json", config_text),
362 (project_dir /
"docs" /
"README.md",
_docs_readme(name)),
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"),
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'}"
377 result.messages.append(
"[DRY RUN] Would copy Sphinx templates to docs/")
379 result.messages.append(
380 f
"[DRY RUN] Would create virtualenv: {project_dir / '.venv'}"
382 result.messages.append(
"[DRY RUN] Scaffold complete (no files written)")
383 _dbg(
"SCAFFOLD", func,
"dry-run end", 2)
387 for d
in dirs_to_create:
389 result.skipped.append(d)
391 d.mkdir(parents=
True, exist_ok=
True)
392 result.created.append(d)
395 for path, content
in files_to_write:
397 result.skipped.append(path)
399 path.write_text(content, encoding=
"utf-8")
400 result.created.append(path)
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)
425 f
"end created={len(result.created)} skipped={len(result.skipped)} "
426 f
"venv={'yes' if result.venv_path else 'no'}",
438 project_dir: Path, name: str, result: ScaffoldResult,
440 """Stamp ``docs/doxygen_work_dir/Doxyfile`` via :mod:`oct.docs.doxyfile_init`.
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.
446 func =
"_write_doxyfile"
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)
454 (result.created
if written
else result.skipped).append(path)
455 _dbg(
"SCAFFOLD", func, f
"{path} {'written' if written else 'kept'}", 3)
459 project_dir: Path, name: str, result: ScaffoldResult,
461 """Render and copy the bundled Sphinx template pack into ``docs/``.
463 Silently skips (with a ``messages`` note) when the bundled template
464 directory is missing - typical for source checkouts that omit the
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."
472 _dbg(
"SCAFFOLD", func,
"templates missing; skipped", 3)
475 docs_dir = project_dir /
"docs"
476 static_dir = docs_dir /
"_static"
479 conf_template = (_SPHINX_TEMPLATES_DIR /
"conf.py.template").read_text(
482 year = datetime.datetime.now().year
485 .replace(
"{{PROJECT_NAME}}", name)
486 .replace(
"{{YEAR}}", str(year))
487 .replace(
"{{AUTHOR}}",
"Option C Dev Team")
488 .replace(
"{{VERSION}}",
"0.1.0")
490 (docs_dir /
"conf.py").write_text(conf_content, encoding=
"utf-8")
491 result.created.append(docs_dir /
"conf.py")
494 idx_template = (_SPHINX_TEMPLATES_DIR /
"index.rst.template").read_text(
497 title = f
"{name} Documentation"
498 underline =
"=" * len(title)
501 .replace(
"{{PROJECT_NAME}}", name)
502 .replace(
"{{TITLE_UNDERLINE}}", underline)
504 (docs_dir /
"index.rst").write_text(idx_content, encoding=
"utf-8")
505 result.created.append(docs_dir /
"index.rst")
508 for static_file
in (
"custom.css",
"Option_C_logo.svg"):
509 src = _SPHINX_TEMPLATES_DIR /
"_static" / static_file
511 shutil.copy2(src, static_dir / static_file)
512 result.created.append(static_dir / static_file)
515 for build_file
in (
"Makefile",
"make.bat"):
516 src = _SPHINX_TEMPLATES_DIR / build_file
518 shutil.copy2(src, docs_dir / build_file)
519 result.created.append(docs_dir / build_file)
521 result.messages.append(
" Copied Sphinx docs template -> docs/")
522 _dbg(
"SCAFFOLD", func,
"templates copied", 3)
526 """FS-539: emit ``oc_status.json`` for a newly-scaffolded project.
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.
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)
540 from importlib.metadata
import PackageNotFoundError, version
542 oct_version = version(
"oct")
543 except PackageNotFoundError:
544 oct_version =
"unknown"
546 oct_version =
"unknown"
548 today = datetime.date.today().isoformat()
550 name: {
"enabled":
True,
"since": today}
551 for name
in (
"linter",
"diagnostics",
"git",
"docs",
"tests",
"scaffold")
557 oct_version_at_migration=oct_version,
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)
565 """Create a ``.venv/`` at the project root using :mod:`venv`.
567 Any OS-level failure is downgraded to a ``messages`` warning so the
568 caller can continue - a failed venv should not abort scaffolding.
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)
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)
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)
590_CLAUDE_SKILLS_TEMPLATE_DIR: Path = (
591 Path(__file__).resolve().parents[1] /
"templates" /
"claude_skills"
596 project_dir: Path, result: ScaffoldResult, *, dry_run: bool =
False,
598 """FS-541: copy the bundled Claude Code skills into the project."""
599 func =
"_copy_claude_skills"
600 skills_dir = project_dir /
".claude" /
"skills"
602 if not _CLAUDE_SKILLS_TEMPLATE_DIR.is_dir():
603 result.messages.append(
604 " Warning: Claude skills templates not found; skipping --with-claude."
606 _dbg(
"SCAFFOLD", func,
"skills templates missing", 3)
610 result.messages.append(
611 f
"[DRY RUN] Would copy Claude skills to {skills_dir}"
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():
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)
626 shutil.copy2(f, dest_file)
627 result.created.append(dest_file)
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 _run_tests_py(str name)
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)
str _docs_readme(str name)
str _tests_readme(str name)