Skip to content
Merged
8 changes: 7 additions & 1 deletion src/autoskillit/cli/doctor/_doctor_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,20 @@ def _collect_uncovered_gitignore_entries(
autoskillit_dir: Path,
gitignore_content: str,
) -> list[str]:
from autoskillit.core import _AUTOSKILLIT_GITIGNORE_ENTRIES, _COMMITTED_BY_DESIGN
from autoskillit.core import (
_AUTOSKILLIT_GITIGNORE_ENTRIES,
_COMMITTED_BY_DESIGN,
is_self_ignored,
)

uncovered: list[str] = []
for item in sorted(autoskillit_dir.iterdir()):
if item.name == ".gitignore":
continue
if item.name in _COMMITTED_BY_DESIGN:
continue
if item.is_dir() and is_self_ignored(item):
continue
check_name = item.name + "/" if item.is_dir() else item.name
if check_name not in gitignore_content:
uncovered.append(item.name)
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/core/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ from .io import destination_location as destination_location
from .io import directory_tree_digest as directory_tree_digest
from .io import dump_yaml_str as dump_yaml_str
from .io import ensure_project_temp as ensure_project_temp
from .io import ensure_self_ignored as ensure_self_ignored
from .io import extract_section as extract_section
from .io import fast_dumps as fast_dumps
from .io import fast_loads as fast_loads
Expand All @@ -156,6 +157,7 @@ from .io import is_git_main_checkout as is_git_main_checkout
from .io import is_git_worktree as is_git_worktree
from .io import is_in_git_repo as is_in_git_repo
from .io import is_python_bytecode_path as is_python_bytecode_path
from .io import is_self_ignored as is_self_ignored
from .io import is_yaml_mapping_node as is_yaml_mapping_node
from .io import load_yaml as load_yaml
from .io import main_checkout_root as main_checkout_root
Expand Down
4 changes: 4 additions & 0 deletions src/autoskillit/core/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
decode_versioned_json_bytes,
directory_tree_digest,
ensure_project_temp,
ensure_self_ignored,
is_python_bytecode_path,
read_versioned_json,
resolve_skill_temp_dir,
Expand Down Expand Up @@ -81,6 +82,7 @@
is_git_main_checkout,
is_git_worktree,
is_in_git_repo,
is_self_ignored,
main_checkout_root,
parse_worktree_porcelain,
pkg_root,
Expand Down Expand Up @@ -130,6 +132,7 @@
"directory_tree_digest",
"dump_yaml_str",
"ensure_project_temp",
"ensure_self_ignored",
"extract_section",
"fast_dumps",
"fast_loads",
Expand All @@ -140,6 +143,7 @@
"is_git_worktree",
"is_in_git_repo",
"is_python_bytecode_path",
"is_self_ignored",
"is_yaml_mapping_node",
"load_yaml",
"main_checkout_root",
Expand Down
22 changes: 14 additions & 8 deletions src/autoskillit/core/io/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def __init__(self, path: Path, cause: OSError) -> None:
"atomic_write",
"decode_versioned_json_bytes",
"ensure_project_temp",
"ensure_self_ignored",
"is_python_bytecode_path",
"read_versioned_json",
"resolve_skill_temp_dir",
Expand Down Expand Up @@ -718,12 +719,24 @@ def _reset_schema_drift_logged_for_tests() -> None:
_COMMITTED_BY_DESIGN: frozenset[str] = frozenset(
{
"config.yaml",
"evidence",
"experiment-types",
"methodology-traditions",
"recipes",
"skills",
"test-source-map.json",
"waivers",
}
)


def ensure_self_ignored(directory: Path) -> None:
"""Write a ``*`` marker if absent; preserve an existing marker."""
marker = directory / ".gitignore"
if not marker.exists():
atomic_write(marker, "# Created by autoskillit automatically.\n*\n")


