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)
47from __future__
import annotations
55from dataclasses
import dataclass, field
56from pathlib
import Path
58from oct.core.diagnostics
import _dbg
59from oct.core.option_c_dir
import OcStatus, write_oc_status
66_SPHINX_TEMPLATES_DIR: Path = Path(__file__).resolve().parents[1] /
"templates" /
"sphinx"
73_DEBUG_CONFIG_TEMPLATE: dict = {
74 "_schema_version":
"2.0",
76 "GENERAL": {
"level": 2,
"color":
"default"}
80 "enable_timestamps":
True,
81 "enable_colors":
True,
82 "include_filename":
True,
83 "output_mode":
"both",
85 "log_file_path":
None,
91 "global_debug_level":
None,
92 "watch_config":
False,
93 "ai_trace_level":
False,
94 "redaction_patterns": [],
97 "instrumentation_enabled":
False,
98 "http_redact_headers": [
"authorization",
"cookie",
"set-cookie",
"x-api-key"],
109 """Structured output from :func:`run_scaffold`.
114 Paths (directories + files) that were created.
116 Paths that already existed and were not overwritten.
118 Informational strings the caller may render — warnings, tips,
119 or dry-run descriptions.
121 The ``.venv/`` that was created, or ``None`` when
122 ``include_venv=False``.
125 created: list[Path] = field(default_factory=list)
126 skipped: list[Path] = field(default_factory=list)
127 messages: list[str] = field(default_factory=list)
128 venv_path: Path |
None =
None
138 f
"# {name} — Architecture\n\n"
139 "## Overview\n\nDescribe the high-level architecture.\n\n"
140 "## Components\n\nList major components and their responsibilities.\n\n"
141 "## Data Flow\n\nDescribe how data flows through the system.\n"
147 "#!/usr/bin/env python3\n"
148 "# -*- coding: utf-8 -*-\n"
149 f
"# {name}/tests/run_tests.py\n\n"
150 "import subprocess\nimport sys\n\n"
151 "sys.exit(subprocess.call([sys.executable, '-m', 'pytest', '--tb=short', '-q']))\n"
157 f
"# {name} — Tests\n\n"
158 "## Running Tests\n\n"
159 "```bash\npython -m pytest tests/ -v\n```\n\n"
160 "## Test Structure\n\nDescribe the test organization.\n"
166 f
"# {name} — Documentation\n\n"
167 "This directory contains project documentation.\n\n"
169 "```bash\noct docs --sphinx\n```\n\n"
170 "Output will be generated in ``docs/_build/html/``.\n"
175 """OI-530: build the scaffolded ``.octrc.json``.
177 Ships with a live ``linter.profile`` default plus an underscore-prefixed
178 ``_git_example`` stanza. JSON has no comments, so we use the same
179 ``_<name>`` convention that ``_schema_version`` and other sentinels
180 already use elsewhere: :func:`oct.core.octrc.load_octrc_safe` ignores
181 underscore-prefixed top-level keys, so the example is discoverable but
182 inert until the operator renames it.
186 "linter": {
"profile":
"compact"},
188 "sphinx_theme":
"sphinx_book_theme",
189 "sphinx_output":
"docs/_build",
190 "include_dependency_graphs":
True,
191 "include_mermaid_diagrams":
True,
192 "autodoc_modules": [],
195 "protected_branches": [
"main",
"master"],
196 "branch_profile_map": {
"main":
"strict",
"dev":
"compact"},
197 "require_signed_commits":
False,
209_GITIGNORE_LINES: tuple[str, ...] = (
215 "# Virtual environments",
230 "# Option C runtime data (FS-539) — config files stay tracked",
234 "# Logs (legacy location pre-v0.19.0; kept for projects mid-migration)",
238 "# Secrets (mirror NEVER_EXPORT_FILE_PATTERNS)",
249_OCTIGNORE_LINES: tuple[str, ...] = (
250 "# oct-specific ignores — lint/format/test/deps all consult this file.",
257 ".formatter_archive/",
270 dry_run: bool =
False,
271 include_venv: bool =
True,
273 """Create a new Option C project under ``base_dir / name``.
278 Project name (becomes the top-level directory).
280 Parent directory — the project is created at ``base_dir / name``.
282 When True, describe what would happen without touching the
285 When True, create a ``.venv/`` at the project root using the
286 stdlib :mod:`venv` module.
291 Structured result. Always returns; never raises for existing
292 files (they are recorded in ``skipped``).
294 func =
"run_scaffold"
295 project_dir = base_dir / name
296 _dbg(
"SCAFFOLD", func, f
"start name={name} dir={project_dir} dry_run={dry_run}", 2)
299 config_text = json.dumps(_DEBUG_CONFIG_TEMPLATE, indent=2)
307 dirs_to_create: list[Path] = [
309 project_dir /
".option_c",
310 project_dir /
".option_c" /
"logs",
311 project_dir /
".option_c" /
"cache",
312 project_dir /
"docs",
313 project_dir /
"docs" /
"_static",
314 project_dir /
"docs" /
"_build",
315 project_dir /
"docs" /
"_templates",
316 project_dir /
"tests",
319 files_to_write: list[tuple[Path, str]] = [
321 (project_dir /
"tests" /
"run_tests.py",
_run_tests_py(name)),
322 (project_dir /
"tests" /
"Tests_README.md",
_tests_readme(name)),
324 (project_dir /
".option_c" /
"debug_config.json", config_text),
325 (project_dir /
"docs" /
"README.md",
_docs_readme(name)),
327 (project_dir /
".option_c" /
"octrc.json",
_octrc_json()),
328 (project_dir /
".gitignore",
"\n".join(_GITIGNORE_LINES) +
"\n"),
329 (project_dir /
".octignore",
"\n".join(_OCTIGNORE_LINES) +
"\n"),
333 for d
in dirs_to_create:
334 result.messages.append(f
"[DRY RUN] Would create directory: {d}")
335 for path, _
in files_to_write:
336 result.messages.append(f
"[DRY RUN] Would write file: {path}")
337 result.messages.append(
338 f
"[DRY RUN] Would write file: {project_dir / '.option_c' / 'oc_status.json'}"
340 result.messages.append(
"[DRY RUN] Would copy Sphinx templates to docs/")
342 result.messages.append(
343 f
"[DRY RUN] Would create virtualenv: {project_dir / '.venv'}"
345 result.messages.append(
"[DRY RUN] Scaffold complete (no files written)")
346 _dbg(
"SCAFFOLD", func,
"dry-run end", 2)
350 for d
in dirs_to_create:
352 result.skipped.append(d)
354 d.mkdir(parents=
True, exist_ok=
True)
355 result.created.append(d)
358 for path, content
in files_to_write:
360 result.skipped.append(path)
362 path.write_text(content, encoding=
"utf-8")
363 result.created.append(path)
369 if platform.system() !=
"Windows":
370 run_tests_path = project_dir /
"tests" /
"run_tests.py"
371 if run_tests_path.exists():
372 os.chmod(str(run_tests_path), 0o755)
383 f
"end created={len(result.created)} skipped={len(result.skipped)} "
384 f
"venv={'yes' if result.venv_path else 'no'}",
396 project_dir: Path, name: str, result: ScaffoldResult,
398 """Render and copy the bundled Sphinx template pack into ``docs/``.
400 Silently skips (with a ``messages`` note) when the bundled template
401 directory is missing — typical for source checkouts that omit the
404 func =
"_copy_sphinx_templates"
405 if not _SPHINX_TEMPLATES_DIR.is_dir():
406 result.messages.append(
407 f
" Warning: Sphinx templates not found at {_SPHINX_TEMPLATES_DIR}; skipping."
409 _dbg(
"SCAFFOLD", func,
"templates missing; skipped", 3)
412 docs_dir = project_dir /
"docs"
413 static_dir = docs_dir /
"_static"
416 conf_template = (_SPHINX_TEMPLATES_DIR /
"conf.py.template").read_text(
419 year = datetime.datetime.now().year
422 .replace(
"{{PROJECT_NAME}}", name)
423 .replace(
"{{YEAR}}", str(year))
424 .replace(
"{{AUTHOR}}",
"Option C Dev Team")
425 .replace(
"{{VERSION}}",
"0.1.0")
427 (docs_dir /
"conf.py").write_text(conf_content, encoding=
"utf-8")
428 result.created.append(docs_dir /
"conf.py")
431 idx_template = (_SPHINX_TEMPLATES_DIR /
"index.rst.template").read_text(
434 title = f
"{name} Documentation"
435 underline =
"=" * len(title)
438 .replace(
"{{PROJECT_NAME}}", name)
439 .replace(
"{{TITLE_UNDERLINE}}", underline)
441 (docs_dir /
"index.rst").write_text(idx_content, encoding=
"utf-8")
442 result.created.append(docs_dir /
"index.rst")
445 for static_file
in (
"custom.css",
"Option_C_logo.svg"):
446 src = _SPHINX_TEMPLATES_DIR /
"_static" / static_file
448 shutil.copy2(src, static_dir / static_file)
449 result.created.append(static_dir / static_file)
452 for build_file
in (
"Makefile",
"make.bat"):
453 src = _SPHINX_TEMPLATES_DIR / build_file
455 shutil.copy2(src, docs_dir / build_file)
456 result.created.append(docs_dir / build_file)
458 result.messages.append(
" Copied Sphinx docs template -> docs/")
459 _dbg(
"SCAFFOLD", func,
"templates copied", 3)
463 """FS-539: emit ``oc_status.json`` for a newly-scaffolded project.
465 A fresh scaffold is ``stage="compliant"`` with every pillar enabled —
466 the scaffolder writes the full Option C layout, so every pillar is
467 satisfied on day zero. ``migrated_from`` is an empty dict because the
468 project was born in the ``.option_c/`` layout rather than migrated.
470 func =
"_write_fresh_oc_status"
471 status_path = project_dir /
".option_c" /
"oc_status.json"
472 if status_path.exists():
473 result.skipped.append(status_path)
477 from importlib.metadata
import PackageNotFoundError, version
479 oct_version = version(
"oct")
480 except PackageNotFoundError:
481 oct_version =
"unknown"
483 oct_version =
"unknown"
485 today = datetime.date.today().isoformat()
487 name: {
"enabled":
True,
"since": today}
488 for name
in (
"linter",
"diagnostics",
"git",
"docs",
"tests",
"scaffold")
494 oct_version_at_migration=oct_version,
496 write_oc_status(project_dir, status)
497 result.created.append(status_path)
498 _dbg(
"SCAFFOLD", func, f
"oc_status written stage=compliant version={oct_version}", 3)
502 """Create a ``.venv/`` at the project root using :mod:`venv`.
504 Any OS-level failure is downgraded to a ``messages`` warning so the
505 caller can continue — a failed venv should not abort scaffolding.
507 func =
"_create_venv"
508 venv_dir = project_dir /
".venv"
509 if venv_dir.exists():
510 result.skipped.append(venv_dir)
511 result.messages.append(f
" .venv/ already exists at {venv_dir}")
512 _dbg(
"SCAFFOLD", func,
"venv already exists; skipped", 3)
515 builder = venv.EnvBuilder(with_pip=
True, clear=
False, upgrade_deps=
False)
516 builder.create(str(venv_dir))
517 except (OSError, RuntimeError)
as exc:
518 result.messages.append(f
" Warning: venv creation failed: {exc}")
519 _dbg(
"SCAFFOLD", func, f
"venv create failed: {exc}", 1)
521 result.created.append(venv_dir)
522 result.venv_path = venv_dir
523 result.messages.append(f
" Created virtualenv -> {venv_dir}")
524 _dbg(
"SCAFFOLD", func, f
"venv created at {venv_dir}", 3)
527_CLAUDE_SKILLS_TEMPLATE_DIR: Path = (
528 Path(__file__).resolve().parents[1] /
"templates" /
"claude_skills"
533 project_dir: Path, result: ScaffoldResult, *, dry_run: bool =
False,
535 """FS-541: copy the bundled Claude Code skills into the project."""
536 func =
"_copy_claude_skills"
537 skills_dir = project_dir /
".claude" /
"skills"
539 if not _CLAUDE_SKILLS_TEMPLATE_DIR.is_dir():
540 result.messages.append(
541 " Warning: Claude skills templates not found; skipping --with-claude."
543 _dbg(
"SCAFFOLD", func,
"skills templates missing", 3)
547 result.messages.append(
548 f
"[DRY RUN] Would copy Claude skills to {skills_dir}"
552 skills_dir.mkdir(parents=
True, exist_ok=
True)
553 for skill_subdir
in _CLAUDE_SKILLS_TEMPLATE_DIR.iterdir():
554 if not skill_subdir.is_dir():
556 dest_skill = skills_dir / skill_subdir.name
557 dest_skill.mkdir(exist_ok=
True)
558 for f
in skill_subdir.iterdir():
559 dest_file = dest_skill / f.name
560 if dest_file.exists():
561 result.skipped.append(dest_file)
563 shutil.copy2(f, dest_file)
564 result.created.append(dest_file)
566 result.messages.append(f
" Copied Claude skills -> {skills_dir}")
567 _dbg(
"SCAFFOLD", func, f
"skills copied to {skills_dir}", 3)
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)