From 79f85955822457d3fc41982d0123e81c089fbac1 Mon Sep 17 00:00:00 2001 From: icorkovic Date: Tue, 7 Jul 2026 08:13:24 +0200 Subject: [PATCH] Clarify and enforce rules for managed and retired files Update documentation and sync-docs.py to clarify which documents to generate by project type and to explicitly handle project-owned files, especially under .github/workflows. Add stricter validation in migration config to prevent collisions between managed, protected, retired, and scaffolded paths, including new support for explicit retired assets with content hashes. Enhance managed state loader to recognize and validate retired asset hashes. Increment version numbers to reflect these changes. --- .agents/guidelines/documentation.md | 5 +- .repo-seed-state.json | 12 ++-- AGENTS.md | 8 ++- docs/templates/readme.template.md | 2 +- docs/templates/tsd.template.md | 8 +-- scripts/sync-docs.py | 104 ++++++++++++++++++++++++++-- 6 files changed, 119 insertions(+), 20 deletions(-) diff --git a/.agents/guidelines/documentation.md b/.agents/guidelines/documentation.md index 3607fd2..fc81478 100644 --- a/.agents/guidelines/documentation.md +++ b/.agents/guidelines/documentation.md @@ -63,8 +63,9 @@ 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; +2. populate only applicable documents from relevant evidence: FSD for + applications or GDD for games, architecture for libraries/applications/games, + user guidance for user-facing products, plus README and changelog; 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 diff --git a/.repo-seed-state.json b/.repo-seed-state.json index e32a730..23d9826 100644 --- a/.repo-seed-state.json +++ b/.repo-seed-state.json @@ -5,9 +5,9 @@ ".agents/conventions/scripts.md": "b5f2340822cd7402d4eb6087428c0b62ffd71802824559c8492f778afcfda245", ".agents/conventions/shell.md": "165c0382882a8cce818be34368dc5b29a0297d7c2c66a40fd7e4729c397764b9", ".agents/guidelines/ci-cd.md": "e4d6bd8bc0f0696f27501ec87fa3c25d85b103e17bfe7a8993fc61ff63d4faa0", - ".agents/guidelines/documentation.md": "07c41e49fabc3ca3807246fe451611b96e6549ac991156e7fbd46271b1bc33f0", + ".agents/guidelines/documentation.md": "c73b9a220b282dabfefd5853bf0032ee485f02530a2ec3aa1e765b2762f78a62", ".agents/guidelines/git.md": "4773262f86d33c6e7c8cae87f652a2e4f979de79956bc723a5374dc6889eede5", - "AGENTS.md": "6585e8c7d15fbeadb98b3945fd79c575db62ccc3efab39535f2055bc536f0694", + "AGENTS.md": "9c33c47abef0e42a2be3e48fd95cddef8af5935ffdda15d7921c8f4e961c7025", "CLAUDE.md": "7050308e6cd5b0b953105fddc8fe5a4d259038dc8de1183414833bc90788554a", "docs/templates/.github/bug-report.template.md": "9e898d893d05a6962300f774120350559082f6b1d68be76e30c2696c1efdb53e", "docs/templates/.github/config.template.yml": "ade4f33049e309848b2f0a64d11c41be3204cd476ac112ee311cdd320cd39313", @@ -15,11 +15,11 @@ "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" + "docs/templates/readme.template.md": "dedfdbef06ccca95a257f1d16d761fb46f976a41a5682f2b6c42ddf3a549298a", + "docs/templates/tsd.template.md": "7d03ea43180ad7388a4ec19aa04126a5825f0cb15518d10e575bcaca11136f93", + "scripts/sync-docs.py": "f00b7a8807162df1af9d6eb3b7b1d8d1aca4c17aa51c4588607c00c160032653" }, - "pack_version": "4.0.0", + "pack_version": "4.0.1", "profile": "library", "schema_version": 1, "tombstones": {} diff --git a/AGENTS.md b/AGENTS.md index d376381..3ed0e4a 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**: 4.0.0 +**Version**: 4.0.1 ## Start Here @@ -119,8 +119,10 @@ 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 and -may upgrade Markdown only while repo-seed provenance proves it unchanged. +`.github/workflows/`, and every unmapped path. The sync manifest cannot manage, +scaffold, retire, or delete workflow files. Scaffolding creates missing +project-owned files and may upgrade Markdown only while repo-seed provenance +proves it unchanged. ## Completion diff --git a/docs/templates/readme.template.md b/docs/templates/readme.template.md index 23d3b1f..2d55d19 100644 --- a/docs/templates/readme.template.md +++ b/docs/templates/readme.template.md @@ -55,7 +55,7 @@ Add examples for common usage. ## Documentation -- [`CHANGELOG.md`](CHANGELOG.md) — version history +- `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. diff --git a/docs/templates/tsd.template.md b/docs/templates/tsd.template.md index b370d73..85b630b 100644 --- a/docs/templates/tsd.template.md +++ b/docs/templates/tsd.template.md @@ -12,10 +12,10 @@ **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. +Describe one substantial technical change. Reference the applicable requirement, +issue, acceptance criteria, 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, diff --git a/scripts/sync-docs.py b/scripts/sync-docs.py index b67c887..e715ef4 100644 --- a/scripts/sync-docs.py +++ b/scripts/sync-docs.py @@ -12,13 +12,14 @@ from dataclasses import dataclass from pathlib import Path, PurePosixPath -SCRIPT_VERSION = "4.0.0" +SCRIPT_VERSION = "4.0.1" MANIFEST_FILE = "manifest.json" FILES_DIRECTORY = "files" TEMPLATE_METADATA_START = "repo-seed-template:start" TEMPLATE_METADATA_END = "repo-seed-template:end" VALID_TYPES = {"managed", "template"} VALID_SCAFFOLD_GROUPS = {"project", "github", "editorconfig"} +PROJECT_OWNED_TREES = (".github/workflows",) SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") SCAFFOLD_SOURCE_PATTERN = re.compile( @@ -51,6 +52,12 @@ class RetiredPathSet: paths: tuple[str, ...] +@dataclass(frozen=True) +class RetiredAsset: + path: str + content_hashes: tuple[str, ...] + + @dataclass(frozen=True) class ScaffoldUpgrade: from_versions: tuple[str, ...] @@ -65,6 +72,7 @@ class MigrationConfig: legacy_version: str legacy_conflicts: str protected_paths: tuple[str, ...] + retired_assets: tuple[RetiredAsset, ...] retired_path_sets: tuple[RetiredPathSet, ...] scaffold_upgrades: tuple[ScaffoldUpgrade, ...] @@ -128,6 +136,18 @@ def safe_child(root: Path, value: str, context: str) -> Path: return child +def is_project_owned_tree_path(value: str) -> bool: + return any( + value == tree or value.startswith(f"{tree}/") + for tree in PROJECT_OWNED_TREES + ) + + +def reject_project_owned_tree_path(value: str, context: str) -> None: + if is_project_owned_tree_path(value): + raise ValueError(f"{context} cannot use project-owned tree: {value}") + + def require_string(data: dict[str, object], key: str, context: str) -> str: value = data.get(key) if not isinstance(value, str) or not value: @@ -179,6 +199,26 @@ def load_migration(value: object, assets: tuple[Asset, ...]) -> MigrationConfig for index, path in enumerate(protected_paths): relative_path(path, f"manifest.migration.protected_paths[{index}]") + raw_retired_assets = value.get("retired_assets") + if not isinstance(raw_retired_assets, list): + raise ValueError("manifest.migration.retired_assets must be an array") + retired_assets: list[RetiredAsset] = [] + retired_asset_paths: set[str] = set() + for index, raw_asset in enumerate(raw_retired_assets): + context = f"manifest.migration.retired_assets[{index}]" + if not isinstance(raw_asset, dict): + raise ValueError(f"{context} must be an object") + path = require_string(raw_asset, "path", context) + content_hashes = require_string_list(raw_asset, "content_hashes", context) + relative_path(path, f"{context}.path") + reject_project_owned_tree_path(path, f"{context}.path") + if path in retired_asset_paths: + raise ValueError(f"Duplicate retired asset: {path}") + 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") + retired_asset_paths.add(path) + retired_assets.append(RetiredAsset(path=path, content_hashes=content_hashes)) + 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") @@ -193,6 +233,7 @@ def load_migration(value: object, assets: tuple[Asset, ...]) -> MigrationConfig paths = require_string_list(raw_set, "paths", context) for path_index, path in enumerate(paths): relative_path(path, f"{context}.paths[{path_index}]") + reject_project_owned_tree_path(path, f"{context}.paths[{path_index}]") if path in retired_paths: raise ValueError(f"Duplicate retired path: {path}") retired_paths.add(path) @@ -201,11 +242,31 @@ def load_migration(value: object, assets: tuple[Asset, ...]) -> MigrationConfig 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): + scaffold_targets = { + asset.scaffold_target + for asset in assets + if asset.scaffold_target is not None + } + if current_paths.intersection(protected_paths): + raise ValueError("Current managed paths cannot also be protected") + if retired_paths.intersection(current_paths) or retired_asset_paths.intersection(current_paths): raise ValueError("Retired paths cannot collide with current managed paths") + if ( + retired_asset_paths.intersection(protected_paths) + or retired_asset_paths.intersection(retired_paths) + or retired_asset_paths.intersection(scaffold_targets) + or retired_paths.intersection(scaffold_targets) + ): + raise ValueError( + "Retired paths cannot collide with protected paths or scaffold targets" + ) 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): + if ( + state_paths.intersection(current_paths) + or state_paths.intersection(protected_paths) + or state_paths.intersection(retired_asset_paths) + ): raise ValueError("Legacy state paths cannot collide with current or protected paths") raw_upgrades = value.get("scaffold_upgrades") @@ -226,11 +287,16 @@ def load_migration(value: object, assets: tuple[Asset, ...]) -> MigrationConfig version_key(version, f"{context}.from_versions[{version_index}]") relative_path(legacy_target, f"{context}.legacy_target") relative_path(template, f"{context}.template") + reject_project_owned_tree_path(legacy_target, f"{context}.legacy_target") 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: + if ( + legacy_target in current_paths + or legacy_target in protected_paths + or legacy_target in retired_asset_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") @@ -249,6 +315,7 @@ def load_migration(value: object, assets: tuple[Asset, ...]) -> MigrationConfig legacy_version=legacy_version, legacy_conflicts=legacy_conflicts, protected_paths=protected_paths, + retired_assets=tuple(retired_assets), retired_path_sets=tuple(retired_sets), scaffold_upgrades=tuple(upgrades), ) @@ -328,6 +395,7 @@ def load_manifest(source_root: Path, validate_sources: bool = True) -> PackManif scaffold_target = raw_asset.get("scaffold_target") relative_path(path, f"{context}.path") + reject_project_owned_tree_path(path, f"{context}.path") if path in paths: raise ValueError(f"Duplicate asset path: {path}") if asset_type not in VALID_TYPES: @@ -350,6 +418,10 @@ def load_manifest(source_root: Path, validate_sources: bool = True) -> PackManif 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") + reject_project_owned_tree_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) @@ -388,6 +460,12 @@ def load_manifest(source_root: Path, validate_sources: bool = True) -> PackManif migration.legacy_version, migration.legacy_conflicts, *migration.protected_paths, + *(asset.path for asset in migration.retired_assets), + *( + path + for retired_set in migration.retired_path_sets + for path in retired_set.paths + ), }: raise ValueError("manifest.state_file cannot collide with migration paths") @@ -538,6 +616,20 @@ def read_managed_state(target_root: Path, manifest: PackManifest) -> ManagedStat if not isinstance(raw.get("profile"), str) or not raw["profile"]: raise ValueError("Managed state profile must be a non-empty string") + allowed_paths = {asset.path for asset in manifest.assets} + protected_paths: set[str] = set() + retired_hashes: dict[str, set[str]] = {} + if manifest.migration: + protected_paths.update(manifest.migration.protected_paths) + retired_hashes.update( + { + asset.path: set(asset.content_hashes) + for asset in manifest.migration.retired_assets + } + ) + allowed_paths.update(protected_paths) + allowed_paths.update(retired_hashes) + def hash_map(key: str) -> dict[str, str]: value = raw.get(key) if not isinstance(value, dict): @@ -551,6 +643,10 @@ def hash_map(key: str) -> dict[str, str]: raise ValueError("Managed state cannot own itself") if not SHA256_PATTERN.fullmatch(hash_value): raise ValueError(f"Managed state hash is invalid for: {path}") + if path not in allowed_paths: + raise ValueError(f"Managed state contains an unknown pack-owned path: {path}") + if path in retired_hashes and hash_value not in retired_hashes[path]: + raise ValueError(f"Managed state hash is not recognized for retired asset: {path}") result[path] = hash_value return result