def ensure_project_temp(project_dir: Path, override: str | None = None) -> Path:
"""Create the autoskillit temp directory with a self-gitignore; idempotent.

Expand All @@ -739,14 +752,7 @@ def ensure_project_temp(project_dir: Path, override: str | None = None) -> Path:
"""
temp_dir = resolve_temp_dir(project_dir, override)
temp_dir.mkdir(parents=True, exist_ok=True)
# Race-safe ordering: .gitignore is the FIRST file written after mkdir,
# before any session content lands. See pytest #12167 / mypy #12442.
gitignore_path = temp_dir / ".gitignore"
if not gitignore_path.exists():
atomic_write(
gitignore_path,
"# Created by autoskillit automatically.\n*\n",
)
ensure_self_ignored(temp_dir)
autoskillit_dir = project_dir / ".autoskillit"
if autoskillit_dir.is_dir():
autoskillit_gitignore = autoskillit_dir / ".gitignore"
Expand Down
8 changes: 8 additions & 0 deletions src/autoskillit/core/io/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,14 @@ def is_generated_path(file_path: str) -> bool:
return False


def is_self_ignored(directory: Path) -> bool:
"""Recognize a regular, non-symlink marker with a standalone ``*`` rule."""
marker = directory / ".gitignore"
if marker.is_symlink() or not marker.is_file():
return False
return "*" in marker.read_text(encoding="utf-8", errors="replace").splitlines()


def _coerce_str_or_path(name: str, value: object) -> str | Path:
"""Reject non-str/Path inputs with a uniform error message.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
"""Crash-durable managed headless session lineage persistence."""
"""Crash-durable managed headless session lineage persistence.

Records live in <anchor>/.autoskillit/managed-headless-session-lineage/, a
self-ignoring runtime directory at the .autoskillit root. Records are durable
across resume; keeping the namespace here repairs existing stores in place.
"""

from __future__ import annotations

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
NativeShellCaptureDecision,
acquire_flock_with_timeout,
atomic_write,
ensure_self_ignored,
is_self_ignored,
)
from autoskillit.execution.session._managed_headless_session_lineage import (
_DISPATCH_INDEX,
Expand Down Expand Up @@ -137,6 +139,9 @@ def _prepare_root(anchor: Path) -> Path:
current.mkdir(mode=0o700, exist_ok=True)
if current.is_symlink() or not current.is_dir():
raise ValueError("Managed lineage namespace is not a regular directory")
ensure_self_ignored(current)
if not is_self_ignored(current):
raise OSError("Lineage namespace requires a self-ignoring .gitignore")
for relative in (
Path(_RECORDS_DIR),
Path(_INDEXES_DIR) / _FINAL_NATIVE_INDEX,
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/skill_resources/arch-constraint-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ classified `REJECT` with `category: "arch_violation"`.
| Canonical maintenance argv | `test_maintenance_install_argv_contract.py` | Hand-built list or tuple argv containing `--maintenance-update` outside `MaintenanceInstallArgv.to_argv()` |
| step_name in run_cmd | `test_anti_pattern_guards.py` | Missing `step_name` in `run_cmd` `with:` blocks in recipe YAML |
| No hardcoded temp paths | `test_python_no_hardcoded_temp.py` | Hardcoded temp-root path strings in Python outside whitelist |
| AutoSkillit root children | `test_autoskillit_root_children_registered.py` | Naming an unclassified `.autoskillit/` child in production Python; runtime directories need a self-ignore marker and a real-Git proof |
| run_python path resolution | `test_run_python_path_resolution.py` | smoke_utils callable with relative-path fallback or path-like param not in `_PATH_LIKE_ARGS` |
| SkillResult kill_reason | `test_skill_result_construction_guard.py` | `SkillResult()` without `kill_reason=` kwarg |
| Never-raises contracts | `test_never_raises_contracts.py` | `mcp.tool()` handlers without top-level `try/except` and "Never raises" docstring |
Expand Down
4 changes: 3 additions & 1 deletion tests/_test_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ class SourceMapRejection(enum.StrEnum):
"tests/arch/_rules.py": _ARCH_HELPER_TEST_TARGETS,
"tests/fleet/_reaper_test_support.py": frozenset({"fleet"}),
"tests/hooks/_interactive_guard_harness.py": frozenset({"hooks", "skills"}),
"tests/_git_topology.py": frozenset({"core", "execution", "server", "integration"}),
"tests/_git_topology.py": frozenset(
{"core", "execution", "server", "integration", "workspace", "fleet"}
),
"tests/fleet/_codex_mcp_env.py": frozenset({"fleet", "integration"}),
"tests/fleet/_descendant_worker.py": frozenset({"fleet"}),
}
Expand Down
133 changes: 133 additions & 0 deletions tests/arch/test_autoskillit_root_children_registered.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Architectural invariant: every source-named .autoskillit child is classified."""

