From c4a3fdb0dfb28422490405eb87e4a14ba9c07f3f Mon Sep 17 00:00:00 2001 From: icorkovic Date: Mon, 6 Jul 2026 21:12:00 +0200 Subject: [PATCH] docs-4.0.0 --- .agents/conventions/unity.md | 46 - .agents/guidelines/documentation.md | 70 +- .github/ISSUE_TEMPLATE/config.yml | 1 + .repo-seed-state.json | 26 + AGENTS.md | 22 +- docs/project/architecture.md | 69 ++ docs/templates/.github/bug-report.template.md | 2 +- .../.github/feature-request.template.md | 2 +- docs/templates/architecture.template.md | 73 ++ docs/templates/changelog.template.md | 8 +- docs/templates/features.template.md | 22 - docs/templates/gdd.template.md | 45 - docs/templates/readme.template.md | 20 +- docs/templates/tsd.template.md | 82 ++ scripts/sync-docs.py | 790 +++++++++++++++++- 15 files changed, 1112 insertions(+), 166 deletions(-) delete mode 100644 .agents/conventions/unity.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .repo-seed-state.json create mode 100644 docs/project/architecture.md create mode 100644 docs/templates/architecture.template.md delete mode 100644 docs/templates/features.template.md delete mode 100644 docs/templates/gdd.template.md create mode 100644 docs/templates/tsd.template.md diff --git a/.agents/conventions/unity.md b/.agents/conventions/unity.md deleted file mode 100644 index 9a8bab0..0000000 --- a/.agents/conventions/unity.md +++ /dev/null @@ -1,46 +0,0 @@ -# Unity / C# Conventions - -**Document role**: Managed coding-agent reference -**Sync destination**: `.agents/conventions/unity.md` -**Local editing**: Do not customize this synced copy - -Apply this with `.agents/conventions/csharp.md`. Repository rules and nearby code take precedence. - -## Components and Lifecycle - -- Keep one `MonoBehaviour` per matching filename. -- Use plain C# classes for logic that does not need Unity lifecycle or serialization. -- Prefer `[SerializeField] private` over public inspector fields. -- Order used callbacks: `Awake`, `OnEnable`, `Start`, `Update`, `LateUpdate`, `FixedUpdate`, `OnDisable`, `OnDestroy`. -- Cache component references in `Awake`; do not call `GetComponent`, `FindObjectOfType`, or `GameObject.Find` every frame. -- Subscribe in `OnEnable` and unsubscribe in `OnDisable`. -- Do not add empty lifecycle methods. - -## Unity Objects and Coroutines - -- Respect Unity's overloaded lifetime checks for `UnityEngine.Object`; do not use `is null` for Unity objects. -- In hot paths, use the implicit Unity object check when it is clear. -- Give coroutine methods a `Coroutine` suffix. -- Keep a `Coroutine` handle when early cancellation is required. -- Use explicit state machines for complex interruptible flows. - -## Physics and Performance - -- Perform physics work in `FixedUpdate`. -- Move rigid bodies through `Rigidbody` APIs rather than transforms. -- Avoid repeated allocations, LINQ, string construction, and component lookup in frame callbacks. -- Pool frequently created gameplay objects where profiling justifies it. -- Profile before making speculative optimizations. - -## Project Structure - -- Prefer C# events or `UnityEvent` over `SendMessage`. -- Use `ScriptableObject` for shared configuration, not mutable runtime state. -- Keep editor-only code under `Editor/` or guard it with `#if UNITY_EDITOR`. -- Do not reference `UnityEditor` from runtime assemblies. -- Centralize tags and layers; use `CompareTag` rather than direct tag-string equality. -- Follow existing asset naming and rename referenced assets through the Unity Editor. - -## Testing - -Use Edit Mode tests for pure logic and Play Mode tests for lifecycle, scenes, and runtime integration. Keep Unity-dependent behavior out of plain classes when practical so it remains easy to test. diff --git a/.agents/guidelines/documentation.md b/.agents/guidelines/documentation.md index 3087a5c..3607fd2 100644 --- a/.agents/guidelines/documentation.md +++ b/.agents/guidelines/documentation.md @@ -9,28 +9,88 @@ - `docs/project/` contains authoritative live documentation. - `docs/templates/` contains managed reference templates, not requirements. - Never edit target templates or copy placeholders and unverified claims into live documents. +- An untouched scaffold is not a requirement. Treat `Draft`, inferred, and + placeholder content as non-authoritative until the project confirms it. +- An accepted FSD or GDD controls intended behavior. Architecture describes the + verified current technical system. An accepted technical design controls only + its scoped change. ## Update the Relevant Document - setup, commands, configuration, or public usage: `README.md` - meaningful changes: `CHANGELOG.md` -- capability status and links: `docs/project/features.md` -- stable components, boundaries, integrations, or data flow: `docs/project/architecture.md` +- accepted application behavior and capabilities: `docs/project/fsd.md` +- accepted gameplay intent and capabilities: `docs/project/gdd.md` +- current components, boundaries, runtime, data, deployment, quality approaches, + decisions, and technical risks: `docs/project/architecture.md` - visible workflows and troubleshooting: `docs/project/user-guide.md` -- functional, technical, or gameplay requirements: the applicable FSD, TSD, or GDD +- one substantial proposed technical change: + `docs/project/designs/.md`, created from + `docs/templates/tsd.template.md` +- optional public capability index: `docs/project/features.md` Update only documents affected by verified behavior. Do not create documentation as busywork. +## Growth Without Reorganization + +Keep `fsd.md`, `gdd.md`, `architecture.md`, and `user-guide.md` as stable +entry points. When one becomes difficult to navigate, retain a concise overview +there and link detail under the applicable folder: + +- functional detail: `docs/project/functional/` +- gameplay detail: `docs/project/game-design/` +- technical detail: `docs/project/architecture/` +- change designs: `docs/project/designs/` +- user guidance: `docs/project/guides/` + +Do not create empty folders or index files preemptively. + +## Technical Designs + +Create a design only for a change involving multiple components, public +contracts, persistence or migration, security, concurrency, deployment, +compatibility, high uncertainty, or difficult rollback. Routine fixes and +localized refactors do not need one. + +Use `Proposed`, `Accepted`, `Implemented`, `Superseded`, or `Rejected` status. +After implementation, update architecture with the resulting current state and +leave the design as historical rationale. Current truth must not remain only in +an old design. + +## Existing-Project Bootstrap + +When asked to populate documentation for an existing repository: + +1. inspect source, tests, configuration, schemas, workflows, assets, and useful + Git history; +2. populate FSD, GDD, architecture, user guidance, README, and changelog only + from relevant evidence; +3. distinguish intended behavior, verified as-built behavior, local inference, + and unknowns where the difference matters; +4. cite repository evidence for meaningful inferences and request confirmation + before treating inferred intent as authoritative; +5. do not invent historical technical designs or reconstruct changelog entries + without evidence. + +Game code can establish implemented mechanics but not reliably establish +intended player experience, balance goals, or future design. + ## Template Review -Scaffolded Markdown contains a source-path marker. It does not track a hash or authorize automatic updates. +Newly scaffolded Markdown contains source-path and content-hash markers. Sync +may upgrade it only while those markers prove the live document is unchanged. +Known older scaffolds may also be upgraded when their recorded provenance or +an approved legacy content hash verifies the original content. -When a managed template changes, compare it manually with the live document, using Git history when useful. Apply only relevant structural improvements and preserve verified project content. Sync never rewrites live project documentation. +When a live document cannot be verified, compare it manually with the managed +template, using Git history when useful. Apply only relevant structural +improvements and preserve verified project content. ## Style - Be concise, factual, and example-driven. - Link to detailed documents instead of duplicating them. +- Keep current state in living documents and proposal history in design records. - Curate changelogs; do not paste commit history. - Omit unknown information rather than inventing it. - Preserve the repository's established formatting and punctuation. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.repo-seed-state.json b/.repo-seed-state.json new file mode 100644 index 0000000..e32a730 --- /dev/null +++ b/.repo-seed-state.json @@ -0,0 +1,26 @@ +{ + "managed_files": { + ".agents/conventions/csharp.md": "66f0a42cb7ded64554912ab31de54f6df1026ec912f8369d230606587ff2d04f", + ".agents/conventions/python.md": "7707c450df5c2fb809820920304aaa36aef7d5ad1598d5d73ae5dcfaaf3353a6", + ".agents/conventions/scripts.md": "b5f2340822cd7402d4eb6087428c0b62ffd71802824559c8492f778afcfda245", + ".agents/conventions/shell.md": "165c0382882a8cce818be34368dc5b29a0297d7c2c66a40fd7e4729c397764b9", + ".agents/guidelines/ci-cd.md": "e4d6bd8bc0f0696f27501ec87fa3c25d85b103e17bfe7a8993fc61ff63d4faa0", + ".agents/guidelines/documentation.md": "07c41e49fabc3ca3807246fe451611b96e6549ac991156e7fbd46271b1bc33f0", + ".agents/guidelines/git.md": "4773262f86d33c6e7c8cae87f652a2e4f979de79956bc723a5374dc6889eede5", + "AGENTS.md": "6585e8c7d15fbeadb98b3945fd79c575db62ccc3efab39535f2055bc536f0694", + "CLAUDE.md": "7050308e6cd5b0b953105fddc8fe5a4d259038dc8de1183414833bc90788554a", + "docs/templates/.github/bug-report.template.md": "9e898d893d05a6962300f774120350559082f6b1d68be76e30c2696c1efdb53e", + "docs/templates/.github/config.template.yml": "ade4f33049e309848b2f0a64d11c41be3204cd476ac112ee311cdd320cd39313", + "docs/templates/.github/feature-request.template.md": "7110742210d16dbf64f01347579189cc653dc049ddcc1386ea4b8325646829ad", + "docs/templates/architecture.template.md": "701a4193f0c5eb903af5172e2457af01e5148bddbeae8343b80fa1d02ed4fe90", + "docs/templates/changelog.template.md": "731c8ef26b6104fd569f516f344197c0437aa609256aa10257eb3eaff6f6d427", + "docs/templates/editorconfig.template": "74b219dcd26beaefc51a9892f5e74b136ac30ff0b04ef70039fbaa9903ead77a", + "docs/templates/readme.template.md": "d0be705d90d1c0cff0f27ed9e70f3848a3ad6498f38d918a2821fe4c2e98ba1c", + "docs/templates/tsd.template.md": "718bf526bfabaa33b34f6fc80fadca19ff5fae790a07d2a93239b2004607b7bf", + "scripts/sync-docs.py": "c6cf2eb16b3b8ebea81308572b0abfc92ab3589ecc98f0c4ff703b8b4068646e" + }, + "pack_version": "4.0.0", + "profile": "library", + "schema_version": 1, + "tombstones": {} +} diff --git a/AGENTS.md b/AGENTS.md index e6ae88d..d376381 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Repository-level instructions for coding agents. **Document role**: Managed coding-agent instructions **Sync destination**: `AGENTS.md` -**Version**: 3.2.3 +**Version**: 4.0.0 ## Start Here @@ -24,6 +24,8 @@ Instruction precedence is: user request, closest child `AGENTS.md`, `.agents/pro - `docs/project/` contains authoritative live project documentation. - `docs/templates/` contains managed, read-only references. - Never edit target templates or treat their placeholders as requirements. +- Untouched scaffolds and unconfirmed Draft or inferred content are not + authoritative requirements. - When behavior changes, update relevant project-owned documentation when practical. Read `.agents/guidelines/documentation.md` for documentation work or changes affecting setup, behavior, architecture, features, guides, or specifications. @@ -78,8 +80,10 @@ Read the relevant Git or CI/CD guideline before performing that specialized work When provided: - FSD controls observable behavior and acceptance criteria; -- TSD controls technical constraints and implementation design; - GDD controls gameplay intent and player experience; +- architecture describes the verified current technical system; +- an accepted technical design under `docs/project/designs/` controls only its + scoped change; - tickets and explicit acceptance criteria define task scope. Report conflicts between requirement sources before implementing. @@ -98,15 +102,25 @@ Do not run irrelevant language or project checks. Report checks that could not r ## Sync Ownership -Routine sync overwrites: +Routine sync updates these files when their pack content differs: - `AGENTS.md`, `CLAUDE.md`, and selected `.agents/` guidance; - `docs/templates/`; - `scripts/sync-docs.py`. +Routine sync retires only unchanged legacy-managed files whose hashes match the old manifest. Modified or unrecorded files, reclassified project-owned files, and legacy conflict output are preserved and reported. + +Routine sync also maintains committed `.repo-seed-state.json` ownership +metadata. A smaller profile removes unchanged managed files that are no longer +selected. Modified stale files are preserved and remain tombstoned for review. +Do not edit this state file manually. + Do not customize these managed files in target repositories. -Project-owned files include `.agents/project.md`, child `AGENTS.md` files, root `README.md` and `CHANGELOG.md`, `.editorconfig`, `.gitignore`, `docs/project/`, and every unmapped path. Scaffolding creates missing project-owned files but never overwrites them. +Project-owned files include `.agents/project.md`, child `AGENTS.md` files, root +`README.md` and `CHANGELOG.md`, `.editorconfig`, `.gitignore`, `docs/project/`, +and every unmapped path. Scaffolding creates missing project-owned files and +may upgrade Markdown only while repo-seed provenance proves it unchanged. ## Completion diff --git a/docs/project/architecture.md b/docs/project/architecture.md new file mode 100644 index 0000000..50162c4 --- /dev/null +++ b/docs/project/architecture.md @@ -0,0 +1,69 @@ +# Architecture and Technical Overview + + + + +**Project**: +**Status**: Draft +**Last Updated**: YYYY-MM-DD + +Describe the verified current technical system. Keep proposed changes in +`docs/project/designs/` and link them here after they are accepted or implemented. + +When this document grows, keep it as the stable overview and move detailed +subjects under `docs/project/architecture/`. Do not create detail files until +they add value. + +## Purpose, Scope, and Quality Goals + +Summarize the system's technical purpose, boundaries, constraints, and the +three to five quality goals that most influence its design. + +## System Context + +Define the system boundary, users, and external systems. + +## Components and Responsibilities + +| Component | Responsibility | Dependencies | +|---|---|---| +| | | | + +## Important Runtime Flows + +Describe the important interactions, including startup, normal operation, +background processing, failure handling, and shutdown where relevant. + +## Data, Interfaces, and Integrations + +Describe persistence, APIs, commands, events, contracts, external integrations, +and important compatibility boundaries. + +## Configuration and Deployment + +Summarize runtimes, configuration sources, environments, storage, hosting, +deployment topology, and operational dependencies. + +## Cross-Cutting Concerns + +Document security, privacy, reliability, observability, performance, +accessibility, capacity, and compatibility approaches that affect the system. + +## Development and Validation + +Summarize build boundaries, test levels, local validation, deployment checks, +and any important environment limitations. + +## Decisions and Design Records + +Link significant technical designs under `docs/project/designs/`. Record only +the resulting current state here; do not copy proposal history into this file. + +## Risks, Technical Debt, and Open Questions + +- + +## Detailed Documentation + +Link any extracted technical documents. This section may remain empty while the +overview is sufficient. diff --git a/docs/templates/.github/bug-report.template.md b/docs/templates/.github/bug-report.template.md index 9043c55..ad773f7 100644 --- a/docs/templates/.github/bug-report.template.md +++ b/docs/templates/.github/bug-report.template.md @@ -2,7 +2,7 @@ **Document role**: Managed project-file template **Template source**: `docs/templates/.github/bug-report.template.md` **Scaffold destination**: `.github/ISSUE_TEMPLATE/bug_report.md` -**Scaffold behavior**: The live file is project-owned and never overwritten by sync +**Scaffold behavior**: The live file is project-owned; only a verified unchanged scaffold may be upgraded --- diff --git a/docs/templates/.github/feature-request.template.md b/docs/templates/.github/feature-request.template.md index e4a2ac9..5fa3a5a 100644 --- a/docs/templates/.github/feature-request.template.md +++ b/docs/templates/.github/feature-request.template.md @@ -2,7 +2,7 @@ **Document role**: Managed project-file template **Template source**: `docs/templates/.github/feature-request.template.md` **Scaffold destination**: `.github/ISSUE_TEMPLATE/feature_request.md` -**Scaffold behavior**: The live file is project-owned and never overwritten by sync +**Scaffold behavior**: The live file is project-owned; only a verified unchanged scaffold may be upgraded --- diff --git a/docs/templates/architecture.template.md b/docs/templates/architecture.template.md new file mode 100644 index 0000000..91ac7ab --- /dev/null +++ b/docs/templates/architecture.template.md @@ -0,0 +1,73 @@ +# Architecture and Technical Overview + + +**Document role**: Managed project-document template +**Template source**: `docs/templates/architecture.template.md` +**Scaffold destination**: `docs/project/architecture.md` +**Scaffold behavior**: The live file is project-owned; only a verified unchanged scaffold may be upgraded + + +**Project**: +**Status**: Draft +**Last Updated**: YYYY-MM-DD + +Describe the verified current technical system. Keep proposed changes in +`docs/project/designs/` and link them here after they are accepted or implemented. + +When this document grows, keep it as the stable overview and move detailed +subjects under `docs/project/architecture/`. Do not create detail files until +they add value. + +## Purpose, Scope, and Quality Goals + +Summarize the system's technical purpose, boundaries, constraints, and the +three to five quality goals that most influence its design. + +## System Context + +Define the system boundary, users, and external systems. + +## Components and Responsibilities + +| Component | Responsibility | Dependencies | +|---|---|---| +| | | | + +## Important Runtime Flows + +Describe the important interactions, including startup, normal operation, +background processing, failure handling, and shutdown where relevant. + +## Data, Interfaces, and Integrations + +Describe persistence, APIs, commands, events, contracts, external integrations, +and important compatibility boundaries. + +## Configuration and Deployment + +Summarize runtimes, configuration sources, environments, storage, hosting, +deployment topology, and operational dependencies. + +## Cross-Cutting Concerns + +Document security, privacy, reliability, observability, performance, +accessibility, capacity, and compatibility approaches that affect the system. + +## Development and Validation + +Summarize build boundaries, test levels, local validation, deployment checks, +and any important environment limitations. + +## Decisions and Design Records + +Link significant technical designs under `docs/project/designs/`. Record only +the resulting current state here; do not copy proposal history into this file. + +## Risks, Technical Debt, and Open Questions + +- + +## Detailed Documentation + +Link any extracted technical documents. This section may remain empty while the +overview is sufficient. diff --git a/docs/templates/changelog.template.md b/docs/templates/changelog.template.md index d041907..674c20c 100644 --- a/docs/templates/changelog.template.md +++ b/docs/templates/changelog.template.md @@ -2,18 +2,18 @@ **Document role**: Managed project-document template - **Template source**: `docs/templates/changelog.template.md` - **Scaffold destination**: `CHANGELOG.md` - -**Scaffold behavior**: The live file is project-owned and never overwritten by sync +**Scaffold behavior**: The live file is project-owned; only a verified unchanged scaffold may be upgraded All meaningful user-facing and developer-facing changes should be documented in this file. Use newest entries first. Do not dump raw git commits here. +Use semantic versions when the project defines a versioned public interface. +Remove empty categories when cutting a release. + ## Unreleased ### Added diff --git a/docs/templates/features.template.md b/docs/templates/features.template.md deleted file mode 100644 index 47fd6a9..0000000 --- a/docs/templates/features.template.md +++ /dev/null @@ -1,22 +0,0 @@ -# Features - - -**Document role**: Managed project-document template -**Template source**: `docs/templates/features.template.md` -**Scaffold destination**: `docs/project/features.md` -**Scaffold behavior**: The live file is project-owned and never overwritten by sync - - -Track feature state and links only. Keep functional requirements in an FSD, technical design in a TSD, and game design in a GDD. - -## Implemented -- - -## Planned -- - -## Maybe Later -- - -## Rejected / Out of Scope -- diff --git a/docs/templates/gdd.template.md b/docs/templates/gdd.template.md deleted file mode 100644 index 8e621cb..0000000 --- a/docs/templates/gdd.template.md +++ /dev/null @@ -1,45 +0,0 @@ -# Game Design Document - - -**Document role**: Managed project-document template -**Template source**: `docs/templates/gdd.template.md` -**Scaffold destination**: `docs/project/gdd.md` -**Scaffold behavior**: The live file is project-owned and never overwritten by sync - - -**Game / Feature**: -**Status**: Draft -**Owner**: -**Last Updated**: YYYY-MM-DD - -Define gameplay intent and player experience. Keep engine, code, persistence, integration, and rollout design in a TSD. - -## High Concept and Design Pillars - -- - -## Target Player and Platform - -Document audience, platform, input method, and expected session length. - -## Core Loop and Rules - -Describe the repeated gameplay loop, actions, constraints, and win or loss conditions. - -## Progression, Economy, and Balance - -- - -## Content, Presentation, and Accessibility - -Summarize levels, encounters, items, narrative, UI, art, audio, feedback, controls, and accessibility requirements that matter to the design. - -## Playtest Criteria - -| ID | Success criterion | Test method | -|---|---|---| -| GDD-001 | | | - -## Open Questions and References - -- diff --git a/docs/templates/readme.template.md b/docs/templates/readme.template.md index 353a4af..23d3b1f 100644 --- a/docs/templates/readme.template.md +++ b/docs/templates/readme.template.md @@ -2,16 +2,17 @@ **Document role**: Managed project-document template - **Template source**: `docs/templates/readme.template.md` - **Scaffold destination**: `README.md` - -**Scaffold behavior**: The live file is project-owned and never overwritten by sync +**Scaffold behavior**: The live file is project-owned; only a verified unchanged scaffold may be upgraded Short description of what this project does and who it is for. +## Why + +Explain the problem this project solves and why someone would choose it. + ## Status Current status: `planning`, `prototype`, `active`, `maintenance`, or `archived`. @@ -56,10 +57,21 @@ Add examples for common usage. - [`CHANGELOG.md`](CHANGELOG.md) — version history +Link the live project documents under `docs/project/` that exist in this repository, such as the architecture document, feature list, or user guide. + ## Known Limitations List verified limitations. Do not invent unsupported claims. +## Help and Contributing + +Explain where users can ask for help and link contribution guidance when the +project accepts contributions. + +## Maintainers + +List the responsible team or a public contact path. + ## License Add license information. diff --git a/docs/templates/tsd.template.md b/docs/templates/tsd.template.md new file mode 100644 index 0000000..b370d73 --- /dev/null +++ b/docs/templates/tsd.template.md @@ -0,0 +1,82 @@ +# Technical Design + + +**Document role**: Managed project-document template +**Template source**: `docs/templates/tsd.template.md` +**Suggested live destination**: `docs/project/designs/.md` +**Usage**: Copy on demand for one substantial technical change + + +**Change**: +**Status**: Proposed / Accepted / Implemented / Superseded / Rejected +**Owner**: +**Last Updated**: YYYY-MM-DD + +Describe one substantial technical change. Reference the FSD or GDD for +behavior and `docs/project/architecture.md` for the current system. This design +records a transition; it is not the canonical description of the current +architecture. + +Create a design for changes involving multiple components, public contracts, +persistence or migration, security, concurrency, deployment, compatibility, +high uncertainty, or difficult rollback. Do not create one for routine changes. + +## Problem and Motivation + +Link the requirement, issue, or acceptance criteria and explain why the change +is needed. + +## Goals and Non-Goals + +### Goals + +- + +### Non-Goals + +- + +## Proposed Design + +| Component | Change | +|---|---| +| | | + +Describe changes to architecture boundaries, interfaces, data flow, and dependencies. + +## Alternatives and Rationale + +Describe credible alternatives, the consequences of doing nothing, and why the +proposed approach is preferred. + +## Data and Interfaces + +Document persistence, migrations, APIs, commands, events, contracts, or messages affected by the change. + +## Security, Reliability, and Observability + +Document failures, retries, logging, metrics, tracing, validation, +authentication, authorization, privacy, and secrets. + +## Performance and Compatibility + +Document expected load, limits, concurrency, backward compatibility, and migration concerns. + +## Validation + +- Unit: +- Integration: +- Smoke/manual: + +## Rollout and Rollback + +Describe deployment order, feature flags, migration, rollback, and release notes when applicable. + +## Architecture Documentation Updates + +List the current-state architecture sections or detailed technical documents +that must be updated when this design is implemented. + +## Risks, Unresolved Questions, and References + +- diff --git a/scripts/sync-docs.py b/scripts/sync-docs.py index 342392b..b67c887 100644 --- a/scripts/sync-docs.py +++ b/scripts/sync-docs.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import re import shutil @@ -11,7 +12,7 @@ from dataclasses import dataclass from pathlib import Path, PurePosixPath -SCRIPT_VERSION = "3.2.3" +SCRIPT_VERSION = "4.0.0" MANIFEST_FILE = "manifest.json" FILES_DIRECTORY = "files" TEMPLATE_METADATA_START = "repo-seed-template:start" @@ -19,6 +20,19 @@ VALID_TYPES = {"managed", "template"} VALID_SCAFFOLD_GROUPS = {"project", "github", "editorconfig"} SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") +SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +SCAFFOLD_SOURCE_PATTERN = re.compile( + r"^$", + re.MULTILINE, +) +SCAFFOLD_HASH_PATTERN = re.compile( + r"^\r?\n?", + re.MULTILINE, +) +LEGACY_PROVENANCE_PATTERN = re.compile( + r'^$', + re.MULTILINE, +) @dataclass(frozen=True) @@ -26,17 +40,43 @@ class Asset: path: str asset_type: str profiles: tuple[str, ...] + previous_hashes: tuple[str, ...] = () scaffold_group: str | None = None scaffold_target: str | None = None +@dataclass(frozen=True) +class RetiredPathSet: + through_version: str + paths: tuple[str, ...] + + +@dataclass(frozen=True) +class ScaffoldUpgrade: + from_versions: tuple[str, ...] + legacy_target: str + template: str + content_hashes: tuple[str, ...] + + +@dataclass(frozen=True) +class MigrationConfig: + legacy_manifest: str + legacy_version: str + legacy_conflicts: str + protected_paths: tuple[str, ...] + retired_path_sets: tuple[RetiredPathSet, ...] + scaffold_upgrades: tuple[ScaffoldUpgrade, ...] + + @dataclass(frozen=True) class PackManifest: schema_version: int pack_version: str - default_profile: str + state_file: str profiles: tuple[str, ...] package_files: tuple[str, ...] + migration: MigrationConfig | None assets: tuple[Asset, ...] @@ -47,6 +87,22 @@ class SyncAction: detail: str +@dataclass(frozen=True) +class LegacyState: + pack_version: str | None + hashes: dict[str, str] + manifest_exists: bool + + +@dataclass(frozen=True) +class ManagedState: + pack_version: str | None + profile: str | None + managed_files: dict[str, str] + tombstones: dict[str, str] + exists: bool + + def relative_path(value: str, context: str) -> Path: if not value or "\\" in value: raise ValueError(f"{context} must be a non-empty POSIX relative path") @@ -97,6 +153,107 @@ def optional_string_list(data: dict[str, object], key: str, context: str) -> tup return tuple(value) +def version_key(value: str, context: str) -> tuple[int, int, int]: + if not SEMVER_PATTERN.fullmatch(value): + raise ValueError(f"{context} must use MAJOR.MINOR.PATCH") + major, minor, patch = value.split(".") + return int(major), int(minor), int(patch) + + +def load_migration(value: object, assets: tuple[Asset, ...]) -> MigrationConfig | None: + if value is None: + return None + if not isinstance(value, dict): + raise ValueError("manifest.migration must be an object") + + legacy_manifest = require_string(value, "legacy_manifest", "manifest.migration") + legacy_version = require_string(value, "legacy_version", "manifest.migration") + legacy_conflicts = require_string(value, "legacy_conflicts", "manifest.migration") + state_paths = {legacy_manifest, legacy_version, legacy_conflicts} + if len(state_paths) != 3: + raise ValueError("Legacy manifest, version, and conflict paths must be distinct") + protected_paths = require_string_list(value, "protected_paths", "manifest.migration") + relative_path(legacy_manifest, "manifest.migration.legacy_manifest") + relative_path(legacy_version, "manifest.migration.legacy_version") + relative_path(legacy_conflicts, "manifest.migration.legacy_conflicts") + for index, path in enumerate(protected_paths): + relative_path(path, f"manifest.migration.protected_paths[{index}]") + + raw_sets = value.get("retired_path_sets") + if not isinstance(raw_sets, list) or not raw_sets: + raise ValueError("manifest.migration.retired_path_sets must be a non-empty array") + retired_sets: list[RetiredPathSet] = [] + retired_paths: set[str] = set() + for index, raw_set in enumerate(raw_sets): + context = f"manifest.migration.retired_path_sets[{index}]" + if not isinstance(raw_set, dict): + raise ValueError(f"{context} must be an object") + through_version = require_string(raw_set, "through_version", context) + version_key(through_version, f"{context}.through_version") + paths = require_string_list(raw_set, "paths", context) + for path_index, path in enumerate(paths): + relative_path(path, f"{context}.paths[{path_index}]") + if path in retired_paths: + raise ValueError(f"Duplicate retired path: {path}") + retired_paths.add(path) + retired_sets.append(RetiredPathSet(through_version=through_version, paths=paths)) + + if retired_paths.intersection(protected_paths): + raise ValueError("Protected paths cannot also be retired") + current_paths = {asset.path for asset in assets} + if retired_paths.intersection(current_paths): + raise ValueError("Retired paths cannot collide with current managed paths") + if legacy_manifest in retired_paths or legacy_conflicts in retired_paths: + raise ValueError("Legacy manifest and conflict paths cannot also be retired") + if state_paths.intersection(current_paths) or state_paths.intersection(protected_paths): + raise ValueError("Legacy state paths cannot collide with current or protected paths") + + raw_upgrades = value.get("scaffold_upgrades") + if not isinstance(raw_upgrades, list): + raise ValueError("manifest.migration.scaffold_upgrades must be an array") + asset_by_path = {asset.path: asset for asset in assets} + upgrades: list[ScaffoldUpgrade] = [] + legacy_targets: set[str] = set() + for index, raw_upgrade in enumerate(raw_upgrades): + context = f"manifest.migration.scaffold_upgrades[{index}]" + if not isinstance(raw_upgrade, dict): + raise ValueError(f"{context} must be an object") + from_versions = require_string_list(raw_upgrade, "from_versions", context) + legacy_target = require_string(raw_upgrade, "legacy_target", context) + template = require_string(raw_upgrade, "template", context) + content_hashes = require_string_list(raw_upgrade, "content_hashes", context) + for version_index, version in enumerate(from_versions): + version_key(version, f"{context}.from_versions[{version_index}]") + relative_path(legacy_target, f"{context}.legacy_target") + relative_path(template, f"{context}.template") + if legacy_target in legacy_targets: + raise ValueError(f"Duplicate scaffold legacy target: {legacy_target}") + if template not in asset_by_path or asset_by_path[template].asset_type != "template": + raise ValueError(f"{context}.template must reference a template asset") + if legacy_target in current_paths or legacy_target in protected_paths: + raise ValueError(f"{context}.legacy_target cannot be managed or protected") + if not all(SHA256_PATTERN.fullmatch(hash_value) for hash_value in content_hashes): + raise ValueError(f"{context}.content_hashes must contain SHA-256 values") + legacy_targets.add(legacy_target) + upgrades.append( + ScaffoldUpgrade( + from_versions=from_versions, + legacy_target=legacy_target, + template=template, + content_hashes=content_hashes, + ) + ) + + return MigrationConfig( + legacy_manifest=legacy_manifest, + legacy_version=legacy_version, + legacy_conflicts=legacy_conflicts, + protected_paths=protected_paths, + retired_path_sets=tuple(retired_sets), + scaffold_upgrades=tuple(upgrades), + ) + + def template_body(source: Path) -> str: lines = source.read_text(encoding="utf-8").splitlines(keepends=True) starts = [index for index, line in enumerate(lines) if TEMPLATE_METADATA_START in line] @@ -126,17 +283,16 @@ def load_manifest(source_root: Path, validate_sources: bool = True) -> PackManif if not isinstance(raw, dict): raise ValueError("Pack manifest root must be an object") - if raw.get("schema_version") != 1: + if raw.get("schema_version") != 2: raise ValueError(f"Unsupported manifest schema_version: {raw.get('schema_version')}") pack_version = require_string(raw, "pack_version", "manifest") if not SEMVER_PATTERN.fullmatch(pack_version): raise ValueError("manifest.pack_version must use MAJOR.MINOR.PATCH") + state_file = require_string(raw, "state_file", "manifest") + relative_path(state_file, "manifest.state_file") profiles = require_string_list(raw, "profiles", "manifest") - default_profile = require_string(raw, "default_profile", "manifest") - if default_profile not in profiles: - raise ValueError("manifest.default_profile must be listed in manifest.profiles") package_files = optional_string_list(raw, "package_files", "manifest") for index, package_file in enumerate(package_files): @@ -167,6 +323,7 @@ def load_manifest(source_root: Path, validate_sources: bool = True) -> PackManif path = require_string(raw_asset, "path", context) asset_type = require_string(raw_asset, "type", context) asset_profiles = require_string_list(raw_asset, "profiles", context) + previous_hashes = optional_string_list(raw_asset, "previous_hashes", context) scaffold_group = raw_asset.get("scaffold_group") scaffold_target = raw_asset.get("scaffold_target") @@ -177,20 +334,25 @@ def load_manifest(source_root: Path, validate_sources: bool = True) -> PackManif raise ValueError(f"{context}.type must be managed or template") if not set(asset_profiles).issubset(profile_set): raise ValueError(f"{context}.profiles contains an unknown profile") + if not all(SHA256_PATTERN.fullmatch(hash_value) for hash_value in previous_hashes): + raise ValueError(f"{context}.previous_hashes must contain SHA-256 values") if asset_type == "template": - if scaffold_group not in VALID_SCAFFOLD_GROUPS: + if (scaffold_group is None) != (scaffold_target is None): + raise ValueError(f"{context} must define both scaffold fields or neither") + if scaffold_group is not None and scaffold_group not in VALID_SCAFFOLD_GROUPS: raise ValueError( f"{context}.scaffold_group must be one of: {', '.join(sorted(VALID_SCAFFOLD_GROUPS))}" ) - if not isinstance(scaffold_target, str) or not scaffold_target: - raise ValueError(f"{context}.scaffold_target must be a non-empty string") - relative_path(scaffold_target, f"{context}.scaffold_target") - if scaffold_target in scaffold_targets: - raise ValueError(f"Duplicate scaffold target: {scaffold_target}") if not path.startswith("docs/templates/"): raise ValueError(f"{context}.path must be under docs/templates") - scaffold_targets.add(scaffold_target) + if scaffold_group is not None: + if not isinstance(scaffold_target, str) or not scaffold_target: + raise ValueError(f"{context}.scaffold_target must be a non-empty string") + relative_path(scaffold_target, f"{context}.scaffold_target") + if scaffold_target in scaffold_targets: + raise ValueError(f"Duplicate scaffold target: {scaffold_target}") + scaffold_targets.add(scaffold_target) elif scaffold_group is not None or scaffold_target is not None: raise ValueError(f"{context} managed assets cannot define scaffold fields") @@ -209,6 +371,7 @@ def load_manifest(source_root: Path, validate_sources: bool = True) -> PackManif path=path, asset_type=asset_type, profiles=asset_profiles, + previous_hashes=previous_hashes, scaffold_group=scaffold_group if isinstance(scaffold_group, str) else None, scaffold_target=scaffold_target if isinstance(scaffold_target, str) else None, ) @@ -217,13 +380,24 @@ def load_manifest(source_root: Path, validate_sources: bool = True) -> PackManif collisions = paths.intersection(scaffold_targets) if collisions: raise ValueError(f"Scaffold targets collide with managed paths: {', '.join(sorted(collisions))}") + migration = load_migration(raw.get("migration"), tuple(assets)) + if state_file in paths or state_file in scaffold_targets or state_file in package_files: + raise ValueError("manifest.state_file cannot collide with pack or target assets") + if migration and state_file in { + migration.legacy_manifest, + migration.legacy_version, + migration.legacy_conflicts, + *migration.protected_paths, + }: + raise ValueError("manifest.state_file cannot collide with migration paths") return PackManifest( - schema_version=1, + schema_version=2, pack_version=pack_version, - default_profile=default_profile, + state_file=state_file, profiles=profiles, package_files=package_files, + migration=migration, assets=tuple(assets), ) @@ -270,18 +444,365 @@ def validate_scaffold_destination(target_root: Path, asset: Asset) -> None: validate_parent_directory(target, target_root, f"Scaffold target '{asset.scaffold_target}'") +def file_hash(path: Path) -> str: + hasher = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def content_hash(content: str) -> str: + normalized = content.replace("\r\n", "\n").replace("\r", "\n") + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def managed_file_hash(path: Path) -> str: + return content_hash(path.read_text(encoding="utf-8")) + + +def read_legacy_state(target_root: Path, migration: MigrationConfig | None) -> LegacyState: + if migration is None: + return LegacyState(pack_version=None, hashes={}, manifest_exists=False) + + version: str | None = None + version_path = safe_child(target_root, migration.legacy_version, "legacy version path") + if version_path.is_symlink(): + raise ValueError(f"Legacy version path cannot be a symbolic link: {migration.legacy_version}") + if version_path.exists(): + if not version_path.is_file(): + raise ValueError(f"Legacy version path is not a file: {migration.legacy_version}") + version = version_path.read_text(encoding="utf-8").strip() + version_key(version, "legacy pack version") + + manifest_path = safe_child(target_root, migration.legacy_manifest, "legacy manifest path") + if manifest_path.is_symlink(): + raise ValueError(f"Legacy manifest cannot be a symbolic link: {migration.legacy_manifest}") + if not manifest_path.exists(): + return LegacyState(pack_version=version, hashes={}, manifest_exists=False) + if not manifest_path.is_file(): + raise ValueError(f"Legacy manifest is not a file: {migration.legacy_manifest}") + + try: + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as ex: + raise ValueError(f"Legacy manifest is not valid JSON: {ex}") from ex + if not isinstance(raw, dict) or not isinstance(raw.get("files"), dict): + raise ValueError("Legacy manifest must contain a files object") + + manifest_version = raw.get("pack_version") + if manifest_version is not None: + if not isinstance(manifest_version, str): + raise ValueError("Legacy manifest pack_version must be a string") + version_key(manifest_version, "legacy manifest pack_version") + if version is not None and version != manifest_version: + raise ValueError("Legacy version file and manifest disagree") + version = manifest_version + + hashes: dict[str, str] = {} + for path, hash_value in raw["files"].items(): + if not isinstance(path, str) or not isinstance(hash_value, str): + raise ValueError("Legacy manifest files must map paths to SHA-256 strings") + relative_path(path, "legacy manifest file path") + if not SHA256_PATTERN.fullmatch(hash_value): + raise ValueError(f"Legacy manifest hash is invalid for: {path}") + hashes[path] = hash_value + + return LegacyState(pack_version=version, hashes=hashes, manifest_exists=True) + + +def read_managed_state(target_root: Path, manifest: PackManifest) -> ManagedState: + state_path = safe_child(target_root, manifest.state_file, "managed state path") + if state_path.is_symlink(): + raise ValueError(f"Managed state cannot be a symbolic link: {manifest.state_file}") + if not state_path.exists(): + return ManagedState( + pack_version=None, + profile=None, + managed_files={}, + tombstones={}, + exists=False, + ) + if not state_path.is_file(): + raise ValueError(f"Managed state is not a file: {manifest.state_file}") + + try: + raw = json.loads(state_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as ex: + raise ValueError(f"Managed state is not valid JSON: {ex}") from ex + if not isinstance(raw, dict) or raw.get("schema_version") != 1: + raise ValueError("Managed state must use schema_version 1") + if not isinstance(raw.get("pack_version"), str): + raise ValueError("Managed state pack_version must be a string") + version_key(raw["pack_version"], "managed state pack_version") + if not isinstance(raw.get("profile"), str) or not raw["profile"]: + raise ValueError("Managed state profile must be a non-empty string") + + def hash_map(key: str) -> dict[str, str]: + value = raw.get(key) + if not isinstance(value, dict): + raise ValueError(f"Managed state {key} must be an object") + result: dict[str, str] = {} + for path, hash_value in value.items(): + if not isinstance(path, str) or not isinstance(hash_value, str): + raise ValueError(f"Managed state {key} must map paths to SHA-256 strings") + relative_path(path, f"managed state {key} path") + if path == manifest.state_file: + raise ValueError("Managed state cannot own itself") + if not SHA256_PATTERN.fullmatch(hash_value): + raise ValueError(f"Managed state hash is invalid for: {path}") + result[path] = hash_value + return result + + managed_files = hash_map("managed_files") + tombstones = hash_map("tombstones") + if set(managed_files).intersection(tombstones): + raise ValueError("Managed state paths cannot be both active and tombstoned") + return ManagedState( + pack_version=raw["pack_version"], + profile=raw["profile"], + managed_files=managed_files, + tombstones=tombstones, + exists=True, + ) + + +def legacy_target(target_root: Path, path: str) -> Path: + return target_root / relative_path(path, "legacy target") + + +def target_resolves_within_root(target_root: Path, target: Path) -> bool: + try: + target.resolve().relative_to(target_root.resolve()) + except ValueError: + return False + return True + + def copy_asset(source_root: Path, target_root: Path, asset: Asset, dry_run: bool) -> SyncAction: source = safe_child(source_root / FILES_DIRECTORY, asset.path, "asset path") target = safe_child(target_root, asset.path, "asset target") + existed = target.is_file() + if existed and managed_file_hash(source) == managed_file_hash(target): + return SyncAction("unchanged", asset.path, "managed copy already matches") if dry_run: - return SyncAction("copy", asset.path, "would overwrite managed copy") + detail = "would overwrite managed copy" if existed else "would create managed copy" + return SyncAction("copy", asset.path, detail) target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, target) - return SyncAction("copy", asset.path, "overwrote managed copy") + detail = "overwrote managed copy" if existed else "created managed copy" + return SyncAction("copy", asset.path, detail) -def add_source_marker(body: str, template_path: str) -> str: +def prune_stale_assets( + source_root: Path, + target_root: Path, + manifest: PackManifest, + selected: tuple[Asset, ...], + state: ManagedState, + legacy_hashes: dict[str, str], + dry_run: bool, +) -> tuple[list[SyncAction], dict[str, str]]: + selected_paths = {asset.path for asset in selected} + candidates = { + path: hash_value + for path, hash_value in state.tombstones.items() + if path not in selected_paths + } + candidates.update( + (path, hash_value) + for path, hash_value in state.managed_files.items() + if path not in selected_paths + ) + if not state.exists: + for asset in manifest.assets: + if asset.path not in selected_paths: + source = safe_child(source_root / FILES_DIRECTORY, asset.path, "asset path") + expected_hash = managed_file_hash(source) + target = target_root / relative_path(asset.path, "bootstrap managed path") + if target.is_file() and not target.is_symlink(): + target_hash = managed_file_hash(target) + legacy_match = ( + asset.path in legacy_hashes + and file_hash(target) == legacy_hashes[asset.path] + ) + if target_hash in asset.previous_hashes or legacy_match: + expected_hash = target_hash + candidates.setdefault(asset.path, expected_hash) + + actions: list[SyncAction] = [] + tombstones: dict[str, str] = {} + protected_paths = { + asset.scaffold_target + for asset in manifest.assets + if asset.scaffold_target is not None + } + if manifest.migration: + protected_paths.update(manifest.migration.protected_paths) + for path, expected_hash in sorted(candidates.items()): + target = target_root / relative_path(path, "stale managed path") + if not target.exists() and not target.is_symlink(): + continue + if path in protected_paths: + actions.append(SyncAction("preserve", path, "stale path is now project-owned")) + continue + if target.is_symlink() or not target_resolves_within_root(target_root, target): + actions.append(SyncAction("preserve", path, "stale managed path is a symbolic link")) + tombstones[path] = expected_hash + elif not target.is_file(): + actions.append(SyncAction("preserve", path, "stale managed path is not a regular file")) + tombstones[path] = expected_hash + elif managed_file_hash(target) != expected_hash: + actions.append(SyncAction("preserve", path, "stale managed file has local changes")) + tombstones[path] = expected_hash + elif dry_run: + actions.append(SyncAction("remove", path, "would remove unchanged stale managed file")) + else: + target.unlink() + actions.append(SyncAction("remove", path, "removed unchanged stale managed file")) + return actions, tombstones + + +def report_project_owned_paths( + target_root: Path, + manifest: PackManifest, + state: ManagedState, + already_reported: set[str], +) -> list[SyncAction]: + if manifest.migration is None or state.pack_version == manifest.pack_version: + return [] + actions: list[SyncAction] = [] + for path in manifest.migration.protected_paths: + target = target_root / relative_path(path, "project-owned path") + if path not in already_reported and (target.exists() or target.is_symlink()): + actions.append(SyncAction("preserve", path, "project-owned path preserved")) + return actions + + +def write_managed_state( + source_root: Path, + target_root: Path, + manifest: PackManifest, + profile: str, + selected: tuple[Asset, ...], + tombstones: dict[str, str], + dry_run: bool, +) -> SyncAction: + state_path = safe_child(target_root, manifest.state_file, "managed state path") + payload = { + "schema_version": 1, + "pack_version": manifest.pack_version, + "profile": profile, + "managed_files": { + asset.path: managed_file_hash( + safe_child(source_root / FILES_DIRECTORY, asset.path, "asset path") + ) + for asset in selected + }, + "tombstones": dict(sorted(tombstones.items())), + } + content = json.dumps(payload, indent=2, sort_keys=True) + "\n" + existed = state_path.is_file() + if existed and state_path.read_text(encoding="utf-8") == content: + return SyncAction("unchanged", manifest.state_file, "managed ownership state already matches") + if dry_run: + detail = "would update managed ownership state" if existed else "would create managed ownership state" + return SyncAction("state", manifest.state_file, detail) + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(content, encoding="utf-8", newline="\n") + detail = "updated managed ownership state" if existed else "created managed ownership state" + return SyncAction("state", manifest.state_file, detail) + + +def retire_legacy_paths( + target_root: Path, + migration: MigrationConfig | None, + state: LegacyState, + dry_run: bool, +) -> list[SyncAction]: + if migration is None or (state.pack_version is None and not state.manifest_exists): + return [] + + retired: set[str] = set() + for retired_set in migration.retired_path_sets: + if state.pack_version is None or version_key( + state.pack_version, "legacy pack version" + ) <= version_key(retired_set.through_version, "retired path version"): + retired.update(retired_set.paths) + + actions: list[SyncAction] = [] + unsupported_version = state.pack_version is not None and not retired + blocked = unsupported_version + for path in sorted(retired): + target = legacy_target(target_root, path) + if not target.exists() and not target.is_symlink(): + continue + expected_hash = state.hashes.get(path) + # The legacy version file is repo-seed-owned even when the old manifest + # omitted its own hash. Recognize it by its recorded version content so + # migration can complete instead of preserving it (and the manifest) + # forever. + version_file_match = ( + expected_hash is None + and path == migration.legacy_version + and state.pack_version is not None + and target.is_file() + and not target.is_symlink() + and target.read_text(encoding="utf-8").strip() == state.pack_version + ) + if target.is_symlink() or not target_resolves_within_root(target_root, target): + actions.append(SyncAction("preserve", path, "retired path is a symbolic link")) + blocked = True + elif not target.is_file(): + actions.append(SyncAction("preserve", path, "retired path is not a regular file")) + blocked = True + elif expected_hash is None and not version_file_match: + actions.append(SyncAction("preserve", path, "retired path is not recorded in the legacy manifest")) + blocked = True + elif expected_hash is not None and file_hash(target) != expected_hash: + actions.append(SyncAction("preserve", path, "retired path has local changes")) + blocked = True + elif dry_run: + actions.append(SyncAction("remove", path, "would remove unchanged retired managed file")) + else: + target.unlink() + actions.append(SyncAction("remove", path, "removed unchanged retired managed file")) + + if state.manifest_exists: + manifest_path = legacy_target(target_root, migration.legacy_manifest) + if blocked: + detail = ( + f"legacy manifest retained because version {state.pack_version} is newer than supported migrations" + if unsupported_version + else "legacy manifest retained while retired files need review" + ) + actions.append( + SyncAction( + "preserve", + migration.legacy_manifest, + detail, + ) + ) + elif dry_run: + actions.append(SyncAction("remove", migration.legacy_manifest, "would remove migrated legacy manifest")) + else: + manifest_path.unlink() + actions.append(SyncAction("remove", migration.legacy_manifest, "removed migrated legacy manifest")) + + for path in migration.protected_paths: + target = legacy_target(target_root, path) + if (target.exists() or target.is_symlink()) and (state.manifest_exists or state.pack_version): + actions.append(SyncAction("preserve", path, "legacy-managed path is now project-owned")) + + conflicts = legacy_target(target_root, migration.legacy_conflicts) + if (conflicts.exists() or conflicts.is_symlink()) and (state.manifest_exists or state.pack_version): + actions.append(SyncAction("preserve", migration.legacy_conflicts, "legacy conflict output requires review")) + + return actions + + +def add_source_path_marker(body: str, template_path: str) -> str: marker = f"" stripped = body.strip() @@ -297,6 +818,135 @@ def add_source_marker(body: str, template_path: str) -> str: return f"{marker}\n\n{stripped}\n" +def add_source_marker(body: str, template_path: str) -> str: + source_marker = f"" + marked = add_source_path_marker(body, template_path) + hash_marker = f"" + return marked.replace(source_marker, f"{source_marker}\n{hash_marker}", 1) + + +def render_scaffold(source_root: Path, asset: Asset) -> str: + source = safe_child(source_root / FILES_DIRECTORY, asset.path, "template path") + body = template_body(source) + if asset.scaffold_target and Path(asset.scaffold_target).suffix.lower() == ".md": + return add_source_marker(body, asset.path) + return body + + +def verified_current_scaffold(content: str, asset: Asset) -> bool | None: + source_matches = list(SCAFFOLD_SOURCE_PATTERN.finditer(content)) + hash_matches = list(SCAFFOLD_HASH_PATTERN.finditer(content)) + if not source_matches and not hash_matches: + return None + if len(source_matches) == 1 and not hash_matches: + return None + if ( + len(source_matches) != 1 + or len(hash_matches) != 1 + or source_matches[0].group("source") != asset.path + ): + return False + without_hash = SCAFFOLD_HASH_PATTERN.sub("", content, count=1) + return content_hash(without_hash) == hash_matches[0].group("hash") + + +def legacy_template_id(asset: Asset) -> str | None: + if asset.path.endswith("/.github/bug-report.template.md"): + return "github-bug-template" + if asset.path.endswith("/.github/feature-request.template.md"): + return "github-feature-template" + if ".template" not in Path(asset.path).name: + return None + return f"{Path(asset.path).name.split('.template', 1)[0]}-template" + + +def verified_legacy_scaffold(content: str, asset: Asset) -> bool | None: + matches = list(LEGACY_PROVENANCE_PATTERN.finditer(content)) + if not matches: + return None + if len(matches) != 1 or matches[0].group("id") != legacy_template_id(asset): + return False + without_marker = LEGACY_PROVENANCE_PATTERN.sub("", content, count=1).rstrip() + return content_hash(without_marker) == matches[0].group("hash") + + +def write_scaffold(target: Path, content: str, dry_run: bool) -> None: + if dry_run: + return + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8", newline="\n") + + +def upgrade_scaffold_asset( + source_root: Path, + target_root: Path, + asset: Asset, + migration: MigrationConfig | None, + state: LegacyState, + dry_run: bool, +) -> SyncAction | None: + if asset.scaffold_target is None: + raise ValueError(f"Template has no scaffold target: {asset.path}") + if migration and asset.scaffold_target in migration.protected_paths: + return None + + target = safe_child(target_root, asset.scaffold_target, "scaffold target") + rendered = render_scaffold(source_root, asset) + if target.is_file() and not target.is_symlink() and target.suffix.lower() == ".md": + content = target.read_text(encoding="utf-8") + current_verified = verified_current_scaffold(content, asset) + legacy_verified = verified_legacy_scaffold(content, asset) + old_current_render = add_source_path_marker( + template_body(safe_child(source_root / FILES_DIRECTORY, asset.path, "template path")), + asset.path, + ) + source_only_marker = ( + current_verified is None + and SCAFFOLD_SOURCE_PATTERN.search(content) is not None + ) + + if current_verified is False or legacy_verified is False: + return SyncAction("preserve", asset.scaffold_target, "scaffold provenance does not match local content") + if current_verified is True or legacy_verified is True or ( + source_only_marker and content == old_current_render + ): + if content == rendered: + return SyncAction("skip", asset.scaffold_target, "scaffold already matches current template") + write_scaffold(target, rendered, dry_run) + detail = "would upgrade verified scaffold" if dry_run else "upgraded verified scaffold" + return SyncAction("upgrade", asset.scaffold_target, detail) + if source_only_marker: + return SyncAction("preserve", asset.scaffold_target, "scaffold has local changes") + + if migration is None or state.pack_version is None: + return None + for upgrade in migration.scaffold_upgrades: + if upgrade.template != asset.path or state.pack_version not in upgrade.from_versions: + continue + legacy_path = legacy_target(target_root, upgrade.legacy_target) + if not legacy_path.is_file() or legacy_path.is_symlink(): + continue + if content_hash(legacy_path.read_text(encoding="utf-8")) not in upgrade.content_hashes: + continue + if upgrade.legacy_target != asset.scaffold_target and (target.exists() or target.is_symlink()): + return SyncAction( + "preserve", + upgrade.legacy_target, + f"verified legacy scaffold retained because {asset.scaffold_target} exists", + ) + + write_scaffold(target, rendered, dry_run) + if upgrade.legacy_target != asset.scaffold_target and not dry_run: + legacy_path.unlink() + detail = ( + f"would migrate verified scaffold to {asset.scaffold_target}" + if dry_run + else f"migrated verified scaffold to {asset.scaffold_target}" + ) + return SyncAction("upgrade", upgrade.legacy_target, detail) + return None + + def scaffold_asset(source_root: Path, target_root: Path, asset: Asset, dry_run: bool) -> SyncAction: if asset.scaffold_target is None: raise ValueError(f"Template has no scaffold target: {asset.path}") @@ -305,16 +955,12 @@ def scaffold_asset(source_root: Path, target_root: Path, asset: Asset, dry_run: if target.exists() or target.is_symlink(): return SyncAction("skip", asset.scaffold_target, "project-owned destination already exists") - source = safe_child(source_root / FILES_DIRECTORY, asset.path, "template path") - body = template_body(source) - if Path(asset.scaffold_target).suffix.lower() == ".md": - body = add_source_marker(body, asset.path) + body = render_scaffold(source_root, asset) if dry_run: return SyncAction("scaffold", asset.scaffold_target, f"would create from {asset.path}") - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(body, encoding="utf-8", newline="\n") + write_scaffold(target, body, dry_run=False) return SyncAction("scaffold", asset.scaffold_target, f"created from {asset.path}") @@ -334,6 +980,13 @@ def synchronize( manifest = load_manifest(source_root) selected = assets_for_profile(manifest, profile) + if profile == "full" and scaffold_project_files: + raise ValueError( + "The full profile is a reference catalog and cannot scaffold project files; " + "choose minimal, library, app, or game" + ) + legacy_state = read_legacy_state(target_root, manifest.migration) + managed_state = read_managed_state(target_root, manifest) requested_groups: set[str] = set() if scaffold_project_files: @@ -353,31 +1006,79 @@ def synchronize( validate_managed_destination(target_root, asset) for asset in scaffold_assets: validate_scaffold_destination(target_root, asset) - - actions = [copy_asset(source_root, target_root, asset, dry_run) for asset in selected] - actions.extend(scaffold_asset(source_root, target_root, asset, dry_run) for asset in scaffold_assets) + state_path = safe_child(target_root, manifest.state_file, "managed state path") + validate_parent_directory(state_path, target_root, "Managed state") + + actions = retire_legacy_paths(target_root, manifest.migration, legacy_state, dry_run) + actions.extend( + report_project_owned_paths( + target_root, + manifest, + managed_state, + {action.path for action in actions}, + ) + ) + prune_actions, tombstones = prune_stale_assets( + source_root, + target_root, + manifest, + selected, + managed_state, + legacy_state.hashes, + dry_run, + ) + actions.extend(prune_actions) + actions.extend(copy_asset(source_root, target_root, asset, dry_run) for asset in selected) + for asset in scaffold_assets: + migration_action = upgrade_scaffold_asset( + source_root, + target_root, + asset, + manifest.migration, + legacy_state, + dry_run, + ) + actions.append( + migration_action + if migration_action is not None + else scaffold_asset(source_root, target_root, asset, dry_run) + ) + actions.append( + write_managed_state( + source_root, + target_root, + manifest, + profile, + selected, + tombstones, + dry_run, + ) + ) return actions def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="Overwrite managed documentation-pack files and optionally scaffold missing project files." + description="Migrate legacy files, synchronize a managed profile, and optionally scaffold project files." ) parser.add_argument( "--source", help="Pack directory containing manifest.json and files/. Auto-detected when run from an extracted pack.", ) parser.add_argument("--target", default=".", help="Target repository. Defaults to the current directory.") - parser.add_argument("--profile", help="Profile name. Defaults to the manifest default.") + parser.add_argument( + "--profile", + help="Profile name. Required on first sync; later syncs reuse the recorded profile when omitted.", + ) parser.add_argument( "--scaffold-project-files", action="store_true", - help="Create missing project-owned documents and .gitignore.", + help="Create missing project files or upgrade verified unchanged Markdown.", ) parser.add_argument( "--scaffold-github-templates", action="store_true", - help="Create missing bug, feature, and chooser files.", + help="Create missing GitHub files or upgrade verified unchanged Markdown.", ) parser.add_argument( "--scaffold-editorconfig", @@ -404,7 +1105,20 @@ def main(argv: list[str] | None = None) -> int: raise ValueError(f"Target repository does not exist or is not a directory: {target_root}") manifest = load_manifest(source_root) - profile = args.profile or manifest.default_profile + if args.profile: + profile = args.profile + else: + state = read_managed_state(target_root, manifest) + if ( + not state.exists + or state.profile not in manifest.profiles + or state.profile == "full" + ): + raise ValueError( + "No reusable project profile is recorded; pass --profile with " + "minimal, library, app, or game" + ) + profile = state.profile actions = synchronize( source_root=source_root, target_root=target_root, @@ -425,10 +1139,18 @@ def main(argv: list[str] | None = None) -> int: print(f"{action.action:<14} {action.path} ({action.detail})") copies = sum(action.action == "copy" for action in actions) + removals = sum(action.action == "remove" for action in actions) + upgrades = sum(action.action == "upgrade" for action in actions) scaffolds = sum(action.action == "scaffold" for action in actions) - skips = sum(action.action == "skip" for action in actions) + state_updates = sum(action.action == "state" for action in actions) + unchanged = sum(action.action == "unchanged" for action in actions) + preserved = sum(action.action in {"preserve", "skip"} for action in actions) mode = "Dry run" if args.dry_run else "Sync" - print(f"{mode} complete. Managed copies: {copies}. Scaffolds: {scaffolds}. Preserved: {skips}.") + print( + f"{mode} complete. Removals: {removals}. Upgrades: {upgrades}. " + f"Managed copies: {copies}. Scaffolds: {scaffolds}. " + f"State updates: {state_updates}. Unchanged: {unchanged}. Preserved: {preserved}." + ) return 0