From e4e7853e80a304f11f54b7b26efd7a2d7de718cc Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Sat, 13 Jun 2026 09:17:58 +0000 Subject: [PATCH 1/2] feat(structure-lock): reusable workflow + stdlib check script (FIVUCSAS#209) Add an ArchUnit-style FREEZE for repo root layout, shared org-wide: - tools/check_repo_structure.py: stdlib-only checker. Reads a per-repo .repo-structure.yml policy (allowed_root_files/dirs, forbidden_root_patterns regexes, required_files); exits 1 listing every offender on drift. Optional --fix moves forbidden root tracking-docs to docs/archive/ (never auto-run in CI). Parses a small single-quoted YAML subset so no PyYAML dependency. - tools/test_check_repo_structure.py: 12 unittest cases (clean pass, forbidden dated docs, TODO, disallowed file/dir, missing required, .git ignored, --fix scope, policy parse/validation). - .github/workflows/repo-structure.yml: reusable workflow_call. Checks out the caller repo + this .github repo (for the script), runs the check on ubuntu-latest (deliberately NOT the self-hosted runner). Policy input passed via env, never interpolated into the shell line. - .pre-commit-hooks.yaml: exports the `repo-structure` hook (language: script) so any repo can run the same gate locally. - .repo-structure.yml + .github/workflows/structure-check.yml: this repo eats its own dog food (frozen root + self-calling gate). --- .github/workflows/repo-structure.yml | 77 ++++++ .github/workflows/structure-check.yml | 12 + .pre-commit-hooks.yaml | 22 ++ .repo-structure.yml | 32 +++ tools/check_repo_structure.py | 347 ++++++++++++++++++++++++++ tools/test_check_repo_structure.py | 109 ++++++++ 6 files changed, 599 insertions(+) create mode 100644 .github/workflows/repo-structure.yml create mode 100644 .github/workflows/structure-check.yml create mode 100644 .pre-commit-hooks.yaml create mode 100644 .repo-structure.yml create mode 100755 tools/check_repo_structure.py create mode 100644 tools/test_check_repo_structure.py diff --git a/.github/workflows/repo-structure.yml b/.github/workflows/repo-structure.yml new file mode 100644 index 0000000..4bef6d3 --- /dev/null +++ b/.github/workflows/repo-structure.yml @@ -0,0 +1,77 @@ +# Reusable org workflow: repo structure-lock (Rollingcat-Software/FIVUCSAS#209). +# +# An ArchUnit-style FREEZE of a repo's root file/folder layout. Any repo in the +# org adds a thin caller that invokes this on pull_request: +# +# # .github/workflows/structure-check.yml in the consuming repo +# name: structure-check +# on: +# pull_request: +# jobs: +# repo-structure: +# uses: Rollingcat-Software/.github/.github/workflows/repo-structure.yml@main +# +# The job checks out the CALLER repo plus this .github repo (for the check +# script), then runs the stdlib-only checker against the caller's +# .repo-structure.yml policy. It FAILS (red check) on any layout drift. +# +# Make it bite: add the resulting status check (job name "structure-check") as a +# REQUIRED check in each repo's branch protection — see the FIVUCSAS PR body. + +name: repo-structure (reusable) + +on: + workflow_call: + inputs: + policy: + description: >- + Optional explicit policy path relative to the repo root. Defaults to + .repo-structure.yml then .github/repo-structure.yml. + required: false + type: string + default: "" + tools-ref: + description: >- + Ref of Rollingcat-Software/.github to fetch the check script from + (pin to a tag/SHA for reproducibility; defaults to the default branch). + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + structure-check: + name: structure-check + # GitHub-hosted on purpose: this is a tiny stdlib-only Python job, so it + # must NOT depend on the resource-constrained self-hosted Hetzner runner. + runs-on: ubuntu-latest + steps: + - name: Check out the calling repository + uses: actions/checkout@v4 + with: + # Root-only scan: skip submodule content (each submodule runs its own + # structure-check). Submodule dirs still appear as entries to validate. + submodules: false + persist-credentials: false + + - name: Fetch the structure-lock tooling (org .github repo) + uses: actions/checkout@v4 + with: + repository: Rollingcat-Software/.github + ref: ${{ inputs.tools-ref }} + path: .repo-structure-tools + persist-credentials: false + + - name: Run repo structure-lock + # Pass the input through env (never interpolate ${{ }} into the shell + # line) so a malformed policy path can't inject shell commands. + env: + POLICY: ${{ inputs.policy }} + run: | + if [ -n "$POLICY" ]; then + python3 .repo-structure-tools/tools/check_repo_structure.py --root . --policy "$POLICY" + else + python3 .repo-structure-tools/tools/check_repo_structure.py --root . + fi diff --git a/.github/workflows/structure-check.yml b/.github/workflows/structure-check.yml new file mode 100644 index 0000000..396eb32 --- /dev/null +++ b/.github/workflows/structure-check.yml @@ -0,0 +1,12 @@ +# Self-check: the org .github repo enforces its own root layout via the reusable +# structure-lock workflow it ships (Rollingcat-Software/FIVUCSAS#209). +name: structure-check + +on: + pull_request: + push: + branches: [main] + +jobs: + repo-structure: + uses: ./.github/workflows/repo-structure.yml diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..351e78b --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,22 @@ +# Pre-commit hook definitions exported by the org .github repo so any repo can +# run the structure-lock locally (Rollingcat-Software/FIVUCSAS#209): +# +# # .pre-commit-config.yaml in the consuming repo +# - repo: https://github.com/Rollingcat-Software/.github +# rev: main # pin to a tag/SHA for reproducibility +# hooks: +# - id: repo-structure +# +# The hook runs once per commit (not per-file) and scans the repo root against +# that repo's .repo-structure.yml. Stdlib-only — no extra dependencies. +- id: repo-structure + name: repo structure-lock + description: Freeze the repo root layout; block stray/dated tracking docs. + # language: script runs the checked-out script file directly (no venv/install, + # no third-party deps). pre-commit runs it from the consuming repo's root, so + # --root . scans the right tree. + entry: tools/check_repo_structure.py + args: ["--root", "."] + language: script + pass_filenames: false + always_run: true diff --git a/.repo-structure.yml b/.repo-structure.yml new file mode 100644 index 0000000..d783b08 --- /dev/null +++ b/.repo-structure.yml @@ -0,0 +1,32 @@ +# Repo structure-lock policy — org .github repo (Rollingcat-Software/FIVUCSAS#209). +# +# This repo hosts the reusable workflow + check script, so it also eats its own +# dog food: the frozen root below is enforced by structure-check.yml, which calls +# repo-structure.yml against this very repo. See tools/check_repo_structure.py. +# +# To add a NEW root entry, list it here in the SAME PR (the reviewed "unfreeze"). + +allowed_root_files: + - "CODE_OF_CONDUCT.md" + - "CONTRIBUTING.md" + - "README.md" + - "SECURITY.md" + - "SUPPORT.md" + - ".gitignore" + - ".pre-commit-hooks.yaml" + - ".repo-structure.yml" + +allowed_root_dirs: + - ".github" # community-health files, issue templates, reusable workflows + - "profile" # org profile/README.md + - "tools" # check_repo_structure.py + its tests + +# Dated / tracking-doc names that must NEVER reappear at the root (single-quoted +# so backslashes stay literal for the regex engine). Tracking goes in issues. +forbidden_root_patterns: + - '.*_(AUDIT|REVIEW|FINDINGS|REGISTER|TRIAGE|SWEEP|STATUS|SESSION|RECONCILIATION)_.*\.md$' + - '.*_\d{4}-\d{2}-\d{2}.*\.md$' + - '^(TODO|ROADMAP|BACKLOG|OPERATOR_TODO|PERSONAL_TODO).*\.md$' + +required_files: + - "README.md" diff --git a/tools/check_repo_structure.py b/tools/check_repo_structure.py new file mode 100755 index 0000000..94fa544 --- /dev/null +++ b/tools/check_repo_structure.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +"""Repo structure-lock — an ArchUnit-style FREEZE for a repository's file/folder layout. + +Reads a per-repo policy (``.repo-structure.yml`` or ``.github/repo-structure.yml``) +and FAILS (exit 1) when the layout drifts from the frozen baseline: + + * a root entry that is not in the allowlist appears, OR + * any path matches a FORBIDDEN regex pattern, OR + * a REQUIRED file is missing. + +The intent (see Rollingcat-Software/FIVUCSAS#209): dated tracking docs +(``*_AUDIT_*``, ``*_2026-06-13*``, ``TODO.md`` …) belong in GitHub issues, never +at a repo root. This gate makes the convention executable: violations block CI the +same way ArchUnit fails the build. + +Stdlib-only — no PyYAML, no third-party deps. The policy file uses a deliberately +small YAML subset (top-level ``key:`` mappings whose values are block lists of +``- "string"`` items, plus ``# comments``) which this module parses directly. + +Usage: + python3 tools/check_repo_structure.py [--root DIR] [--policy FILE] [--fix] + +Exit codes: + 0 layout matches the policy (clean) + 1 one or more violations + 2 usage / policy error (no policy found, malformed policy, bad args) +""" +from __future__ import annotations + +import argparse +import os +import re +import sys +from dataclasses import dataclass, field + +DEFAULT_POLICY_NAMES = (".repo-structure.yml", ".github/repo-structure.yml") + +# Recognised policy keys. Anything else in the policy file is an error so typos +# (e.g. ``allowed_root_file:``) surface loudly instead of silently doing nothing. +KNOWN_KEYS = ( + "allowed_root_files", + "allowed_root_dirs", + "forbidden_root_patterns", + "required_files", +) + + +@dataclass +class Policy: + allowed_root_files: set[str] = field(default_factory=set) + allowed_root_dirs: set[str] = field(default_factory=set) + forbidden_root_patterns: list[str] = field(default_factory=list) + required_files: list[str] = field(default_factory=list) + + +def _strip_inline_comment(value: str) -> str: + """Drop an unquoted trailing ``# comment`` from a scalar value.""" + out = [] + quote = None + for ch in value: + if quote: + out.append(ch) + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + out.append(ch) + elif ch == "#": + break + else: + out.append(ch) + return "".join(out).strip() + + +def _unquote(value: str) -> str: + """Strip a single layer of surrounding quotes; keep the inner bytes verbatim. + + NOTE: this is intentionally literal — it does NOT process YAML/JSON backslash + escapes. Regex patterns therefore must be written with SINGLE backslashes in + the policy (``\\d``, ``\\.``) and are best wrapped in single quotes, e.g. + ``- '.*_\\d{4}-\\d{2}-\\d{2}.*\\.md$'``. (Double-quoting and writing ``\\\\d`` + would store a literal backslash and break the pattern.) + """ + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + return value[1:-1] + return value + + +def parse_policy(text: str, source: str) -> Policy: + """Parse the small YAML subset used by ``.repo-structure.yml``. + + Supported shape (only): + + key: [list item, ...] # inline flow list, or + key: + - "item" # block list, one item per line + - item + + Scalars may be single/double quoted. ``#`` starts a comment. Indentation of + list items must be deeper than their key. Anything outside this grammar is a + policy error (exit 2) rather than a silent pass. + """ + policy = Policy() + current_key: str | None = None + + lines = text.splitlines() + for lineno, raw in enumerate(lines, start=1): + # Blank or comment-only line. + if not raw.strip() or raw.lstrip().startswith("#"): + continue + + indent = len(raw) - len(raw.lstrip(" ")) + stripped = raw.strip() + + if stripped.startswith("- "): + if current_key is None: + raise ValueError( + f"{source}:{lineno}: list item with no preceding key: {raw!r}" + ) + item = _unquote(_strip_inline_comment(stripped[2:])) + if item: + _append(policy, current_key, item, source, lineno) + continue + + # Otherwise it must be a ``key:`` mapping line at column 0. + if indent != 0: + raise ValueError(f"{source}:{lineno}: unexpected indentation: {raw!r}") + if ":" not in stripped: + raise ValueError(f"{source}:{lineno}: expected 'key:' mapping: {raw!r}") + + key, _, rest = stripped.partition(":") + key = key.strip() + if key not in KNOWN_KEYS: + raise ValueError( + f"{source}:{lineno}: unknown policy key {key!r} " + f"(expected one of {', '.join(KNOWN_KEYS)})" + ) + current_key = key + rest = _strip_inline_comment(rest) + if rest: + # Inline flow list: key: [a, b] or a bare scalar (rejected). + if rest.startswith("[") and rest.endswith("]"): + for piece in rest[1:-1].split(","): + item = _unquote(piece.strip()) + if item: + _append(policy, key, item, source, lineno) + else: + raise ValueError( + f"{source}:{lineno}: value for {key!r} must be a list " + f"(use a block list or [a, b]); got {rest!r}" + ) + + return policy + + +def _append(policy: Policy, key: str, item: str, source: str, lineno: int) -> None: + if key == "allowed_root_files": + policy.allowed_root_files.add(item) + elif key == "allowed_root_dirs": + policy.allowed_root_dirs.add(item) + elif key == "forbidden_root_patterns": + try: + re.compile(item) + except re.error as exc: + raise ValueError( + f"{source}:{lineno}: invalid regex in forbidden_root_patterns: " + f"{item!r} ({exc})" + ) from exc + policy.forbidden_root_patterns.append(item) + elif key == "required_files": + policy.required_files.append(item) + + +def find_policy(root: str, explicit: str | None) -> str: + if explicit: + path = explicit if os.path.isabs(explicit) else os.path.join(root, explicit) + if not os.path.isfile(path): + raise FileNotFoundError(f"policy file not found: {explicit}") + return path + for name in DEFAULT_POLICY_NAMES: + candidate = os.path.join(root, name) + if os.path.isfile(candidate): + return candidate + raise FileNotFoundError( + "no policy file found (looked for " + + " and ".join(DEFAULT_POLICY_NAMES) + + "). Add one frozen from the current clean root." + ) + + +def list_root_entries(root: str) -> list[tuple[str, bool]]: + """Return ``(name, is_dir)`` for each top-level entry, excluding ``.git``.""" + entries = [] + for name in sorted(os.listdir(root)): + if name == ".git": + continue + is_dir = os.path.isdir(os.path.join(root, name)) + entries.append((name, is_dir)) + return entries + + +def check(root: str, policy: Policy) -> list[str]: + """Return a list of human-readable violation strings (empty == clean).""" + violations: list[str] = [] + compiled = [(p, re.compile(p)) for p in policy.forbidden_root_patterns] + + for name, is_dir in list_root_entries(root): + # Forbidden patterns take precedence and apply to files and dirs alike. + matched_forbidden = False + for pattern, rx in compiled: + if rx.search(name): + violations.append( + f"FORBIDDEN: root entry {name!r} matches forbidden pattern " + f"/{pattern}/ — tracking docs belong in GitHub issues, not the repo root" + ) + matched_forbidden = True + break + if matched_forbidden: + continue + + if is_dir: + if name not in policy.allowed_root_dirs: + violations.append( + f"DISALLOWED DIR: {name!r} is not in allowed_root_dirs — " + f"if intentional, add it to .repo-structure.yml" + ) + else: + if name not in policy.allowed_root_files: + violations.append( + f"DISALLOWED FILE: {name!r} is not in allowed_root_files — " + f"if intentional, add it to .repo-structure.yml" + ) + + for required in policy.required_files: + if not os.path.isfile(os.path.join(root, required)): + violations.append(f"MISSING REQUIRED FILE: {required!r}") + + return violations + + +def suggest_fixes(root: str, policy: Policy) -> list[tuple[str, str]]: + """Return ``(src, dst)`` moves for forbidden root files into docs/archive/. + + Only suggests moves for *files* matching a forbidden pattern (never dirs, + never disallowed-but-not-forbidden entries — those need a human decision). + """ + moves: list[tuple[str, str]] = [] + compiled = [re.compile(p) for p in policy.forbidden_root_patterns] + for name, is_dir in list_root_entries(root): + if is_dir: + continue + if any(rx.search(name) for rx in compiled): + moves.append( + ( + os.path.join(root, name), + os.path.join(root, "docs", "archive", name), + ) + ) + return moves + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Freeze and enforce a repo's root file/folder layout (FIVUCSAS#209)." + ) + parser.add_argument( + "--root", default=".", help="repository root to scan (default: cwd)" + ) + parser.add_argument( + "--policy", + default=None, + help="policy file path (default: .repo-structure.yml or .github/repo-structure.yml)", + ) + parser.add_argument( + "--fix", + action="store_true", + help="MOVE forbidden root tracking-docs into docs/archive/ " + "(convenience helper — never run automatically in CI)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="with --fix, only print the moves that would be made", + ) + args = parser.parse_args(argv) + + root = os.path.abspath(args.root) + if not os.path.isdir(root): + print(f"error: --root is not a directory: {root}", file=sys.stderr) + return 2 + + try: + policy_path = find_policy(root, args.policy) + with open(policy_path, "r", encoding="utf-8") as handle: + policy = parse_policy(handle.read(), os.path.relpath(policy_path, root)) + except (FileNotFoundError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + if args.fix: + moves = suggest_fixes(root, policy) + if not moves: + print("no forbidden root files to move.") + return 0 + for src, dst in moves: + if args.dry_run: + print(f"would move: {os.path.relpath(src, root)} -> {os.path.relpath(dst, root)}") + else: + os.makedirs(os.path.dirname(dst), exist_ok=True) + os.replace(src, dst) + print(f"moved: {os.path.relpath(src, root)} -> {os.path.relpath(dst, root)}") + print( + "\n--fix is a convenience helper; review the moves, then re-run the " + "check to confirm the gate passes." + ) + return 0 + + violations = check(root, policy) + if violations: + print( + f"Repo structure-lock FAILED for {root} " + f"({len(violations)} violation(s)):\n", + file=sys.stderr, + ) + for item in violations: + print(f" - {item}", file=sys.stderr) + print( + "\nThe layout drifted from the frozen baseline in " + f"{os.path.relpath(policy_path, root)}.\n" + "Dated tracking docs (audits/reviews/sessions/TODOs) go in GitHub " + "issues, not the repo root.\n" + "To intentionally allow a NEW root entry, add it to the policy in the " + "same PR (that is the explicit, reviewed unfreeze).", + file=sys.stderr, + ) + return 1 + + print( + f"Repo structure-lock PASSED — root layout matches " + f"{os.path.relpath(policy_path, root)}." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_check_repo_structure.py b/tools/test_check_repo_structure.py new file mode 100644 index 0000000..5aca2d1 --- /dev/null +++ b/tools/test_check_repo_structure.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Stdlib-only tests for check_repo_structure.py (run: python3 -m unittest).""" +import os +import tempfile +import unittest + +import check_repo_structure as c + +POLICY = """\ +allowed_root_files: + - "README.md" + - "LICENSE" + - ".gitignore" +allowed_root_dirs: + - "src" + - ".github" +forbidden_root_patterns: + - '.*_(AUDIT|REVIEW|SESSION)_.*\\.md$' + - '.*_\\d{4}-\\d{2}-\\d{2}.*\\.md$' + - '^(TODO|ROADMAP|BACKLOG).*\\.md$' +required_files: + - "README.md" + - "LICENSE" +""" + + +def make_repo(entries): + """entries: dict name -> True(dir) | str(file contents).""" + root = tempfile.mkdtemp() + for name, val in entries.items(): + path = os.path.join(root, name) + if val is True: + os.makedirs(path) + else: + with open(path, "w", encoding="utf-8") as handle: + handle.write(val if isinstance(val, str) else "") + return root + + +class ParsePolicyTests(unittest.TestCase): + def test_parses_lists_and_keeps_single_backslash_regex(self): + p = c.parse_policy(POLICY, "test") + self.assertIn("README.md", p.allowed_root_files) + self.assertIn("src", p.allowed_root_dirs) + self.assertEqual(p.required_files, ["README.md", "LICENSE"]) + # backslash must be literal-single so \d means digit-class + self.assertIn(r".*_\d{4}-\d{2}-\d{2}.*\.md$", p.forbidden_root_patterns) + + def test_unknown_key_is_error(self): + with self.assertRaises(ValueError): + c.parse_policy("allowed_root_file:\n - x\n", "test") + + def test_bad_regex_is_error(self): + with self.assertRaises(ValueError): + c.parse_policy("forbidden_root_patterns:\n - '([unclosed'\n", "test") + + def test_inline_flow_list(self): + p = c.parse_policy('allowed_root_files: ["a", "b"]\n', "test") + self.assertEqual(p.allowed_root_files, {"a", "b"}) + + +class CheckTests(unittest.TestCase): + def setUp(self): + self.policy = c.parse_policy(POLICY, "test") + + def test_clean_root_passes(self): + root = make_repo({"README.md": "", "LICENSE": "", ".gitignore": "", "src": True}) + self.assertEqual(c.check(root, self.policy), []) + + def test_forbidden_dated_doc_fails(self): + root = make_repo({"README.md": "", "LICENSE": "", "X_AUDIT_2026-06-13.md": ""}) + viol = c.check(root, self.policy) + self.assertTrue(any("FORBIDDEN" in v for v in viol)) + + def test_todo_fails(self): + root = make_repo({"README.md": "", "LICENSE": "", "TODO.md": ""}) + viol = c.check(root, self.policy) + self.assertTrue(any("FORBIDDEN" in v and "TODO" in v for v in viol)) + + def test_disallowed_file_fails(self): + root = make_repo({"README.md": "", "LICENSE": "", "stray.txt": ""}) + viol = c.check(root, self.policy) + self.assertTrue(any("DISALLOWED FILE" in v for v in viol)) + + def test_disallowed_dir_fails(self): + root = make_repo({"README.md": "", "LICENSE": "", "weird": True}) + viol = c.check(root, self.policy) + self.assertTrue(any("DISALLOWED DIR" in v for v in viol)) + + def test_missing_required_fails(self): + root = make_repo({"README.md": "", ".gitignore": ""}) # no LICENSE + viol = c.check(root, self.policy) + self.assertTrue(any("MISSING REQUIRED" in v for v in viol)) + + def test_git_dir_ignored(self): + root = make_repo({"README.md": "", "LICENSE": "", ".git": True}) + self.assertEqual(c.check(root, self.policy), []) + + def test_fix_only_moves_forbidden_files_not_dirs(self): + root = make_repo( + {"README.md": "", "LICENSE": "", "TODO.md": "", "weird": True, "stray.txt": ""} + ) + moves = c.suggest_fixes(root, self.policy) + names = [os.path.basename(s) for s, _ in moves] + self.assertEqual(names, ["TODO.md"]) # not weird/ (dir), not stray.txt (not forbidden) + + +if __name__ == "__main__": + unittest.main() From dd68fa8b608b4b90cce0df857680ff7e9275b913 Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Sat, 13 Jun 2026 09:20:39 +0000 Subject: [PATCH 2/2] fix(structure-lock): ignore the .repo-structure-tools checkout dir The reusable workflow checks this tooling repo out into .repo-structure-tools/ inside the caller's workspace, so the scanner saw it as a root entry and failed the .github repo's own self-check with "DISALLOWED DIR: .repo-structure-tools" (caught by the live PR run). Always-ignore .git and .repo-structure-tools at the root. Adds a test for the tooling-checkout case. --- tools/check_repo_structure.py | 11 +++++++++-- tools/test_check_repo_structure.py | 7 +++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tools/check_repo_structure.py b/tools/check_repo_structure.py index 94fa544..a48376a 100755 --- a/tools/check_repo_structure.py +++ b/tools/check_repo_structure.py @@ -35,6 +35,13 @@ DEFAULT_POLICY_NAMES = (".repo-structure.yml", ".github/repo-structure.yml") +# Root entries that are never part of a repo's layout and must always be ignored: +# .git — the git dir +# .repo-structure-tools — where the reusable workflow checks out THIS tooling +# repo into the scanned workspace (else the gate would +# flag its own checkout as a disallowed dir). +ALWAYS_IGNORED = frozenset({".git", ".repo-structure-tools"}) + # Recognised policy keys. Anything else in the policy file is an error so typos # (e.g. ``allowed_root_file:``) surface loudly instead of silently doing nothing. KNOWN_KEYS = ( @@ -190,10 +197,10 @@ def find_policy(root: str, explicit: str | None) -> str: def list_root_entries(root: str) -> list[tuple[str, bool]]: - """Return ``(name, is_dir)`` for each top-level entry, excluding ``.git``.""" + """Return ``(name, is_dir)`` for each top-level entry, minus ALWAYS_IGNORED.""" entries = [] for name in sorted(os.listdir(root)): - if name == ".git": + if name in ALWAYS_IGNORED: continue is_dir = os.path.isdir(os.path.join(root, name)) entries.append((name, is_dir)) diff --git a/tools/test_check_repo_structure.py b/tools/test_check_repo_structure.py index 5aca2d1..61ba3c4 100644 --- a/tools/test_check_repo_structure.py +++ b/tools/test_check_repo_structure.py @@ -96,6 +96,13 @@ def test_git_dir_ignored(self): root = make_repo({"README.md": "", "LICENSE": "", ".git": True}) self.assertEqual(c.check(root, self.policy), []) + def test_tooling_checkout_dir_ignored(self): + # The reusable workflow checks the tools repo out here; never flag it. + root = make_repo( + {"README.md": "", "LICENSE": "", ".repo-structure-tools": True} + ) + self.assertEqual(c.check(root, self.policy), []) + def test_fix_only_moves_forbidden_files_not_dirs(self): root = make_repo( {"README.md": "", "LICENSE": "", "TODO.md": "", "weird": True, "stray.txt": ""}