from __future__ import annotations

import ast
import re
from collections.abc import Iterator
from pathlib import Path

import pytest

pytestmark = [pytest.mark.layer("arch"), pytest.mark.small]

_STR_RX = re.compile(r"(?:^|[/\"'`\s(~])\.autoskillit/([A-Za-z0-9._-]+)")

# These names are accounted for elsewhere: home-level artifacts, a read-only
# legacy path, a runtime directory with a real-git proof, and a tracked gap.
_CLASSIFIED_ELSEWHERE: set[str] = {
# Home-level hook state: hooks/guards/mcp_health_advisor.py:36.
"active_kitchens.json",
# Home-level marketplace shape: core/types/constants/_type_constants_retirements.py:309.
"marketplace",
# Home-level plugin storage: cli/doctor/_doctor_install.py:355.
"plugin-generations",
# Home-level hook projection repair: workspace/_projected_artifact/_hook_repair.py:538.
"plugin-projections",
# Home-level dismissal state: cli/update/_update_checks.py:79.
"update_check.json",
# Legacy path is read only for migration: cli/install/_marketplace.py:162.
"scripts",
# Runtime self-ignore is proven by tests/workspace/test_clone_gate_runtime_state.py.
"managed-headless-session-lineage",
# Known gap: init writes this without a keep-out rule; tracked by #5258
# recommendation 4.
".state.yaml",
}


def _str_const(node: ast.AST) -> str | None:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
return None


def _assignment_targets(node: ast.AST) -> Iterator[ast.expr]:
if isinstance(node, ast.Assign):
yield from node.targets
elif isinstance(node, ast.AnnAssign) and node.value is not None:
yield node.target


def _is_root(node: ast.AST, roots: frozenset[str]) -> bool:
if _str_const(node) == ".autoskillit":
return True
if isinstance(node, ast.Name):
return node.id in roots
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div):
return _str_const(node.right) == ".autoskillit"
if isinstance(node, ast.Call) and node.args:
return _str_const(node.args[-1]) == ".autoskillit"
return False


def _children_named_in(tree: ast.Module) -> set[str]:
"""Collect children from literal paths and simple syntactic path expressions.

Recognize direct root assignments, slash joins, and adjacent literal
arguments or sequence elements. Root names are matched across the module
without scope analysis; dynamic strings and indirect aliases are not traced.
"""
roots = {
target.id
for node in ast.walk(tree)
for target in _assignment_targets(node)
if isinstance(node, (ast.Assign, ast.AnnAssign))
and node.value is not None
and isinstance(target, ast.Name)
and _is_root(node.value, frozenset())
}
names: set[str] = set()
for node in ast.walk(tree):
text = _str_const(node)
if text is not None:
names.update(match.group(1) for match in _STR_RX.finditer(text))
if (
isinstance(node, ast.BinOp)
and isinstance(node.op, ast.Div)
and _is_root(node.left, roots)
):
right = _str_const(node.right)
if right:
names.add(right.split("/")[0])
seq = (
node.elts
if isinstance(node, (ast.Tuple, ast.List))
else node.args
if isinstance(node, ast.Call)
else ()
)
for first, second in zip(seq, seq[1:]):
if _str_const(first) == ".autoskillit" and _str_const(second):
names.add(_str_const(second).split("/")[0])
return {name.rstrip(".") for name in names} - {""}


def _src_root() -> Path:
return Path(__file__).resolve().parent.parent.parent / "src" / "autoskillit"


def test_every_autoskillit_child_named_in_src_is_classified() -> None:
from autoskillit.core.io import _AUTOSKILLIT_GITIGNORE_ENTRIES, _COMMITTED_BY_DESIGN

src = _src_root()
assert src.is_dir(), f"src/autoskillit not found at {src}"

named = {
child
for path in src.rglob("*.py")
for child in _children_named_in(ast.parse(path.read_text(encoding="utf-8")))
}
ignored_names = {entry.rstrip("/") for entry in _AUTOSKILLIT_GITIGNORE_ENTRIES}
unclassified = sorted(
named - set(_COMMITTED_BY_DESIGN) - ignored_names - {".gitignore"} - _CLASSIFIED_ELSEWHERE
)

assert not unclassified, (
f"Unclassified .autoskillit children named in src/autoskillit: {unclassified}. "
f"Commit a persistent child by adding it to _COMMITTED_BY_DESIGN; list a file "
f"in _AUTOSKILLIT_GITIGNORE_ENTRIES only when a setup command writes it, since "
f"init and ensure_project_temp maintain the tracked .gitignore. For a runtime "
f"directory, call ensure_self_ignored when creating it, prove the behavior with "
f"a real-git test, and name that test in an adjacent justification comment."
)
2 changes: 2 additions & 0 deletions tests/arch/test_layer_enforcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -2002,6 +2002,8 @@ def test_default_classes_only_instantiated_inside_factory_or_allowlist() -> None
"tests/execution/test_process_tether.py": frozenset({"autoskillit.config"}),
# workspace tests
"tests/workspace/test_clone_ci_contract.py": frozenset({"autoskillit.execution"}),
# Clone cleanliness is exercised against the real execution-layer lineage writer.
"tests/workspace/test_clone_gate_runtime_state.py": frozenset({"autoskillit.execution"}),
# the frontmatter write scope round-trips through the stdlib-only write-scope
# authority and the hook-side manifest decoder it feeds
"tests/workspace/test_skill_write_boundary_contract.py": frozenset({"autoskillit.hooks"}),
Expand Down
25 changes: 25 additions & 0 deletions tests/cli/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,31 @@ def test_doctor_gitignore_ok_when_all_covered(
assert result.severity == Severity.OK


def test_doctor_gitignore_exempts_only_self_ignoring_directories(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from autoskillit.core.io import _AUTOSKILLIT_GITIGNORE_ENTRIES

autoskillit_dir = tmp_path / ".autoskillit"
autoskillit_dir.mkdir()
(autoskillit_dir / ".gitignore").write_text(
"\n".join(_AUTOSKILLIT_GITIGNORE_ENTRIES) + "\n", encoding="utf-8"
)
runtime_dir = autoskillit_dir / "runtime-a"
runtime_dir.mkdir()
(runtime_dir / ".gitignore").write_text("*\n", encoding="utf-8")
(autoskillit_dir / "runtime-b").mkdir()

monkeypatch.chdir(tmp_path)
from autoskillit.cli.doctor import _check_gitignore_completeness
from autoskillit.core import Severity

result = _check_gitignore_completeness(tmp_path)
assert result.severity == Severity.WARNING
assert "runtime-b" in result.message
assert "runtime-a" not in result.message


# SS-DOC-1
def test_doctor_includes_secret_scanning_hook_check(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
Expand Down
11 changes: 6 additions & 5 deletions tests/cli/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,11 @@ def test_init_all_created_files_covered_by_gitignore(tmp_path: Path) -> None:
be in the committed allowlist.
"""
from autoskillit.cli._init_helpers import _create_secrets_template
from autoskillit.core.io import _COMMITTED_BY_DESIGN, ensure_project_temp
from autoskillit.core.io import (
_COMMITTED_BY_DESIGN,
ensure_project_temp,
is_self_ignored,
)

ensure_project_temp(tmp_path)
_create_secrets_template(tmp_path)
Expand All @@ -552,10 +556,7 @@ def test_init_all_created_files_covered_by_gitignore(tmp_path: Path) -> None:
continue
if item.is_dir() and (item / ".gitignore").exists():
# Self-gitignoring directory — its own .gitignore covers its contents.
self_ignore = (item / ".gitignore").read_text()
assert "*" in self_ignore, (
f"{item.name}/ is a directory without a self-gitignore covering '*'"
)
assert is_self_ignored(item)
continue
check_name = item.name + "/" if item.is_dir() else item.name
assert check_name in gitignore_content, (
Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,13 @@ def exploration_snapshot_service() -> MagicMock:
return service


@pytest.fixture
def isolated_git_config(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep real-Git visibility tests independent of user and system config."""
monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull)
monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")


@pytest.fixture
def remote_only_base_clone(tmp_path: _Path, monkeypatch: pytest.MonkeyPatch) -> tuple[_Path, str]:
"""Create an isolated ``feat`` clone with only remote ``develop`` history."""
Expand Down
Loading
Loading