From d8eda451e38b5281f212502e95cb7c5a4a03b964 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 30 Oct 2026 22:09:11 -0700 Subject: [PATCH 01/36] feat(repo): dokimi_config.v1 schema and repo_config loader Adds the repository config (.github/dokimi/config.yaml, or .dokimi/config.yaml, or an explicit path) that declares subjects (skills, or the repository itself), where each subject's evals live, plan defaults, shared paths and the budget and baseline store. dokimi.repo_config discovers and validates it, resolves subjects and suites, computes skill_sha and suite_hash, and maps changed files to subjects, suites, the shared list, or nothing. tree_digest moves from dokimi.commands.repo_run into dokimi.utils, so the build context and the repo config share one tree hash. Its output for a directory without exclusions is unchanged. Part of #318. Library only: no CLI reads the config yet. Signed-off-by: Nikolay Petrov --- ...261030_180000_napetrov_feat-repo-config.md | 16 + dokimi/docs/reference/cli-interface.json | 1 + dokimi/dokimi/artifacts.py | 3 +- dokimi/dokimi/commands/repo_run.py | 23 +- dokimi/dokimi/repo_config.py | 311 ++++++++++++++++ dokimi/dokimi/runs/kinds.py | 1 + dokimi/dokimi/schemas/dokimi_config.v1.json | 84 +++++ dokimi/dokimi/utils.py | 27 +- .../fixtures/artifacts/dokimi_config.v1.json | 13 + dokimi/tests/test_import_architecture.py | 2 +- dokimi/tests/test_repo_config.py | 337 ++++++++++++++++++ dokimi/tests/test_runs_scan.py | 1 + dokimi/tests/test_runs_show.py | 1 + dokimi/tests/test_tree_digest.py | 69 ++++ 14 files changed, 866 insertions(+), 23 deletions(-) create mode 100644 dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md create mode 100644 dokimi/dokimi/repo_config.py create mode 100644 dokimi/dokimi/schemas/dokimi_config.v1.json create mode 100644 dokimi/tests/fixtures/artifacts/dokimi_config.v1.json create mode 100644 dokimi/tests/test_repo_config.py create mode 100644 dokimi/tests/test_tree_digest.py diff --git a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md new file mode 100644 index 000000000..769e3a5ce --- /dev/null +++ b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md @@ -0,0 +1,16 @@ + + +### Added + +- **`dokimi_config.v1`, the repository config schema and loader (library only, no CLI yet)** — a + repository's `.github/dokimi/config.yaml` (or `.dokimi/config.yaml`) declares its subjects (skills, and + the repository itself with `kind: repo`), where each subject's evals are (beside the skill by default, + or a central glob such as `evaluation/harbor/tasks/onetbb-*`), the plan defaults, `shared` paths for + the smoke set, and the budget and baseline store. The loader `dokimi.repo_config` finds the file in + the order explicit path, `.github/dokimi/config.yaml`, `.dokimi/config.yaml` under the repository root, + validates it against the schema, and reports each subject's `skill_sha` (the tree digest of the skill + directory without its evals) and `suite_hash`. Nothing reads the file yet: the commands that use it + come later, and existing `.agent-eval/` lookups are unchanged. diff --git a/dokimi/docs/reference/cli-interface.json b/dokimi/docs/reference/cli-interface.json index c9eb437d1..c54471aa8 100644 --- a/dokimi/docs/reference/cli-interface.json +++ b/dokimi/docs/reference/cli-interface.json @@ -12884,6 +12884,7 @@ "'answers'", "'arms'", "'cli_routes'", + "'dokimi_config'", "'eval'", "'explore_runs'", "'guidance_reach'", diff --git a/dokimi/dokimi/artifacts.py b/dokimi/dokimi/artifacts.py index 0088ff5eb..b71f3fe6c 100644 --- a/dokimi/dokimi/artifacts.py +++ b/dokimi/dokimi/artifacts.py @@ -13,7 +13,7 @@ repository offers an agent), ``plan`` (the cells a run promises to measure), ``result`` (one status per planned cell), ``repo_budget`` (what a repository puts into an agent's context) and ``task_inspect`` (which eval-task format each -path under a tree is). This module stamps each with a +path under a tree is) and ``dokimi_config`` (a repository's subjects and where their evals are). This module stamps each with a ``schema_version`` and validates it against the JSON Schema in ``dokimi/schemas/`` on save and load, so fixtures and baselines stay forward-compatible and self-describing. @@ -58,6 +58,7 @@ "repo_budget": "repo_budget.v1", "task_inspect": "task_inspect.v1", "cli_routes": "cli_routes.v1", + "dokimi_config": "dokimi_config.v1", } # kind -> superseded versions still accepted on *load*, newest first. An artifact written under an diff --git a/dokimi/dokimi/commands/repo_run.py b/dokimi/dokimi/commands/repo_run.py index 11dd70cda..9b64a7387 100644 --- a/dokimi/dokimi/commands/repo_run.py +++ b/dokimi/dokimi/commands/repo_run.py @@ -21,6 +21,7 @@ from dokimi.repos.harness_profile import DELEGATION_MODES from dokimi.exit_codes import EXIT_USAGE +from dokimi.utils import tree_digest def _context(args: argparse.Namespace, out: Path, pin: bool = False): @@ -60,14 +61,14 @@ def _context(args: argparse.Namespace, out: Path, pin: bool = False): sha = getattr(tree, "sha", None) if sha is None: # a working tree: tag by what is built, or an edited Dockerfile reuses a stale image ctx_dir = export_build_context(tree, manifest, out / "_eval" / "build-context") - tag = "worktree-" + context_digest(ctx_dir)[:12] + tag = "worktree-" + tree_digest(ctx_dir)[:12] else: tag = sha[:12] image = f"dokimi/{slug(manifest.id).lower()}:{tag}" elif not runner.image_exists(image): raise ValueError(f"--image {image} does not exist") if pin: - stamp = {"tree": tree.label, "eval_dir": context_digest(staged), "image": image} + stamp = {"tree": tree.label, "eval_dir": tree_digest(staged), "image": image} if _uses_builtin_graders(staged, manifest): # else no score depends on them: an upgrade keeps the batch stamp["graders"] = graders_digest() _pin_batch(out, stamp) @@ -96,24 +97,6 @@ def _context(args: argparse.Namespace, out: Path, pin: bool = False): return ctx, tree -def context_digest(root: Path) -> str: - """sha256 over every path, mode bit and byte of a build context (a link by its target).""" - import hashlib - import os - - h = hashlib.sha256() - for path in sorted(root.rglob("*")): - rel = path.relative_to(root).as_posix() - if path.is_symlink(): - h.update(f"L {rel}\0{os.readlink(path)}\0".encode()) - elif path.is_file(): - h.update(f"F {rel}\0{int(os.access(path, os.X_OK))}\0".encode()) - h.update(hashlib.sha256(path.read_bytes()).digest()) - elif path.is_dir(): - h.update(f"D {rel}\0".encode()) - return h.hexdigest() - - def _require_grade(content: Path, manifest: RepoManifest, ids: list[str] | None) -> None: """Refuse the selected tasks when one needs ``entry_points.grade`` and the manifest has none.""" from dokimi.repos.manifest import ManifestError, TaskSummary, grade_problems diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py new file mode 100644 index 000000000..293574415 --- /dev/null +++ b/dokimi/dokimi/repo_config.py @@ -0,0 +1,311 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""A repository's dokimi config (``dokimi_config.v1``): its subjects, their evals, changed-file impact. + +Discovery, in order: an explicit path, ``.github/dokimi/config.yaml``, then ``.dokimi/config.yaml``, +all under the repository root (the nearest directory at or above the start holding ``.git``, else +the start itself). Every path in the config is repository-relative; a glob may use ``**``. + +A subject is a skill (``skill/``, a directory whose evals are a suite beside it or elsewhere) +or the repository (``kind: repo``, a manifest). ``skill_sha`` is the tree digest of a skill directory +without the files its suite matches; ``suite_hash`` digests the suite's files, plus the manifest of a +repository. A changed file maps to a suite, to a skill, to the ``shared`` list, or to nothing. +""" + +from __future__ import annotations + +import fnmatch +import hashlib +from dataclasses import dataclass, field, replace +from pathlib import Path, PurePosixPath +from typing import Any, Iterable + +import yaml + +from dokimi.artifacts import ArtifactValidationError, validate_artifact +from dokimi.utils import tree_digest + +KIND = "dokimi_config" +CONFIG_CANDIDATES = (".github/dokimi/config.yaml", ".dokimi/config.yaml") +DEFAULT_SUITE = "{skill}/evals" +SKILL_PLACEHOLDER = "{skill}" + + +class ConfigError(ValueError): + """The config is missing, unreadable, invalid or does not resolve; the message names the file or subject.""" + + +@dataclass(frozen=True) +class Subject: + """One thing under test. The hashes are None until :meth:`RepoConfig.subjects` computes them.""" + + id: str + kind: str # "skill" or "repo" + path: str # the skill directory, repository-relative; "." for a repository + manifest: str | None # repository only + suite_globs: tuple[str, ...] # repository-relative, from the suite spec with {skill} filled in + skill_sha: str | None = None + suite_hash: str | None = None + + @property + def eval_globs(self) -> tuple[str, ...]: + """Every glob whose matches are this subject's evals: its suite, and a repository's manifest.""" + return self.suite_globs + ((self.manifest,) if self.manifest else ()) + + +@dataclass(frozen=True) +class Impact: + """Where each changed file goes. ``unclaimed`` is for the caller to report as inconclusive.""" + + skills: dict[str, list[str]] = field(default_factory=dict) # subject id -> changed files in its skill + suites: dict[str, list[str]] = field(default_factory=dict) # subject id -> changed files in its evals + shared: list[str] = field(default_factory=list) # match ``shared``: the smoke set + unclaimed: list[str] = field(default_factory=list) # nothing claims them + + @property + def subjects(self) -> list[str]: + """Every subject id a changed file reaches, skill or suite, sorted.""" + return sorted(set(self.skills) | set(self.suites)) + + +@dataclass(frozen=True) +class RepoConfig: + """A schema-valid config, and the repository root its paths are relative to.""" + + path: Path + root: Path + data: dict[str, Any] + + def subjects(self, *, hashes: bool = True) -> list[Subject]: + """The subjects after defaults and explicit overrides, sorted by id; ``hashes=False`` skips the tree walks.""" + resolved = _resolve(self.data, self.root) + for subject in resolved: + _check_suite(subject, self.root) + return [_hashed(s, self.root) for s in resolved] if hashes else resolved + + def subjects_for_changed(self, paths: Iterable[str]) -> Impact: + """Map repository-relative changed paths onto subjects, suites and ``shared``; the rest is unclaimed.""" + subjects = self.subjects(hashes=False) + shared = [*_as_list(self.data.get("shared")), *_own_path(self)] + impact = Impact() + for raw in paths: + rel = _norm(str(raw)) + if rel is None: + impact.unclaimed.append(str(raw)) + continue + _map_one(rel, subjects, shared, impact) + return impact + + +def find_repo_root(start: Path) -> Path: + """The nearest directory at or above ``start`` holding ``.git`` (a directory, or a file in a worktree); else ``start``.""" + here = Path(start).resolve() + for directory in (here, *here.parents): + if (directory / ".git").exists(): + return directory + return here + + +def discover(start: Path | None = None, *, explicit: Path | None = None) -> Path | None: + """The config file to use: ``explicit`` (relative to ``start``, and it must exist), else the first candidate under the root, else None.""" + here = Path(start or Path.cwd()).resolve() + if explicit is not None: + path = Path(explicit) if Path(explicit).is_absolute() else here / explicit + if not path.is_file(): + raise ConfigError(f"config not found: {path}") + return path + root = find_repo_root(here) + for rel in CONFIG_CANDIDATES: + if (root / rel).is_file(): + return root / rel + return None + + +def load(path: Path, *, root: Path) -> RepoConfig: + """Read and schema-validate the config at ``path``; its paths are relative to ``root``.""" + path = Path(path) + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc: + raise ConfigError(f"{path}: cannot read the config: {exc}") from exc + if not isinstance(data, dict): + raise ConfigError(f"{path}: the config must be a mapping") + try: + validate_artifact(KIND, data) + except ArtifactValidationError as exc: + raise ConfigError(f"{path}: {exc}") from exc + return RepoConfig(path=path, root=Path(root), data=data) + + +def load_repo_config(start: Path | None = None, *, explicit: Path | None = None) -> RepoConfig: + """:func:`discover` then :func:`load`, rooted at the repository above ``start``; raises when no config is found.""" + here = Path(start or Path.cwd()).resolve() + path = discover(here, explicit=explicit) + if path is None: + raise ConfigError(f"no dokimi config under {find_repo_root(here)}: looked for {', '.join(CONFIG_CANDIDATES)}") + return load(path, root=find_repo_root(here)) + + +def _norm(value: str) -> str | None: + """A repository-relative POSIX path or glob, or None when it is empty, absolute or climbs out.""" + norm = PurePosixPath(value.replace("\\", "/")).as_posix() + if norm in ("", ".") or norm.startswith("/") or ".." in norm.split("/"): + return None + return norm + + +def _pattern(value: Any, where: str) -> str: + norm = _norm(str(value)) + if norm is None: + raise ConfigError(f"{where}: {value!r} must be a path or glob inside the repository") + return norm + + +def _as_list(value: Any) -> list[str]: + if value is None: + return [] + return [value] if isinstance(value, str) else list(value) + + +def _own_path(config: RepoConfig) -> list[str]: + """The config file is shared: a change to it reruns the smoke set. Empty when it sits outside the root.""" + try: + return [config.path.resolve().relative_to(config.root.resolve()).as_posix()] + except ValueError: + return [] + + +def _claims(pattern: str, segs: tuple[str, ...]) -> bool: + """True when the glob matches ``segs`` or one of its parent directories (a directory glob claims what is under it).""" + pat = tuple(pattern.split("/")) + return any(_match(pat, segs[:k]) for k in range(1, len(segs) + 1)) + + +def _match(pat: tuple[str, ...], segs: tuple[str, ...]) -> bool: + if not pat: + return not segs + if pat[0] == "**": + return any(_match(pat[1:], segs[i:]) for i in range(len(segs) + 1)) + return bool(segs) and fnmatch.fnmatchcase(segs[0], pat[0]) and _match(pat[1:], segs[1:]) + + +def _map_one(rel: str, subjects: list[Subject], shared: list[str], impact: Impact) -> None: + segs = tuple(rel.split("/")) + is_shared = any(_claims(g, segs) for g in shared) + suites = [s.id for s in subjects if any(_claims(g, segs) for g in s.eval_globs)] + skill = None if suites else _skill_owner(subjects, segs) + if is_shared: + impact.shared.append(rel) + for sid in suites: + impact.suites.setdefault(sid, []).append(rel) + if skill is not None: + impact.skills.setdefault(skill.id, []).append(rel) + if not (is_shared or suites or skill is not None): + impact.unclaimed.append(rel) + + +def _skill_owner(subjects: list[Subject], segs: tuple[str, ...]) -> Subject | None: + """The skill whose directory holds ``segs``; the deepest one when skills nest.""" + owners = [s for s in subjects if s.kind == "skill" and segs[:len(_segs(s.path))] == _segs(s.path)] + return max(owners, key=lambda s: len(_segs(s.path)), default=None) + + +def _segs(path: str) -> tuple[str, ...]: + return tuple(path.split("/")) + + +def _resolve(data: dict[str, Any], root: Path) -> list[Subject]: + """Defaults plus explicit subjects; an explicit subject replaces a default with its id or its directory.""" + template = (data.get("defaults") or {}).get("suite", DEFAULT_SUITE) + explicit = [_subject(entry, root, template) for entry in data.get("subjects") or []] + taken_ids = {s.id for s in explicit} + taken_paths = {s.path for s in explicit} + found = [_subject({"id": f"skill/{PurePosixPath(rel).name}", "path": rel}, root, template) + for rel in _skill_dirs(data, root) if rel not in taken_paths] + subjects = [*explicit, *(s for s in found if s.id not in taken_ids)] + _check_unique(subjects) + return sorted(subjects, key=lambda s: s.id) + + +def _skill_dirs(data: dict[str, Any], root: Path) -> list[str]: + """Repository-relative directories matched by ``defaults.skills``; files do not count.""" + rels: set[str] = set() + for pattern in _as_list((data.get("defaults") or {}).get("skills")): + for match in root.glob(_pattern(pattern, "defaults.skills")): + if match.is_dir(): + rels.add(match.relative_to(root).as_posix()) + return sorted(rels) + + +def _check_unique(subjects: list[Subject]) -> None: + ids: set[str] = set() + paths: set[str] = set() + for s in subjects: + if s.id in ids: + raise ConfigError(f"subject id {s.id!r} is used by more than one subject; give one of them a distinct id") + ids.add(s.id) + if s.kind == "skill": + if s.path in paths: + raise ConfigError(f"two subjects share the skill directory {s.path!r}") + paths.add(s.path) + + +def _subject(entry: dict[str, Any], root: Path, template: Any) -> Subject: + sid = str(entry["id"]) + if entry.get("kind", "skill") == "repo": + manifest = _pattern(entry.get("manifest"), f"subject {sid}: manifest") + if not (root / manifest).is_file(): + raise ConfigError(f"subject {sid}: manifest {manifest} is not a file under {root}") + globs = _suite_globs(entry.get("suite"), where=f"subject {sid}: suite", skill=None) + return Subject(id=sid, kind="repo", path=".", manifest=manifest, suite_globs=globs) + path = _pattern(entry.get("path"), f"subject {sid}: path") + if not (root / path).is_dir(): + raise ConfigError(f"subject {sid}: {path} is not a directory under {root}") + globs = _suite_globs(entry.get("suite", template), where=f"subject {sid}: suite", skill=path) + return Subject(id=sid, kind="skill", path=path, manifest=None, suite_globs=globs) + + +def _suite_globs(spec: Any, *, where: str, skill: str | None) -> tuple[str, ...]: + """Flatten a suite spec (one glob, a list, or named layouts) into repository-relative globs.""" + if spec is None: + return () + values = list(spec.values()) if isinstance(spec, dict) else [spec] + globs: list[str] = [] + for value in values: + for text in _as_list(value): + if skill is None and SKILL_PLACEHOLDER in text: + raise ConfigError(f"{where}: {SKILL_PLACEHOLDER} needs a skill subject, not a repository") + globs.append(_pattern((text.replace(SKILL_PLACEHOLDER, skill) if skill else text), where)) + return tuple(dict.fromkeys(globs)) + + +def _suite_files(root: Path, globs: tuple[str, ...]) -> dict[str, Path]: + found: dict[str, Path] = {} + for glob in globs: + for match in sorted(root.glob(glob)): + found.setdefault(match.relative_to(root).as_posix(), match) + return found + + +def _digest_files(files: dict[str, Path]) -> str: + h = hashlib.sha256() + for rel in sorted(files): + h.update(f"{rel}\0{tree_digest(files[rel])}\n".encode()) + return "sha256:" + h.hexdigest() + + +def _check_suite(subject: Subject, root: Path) -> None: + if subject.kind == "skill" and subject.path in _suite_files(root, subject.suite_globs): + raise ConfigError(f"subject {subject.id}: its suite matches the whole skill directory {subject.path}") + + +def _hashed(subject: Subject, root: Path) -> Subject: + files = _suite_files(root, subject.suite_globs) + if subject.kind == "repo": + assert subject.manifest is not None # set by _subject for kind repo + files.setdefault(subject.manifest, root / subject.manifest) + return replace(subject, suite_hash=_digest_files(files)) + inside = [rel[len(subject.path) + 1:] for rel in files if rel.startswith(subject.path + "/")] + skill_sha = "sha256:" + tree_digest(root / subject.path, exclude=inside) + return replace(subject, skill_sha=skill_sha, suite_hash=_digest_files(files)) diff --git a/dokimi/dokimi/runs/kinds.py b/dokimi/dokimi/runs/kinds.py index cb8e49a39..8284e1677 100644 --- a/dokimi/dokimi/runs/kinds.py +++ b/dokimi/dokimi/runs/kinds.py @@ -65,6 +65,7 @@ class KindFields: "repo_budget": KindFields(subject=("tree",), counts=("entries",)), "task_inspect": KindFields(subject=("root",), counts=("items",)), "cli_routes": KindFields(counts=("roots", "leaves", "tombstones")), + "dokimi_config": KindFields(subject=("subjects[].id",), counts=("subjects", "shared")), } #: Every schema_version an artifact may carry (current and superseded) -> its kind. diff --git a/dokimi/dokimi/schemas/dokimi_config.v1.json b/dokimi/dokimi/schemas/dokimi_config.v1.json new file mode 100644 index 000000000..3c1690e42 --- /dev/null +++ b/dokimi/dokimi/schemas/dokimi_config.v1.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/intel/dokimi/schemas/dokimi_config.v1.json", + "title": "Repository dokimi config v1", + "description": "What a repository's dokimi config declares: its subjects (skills, or the repository itself), where each subject's evals live, the defaults a plan starts from, the changed-file rules for impact analysis and the budget and baseline store. Read by dokimi.repo_config from --config, .github/dokimi/config.yaml or .dokimi/config.yaml. Holds defaults only: what a run actually used is recorded in plan.v1.", + "type": "object", + "required": ["schema_version"], + "additionalProperties": false, + "$defs": { + "globs": { + "description": "One repository-relative glob, or a list of them.", + "oneOf": [ + {"type": "string", "minLength": 1}, + {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}} + ] + }, + "suite": { + "description": "Where a subject's evals are: one glob (or list), or named layouts (harbor, beside) each a glob or list. {skill} stands for the subject's path.", + "oneOf": [ + {"$ref": "#/$defs/globs"}, + {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/$defs/globs"}} + ] + } + }, + "properties": { + "schema_version": {"const": "dokimi_config.v1"}, + "defaults": { + "type": "object", + "additionalProperties": false, + "properties": { + "skills": {"$ref": "#/$defs/globs", "description": "Each directory matching is a subject skill/."}, + "suite": {"$ref": "#/$defs/suite", "description": "Default suite for a skill subject. Absent means {skill}/evals."} + } + }, + "subjects": { + "type": "array", + "items": { + "type": "object", + "required": ["id"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1, "description": "skill/, or repo"}, + "kind": {"enum": ["skill", "repo"], "default": "skill"}, + "path": {"type": "string", "minLength": 1, "description": "The skill directory, repository-relative."}, + "suite": {"$ref": "#/$defs/suite", "description": "Overrides defaults.suite for this subject."}, + "manifest": {"type": "string", "minLength": 1, "description": "The repository manifest (kind repo)."} + }, + "if": {"properties": {"kind": {"const": "repo"}}, "required": ["kind"]}, + "then": {"required": ["manifest"], "not": {"required": ["path"]}}, + "else": {"required": ["path"], "not": {"required": ["manifest"]}} + } + }, + "shared": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "description": "Repository-relative globs of changed files that touch every subject (a harness, a shared script): impact analysis runs the smoke set for them." + }, + "plan": { + "type": "object", + "additionalProperties": false, + "properties": { + "models": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, + "reps": {"type": "integer", "minimum": 1}, + "arms": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}} + } + }, + "budget": { + "type": "object", + "additionalProperties": false, + "properties": { + "pr_max_usd": {"type": "number", "minimum": 0}, + "record_max_usd": {"type": "number", "minimum": 0} + } + }, + "baseline": { + "type": "object", + "required": ["store"], + "additionalProperties": false, + "properties": { + "store": {"type": "string", "pattern": "^(off|branch:.+)$", "description": "off, or branch: for the baseline branch."} + } + } + } +} diff --git a/dokimi/dokimi/utils.py b/dokimi/dokimi/utils.py index 024cc1fcf..733017d80 100644 --- a/dokimi/dokimi/utils.py +++ b/dokimi/dokimi/utils.py @@ -1,10 +1,11 @@ # Copyright (C) 2026 Intel Corporation # SPDX-License-Identifier: Apache-2.0 +import hashlib import json import os from pathlib import Path -from typing import Tuple, Dict, Any +from typing import Iterable, Tuple, Dict, Any from dokimi.defaults import BEDROCK_MODEL_IDS, bedrock_profile from dokimi.exit_codes import EXIT_USAGE @@ -92,6 +93,30 @@ def digest_text(text: str) -> str: return "sha256:" + hashlib.sha256((text or "").encode("utf-8", errors="surrogatepass")).hexdigest() +def tree_digest(root: Path, *, exclude: Iterable[str] = ()) -> str: + """sha256 (bare hex) over every path, mode bit and byte under ``root``; a link by its target. + + A file ``root`` digests as itself (its path is ``.``). Entries whose path, relative to + ``root``, equals one of ``exclude`` or sits under it are left out, so a skill's digest can + leave its eval directory out. Output is deterministic: sorted walk, no timestamps. + """ + skip = tuple(e.strip("/") for e in exclude) + entries = [root] if root.is_file() else sorted(root.rglob("*")) + h = hashlib.sha256() + for path in entries: + rel = "." if path == root else path.relative_to(root).as_posix() + if any(rel == e or rel.startswith(e + "/") for e in skip): + continue + if path.is_symlink(): + h.update(f"L {rel}\0{os.readlink(path)}\0".encode()) + elif path.is_file(): + h.update(f"F {rel}\0{int(os.access(path, os.X_OK))}\0".encode()) + h.update(hashlib.sha256(path.read_bytes()).digest()) + elif path.is_dir(): + h.update(f"D {rel}\0".encode()) + return h.hexdigest() + + def retry_writable(func: Any, path: str, _exc: Any) -> None: """``shutil.rmtree`` ``onerror`` hook: set the write bit on ``path`` and retry once. diff --git a/dokimi/tests/fixtures/artifacts/dokimi_config.v1.json b/dokimi/tests/fixtures/artifacts/dokimi_config.v1.json new file mode 100644 index 000000000..1d36898b1 --- /dev/null +++ b/dokimi/tests/fixtures/artifacts/dokimi_config.v1.json @@ -0,0 +1,13 @@ +{ + "schema_version": "dokimi_config.v1", + "defaults": {"skills": "skills/*", "suite": "{skill}/evals"}, + "subjects": [ + {"id": "skill/onetbb", "path": "skills/onetbb", + "suite": {"harbor": "evaluation/harbor/tasks/onetbb-*"}}, + {"id": "repo", "kind": "repo", "manifest": ".github/dokimi/repo.yaml"} + ], + "shared": ["scripts/harness/**"], + "plan": {"models": ["sonnet"], "reps": 3, "arms": ["none", "approved", "candidate"]}, + "budget": {"pr_max_usd": 5, "record_max_usd": 30}, + "baseline": {"store": "branch:dokimi-baselines"} +} diff --git a/dokimi/tests/test_import_architecture.py b/dokimi/tests/test_import_architecture.py index 200cd63f2..20d99ce39 100644 --- a/dokimi/tests/test_import_architecture.py +++ b/dokimi/tests/test_import_architecture.py @@ -63,7 +63,7 @@ # ingest, static metrics, gates. ("components", ( "dokimi.agent_profiles", "dokimi.gate", "dokimi.ingest", "dokimi.mcp", - "dokimi.metrics", "dokimi.skills", + "dokimi.metrics", "dokimi.repo_config", "dokimi.skills", )), # What everything shares: defaults, registries, artifact and record I/O, the LLM and # process chokepoints, console output. diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py new file mode 100644 index 000000000..68bf90271 --- /dev/null +++ b/dokimi/tests/test_repo_config.py @@ -0,0 +1,337 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""dokimi_config.v1: discovery, schema, subject and suite resolution, skill_sha, changed-file impact.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dokimi.repo_config import ConfigError, Subject, discover, find_repo_root, load, load_repo_config + +HEADER = "schema_version: dokimi_config.v1\n" +BESIDE_CONFIG = HEADER + "defaults:\n skills: skills/*\n" +CENTRAL_CONFIG = ( + HEADER + + "defaults:\n skills: skills/*\n" + + "subjects:\n" + + " - id: skill/onetbb\n" + + " path: skills/onetbb\n" + + " suite:\n" + + " harbor: evaluation/harbor/tasks/onetbb-*\n" +) +BESIDE = { + ".github/dokimi/config.yaml": BESIDE_CONFIG, + "skills/onetbb/SKILL.md": "# onetbb\n", + "skills/onetbb/scripts/run.py": "print('run')\n", + "skills/onetbb/evals/task-1/task.toml": "id = 'one'\n", + "skills/onetbb/evals/questions.json": "[]\n", + "skills/fastcontext/SKILL.md": "# fastcontext\n", + "README.md": "readme\n", +} +CENTRAL = { + ".github/dokimi/config.yaml": CENTRAL_CONFIG, + "skills/onetbb/SKILL.md": "# onetbb\n", + "evaluation/harbor/tasks/onetbb-001/task.toml": "id = '1'\n", + "evaluation/harbor/tasks/onetbb-002/task.toml": "id = '2'\n", + "evaluation/harbor/tasks/other-001/task.toml": "id = 'other'\n", +} + + +def _tree(root: Path, files: dict[str, str]) -> Path: + """A repository at ``root`` (it always has a ``.git``) holding ``files``; returns the resolved root.""" + (root / ".git").mkdir(parents=True, exist_ok=True) + for rel, text in files.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return root.resolve() + + +def _subjects(root: Path) -> dict[str, Subject]: + return {s.id: s for s in load_repo_config(root).subjects()} + + +# --- discovery --------------------------------------------------------------------------------- + + +def test_discovery_prefers_github_then_falls_back_to_dokimi(tmp_path): + root = _tree(tmp_path / "repo", {".dokimi/config.yaml": BESIDE_CONFIG}) + assert discover(root) == root / ".dokimi/config.yaml" + _tree(root, {".github/dokimi/config.yaml": BESIDE_CONFIG}) + assert discover(root) == root / ".github/dokimi/config.yaml" + + +def test_an_explicit_path_wins_over_the_repository_config(tmp_path): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": BESIDE_CONFIG, "ci/dokimi.yaml": BESIDE_CONFIG}) + assert discover(root, explicit=Path("ci/dokimi.yaml")) == root / "ci/dokimi.yaml" + assert discover(root, explicit=root / "ci/dokimi.yaml") == root / "ci/dokimi.yaml" + + +def test_an_explicit_path_that_does_not_exist_is_an_error_not_a_fallback(tmp_path): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": BESIDE_CONFIG}) + with pytest.raises(ConfigError, match="config not found"): + discover(root, explicit=Path("missing.yaml")) + + +def test_no_config_is_none_from_discover_and_an_error_from_load_repo_config(tmp_path): + root = _tree(tmp_path / "repo", {"README.md": "x\n"}) + assert discover(root) is None + with pytest.raises(ConfigError, match="no dokimi config under"): + load_repo_config(root) + + +def test_the_root_is_found_from_a_subdirectory(tmp_path): + root = _tree(tmp_path / "repo", {**BESIDE}) + assert find_repo_root(root / "skills" / "onetbb" / "evals") == root + assert discover(root / "skills" / "onetbb") == root / ".github/dokimi/config.yaml" + assert load_repo_config(root / "skills" / "onetbb").root == root + + +# --- schema ------------------------------------------------------------------------------------ + + +@pytest.mark.parametrize( + ("body", "needle"), + [ + ("defaults:\n skills: skills/*\n", "schema_version"), + ("schema_version: dokimi_config.v2\n", "schema_version"), + (HEADER + "bogus: 1\n", "bogus"), + (HEADER + "subjects:\n - id: skill/x\n kind: repo\n", "manifest"), + (HEADER + "subjects:\n - id: skill/x\n", "path"), + (HEADER + "subjects:\n - id: skill/x\n path: skills/x\n manifest: m.yaml\n", "manifest"), + (HEADER + "subjects:\n - id: skill/x\n kind: catalog\n path: skills/x\n", "kind"), + (HEADER + "baseline:\n store: s3://bucket\n", "store"), + (HEADER + "plan:\n reps: 0\n", "reps"), + (HEADER + "budget:\n pr_max_usd: -1\n", "pr_max_usd"), + ("- just\n- a list\n", "mapping"), + (HEADER + "subjects: [unclosed\n", "cannot read"), + ], +) +def test_an_invalid_config_is_refused_with_the_file_and_the_reason(tmp_path, body, needle): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body}) + with pytest.raises(ConfigError, match=needle) as err: + load_repo_config(root) + assert ".github/dokimi/config.yaml" in str(err.value) + + +def test_a_valid_full_config_loads_with_plan_budget_and_baseline(tmp_path): + body = ( + HEADER + + "shared: [scripts/harness/**]\n" + + "plan: {models: [sonnet], reps: 3, arms: [none, approved, candidate]}\n" + + "budget: {pr_max_usd: 5, record_max_usd: 30}\n" + + "baseline: {store: 'branch:dokimi-baselines'}\n" + ) + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body}) + config = load_repo_config(root) + assert config.data["plan"]["reps"] == 3 + assert config.data["baseline"]["store"] == "branch:dokimi-baselines" + + +# --- subject resolution ------------------------------------------------------------------------ + + +def test_beside_layout_gives_one_skill_subject_per_directory_with_its_evals(tmp_path): + root = _tree(tmp_path / "repo", BESIDE) + subjects = load_repo_config(root).subjects(hashes=False) + assert [s.id for s in subjects] == ["skill/fastcontext", "skill/onetbb"] + onetbb = subjects[1] + assert (onetbb.kind, onetbb.path, onetbb.suite_globs) == ("skill", "skills/onetbb", ("skills/onetbb/evals",)) + assert onetbb.skill_sha is None and onetbb.suite_hash is None + + +def test_central_layout_uses_the_explicit_suite_glob(tmp_path): + root = _tree(tmp_path / "repo", CENTRAL) + subjects = {s.id: s for s in load_repo_config(root).subjects(hashes=False)} + assert set(subjects) == {"skill/onetbb"} + assert subjects["skill/onetbb"].suite_globs == ("evaluation/harbor/tasks/onetbb-*",) + + +def test_an_explicit_subject_replaces_the_default_with_the_same_id(tmp_path): + root = _tree(tmp_path / "repo", CENTRAL) + assert len(load_repo_config(root).subjects(hashes=False)) == 1 + + +def test_an_explicit_subject_replaces_the_default_with_the_same_directory(tmp_path): + body = HEADER + "defaults:\n skills: skills/*\nsubjects:\n - id: skill/ot\n path: skills/onetbb\n" + root = _tree(tmp_path / "repo", {**BESIDE, ".github/dokimi/config.yaml": body}) + ids = [s.id for s in load_repo_config(root).subjects(hashes=False)] + assert ids == ["skill/fastcontext", "skill/ot"] + + +def test_a_repository_subject_takes_its_manifest_and_no_skill(tmp_path): + body = HEADER + "subjects:\n - id: repo\n kind: repo\n manifest: .github/dokimi/repo.yaml\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, ".github/dokimi/repo.yaml": "x: 1\n"}) + (subject,) = load_repo_config(root).subjects(hashes=False) + assert (subject.kind, subject.path, subject.manifest) == ("repo", ".", ".github/dokimi/repo.yaml") + assert subject.suite_globs == () + + +@pytest.mark.parametrize( + ("body", "files", "needle"), + [ + (HEADER + "subjects:\n - id: skill/a\n path: skills/a\n - id: skill/a\n path: skills/b\n", + {"skills/a/SKILL.md": "a", "skills/b/SKILL.md": "b"}, "more than one subject"), + (HEADER + "subjects:\n - id: skill/a\n path: skills/a\n - id: skill/b\n path: skills/a\n", + {"skills/a/SKILL.md": "a"}, "share the skill directory"), + (HEADER + "subjects:\n - id: skill/a\n path: skills/missing\n", {}, "is not a directory"), + (HEADER + "subjects:\n - id: skill/a\n path: ../outside\n", {}, "inside the repository"), + (HEADER + "subjects:\n - id: repo\n kind: repo\n manifest: nope.yaml\n", {}, "not a file"), + (HEADER + "subjects:\n - id: repo\n kind: repo\n manifest: m.yaml\n suite: '{skill}/evals'\n", + {"m.yaml": "m"}, "needs a skill subject"), + (HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: '{skill}'\n", + {"skills/a/SKILL.md": "a"}, "whole skill directory"), + ], +) +def test_a_subject_that_does_not_resolve_is_refused(tmp_path, body, files, needle): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, **files}) + with pytest.raises(ConfigError, match=needle): + load_repo_config(root).subjects(hashes=False) + + +def test_two_skill_directories_with_one_name_need_an_explicit_id(tmp_path): + body = HEADER + "defaults:\n skills: skills/*/*\n" + files = {".github/dokimi/config.yaml": body, "skills/a/x/SKILL.md": "a", "skills/b/x/SKILL.md": "b"} + root = _tree(tmp_path / "repo", files) + with pytest.raises(ConfigError, match="more than one subject"): + load_repo_config(root).subjects(hashes=False) + + +# --- skill_sha and suite_hash ------------------------------------------------------------------ + + +def test_eval_edits_do_not_move_skill_sha_but_move_suite_hash(tmp_path): + root = _tree(tmp_path / "repo", BESIDE) + before = _subjects(root)["skill/onetbb"] + (root / "skills/onetbb/evals/questions.json").write_text("[1]\n", encoding="utf-8") + (root / "skills/onetbb/evals/task-2").mkdir() + (root / "skills/onetbb/evals/task-2/task.toml").write_text("id = 'two'\n", encoding="utf-8") + after = _subjects(root)["skill/onetbb"] + assert after.skill_sha == before.skill_sha + assert after.suite_hash != before.suite_hash + + +def test_a_skill_edit_moves_skill_sha_and_a_file_outside_the_skill_does_not(tmp_path): + root = _tree(tmp_path / "repo", BESIDE) + before = _subjects(root)["skill/onetbb"].skill_sha + (root / "README.md").write_text("changed\n", encoding="utf-8") + assert _subjects(root)["skill/onetbb"].skill_sha == before + (root / "skills/onetbb/SKILL.md").write_text("# changed\n", encoding="utf-8") + assert _subjects(root)["skill/onetbb"].skill_sha != before + + +def test_skill_sha_is_the_same_for_the_same_skill_content_at_another_path(tmp_path): + a = _tree(tmp_path / "a", BESIDE) + b = _tree(tmp_path / "b", BESIDE) + assert _subjects(a)["skill/onetbb"].skill_sha == _subjects(b)["skill/onetbb"].skill_sha + assert _subjects(a)["skill/onetbb"].skill_sha.startswith("sha256:") + + +def test_central_layout_suite_is_outside_the_skill_so_its_edits_leave_skill_sha_alone(tmp_path): + root = _tree(tmp_path / "repo", CENTRAL) + before = _subjects(root)["skill/onetbb"] + (root / "evaluation/harbor/tasks/onetbb-001/task.toml").write_text("id = 'edited'\n", encoding="utf-8") + after = _subjects(root)["skill/onetbb"] + assert after.skill_sha == before.skill_sha + assert after.suite_hash != before.suite_hash + + +def test_a_suite_glob_matches_only_what_it_names(tmp_path): + root = _tree(tmp_path / "repo", CENTRAL) + before = _subjects(root)["skill/onetbb"].suite_hash + (root / "evaluation/harbor/tasks/other-001/task.toml").write_text("id = 'x'\n", encoding="utf-8") + assert _subjects(root)["skill/onetbb"].suite_hash == before + + +def test_a_repository_subject_hashes_its_manifest_and_has_no_skill_sha(tmp_path): + body = HEADER + "subjects:\n - id: repo\n kind: repo\n manifest: m.yaml\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, "m.yaml": "a: 1\n"}) + before = _subjects(root)["repo"] + assert before.skill_sha is None and before.suite_hash.startswith("sha256:") + (root / "m.yaml").write_text("a: 2\n", encoding="utf-8") + assert _subjects(root)["repo"].suite_hash != before.suite_hash + + +# --- changed files -> subjects ----------------------------------------------------------------- + + +def test_changed_files_map_to_skills_and_suites_and_the_rest_is_unclaimed(tmp_path): + root = _tree(tmp_path / "repo", BESIDE) + impact = load_repo_config(root).subjects_for_changed([ + "skills/onetbb/SKILL.md", + "skills/onetbb/evals/task-1/task.toml", + "./skills/fastcontext/SKILL.md", + "README.md", + ]) + assert impact.skills == {"skill/onetbb": ["skills/onetbb/SKILL.md"], "skill/fastcontext": ["skills/fastcontext/SKILL.md"]} + assert impact.suites == {"skill/onetbb": ["skills/onetbb/evals/task-1/task.toml"]} + assert impact.subjects == ["skill/fastcontext", "skill/onetbb"] + assert impact.unclaimed == ["README.md"] + assert impact.shared == [] + + +def test_a_deleted_eval_is_still_claimed_by_its_suite_glob(tmp_path): + root = _tree(tmp_path / "repo", CENTRAL) + impact = load_repo_config(root).subjects_for_changed(["evaluation/harbor/tasks/onetbb-009/task.toml"]) + assert impact.suites == {"skill/onetbb": ["evaluation/harbor/tasks/onetbb-009/task.toml"]} + assert impact.unclaimed == [] and impact.skills == {} + + +def test_shared_paths_and_the_config_itself_are_smoke_and_not_unclaimed(tmp_path): + body = CENTRAL_CONFIG + "shared: [scripts/harness/**]\n" + root = _tree(tmp_path / "repo", {**CENTRAL, ".github/dokimi/config.yaml": body}) + impact = load_repo_config(root).subjects_for_changed([ + "scripts/harness/run.sh", + ".github/dokimi/config.yaml", + "docs/unrelated.md", + ]) + assert impact.shared == ["scripts/harness/run.sh", ".github/dokimi/config.yaml"] + assert impact.unclaimed == ["docs/unrelated.md"] + + +def test_a_shared_file_that_a_subject_claims_is_both_smoke_and_attributed(tmp_path): + body = CENTRAL_CONFIG + "shared: [skills/onetbb/SKILL.md]\n" + root = _tree(tmp_path / "repo", {**CENTRAL, ".github/dokimi/config.yaml": body}) + impact = load_repo_config(root).subjects_for_changed(["skills/onetbb/SKILL.md"]) + assert impact.shared == ["skills/onetbb/SKILL.md"] + assert impact.skills == {"skill/onetbb": ["skills/onetbb/SKILL.md"]} + assert impact.unclaimed == [] + + +def test_a_changed_path_that_climbs_out_or_is_absolute_is_unclaimed(tmp_path): + root = _tree(tmp_path / "repo", BESIDE) + impact = load_repo_config(root).subjects_for_changed(["/etc/passwd", "../secret", ""]) + assert impact.unclaimed == ["/etc/passwd", "../secret", ""] + + +def test_a_repository_subject_claims_its_manifest_and_nothing_else(tmp_path): + body = HEADER + "subjects:\n - id: repo\n kind: repo\n manifest: .github/dokimi/repo.yaml\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, ".github/dokimi/repo.yaml": "x: 1\n"}) + impact = load_repo_config(root).subjects_for_changed([".github/dokimi/repo.yaml", "src/main.py"]) + assert impact.suites == {"repo": [".github/dokimi/repo.yaml"]} + assert impact.unclaimed == ["src/main.py"] + + +def test_a_file_goes_to_the_deepest_of_two_nested_skills(tmp_path): + body = HEADER + ( + "subjects:\n" + " - id: skill/outer\n path: skills/outer\n" + " - id: skill/inner\n path: skills/outer/inner\n" + ) + files = { + ".github/dokimi/config.yaml": body, + "skills/outer/SKILL.md": "outer\n", + "skills/outer/inner/SKILL.md": "inner\n", + } + root = _tree(tmp_path / "repo", files) + impact = load_repo_config(root).subjects_for_changed(["skills/outer/inner/SKILL.md", "skills/outer/notes.md"]) + assert impact.skills == {"skill/inner": ["skills/outer/inner/SKILL.md"], "skill/outer": ["skills/outer/notes.md"]} + + +def test_load_reads_a_file_and_roots_it_where_told(tmp_path): + root = _tree(tmp_path / "repo", BESIDE) + config = load(root / ".github/dokimi/config.yaml", root=root) + assert config.root == root and config.path == root / ".github/dokimi/config.yaml" diff --git a/dokimi/tests/test_runs_scan.py b/dokimi/tests/test_runs_scan.py index 9b5b9997d..077071083 100644 --- a/dokimi/tests/test_runs_scan.py +++ b/dokimi/tests/test_runs_scan.py @@ -48,6 +48,7 @@ "repo_budget.v1": ({}, "toy", None, None), "task_inspect.v1": ({}, "tests/fixtures/task_formats", None, None), "cli_routes.v1": ({}, None, None, None), + "dokimi_config.v1": ({}, "skill/onetbb, repo", None, None), } diff --git a/dokimi/tests/test_runs_show.py b/dokimi/tests/test_runs_show.py index 07ad7a9f5..55af8e09a 100644 --- a/dokimi/tests/test_runs_show.py +++ b/dokimi/tests/test_runs_show.py @@ -38,6 +38,7 @@ "subject_scorecard.v2": {"cells": 0}, "cli_routes.v1": {"roots": 2, "leaves": 2, "tombstones": 1}, "task_inspect.v1": {"items": 4}, + "dokimi_config.v1": {"subjects": 2, "shared": 1}, "task_runs.v1": {"tasks": 6, "harnesses": 2, "results": 24}, "tool_preference.v1": {"items": 16, "results": 16}, } diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py new file mode 100644 index 000000000..ff63eef15 --- /dev/null +++ b/dokimi/tests/test_tree_digest.py @@ -0,0 +1,69 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""dokimi.utils.tree_digest: the one tree hash the build context and the repo config share.""" + +from __future__ import annotations + +from pathlib import Path + +from dokimi.utils import tree_digest + + +def _write(root: Path, files: dict[str, str]) -> Path: + for rel, text in files.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return root + + +def test_the_same_content_at_another_path_digests_the_same(tmp_path): + a = _write(tmp_path / "a", {"SKILL.md": "x\n", "scripts/run.py": "print(1)\n"}) + b = _write(tmp_path / "b", {"SKILL.md": "x\n", "scripts/run.py": "print(1)\n"}) + assert tree_digest(a) == tree_digest(b) + + +def test_a_byte_a_new_file_or_a_rename_each_moves_the_digest(tmp_path): + root = _write(tmp_path / "t", {"SKILL.md": "x\n", "a.txt": "1\n"}) + base = tree_digest(root) + (root / "a.txt").write_text("2\n", encoding="utf-8") + edited = tree_digest(root) + assert edited != base + (root / "b.txt").write_text("new\n", encoding="utf-8") + assert tree_digest(root) not in (base, edited) + (root / "a.txt").rename(root / "c.txt") + assert tree_digest(root) != base + + +def test_the_executable_bit_is_part_of_the_digest(tmp_path): + root = _write(tmp_path / "t", {"run.sh": "echo\n"}) + before = tree_digest(root) + (root / "run.sh").chmod(0o755) + assert tree_digest(root) != before + + +def test_exclude_leaves_out_a_subtree_and_nothing_else(tmp_path): + full = _write(tmp_path / "full", {"SKILL.md": "x\n", "evals/q.json": "[]\n", "evalsx/keep.txt": "k\n"}) + bare = _write(tmp_path / "bare", {"SKILL.md": "x\n", "evalsx/keep.txt": "k\n"}) + assert tree_digest(full, exclude=["evals"]) == tree_digest(bare) + assert tree_digest(full) != tree_digest(bare) + + +def test_exclude_takes_a_file_path_too(tmp_path): + full = _write(tmp_path / "full", {"a.txt": "a\n", "b/notes.md": "n\n"}) + bare = _write(tmp_path / "bare", {"a.txt": "a\n"}) + (bare / "b").mkdir() # the directory entry stays; only the excluded file goes + assert tree_digest(full, exclude=["b/notes.md"]) == tree_digest(bare) + + +def test_a_file_root_digests_as_itself(tmp_path): + one = _write(tmp_path / "one", {"q.json": "[1]\n"}) + two = _write(tmp_path / "two", {"elsewhere.json": "[1]\n"}) + assert tree_digest(one / "q.json") == tree_digest(two / "elsewhere.json") + assert tree_digest(one / "q.json") != tree_digest(_write(tmp_path / "three", {"q.json": "[2]\n"}) / "q.json") + + +def test_the_digest_is_bare_hex(tmp_path): + digest = tree_digest(_write(tmp_path / "t", {"a": "a\n"})) + assert len(digest) == 64 and all(c in "0123456789abcdef" for c in digest) From ac28b88fa0362ec0e8995b5db98adc01e70e01ba Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 30 Oct 2026 22:16:40 -0700 Subject: [PATCH 02/36] fix(repo): normalise shared globs, refuse ancestor suites, escape skill dirs Self-review of the loader found three wrong results: - shared globs were matched raw, so './scripts/harness/**' never matched a changed path and the smoke set silently skipped it. They now go through the same normalisation as the suite globs, and a glob that climbs out is refused. - a suite that is an ancestor of the skill directory (suite: skills) passed the whole-skill guard, which only checked equality, and took SKILL.md edits away from the skill. The guard now refuses a suite that contains the skill. - a skill directory with [ ] in its name matched no suite files because the name went into the glob unescaped. The skill name is now glob-escaped. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 19 +++++++++++++------ dokimi/tests/test_repo_config.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 293574415..fd2f8c565 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -16,6 +16,7 @@ from __future__ import annotations import fnmatch +import glob import hashlib from dataclasses import dataclass, field, replace from pathlib import Path, PurePosixPath @@ -87,7 +88,7 @@ def subjects(self, *, hashes: bool = True) -> list[Subject]: def subjects_for_changed(self, paths: Iterable[str]) -> Impact: """Map repository-relative changed paths onto subjects, suites and ``shared``; the rest is unclaimed.""" subjects = self.subjects(hashes=False) - shared = [*_as_list(self.data.get("shared")), *_own_path(self)] + shared = [*(_pattern(g, "shared") for g in _as_list(self.data.get("shared"))), *_own_path(self)] impact = Impact() for raw in paths: rel = _norm(str(raw)) @@ -276,14 +277,14 @@ def _suite_globs(spec: Any, *, where: str, skill: str | None) -> tuple[str, ...] for text in _as_list(value): if skill is None and SKILL_PLACEHOLDER in text: raise ConfigError(f"{where}: {SKILL_PLACEHOLDER} needs a skill subject, not a repository") - globs.append(_pattern((text.replace(SKILL_PLACEHOLDER, skill) if skill else text), where)) + globs.append(_pattern((text.replace(SKILL_PLACEHOLDER, glob.escape(skill)) if skill else text), where)) return tuple(dict.fromkeys(globs)) def _suite_files(root: Path, globs: tuple[str, ...]) -> dict[str, Path]: found: dict[str, Path] = {} - for glob in globs: - for match in sorted(root.glob(glob)): + for pattern in globs: + for match in sorted(root.glob(pattern)): found.setdefault(match.relative_to(root).as_posix(), match) return found @@ -296,8 +297,14 @@ def _digest_files(files: dict[str, Path]) -> str: def _check_suite(subject: Subject, root: Path) -> None: - if subject.kind == "skill" and subject.path in _suite_files(root, subject.suite_globs): - raise ConfigError(f"subject {subject.id}: its suite matches the whole skill directory {subject.path}") + """A skill's suite must not hold the skill directory: not the directory itself, nor one of its ancestors.""" + if subject.kind != "skill": + return + for rel in _suite_files(root, subject.suite_globs): + if rel == subject.path: + raise ConfigError(f"subject {subject.id}: its suite matches the whole skill directory {subject.path}") + if subject.path.startswith(rel + "/"): + raise ConfigError(f"subject {subject.id}: its suite {rel} contains the skill directory {subject.path}") def _hashed(subject: Subject, root: Path) -> Subject: diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index 68bf90271..d61c6f193 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -184,6 +184,8 @@ def test_a_repository_subject_takes_its_manifest_and_no_skill(tmp_path): {"m.yaml": "m"}, "needs a skill subject"), (HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: '{skill}'\n", {"skills/a/SKILL.md": "a"}, "whole skill directory"), + (HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: skills\n", + {"skills/a/SKILL.md": "a"}, "contains the skill directory"), ], ) def test_a_subject_that_does_not_resolve_is_refused(tmp_path, body, files, needle): @@ -335,3 +337,30 @@ def test_load_reads_a_file_and_roots_it_where_told(tmp_path): root = _tree(tmp_path / "repo", BESIDE) config = load(root / ".github/dokimi/config.yaml", root=root) assert config.root == root and config.path == root / ".github/dokimi/config.yaml" + + +def test_shared_globs_are_normalised_like_changed_paths(tmp_path): + body = CENTRAL_CONFIG + "shared: ['./scripts/harness/**', 'scripts//lib/*.sh']\n" + root = _tree(tmp_path / "repo", {**CENTRAL, ".github/dokimi/config.yaml": body}) + impact = load_repo_config(root).subjects_for_changed(["scripts/harness/run.sh", "scripts/lib/a.sh"]) + assert impact.shared == ["scripts/harness/run.sh", "scripts/lib/a.sh"] + assert impact.unclaimed == [] + + +def test_a_shared_glob_that_climbs_out_is_refused(tmp_path): + body = CENTRAL_CONFIG + "shared: ['../x/**']\n" + root = _tree(tmp_path / "repo", {**CENTRAL, ".github/dokimi/config.yaml": body}) + with pytest.raises(ConfigError, match="inside the repository"): + load_repo_config(root).subjects_for_changed(["x/a"]) + + +def test_a_skill_directory_with_brackets_in_its_name_keeps_its_evals(tmp_path): + files = { + ".github/dokimi/config.yaml": BESIDE_CONFIG, + "skills/foo[1]/SKILL.md": "# foo\n", + "skills/foo[1]/evals/a.yaml": "a: 1\n", + } + root = _tree(tmp_path / "repo", files) + impact = load_repo_config(root).subjects_for_changed(["skills/foo[1]/evals/a.yaml", "skills/foo[1]/SKILL.md"]) + assert impact.suites == {"skill/foo[1]": ["skills/foo[1]/evals/a.yaml"]} + assert impact.skills == {"skill/foo[1]": ["skills/foo[1]/SKILL.md"]} From 4a64bda814d23acbe6196ec5b03197e64e2a2ed7 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 30 Oct 2026 22:26:37 -0700 Subject: [PATCH 03/36] fix(repo): a file another suite claims still moves its own skill The skill that holds a file was dropped from impact whenever any suite claimed the file, while skill_sha still hashed it. A suite that points into another skill's directory (skill/b with skills/a/notes/*) therefore moved skill/a's skill_sha and left skill/a out of the impact. Now a skill is named unless its own suite holds the file, which is the rule _hashed uses. Also drops the stale "--config" flag from the schema description: no such flag exists. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 4 +++- dokimi/dokimi/schemas/dokimi_config.v1.json | 2 +- dokimi/tests/test_repo_config.py | 22 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index fd2f8c565..8a5696135 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -195,7 +195,9 @@ def _map_one(rel: str, subjects: list[Subject], shared: list[str], impact: Impac segs = tuple(rel.split("/")) is_shared = any(_claims(g, segs) for g in shared) suites = [s.id for s in subjects if any(_claims(g, segs) for g in s.eval_globs)] - skill = None if suites else _skill_owner(subjects, segs) + skill = _skill_owner(subjects, segs) + if skill is not None and any(_claims(g, segs) for g in skill.suite_globs): + skill = None # the skill's own suite holds this file: it is eval, and skill_sha leaves it out if is_shared: impact.shared.append(rel) for sid in suites: diff --git a/dokimi/dokimi/schemas/dokimi_config.v1.json b/dokimi/dokimi/schemas/dokimi_config.v1.json index 3c1690e42..9b08a0900 100644 --- a/dokimi/dokimi/schemas/dokimi_config.v1.json +++ b/dokimi/dokimi/schemas/dokimi_config.v1.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/intel/dokimi/schemas/dokimi_config.v1.json", "title": "Repository dokimi config v1", - "description": "What a repository's dokimi config declares: its subjects (skills, or the repository itself), where each subject's evals live, the defaults a plan starts from, the changed-file rules for impact analysis and the budget and baseline store. Read by dokimi.repo_config from --config, .github/dokimi/config.yaml or .dokimi/config.yaml. Holds defaults only: what a run actually used is recorded in plan.v1.", + "description": "What a repository's dokimi config declares: its subjects (skills, or the repository itself), where each subject's evals live, the defaults a plan starts from, the changed-file rules for impact analysis and the budget and baseline store. Read by dokimi.repo_config from .github/dokimi/config.yaml or .dokimi/config.yaml, or an explicit path. Holds defaults only: what a run actually used is recorded in plan.v1.", "type": "object", "required": ["schema_version"], "additionalProperties": false, diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index d61c6f193..c585c8b69 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -354,6 +354,28 @@ def test_a_shared_glob_that_climbs_out_is_refused(tmp_path): load_repo_config(root).subjects_for_changed(["x/a"]) +def test_a_file_that_another_subjects_suite_claims_still_moves_its_own_skill(tmp_path): + body = ( + HEADER + + "subjects:\n" + + " - id: skill/a\n path: skills/a\n" + + " - id: skill/b\n path: skills/b\n suite: skills/a/notes/*\n" + ) + files = { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "skills/a/notes/n1.md": "n\n", + "skills/b/SKILL.md": "# b\n", + } + root = _tree(tmp_path / "repo", files) + before = _subjects(root)["skill/a"].skill_sha + (root / "skills/a/notes/n1.md").write_text("changed\n", encoding="utf-8") + assert _subjects(root)["skill/a"].skill_sha != before # the hash counts the file ... + impact = load_repo_config(root).subjects_for_changed(["skills/a/notes/n1.md"]) + assert impact.skills == {"skill/a": ["skills/a/notes/n1.md"]} # ... so the impact must name the skill + assert impact.suites == {"skill/b": ["skills/a/notes/n1.md"]} + + def test_a_skill_directory_with_brackets_in_its_name_keeps_its_evals(tmp_path): files = { ".github/dokimi/config.yaml": BESIDE_CONFIG, From 911d072a16343e511573049511d563168377b17e Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 12:30:16 -0700 Subject: [PATCH 04/36] Address review: a symlink root digests as its link tree_digest followed a directory symlink root into its target, and hashed a dangling root as an empty tree. Retargeting a suite-directory link between two identical trees left the suite hash unchanged. A symlink root is now one entry, hashed by its target text like an inner symlink. The rename check now compares against a digest taken right before the rename and renames to a name in the same sort position: the old final assertion passed even with file names left out of the hash. The executable-bit test starts from an explicit 0o644 and skips on Windows. Thread: PRRT_kwDOVBg7g86ySzNj Thread: PRRT_kwDOVBg7g86ySzNp Thread: PRRT_kwDOVBg7g86ySzNt Signed-off-by: Nikolay Petrov --- dokimi/dokimi/utils.py | 5 +++-- dokimi/tests/test_tree_digest.py | 34 ++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/dokimi/dokimi/utils.py b/dokimi/dokimi/utils.py index 32ead8900..cceb8a720 100644 --- a/dokimi/dokimi/utils.py +++ b/dokimi/dokimi/utils.py @@ -97,12 +97,13 @@ def digest_text(text: str) -> str: def tree_digest(root: Path, *, exclude: Iterable[str] = ()) -> str: """sha256 (bare hex) over every path, mode bit and byte under ``root``; a link by its target. - A file ``root`` digests as itself (its path is ``.``). Entries whose path, relative to + A file ``root`` digests as itself (its path is ``.``), and so does a symlink ``root`` + (by its target, dangling or not; it is not walked). Entries whose path, relative to ``root``, equals one of ``exclude`` or sits under it are left out, so a skill's digest can leave its eval directory out. Output is deterministic: sorted walk, no timestamps. """ skip = tuple(e.strip("/") for e in exclude) - entries = [root] if root.is_file() else sorted(root.rglob("*")) + entries = [root] if root.is_symlink() or root.is_file() else sorted(root.rglob("*")) h = hashlib.sha256() for path in entries: rel = "." if path == root else path.relative_to(root).as_posix() diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py index ff63eef15..929a98ee4 100644 --- a/dokimi/tests/test_tree_digest.py +++ b/dokimi/tests/test_tree_digest.py @@ -5,10 +5,15 @@ from __future__ import annotations +import os from pathlib import Path +import pytest + from dokimi.utils import tree_digest +symlinks = pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") + def _write(root: Path, files: dict[str, str]) -> Path: for rel, text in files.items(): @@ -32,17 +37,42 @@ def test_a_byte_a_new_file_or_a_rename_each_moves_the_digest(tmp_path): assert edited != base (root / "b.txt").write_text("new\n", encoding="utf-8") assert tree_digest(root) not in (base, edited) - (root / "a.txt").rename(root / "c.txt") - assert tree_digest(root) != base + before_rename = tree_digest(root) + (root / "a.txt").rename(root / "a2.txt") # same sort position, so only the name can move the digest + assert tree_digest(root) != before_rename +@pytest.mark.skipif(os.name == "nt", reason="chmod does not change the executable bit on Windows") def test_the_executable_bit_is_part_of_the_digest(tmp_path): root = _write(tmp_path / "t", {"run.sh": "echo\n"}) + (root / "run.sh").chmod(0o644) before = tree_digest(root) (root / "run.sh").chmod(0o755) assert tree_digest(root) != before +@symlinks +def test_a_directory_symlink_root_digests_its_target_not_the_tree_under_it(tmp_path): + one = _write(tmp_path / "one", {"SKILL.md": "x\n"}) + two = _write(tmp_path / "two", {"SKILL.md": "x\n"}) + link = tmp_path / "link" + os.symlink(one, link, target_is_directory=True) + before = tree_digest(link) + link.unlink() + os.symlink(two, link, target_is_directory=True) + assert tree_digest(link) != before + + +@symlinks +def test_a_dangling_root_symlink_digests_its_target_text(tmp_path): + link = tmp_path / "link" + os.symlink("missing-a", link) + before = tree_digest(link) + link.unlink() + os.symlink("missing-b", link) + assert tree_digest(link) != before + + def test_exclude_leaves_out_a_subtree_and_nothing_else(tmp_path): full = _write(tmp_path / "full", {"SKILL.md": "x\n", "evals/q.json": "[]\n", "evalsx/keep.txt": "k\n"}) bare = _write(tmp_path / "bare", {"SKILL.md": "x\n", "evalsx/keep.txt": "k\n"}) From 43e0998a3599ad84912d62a3b61c7c0bd6f38823 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 12:30:16 -0700 Subject: [PATCH 05/36] Address review: manifest brackets, drive paths, malformed globs The manifest is a literal path: escape it before it joins eval_globs, so manifests/repo[1].yaml is claimed as written. Refuse drive-qualified paths (C:\..., C:skill) in every path and glob field, as absolute ones are. A glob whose ** is not a whole path component raises ConfigError naming the field and the pattern, instead of ValueError on Python 3.11/3.12. A matched symlink is now hashed by its target through the tree_digest fix in the previous commit; the loader tests retarget a matched dangling and a matched directory symlink and expect the suite hash to move. Thread: PRRT_kwDOVBg7g86ySzNi Thread: PRRT_kwDOVBg7g86ySzNf Thread: PRRT_kwDOVBg7g86ySzNg Thread: PRRT_kwDOVBg7g86ySzNh Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 21 +++++++--- dokimi/tests/test_repo_config.py | 71 ++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 8a5696135..0c0d95ecc 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -18,6 +18,7 @@ import fnmatch import glob import hashlib +import re from dataclasses import dataclass, field, replace from pathlib import Path, PurePosixPath from typing import Any, Iterable @@ -31,6 +32,7 @@ CONFIG_CANDIDATES = (".github/dokimi/config.yaml", ".dokimi/config.yaml") DEFAULT_SUITE = "{skill}/evals" SKILL_PLACEHOLDER = "{skill}" +DRIVE = re.compile(r"[A-Za-z]:") class ConfigError(ValueError): @@ -51,8 +53,8 @@ class Subject: @property def eval_globs(self) -> tuple[str, ...]: - """Every glob whose matches are this subject's evals: its suite, and a repository's manifest.""" - return self.suite_globs + ((self.manifest,) if self.manifest else ()) + """Every glob whose matches are this subject's evals: its suite, and the manifest, escaped to a literal.""" + return self.suite_globs + ((glob.escape(self.manifest),) if self.manifest else ()) @dataclass(frozen=True) @@ -151,7 +153,8 @@ def load_repo_config(start: Path | None = None, *, explicit: Path | None = None) def _norm(value: str) -> str | None: """A repository-relative POSIX path or glob, or None when it is empty, absolute or climbs out.""" norm = PurePosixPath(value.replace("\\", "/")).as_posix() - if norm in ("", ".") or norm.startswith("/") or ".." in norm.split("/"): + # A drive (C:, C:skill) makes Path join discard the root on Windows, so it is refused like an absolute path. + if norm in ("", ".") or norm.startswith("/") or DRIVE.match(norm) or ".." in norm.split("/"): return None return norm @@ -163,6 +166,14 @@ def _pattern(value: Any, where: str) -> str: return norm +def _glob(value: Any, where: str) -> str: + """A glob from a glob field: ``**`` must be a whole path component (the glob module raises otherwise, on 3.11/3.12).""" + norm = _pattern(value, where) + if any("**" in seg and seg != "**" for seg in norm.split("/")): + raise ConfigError(f"{where}: {value!r} is not a valid glob, '**' must be a whole path component") + return norm + + def _as_list(value: Any) -> list[str]: if value is None: return [] @@ -235,7 +246,7 @@ def _skill_dirs(data: dict[str, Any], root: Path) -> list[str]: """Repository-relative directories matched by ``defaults.skills``; files do not count.""" rels: set[str] = set() for pattern in _as_list((data.get("defaults") or {}).get("skills")): - for match in root.glob(_pattern(pattern, "defaults.skills")): + for match in root.glob(_glob(pattern, "defaults.skills")): if match.is_dir(): rels.add(match.relative_to(root).as_posix()) return sorted(rels) @@ -279,7 +290,7 @@ def _suite_globs(spec: Any, *, where: str, skill: str | None) -> tuple[str, ...] for text in _as_list(value): if skill is None and SKILL_PLACEHOLDER in text: raise ConfigError(f"{where}: {SKILL_PLACEHOLDER} needs a skill subject, not a repository") - globs.append(_pattern((text.replace(SKILL_PLACEHOLDER, glob.escape(skill)) if skill else text), where)) + globs.append(_glob((text.replace(SKILL_PLACEHOLDER, glob.escape(skill)) if skill else text), where)) return tuple(dict.fromkeys(globs)) diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index c585c8b69..610eb0263 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -5,6 +5,7 @@ from __future__ import annotations +import os from pathlib import Path import pytest @@ -386,3 +387,73 @@ def test_a_skill_directory_with_brackets_in_its_name_keeps_its_evals(tmp_path): impact = load_repo_config(root).subjects_for_changed(["skills/foo[1]/evals/a.yaml", "skills/foo[1]/SKILL.md"]) assert impact.suites == {"skill/foo[1]": ["skills/foo[1]/evals/a.yaml"]} assert impact.skills == {"skill/foo[1]": ["skills/foo[1]/SKILL.md"]} + + +def test_a_manifest_with_brackets_in_its_name_is_claimed_as_written(tmp_path): + body = HEADER + "subjects:\n - id: repo\n kind: repo\n manifest: manifests/repo[1].yaml\n" + files = {".github/dokimi/config.yaml": body, "manifests/repo[1].yaml": "x: 1\n", "manifests/repo1.yaml": "x: 2\n"} + root = _tree(tmp_path / "repo", files) + impact = load_repo_config(root).subjects_for_changed(["manifests/repo[1].yaml", "manifests/repo1.yaml"]) + assert impact.suites == {"repo": ["manifests/repo[1].yaml"]} + assert impact.unclaimed == ["manifests/repo1.yaml"] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_retargeting_a_matched_dangling_symlink_moves_suite_hash(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/*\n" + files = {".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "# a\n", "evaluation/keep.txt": "k\n"} + root = _tree(tmp_path / "repo", files) + link = root / "evaluation" / "task" + os.symlink("missing-a", link) + before = _subjects(root)["skill/a"].suite_hash + link.unlink() + os.symlink("missing-b", link) + assert _subjects(root)["skill/a"].suite_hash != before + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_retargeting_a_matched_directory_symlink_moves_suite_hash(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/*\n" + files = { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "one/q.json": "[]\n", + "two/q.json": "[]\n", + } + root = _tree(tmp_path / "repo", files) + (root / "evaluation").mkdir() + os.symlink(root / "one", root / "evaluation" / "linked", target_is_directory=True) + before = _subjects(root)["skill/a"].suite_hash + (root / "evaluation" / "linked").unlink() + os.symlink(root / "two", root / "evaluation" / "linked", target_is_directory=True) + assert _subjects(root)["skill/a"].suite_hash != before + + +@pytest.mark.parametrize( + ("body", "where"), + [ + (HEADER + "subjects:\n - id: skill/a\n path: '{bad}'\n", "path"), + (HEADER + "subjects:\n - id: repo\n kind: repo\n manifest: '{bad}'\n", "manifest"), + (HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: '{bad}'\n", "suite"), + (HEADER + "defaults:\n skills: '{bad}'\n", "defaults.skills"), + ], +) +@pytest.mark.parametrize("bad", [r"C:\outside\skill", "C:skill", "C:/outside/skill", "c:skills/a"]) +def test_a_drive_qualified_path_is_refused_in_every_field(tmp_path, body, where, bad): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body.replace("{bad}", bad), "skills/a/SKILL.md": "a"}) + with pytest.raises(ConfigError, match="inside the repository"): + load_repo_config(root).subjects(hashes=False) + + +@pytest.mark.parametrize( + ("body", "needle"), + [ + (HEADER + "defaults:\n skills: 'skills/a**b'\n", "defaults.skills"), + (HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: 'evaluation/a**b'\n", "suite"), + ], +) +def test_a_malformed_glob_is_a_config_error_that_names_the_field_and_the_pattern(tmp_path, body, needle): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "a"}) + with pytest.raises(ConfigError, match=needle) as err: + load_repo_config(root).subjects(hashes=False) + assert "a**b" in str(err.value) From d2a5ceadfe7776b2eee8e62b8c35b7b32f7a9c53 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 12:33:03 -0700 Subject: [PATCH 06/36] Keep a symlinked skill directory's content in skill_sha Self-review of the symlink-root change: a skill directory that is a symlink was hashed by its link text, so editing the target no longer moved skill_sha. The skill is now digested through its resolved target, as before; only suite matches are hashed by their link. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 3 ++- dokimi/tests/test_repo_config.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 0c0d95ecc..b5432b9c5 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -327,5 +327,6 @@ def _hashed(subject: Subject, root: Path) -> Subject: files.setdefault(subject.manifest, root / subject.manifest) return replace(subject, suite_hash=_digest_files(files)) inside = [rel[len(subject.path) + 1:] for rel in files if rel.startswith(subject.path + "/")] - skill_sha = "sha256:" + tree_digest(root / subject.path, exclude=inside) + # A skill directory that is a symlink is its target's content (a suite match is hashed by its link instead). + skill_sha = "sha256:" + tree_digest((root / subject.path).resolve(), exclude=inside) return replace(subject, skill_sha=skill_sha, suite_hash=_digest_files(files)) diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index 610eb0263..731e8a342 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -457,3 +457,13 @@ def test_a_malformed_glob_is_a_config_error_that_names_the_field_and_the_pattern with pytest.raises(ConfigError, match=needle) as err: load_repo_config(root).subjects(hashes=False) assert "a**b" in str(err.value) + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_skill_directory_that_is_a_symlink_hashes_the_content_it_points_to(tmp_path): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": BESIDE_CONFIG, "vendor/a/SKILL.md": "# a\n"}) + (root / "skills").mkdir() + os.symlink(root / "vendor" / "a", root / "skills" / "a", target_is_directory=True) + before = _subjects(root)["skill/a"].skill_sha + (root / "vendor/a/SKILL.md").write_text("# a, edited\n", encoding="utf-8") + assert _subjects(root)["skill/a"].skill_sha != before From 72eb3bf0bb31ba48562d6966af35e47789cf39b7 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 12:26:13 -0700 Subject: [PATCH 07/36] fix(repo): nested skills, evals-only suites, store false, budget numbers A nested skill is its own subject: its directory leaves the outer skill_sha, and impact names only the inner skill. A skill's suite may hold only its evals; a suite that claims its SKILL.md, scripts or references is refused at load, naming the subject and the file. baseline.store accepts false (read as off). A budget number YAML reads as text (1e3) is refused with the plain decimal or the 1.0e+3 spelling. Signed-off-by: Nikolay Petrov --- ...261030_180000_napetrov_feat-repo-config.md | 8 ++- dokimi/dokimi/repo_config.py | 48 ++++++++++++-- dokimi/dokimi/schemas/dokimi_config.v1.json | 2 +- dokimi/tests/test_repo_config.py | 66 +++++++++++++++++++ 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md index 769e3a5ce..51cbb553c 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md @@ -12,5 +12,9 @@ SPDX-License-Identifier: Apache-2.0 the smoke set, and the budget and baseline store. The loader `dokimi.repo_config` finds the file in the order explicit path, `.github/dokimi/config.yaml`, `.dokimi/config.yaml` under the repository root, validates it against the schema, and reports each subject's `skill_sha` (the tree digest of the skill - directory without its evals) and `suite_hash`. Nothing reads the file yet: the commands that use it - come later, and existing `.agent-eval/` lookups are unchanged. + directory without its evals and without any nested skill directory, which is its own subject) and + `suite_hash`. A skill's suite may hold only its evals: a suite that claims its skill's `SKILL.md`, + scripts or references is refused at load, naming the subject and the file. `baseline.store: false` + reads as `off`, and a budget number that YAML reads as text (`1e3`) is refused with the plain + decimal or the `1.0e+3` spelling. Nothing reads the file yet: the commands that use it come later, + and existing `.agent-eval/` lookups are unchanged. diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index b5432b9c5..8c4d01cce 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -85,7 +85,7 @@ def subjects(self, *, hashes: bool = True) -> list[Subject]: resolved = _resolve(self.data, self.root) for subject in resolved: _check_suite(subject, self.root) - return [_hashed(s, self.root) for s in resolved] if hashes else resolved + return [_hashed(s, self.root, resolved) for s in resolved] if hashes else resolved def subjects_for_changed(self, paths: Iterable[str]) -> Impact: """Map repository-relative changed paths onto subjects, suites and ``shared``; the rest is unclaimed.""" @@ -134,13 +134,35 @@ def load(path: Path, *, root: Path) -> RepoConfig: raise ConfigError(f"{path}: cannot read the config: {exc}") from exc if not isinstance(data, dict): raise ConfigError(f"{path}: the config must be a mapping") + _check_budget_numbers(data, path) try: validate_artifact(KIND, data) except ArtifactValidationError as exc: raise ConfigError(f"{path}: {exc}") from exc + baseline = data.get("baseline") + if isinstance(baseline, dict) and baseline.get("store") is False: + baseline["store"] = "off" # PyYAML reads an unquoted off as false return RepoConfig(path=path, root=Path(root), data=data) +_NUMBER_AS_TEXT = re.compile(r"[-+]?[0-9.]+[eE][-+]?[0-9]+") + + +def _check_budget_numbers(data: dict[str, Any], path: Path) -> None: + """PyYAML reads ``1e3`` (no dot, no signed exponent) as text; say so, with the two spellings that parse.""" + budget = data.get("budget") + if not isinstance(budget, dict): + return + for key in ("pr_max_usd", "record_max_usd"): + value = budget.get(key) + if isinstance(value, str) and _NUMBER_AS_TEXT.fullmatch(value.strip()): + plain = ("%f" % float(value)).rstrip("0").rstrip(".") + raise ConfigError( + f"{path}: budget.{key} is the text {value!r}, not a number: YAML reads it as text. " + f"Write {plain} (the plain decimal) or 1.0e+3 style (a dot and a signed exponent)" + ) + + def load_repo_config(start: Path | None = None, *, explicit: Path | None = None) -> RepoConfig: """:func:`discover` then :func:`load`, rooted at the repository above ``start``; raises when no config is found.""" here = Path(start or Path.cwd()).resolve() @@ -309,24 +331,40 @@ def _digest_files(files: dict[str, Path]) -> str: return "sha256:" + h.hexdigest() +def _within(rel: str, base: str) -> bool: + """True when ``rel`` is ``base`` or sits under it (repository-relative paths).""" + return rel == base or rel.startswith(base + "/") + + def _check_suite(subject: Subject, root: Path) -> None: - """A skill's suite must not hold the skill directory: not the directory itself, nor one of its ancestors.""" + """A skill's suite holds only its evals: not the skill directory, not an ancestor, no other file of the skill.""" if subject.kind != "skill": return + evals = f"{subject.path}/evals" for rel in _suite_files(root, subject.suite_globs): if rel == subject.path: raise ConfigError(f"subject {subject.id}: its suite matches the whole skill directory {subject.path}") if subject.path.startswith(rel + "/"): raise ConfigError(f"subject {subject.id}: its suite {rel} contains the skill directory {subject.path}") + if _within(rel, subject.path) and not _within(rel, evals): + raise ConfigError( + f"subject {subject.id}: its suite claims {rel}, a file of its skill outside its evals dir {evals}" + ) -def _hashed(subject: Subject, root: Path) -> Subject: +def _hashed(subject: Subject, root: Path, all_subjects: list[Subject]) -> Subject: files = _suite_files(root, subject.suite_globs) if subject.kind == "repo": assert subject.manifest is not None # set by _subject for kind repo files.setdefault(subject.manifest, root / subject.manifest) return replace(subject, suite_hash=_digest_files(files)) - inside = [rel[len(subject.path) + 1:] for rel in files if rel.startswith(subject.path + "/")] + inside = [rel[len(subject.path) + 1:] for rel in files if _within(rel, subject.path) and rel != subject.path] + # a nested skill is its own subject: its files belong to its own skill_sha, not to the outer one + nested = [ + s.path[len(subject.path) + 1:] + for s in all_subjects + if s.kind == "skill" and s.path != subject.path and _within(s.path, subject.path) + ] # A skill directory that is a symlink is its target's content (a suite match is hashed by its link instead). - skill_sha = "sha256:" + tree_digest((root / subject.path).resolve(), exclude=inside) + skill_sha = "sha256:" + tree_digest((root / subject.path).resolve(), exclude=[*inside, *nested]) return replace(subject, skill_sha=skill_sha, suite_hash=_digest_files(files)) diff --git a/dokimi/dokimi/schemas/dokimi_config.v1.json b/dokimi/dokimi/schemas/dokimi_config.v1.json index 9b08a0900..97dd0f4a0 100644 --- a/dokimi/dokimi/schemas/dokimi_config.v1.json +++ b/dokimi/dokimi/schemas/dokimi_config.v1.json @@ -77,7 +77,7 @@ "required": ["store"], "additionalProperties": false, "properties": { - "store": {"type": "string", "pattern": "^(off|branch:.+)$", "description": "off, or branch: for the baseline branch."} + "store": {"anyOf": [{"type": "string", "pattern": "^(off|branch:.+)$"}, {"const": false}], "description": "off (false is read as off too: an unquoted off is a boolean in YAML 1.1), or branch: for the baseline branch."} } } } diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index 731e8a342..50b7fc7a9 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -467,3 +467,69 @@ def test_a_skill_directory_that_is_a_symlink_hashes_the_content_it_points_to(tmp before = _subjects(root)["skill/a"].skill_sha (root / "vendor/a/SKILL.md").write_text("# a, edited\n", encoding="utf-8") assert _subjects(root)["skill/a"].skill_sha != before + + +def test_a_nested_skill_is_not_part_of_the_outer_skill_sha(tmp_path): + body = ( + HEADER + + "subjects:\n" + + " - id: skill/outer\n path: skills/outer\n" + + " - id: skill/inner\n path: skills/outer/inner\n" + ) + files = { + ".github/dokimi/config.yaml": body, + "skills/outer/SKILL.md": "outer\n", + "skills/outer/inner/SKILL.md": "inner\n", + } + root = _tree(tmp_path / "repo", files) + before = _subjects(root) + (root / "skills/outer/inner/SKILL.md").write_text("changed\n", encoding="utf-8") + after = _subjects(root) + assert after["skill/outer"].skill_sha == before["skill/outer"].skill_sha + assert after["skill/inner"].skill_sha != before["skill/inner"].skill_sha + (root / "skills/outer/notes.md").write_text("n\n", encoding="utf-8") + assert _subjects(root)["skill/outer"].skill_sha != after["skill/outer"].skill_sha + + +@pytest.mark.parametrize( + ("suite", "claimed"), + [ + ("'{skill}/*'", "skills/a/SKILL.md"), + ("'{skill}/scripts/*'", "skills/a/scripts/run.py"), + ], +) +def test_a_suite_that_claims_a_file_of_its_skill_outside_its_evals_is_refused(tmp_path, suite, claimed): + body = HEADER + f"subjects:\n - id: skill/a\n path: skills/a\n suite: {suite}\n" + files = { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "a\n", + "skills/a/evals/q.json": "[]\n", + "skills/a/scripts/run.py": "print(1)\n", + } + root = _tree(tmp_path / "repo", files) + with pytest.raises(ConfigError, match="skill/a") as err: + load_repo_config(root).subjects(hashes=False) + assert claimed in str(err.value) and "outside its evals dir skills/a/evals" in str(err.value) + + +def test_a_suite_inside_the_evals_dir_is_accepted(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: '{skill}/evals/*'\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "a\n", "skills/a/evals/q.json": "[]\n"}) + assert _subjects(root)["skill/a"].suite_hash.startswith("sha256:") + + +@pytest.mark.parametrize("store_line", ["store: false", "store: 'off'", "store: off"]) +def test_an_off_store_loads_as_off_whether_quoted_or_read_as_false(tmp_path, store_line): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": HEADER + f"baseline:\n {store_line}\n"}) + assert load_repo_config(root).data["baseline"]["store"] == "off" + + +def test_a_budget_number_written_as_text_gets_a_hint_with_the_plain_decimal(tmp_path): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": HEADER + "budget:\n pr_max_usd: 1e3\n"}) + with pytest.raises(ConfigError, match=r"budget\.pr_max_usd is the text '1e3'.*Write 1000 .*1\.0e\+3"): + load_repo_config(root) + + +def test_a_budget_number_with_a_dot_and_signed_exponent_loads(tmp_path): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": HEADER + "budget:\n pr_max_usd: 1.0e+3\n"}) + assert load_repo_config(root).data["budget"]["pr_max_usd"] == 1000.0 From bf015b45217a5195ebafaab5b16df1c8f0313238 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 12:39:14 -0700 Subject: [PATCH 08/36] fix(repo): exact budget hint; budget text that is no number is a config error The plain-decimal hint used %f, so 1e-7 was hinted as 0. Format the Decimal instead. A budget text the regex matches but float() rejects (1.2.3e4, .e5) raised ValueError out of load(); it now falls through to the schema's type error, a ConfigError. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 9 +++++++-- dokimi/tests/test_repo_config.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 8c4d01cce..44a4c6095 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -19,6 +19,7 @@ import glob import hashlib import re +from decimal import Decimal, InvalidOperation from dataclasses import dataclass, field, replace from pathlib import Path, PurePosixPath from typing import Any, Iterable @@ -155,8 +156,12 @@ def _check_budget_numbers(data: dict[str, Any], path: Path) -> None: return for key in ("pr_max_usd", "record_max_usd"): value = budget.get(key) - if isinstance(value, str) and _NUMBER_AS_TEXT.fullmatch(value.strip()): - plain = ("%f" % float(value)).rstrip("0").rstrip(".") + text = value.strip() if isinstance(value, str) else "" + if _NUMBER_AS_TEXT.fullmatch(text): + try: + plain = format(Decimal(text), "f") # exact: 1e-7 is 0.0000001, not 0 + except InvalidOperation: + continue # text that is no number at all: the schema refuses it as a type error raise ConfigError( f"{path}: budget.{key} is the text {value!r}, not a number: YAML reads it as text. " f"Write {plain} (the plain decimal) or 1.0e+3 style (a dot and a signed exponent)" diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index 50b7fc7a9..f94e0f41d 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -533,3 +533,17 @@ def test_a_budget_number_written_as_text_gets_a_hint_with_the_plain_decimal(tmp_ def test_a_budget_number_with_a_dot_and_signed_exponent_loads(tmp_path): root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": HEADER + "budget:\n pr_max_usd: 1.0e+3\n"}) assert load_repo_config(root).data["budget"]["pr_max_usd"] == 1000.0 + + +@pytest.mark.parametrize(("value", "plain"), [("1e-7", "0.0000001"), ("1.5e-7", "0.00000015"), ("1e+3", "1000")]) +def test_the_plain_decimal_hint_keeps_the_value_it_was_written_with(tmp_path, value, plain): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": HEADER + f"budget:\n pr_max_usd: '{value}'\n"}) + with pytest.raises(ConfigError, match=rf"Write {plain} \(the plain decimal\)"): + load_repo_config(root) + + +@pytest.mark.parametrize("value", ["1.2.3e4", ".e5"]) +def test_a_budget_text_that_is_no_number_is_a_config_error_not_a_crash(tmp_path, value): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": HEADER + f"budget:\n pr_max_usd: '{value}'\n"}) + with pytest.raises(ConfigError, match=r"pr_max_usd.*not of type 'number'"): + load_repo_config(root) From 81b24fc70c4c87ee07a5a872905ca27e18a38331 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 12:47:37 -0700 Subject: [PATCH 09/36] fix(repo): a nan or inf budget is refused; symlinked evals hash their target A budget of .nan or .inf passed the schema and disabled the cap check, so it is now refused with a message. A suite directory is walked file by file, and a symlink is hashed by the file it points to, so an edit to a shared eval moves suite_hash. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 22 +++++++++++++++++++++- dokimi/tests/test_repo_config.py | 24 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 44a4c6095..a869b25ef 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -18,6 +18,8 @@ import fnmatch import glob import hashlib +import math +import os import re from decimal import Decimal, InvalidOperation from dataclasses import dataclass, field, replace @@ -156,6 +158,8 @@ def _check_budget_numbers(data: dict[str, Any], path: Path) -> None: return for key in ("pr_max_usd", "record_max_usd"): value = budget.get(key) + if isinstance(value, float) and not math.isfinite(value): + raise ConfigError(f"{path}: budget.{key} must be a finite number, not {value}: a cap of nan or inf never stops the spend") text = value.strip() if isinstance(value, str) else "" if _NUMBER_AS_TEXT.fullmatch(text): try: @@ -330,12 +334,28 @@ def _suite_files(root: Path, globs: tuple[str, ...]) -> dict[str, Path]: def _digest_files(files: dict[str, Path]) -> str: + """One digest over the suite's files. A directory is walked file by file, and a symlink is hashed by what it points to.""" h = hashlib.sha256() for rel in sorted(files): - h.update(f"{rel}\0{tree_digest(files[rel])}\n".encode()) + for name, path in _walk(files[rel], rel): + target = path.resolve() if path.exists() else path + h.update(f"{name}\0{tree_digest(target)}\n".encode()) return "sha256:" + h.hexdigest() +def _walk(path: Path, rel: str) -> list[tuple[str, Path]]: + """The regular files under ``path`` (or ``path`` itself); a symlink to a directory is not followed.""" + if not path.is_dir(): + return [(rel, path)] + found = [] + for dirpath, _dirs, names in os.walk(path): + for name in names: + full = Path(dirpath) / name + if full.is_file(): + found.append((f"{rel}/{full.relative_to(path).as_posix()}", full)) + return sorted(found) + + def _within(rel: str, base: str) -> bool: """True when ``rel`` is ``base`` or sits under it (repository-relative paths).""" return rel == base or rel.startswith(base + "/") diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index f94e0f41d..3f5dc8357 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -547,3 +547,27 @@ def test_a_budget_text_that_is_no_number_is_a_config_error_not_a_crash(tmp_path, root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": HEADER + f"budget:\n pr_max_usd: '{value}'\n"}) with pytest.raises(ConfigError, match=r"pr_max_usd.*not of type 'number'"): load_repo_config(root) + + +@pytest.mark.parametrize("key", ["pr_max_usd", "record_max_usd"]) +@pytest.mark.parametrize("value", [".nan", ".inf", "-.inf"]) +def test_a_budget_that_is_not_a_finite_number_is_refused(tmp_path, key, value): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": HEADER + f"budget:\n {key}: {value}\n"}) + with pytest.raises(ConfigError, match=rf"budget\.{key} must be a finite number"): + load_repo_config(root) + + +def test_an_eval_that_is_a_symlink_hashes_the_file_it_points_to(tmp_path): + import os + + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "a\n", + "shared/q.json": "[1]\n", + "skills/a/evals/.keep": "", + }) + os.symlink("../../../shared/q.json", root / "skills/a/evals/q.json") + before = _subjects(root)["skill/a"].suite_hash + (root / "shared/q.json").write_text("[2]\n", encoding="utf-8") + assert _subjects(root)["skill/a"].suite_hash != before From 1a078943f4c288e6a837dfc5c9bf0a27570b1e47 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 12:54:40 -0700 Subject: [PATCH 10/36] fix(repo): follow symlinked eval directories; refuse an evals dir that links to its skill A symlinked directory inside a suite was skipped, so an edit to a shared eval did not move suite_hash. The walk now follows symlinks, visiting each real directory once. An evals directory that resolves to its own skill (or an ancestor) is refused, since the suite would hold SKILL.md. Signed-off-by: Nikolay Petrov --- ...261030_180000_napetrov_feat-repo-config.md | 3 +- dokimi/dokimi/repo_config.py | 29 ++++++++++++++----- dokimi/tests/test_repo_config.py | 26 +++++++++++++++++ 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md index 51cbb553c..53994c67f 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md @@ -16,5 +16,6 @@ SPDX-License-Identifier: Apache-2.0 `suite_hash`. A skill's suite may hold only its evals: a suite that claims its skill's `SKILL.md`, scripts or references is refused at load, naming the subject and the file. `baseline.store: false` reads as `off`, and a budget number that YAML reads as text (`1e3`) is refused with the plain - decimal or the `1.0e+3` spelling. Nothing reads the file yet: the commands that use it come later, + decimal or the `1.0e+3` spelling, and a `.nan` or `.inf` budget is refused. A suite directory is + hashed file by file, following symlinks, so an edit to a shared eval moves `suite_hash`. Nothing reads the file yet: the commands that use it come later, and existing `.agent-eval/` lookups are unchanged. diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index a869b25ef..f8f53df3b 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -19,7 +19,6 @@ import glob import hashlib import math -import os import re from decimal import Decimal, InvalidOperation from dataclasses import dataclass, field, replace @@ -344,15 +343,24 @@ def _digest_files(files: dict[str, Path]) -> str: def _walk(path: Path, rel: str) -> list[tuple[str, Path]]: - """The regular files under ``path`` (or ``path`` itself); a symlink to a directory is not followed.""" + """The regular files under ``path`` (or ``path`` itself), following symlinks; a directory is visited once, so a cycle ends.""" if not path.is_dir(): return [(rel, path)] - found = [] - for dirpath, _dirs, names in os.walk(path): - for name in names: - full = Path(dirpath) / name - if full.is_file(): - found.append((f"{rel}/{full.relative_to(path).as_posix()}", full)) + found: list[tuple[str, Path]] = [] + seen: set[Path] = set() + + def visit(directory: Path, prefix: str) -> None: + real = directory.resolve() + if real in seen: + return + seen.add(real) + for child in sorted(directory.iterdir()): + if child.is_dir(): + visit(child, f"{prefix}/{child.name}") + elif child.is_file(): + found.append((f"{prefix}/{child.name}", child)) + + visit(path, rel) return sorted(found) @@ -366,6 +374,11 @@ def _check_suite(subject: Subject, root: Path) -> None: if subject.kind != "skill": return evals = f"{subject.path}/evals" + if (root / subject.path).resolve().is_relative_to((root / evals).resolve()): + raise ConfigError( + f"subject {subject.id}: its evals dir {evals} links to its skill or an ancestor of it, " + "so the suite would hold the skill's own files" + ) for rel in _suite_files(root, subject.suite_globs): if rel == subject.path: raise ConfigError(f"subject {subject.id}: its suite matches the whole skill directory {subject.path}") diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index 3f5dc8357..cc5d77bed 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -571,3 +571,29 @@ def test_an_eval_that_is_a_symlink_hashes_the_file_it_points_to(tmp_path): before = _subjects(root)["skill/a"].suite_hash (root / "shared/q.json").write_text("[2]\n", encoding="utf-8") assert _subjects(root)["skill/a"].suite_hash != before + + +def test_an_eval_directory_that_is_a_symlink_hashes_the_files_it_reaches(tmp_path): + import os + + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "a\n", + "common/q.json": "[1]\n", + "skills/a/evals/.keep": "", + }) + os.symlink("../../../common", root / "skills/a/evals/shared") + before = _subjects(root)["skill/a"].suite_hash + (root / "common/q.json").write_text("[2]\n", encoding="utf-8") + assert _subjects(root)["skill/a"].suite_hash != before + + +def test_an_evals_dir_that_links_to_its_own_skill_is_refused(tmp_path): + import os + + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "a\n"}) + os.symlink(".", root / "skills/a/evals") + with pytest.raises(ConfigError, match=r"skill/a.*evals.*link"): + load_repo_config(root).subjects(hashes=False) From 2b2c04fb938c60e6a14e6cc3b891aa442ca553fd Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 13:32:33 -0700 Subject: [PATCH 11/36] fix(repo): a suite claims whole directories of its skill, not single files The evals-only rule refused a suite that claims a whole directory such as tests/** of its skill. A suite may now claim any directory of its skill whole; that directory leaves skill_sha. A suite that picks an individual file of its skill (SKILL.md, a script, a reference, one eval file) is refused at load, naming the subject and the file. Signed-off-by: Nikolay Petrov --- ...261030_180000_napetrov_feat-repo-config.md | 5 ++-- dokimi/dokimi/repo_config.py | 25 ++++++++++------- dokimi/tests/test_repo_config.py | 28 +++++++++++++++++-- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md index 53994c67f..81b7b8c82 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md @@ -13,8 +13,9 @@ SPDX-License-Identifier: Apache-2.0 the order explicit path, `.github/dokimi/config.yaml`, `.dokimi/config.yaml` under the repository root, validates it against the schema, and reports each subject's `skill_sha` (the tree digest of the skill directory without its evals and without any nested skill directory, which is its own subject) and - `suite_hash`. A skill's suite may hold only its evals: a suite that claims its skill's `SKILL.md`, - scripts or references is refused at load, naming the subject and the file. `baseline.store: false` + `suite_hash`. A skill's suite claims whole directories of its skill (its evals, or a directory such as + `tests/**`), and those directories leave `skill_sha`. A suite that picks an individual file of its skill + (`SKILL.md`, a script, a reference, one eval file) is refused at load, naming the subject and the file. `baseline.store: false` reads as `off`, and a budget number that YAML reads as text (`1e3`) is refused with the plain decimal or the `1.0e+3` spelling, and a `.nan` or `.inf` budget is refused. A suite directory is hashed file by file, following symlinks, so an edit to a shared eval moves `suite_hash`. Nothing reads the file yet: the commands that use it come later, diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index f8f53df3b..0f425c70a 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -370,24 +370,29 @@ def _within(rel: str, base: str) -> bool: def _check_suite(subject: Subject, root: Path) -> None: - """A skill's suite holds only its evals: not the skill directory, not an ancestor, no other file of the skill.""" + """A skill's suite claims whole directories of its skill, never single files of it (SKILL.md, scripts, references).""" if subject.kind != "skill": return - evals = f"{subject.path}/evals" - if (root / subject.path).resolve().is_relative_to((root / evals).resolve()): - raise ConfigError( - f"subject {subject.id}: its evals dir {evals} links to its skill or an ancestor of it, " - "so the suite would hold the skill's own files" - ) - for rel in _suite_files(root, subject.suite_globs): + matches = _suite_files(root, subject.suite_globs) + real_skill = (root / subject.path).resolve() + for rel, path in matches.items(): if rel == subject.path: raise ConfigError(f"subject {subject.id}: its suite matches the whole skill directory {subject.path}") if subject.path.startswith(rel + "/"): raise ConfigError(f"subject {subject.id}: its suite {rel} contains the skill directory {subject.path}") - if _within(rel, subject.path) and not _within(rel, evals): + if path.is_dir() and real_skill.is_relative_to(path.resolve()): raise ConfigError( - f"subject {subject.id}: its suite claims {rel}, a file of its skill outside its evals dir {evals}" + f"subject {subject.id}: its directory {rel} links to its skill or an ancestor of it, " + "so the suite would hold the skill's own files" ) + dirs = [rel for rel, path in matches.items() if path.is_dir() and _within(rel, subject.path)] + for rel, path in matches.items(): + if path.is_dir() or not _within(rel, subject.path) or any(_within(rel, d) for d in dirs): + continue + raise ConfigError( + f"subject {subject.id}: its suite claims the file {rel} of its skill, not a whole directory; " + f"claim the directory instead, for example {subject.path}/evals/**" + ) def _hashed(subject: Subject, root: Path, all_subjects: list[Subject]) -> Subject: diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index cc5d77bed..9abc2e711 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -495,25 +495,47 @@ def test_a_nested_skill_is_not_part_of_the_outer_skill_sha(tmp_path): ("suite", "claimed"), [ ("'{skill}/*'", "skills/a/SKILL.md"), + ("'{skill}/SKILL.md'", "skills/a/SKILL.md"), ("'{skill}/scripts/*'", "skills/a/scripts/run.py"), + ("'{skill}/references/*.md'", "skills/a/references/r.md"), + ("'{skill}/evals/*.json'", "skills/a/evals/q.json"), ], ) -def test_a_suite_that_claims_a_file_of_its_skill_outside_its_evals_is_refused(tmp_path, suite, claimed): +def test_a_suite_that_picks_an_individual_file_of_its_skill_is_refused(tmp_path, suite, claimed): body = HEADER + f"subjects:\n - id: skill/a\n path: skills/a\n suite: {suite}\n" files = { ".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "a\n", "skills/a/evals/q.json": "[]\n", "skills/a/scripts/run.py": "print(1)\n", + "skills/a/references/r.md": "r\n", } root = _tree(tmp_path / "repo", files) with pytest.raises(ConfigError, match="skill/a") as err: load_repo_config(root).subjects(hashes=False) - assert claimed in str(err.value) and "outside its evals dir skills/a/evals" in str(err.value) + assert claimed in str(err.value) and "not a whole directory" in str(err.value) + + +def test_a_suite_that_claims_a_whole_directory_of_its_skill_is_accepted_and_leaves_skill_sha(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: '{skill}/tests/**'\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "a\n", + "skills/a/tests/q.json": "[]\n", + "skills/a/tests/sub/r.json": "[]\n", + }) + before = _subjects(root)["skill/a"] + (root / "skills/a/tests/sub/r.json").write_text("[2]\n", encoding="utf-8") + after = _subjects(root)["skill/a"] + assert after.skill_sha == before.skill_sha and after.suite_hash != before.suite_hash + (root / "skills/a/SKILL.md").write_text("b\n", encoding="utf-8") + assert _subjects(root)["skill/a"].skill_sha != after.skill_sha + impact = load_repo_config(root).subjects_for_changed(["skills/a/tests/sub/r.json"]) + assert impact.suites == {"skill/a": ["skills/a/tests/sub/r.json"]} and impact.skills == {} def test_a_suite_inside_the_evals_dir_is_accepted(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: '{skill}/evals/*'\n" + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: '{skill}/evals/**'\n" root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "a\n", "skills/a/evals/q.json": "[]\n"}) assert _subjects(root)["skill/a"].suite_hash.startswith("sha256:") From c8d18fdf8c4438e94d47141a18bb3f56682331c2 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 13:54:40 -0700 Subject: [PATCH 12/36] fix(repo): a suite symlink also counts by its target text The rebase onto the review fixes on fork/feat/dokimi-config: a retargeted directory symlink between two identical trees left suite_hash unchanged, because the walk hashed only the content it reaches. A symlink in a suite now adds its target text to the digest, as tree_digest does for a link, so a retarget moves the hash and an edit to the content still moves it. os is imported again for readlink. Signed-off-by: Nikolay Petrov --- ...261030_180000_napetrov_feat-repo-config.md | 2 +- dokimi/dokimi/repo_config.py | 35 +++++++++++-------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md index 81b7b8c82..a4725a170 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md @@ -18,5 +18,5 @@ SPDX-License-Identifier: Apache-2.0 (`SKILL.md`, a script, a reference, one eval file) is refused at load, naming the subject and the file. `baseline.store: false` reads as `off`, and a budget number that YAML reads as text (`1e3`) is refused with the plain decimal or the `1.0e+3` spelling, and a `.nan` or `.inf` budget is refused. A suite directory is - hashed file by file, following symlinks, so an edit to a shared eval moves `suite_hash`. Nothing reads the file yet: the commands that use it come later, + hashed file by file, following symlinks; a link also counts by its target text, so an edit to a shared eval, or a retarget of its link, moves `suite_hash`. Nothing reads the file yet: the commands that use it come later, and existing `.agent-eval/` lookups are unchanged. diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 0f425c70a..32d2b0752 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -18,6 +18,7 @@ import fnmatch import glob import hashlib +import os import math import re from decimal import Decimal, InvalidOperation @@ -333,32 +334,38 @@ def _suite_files(root: Path, globs: tuple[str, ...]) -> dict[str, Path]: def _digest_files(files: dict[str, Path]) -> str: - """One digest over the suite's files. A directory is walked file by file, and a symlink is hashed by what it points to.""" + """One digest over the suite's files. A directory is walked file by file; a symlink counts by its target text + (as tree_digest does) and by what it points to, so retargeting a link moves the hash even between identical trees.""" h = hashlib.sha256() for rel in sorted(files): for name, path in _walk(files[rel], rel): + if path.is_symlink(): + h.update(f"{name}\0-> {os.readlink(path)}\n".encode()) + if path.is_dir(): + continue # a linked directory: its files are walked and hashed one by one target = path.resolve() if path.exists() else path h.update(f"{name}\0{tree_digest(target)}\n".encode()) return "sha256:" + h.hexdigest() def _walk(path: Path, rel: str) -> list[tuple[str, Path]]: - """The regular files under ``path`` (or ``path`` itself), following symlinks; a directory is visited once, so a cycle ends.""" - if not path.is_dir(): - return [(rel, path)] + """Entries under ``path`` (or ``path`` itself): regular files and symlinks. Symlinks are followed into their + directories; a directory is visited once by its resolved path, so a cycle ends.""" found: list[tuple[str, Path]] = [] seen: set[Path] = set() - def visit(directory: Path, prefix: str) -> None: - real = directory.resolve() - if real in seen: - return - seen.add(real) - for child in sorted(directory.iterdir()): - if child.is_dir(): - visit(child, f"{prefix}/{child.name}") - elif child.is_file(): - found.append((f"{prefix}/{child.name}", child)) + def visit(entry: Path, name: str) -> None: + if entry.is_symlink(): + found.append((name, entry)) + if entry.is_dir(): + real = entry.resolve() + if real in seen: + return + seen.add(real) + for child in sorted(entry.iterdir()): + visit(child, f"{name}/{child.name}") + elif entry.is_file() and not entry.is_symlink(): + found.append((name, entry)) visit(path, rel) return sorted(found) From 031b7ff0ce0ccb68c65af071d26c6281657163ca Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 14:25:17 -0700 Subject: [PATCH 13/36] fix(repo): refuse a link into the skill, an id clash, and a huge budget exponent Found in the self-review of the rebased head: a suite link such as evals/skill_link.md -> ../SKILL.md reached the skill's own files, so the suite held them; an explicit subject took the id of a default skill at another directory and dropped it without a word; a budget written as 1e99999999 expanded to a 100 MB error message. Each now has a red-then-green test. A link the suite walks that resolves into its skill is refused; the id clash is a ConfigError naming both directories; the budget hint gives no plain decimal past exponent 30. Signed-off-by: Nikolay Petrov --- ...261030_180000_napetrov_feat-repo-config.md | 2 +- dokimi/dokimi/repo_config.py | 33 ++++++++++++++----- dokimi/tests/test_repo_config.py | 26 +++++++++++++++ 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md index a4725a170..34457fafd 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md @@ -15,7 +15,7 @@ SPDX-License-Identifier: Apache-2.0 directory without its evals and without any nested skill directory, which is its own subject) and `suite_hash`. A skill's suite claims whole directories of its skill (its evals, or a directory such as `tests/**`), and those directories leave `skill_sha`. A suite that picks an individual file of its skill - (`SKILL.md`, a script, a reference, one eval file) is refused at load, naming the subject and the file. `baseline.store: false` + (`SKILL.md`, a script, a reference, one eval file) is refused at load, naming the subject and the file. A link in a suite that resolves into its skill is refused too. An explicit subject whose id is taken by a default skill at another directory is refused instead of silently replacing it. `baseline.store: false` reads as `off`, and a budget number that YAML reads as text (`1e3`) is refused with the plain decimal or the `1.0e+3` spelling, and a `.nan` or `.inf` budget is refused. A suite directory is hashed file by file, following symlinks; a link also counts by its target text, so an edit to a shared eval, or a retarget of its link, moves `suite_hash`. Nothing reads the file yet: the commands that use it come later, diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 32d2b0752..595e0e788 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -163,13 +163,13 @@ def _check_budget_numbers(data: dict[str, Any], path: Path) -> None: text = value.strip() if isinstance(value, str) else "" if _NUMBER_AS_TEXT.fullmatch(text): try: - plain = format(Decimal(text), "f") # exact: 1e-7 is 0.0000001, not 0 + number = Decimal(text) except InvalidOperation: continue # text that is no number at all: the schema refuses it as a type error - raise ConfigError( - f"{path}: budget.{key} is the text {value!r}, not a number: YAML reads it as text. " - f"Write {plain} (the plain decimal) or 1.0e+3 style (a dot and a signed exponent)" - ) + spelled = "Write the number with a dot and a signed exponent, 1.0e+3 style" + if abs(number.adjusted()) <= 30: # exact: 1e-7 is 0.0000001, not 0; a huge exponent is not expanded + spelled = f"Write {format(number, 'f')} (the plain decimal) or 1.0e+3 style (a dot and a signed exponent)" + raise ConfigError(f"{path}: budget.{key} is the text {value!r}, not a number: YAML reads it as text. {spelled}") def load_repo_config(start: Path | None = None, *, explicit: Path | None = None) -> RepoConfig: @@ -264,11 +264,20 @@ def _resolve(data: dict[str, Any], root: Path) -> list[Subject]: """Defaults plus explicit subjects; an explicit subject replaces a default with its id or its directory.""" template = (data.get("defaults") or {}).get("suite", DEFAULT_SUITE) explicit = [_subject(entry, root, template) for entry in data.get("subjects") or []] - taken_ids = {s.id for s in explicit} taken_paths = {s.path for s in explicit} - found = [_subject({"id": f"skill/{PurePosixPath(rel).name}", "path": rel}, root, template) - for rel in _skill_dirs(data, root) if rel not in taken_paths] - subjects = [*explicit, *(s for s in found if s.id not in taken_ids)] + found: list[Subject] = [] + for rel in _skill_dirs(data, root): + if rel in taken_paths: + continue + default = _subject({"id": f"skill/{PurePosixPath(rel).name}", "path": rel}, root, template) + clash = next((s for s in explicit if s.id == default.id), None) + if clash is not None: + raise ConfigError( + f"subject id {default.id!r} is the explicit subject at {clash.path}, and the default skill at {rel} " + "has the same id: give one of them a distinct id" + ) + found.append(default) + subjects = [*explicit, *found] _check_unique(subjects) return sorted(subjects, key=lambda s: s.id) @@ -392,6 +401,12 @@ def _check_suite(subject: Subject, root: Path) -> None: f"subject {subject.id}: its directory {rel} links to its skill or an ancestor of it, " "so the suite would hold the skill's own files" ) + for name, entry in _walk(path, rel): + if entry.is_symlink() and entry.resolve().is_relative_to(real_skill): + raise ConfigError( + f"subject {subject.id}: its suite holds {name}, a link into its skill {subject.path}; " + "a suite holds its own files, not links into the skill" + ) dirs = [rel for rel, path in matches.items() if path.is_dir() and _within(rel, subject.path)] for rel, path in matches.items(): if path.is_dir() or not _within(rel, subject.path) or any(_within(rel, d) for d in dirs): diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index 9abc2e711..371f9150f 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -619,3 +619,29 @@ def test_an_evals_dir_that_links_to_its_own_skill_is_refused(tmp_path): os.symlink(".", root / "skills/a/evals") with pytest.raises(ConfigError, match=r"skill/a.*evals.*link"): load_repo_config(root).subjects(hashes=False) + + +def test_an_explicit_subject_that_takes_the_id_of_another_default_is_refused(tmp_path): + body = HEADER + "defaults:\n skills: skills/*/*\nsubjects:\n - {id: skill/foo, path: skills/a/foo}\n" + files = {".github/dokimi/config.yaml": body, "skills/a/foo/SKILL.md": "a\n", "skills/b/foo/SKILL.md": "b\n"} + root = _tree(tmp_path / "repo", files) + with pytest.raises(ConfigError, match=r"skill/foo.*skills/b/foo.*distinct id"): + load_repo_config(root).subjects(hashes=False) + + +def test_a_budget_with_a_huge_exponent_is_refused_with_a_short_message(tmp_path): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": HEADER + "budget:\n pr_max_usd: 1e99999999\n"}) + with pytest.raises(ConfigError, match=r"pr_max_usd") as err: + load_repo_config(root) + assert len(str(err.value)) < 500 + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +@pytest.mark.parametrize(("link", "target"), [("evals/skill_link.md", "../SKILL.md"), ("evals/up", "..")]) +def test_a_suite_link_into_its_skill_is_refused(tmp_path, link, target): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n" + files = {".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "a\n", "skills/a/evals/e.json": "{}\n"} + root = _tree(tmp_path / "repo", files) + os.symlink(target, root / "skills/a" / link) + with pytest.raises(ConfigError, match=r"skill/a.*link"): + load_repo_config(root).subjects(hashes=False) From 479e791054a8bed9fa07c9d62370f6ad61dc1cfd Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 22:59:13 -0700 Subject: [PATCH 14/36] fix(repo): literal config paths, link-aware impact, case-sensitive globs - The config's own path is escaped before it joins the impact globs, so a bracketed config name claims only its own change. - Suite and skill globs match names from the directory listing, so matching is case-sensitive on every platform, as impact is. ** matches zero or more directories and does not recurse through a symlink; a symlink loop is a ConfigError naming the link. - Impact follows links: a change at a symlink target maps to the skill or suite that reads it. Two skills that alias one directory both own its change. A skill linked into another skill's directory is refused. - A suite may hold only its own evals: a suite that links into its skill, or to an ancestor of it, is refused at load. - An unreadable directory is a ConfigError naming the path, not a PermissionError out of the loader. - Helpers split out of _glob_paths and _check_suite for the complexity cap. Thread: PRRT_kwDOVBg7g86yUBWw Thread: PRRT_kwDOVBg7g86yWE0a Thread: PRRT_kwDOVBg7g86yUBWz Thread: PRRT_kwDOVBg7g86yUBW3 Thread: PRRT_kwDOVBg7g86yWEHL Thread: PRRT_kwDOVBg7g86yWE0e Thread: PRRT_kwDOVBg7g86yWEHN Thread: PRRT_kwDOVBg7g86yWEHP Thread: PRRT_kwDOVBg7g86yWEHR Thread: PRRT_kwDOVBg7g86yWE0P Thread: PRRT_kwDOVBg7g86yWE0j Thread: PRRT_kwDOVBg7g86yVRTz Thread: PRRT_kwDOVBg7g86yVRT2 Thread: PRRT_kwDOVBg7g86yVRT6 Signed-off-by: Nikolay Petrov --- dokimi/dokimi/artifacts.py | 5 +- dokimi/dokimi/repo_config.py | 284 +++++++++++++++++++++++++------ dokimi/tests/test_repo_config.py | 160 ++++++++++++++++- 3 files changed, 393 insertions(+), 56 deletions(-) diff --git a/dokimi/dokimi/artifacts.py b/dokimi/dokimi/artifacts.py index b71f3fe6c..cd33a9220 100644 --- a/dokimi/dokimi/artifacts.py +++ b/dokimi/dokimi/artifacts.py @@ -12,8 +12,9 @@ what moves it), ``repo_environment`` (static inspection of what a repository offers an agent), ``plan`` (the cells a run promises to measure), ``result`` (one status per planned cell), ``repo_budget`` (what a repository puts -into an agent's context) and ``task_inspect`` (which eval-task format each -path under a tree is) and ``dokimi_config`` (a repository's subjects and where their evals are). This module stamps each with a +into an agent's context), ``task_inspect`` (which eval-task format each +path under a tree is) and ``dokimi_config`` (a repository's subjects and where +their evals are). This module stamps each with a ``schema_version`` and validates it against the JSON Schema in ``dokimi/schemas/`` on save and load, so fixtures and baselines stay forward-compatible and self-describing. diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 595e0e788..73a7c95d4 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -84,24 +84,33 @@ class RepoConfig: data: dict[str, Any] def subjects(self, *, hashes: bool = True) -> list[Subject]: - """The subjects after defaults and explicit overrides, sorted by id; ``hashes=False`` skips the tree walks.""" - resolved = _resolve(self.data, self.root) - for subject in resolved: - _check_suite(subject, self.root) - return [_hashed(s, self.root, resolved) for s in resolved] if hashes else resolved + """The subjects after defaults and explicit overrides, sorted by id; ``hashes=False`` skips the tree walks. + A directory that cannot be read raises ConfigError naming it.""" + try: + resolved = _resolve(self.data, self.root) + _check_nesting(resolved, self.root) + for subject in resolved: + _check_suite(subject, self.root) + return [_hashed(s, self.root, resolved) for s in resolved] if hashes else resolved + except OSError as exc: + raise _unreadable(exc) from exc def subjects_for_changed(self, paths: Iterable[str]) -> Impact: """Map repository-relative changed paths onto subjects, suites and ``shared``; the rest is unclaimed.""" - subjects = self.subjects(hashes=False) - shared = [*(_pattern(g, "shared") for g in _as_list(self.data.get("shared"))), *_own_path(self)] - impact = Impact() - for raw in paths: - rel = _norm(str(raw)) - if rel is None: - impact.unclaimed.append(str(raw)) - continue - _map_one(rel, subjects, shared, impact) - return impact + try: + subjects = self.subjects(hashes=False) + reach = {s.id: _reach(s, self.root) for s in subjects} + shared = [*(_pattern(g, "shared") for g in _as_list(self.data.get("shared"))), *_own_path(self)] + impact = Impact() + for raw in paths: + rel = _norm(str(raw)) + if rel is None: + impact.unclaimed.append(str(raw)) + continue + _map_one(rel, subjects, reach, shared, impact) + return impact + except OSError as exc: + raise _unreadable(exc) from exc def find_repo_root(start: Path) -> Path: @@ -212,9 +221,10 @@ def _as_list(value: Any) -> list[str]: def _own_path(config: RepoConfig) -> list[str]: - """The config file is shared: a change to it reruns the smoke set. Empty when it sits outside the root.""" + """The config file is shared: a change to it reruns the smoke set. Empty when it sits outside the root. + It is a literal path, but ``shared`` is matched as a glob, so its metacharacters are escaped.""" try: - return [config.path.resolve().relative_to(config.root.resolve()).as_posix()] + return [glob.escape(config.path.resolve().relative_to(config.root.resolve()).as_posix())] except ValueError: return [] @@ -233,27 +243,75 @@ def _match(pat: tuple[str, ...], segs: tuple[str, ...]) -> bool: return bool(segs) and fnmatch.fnmatchcase(segs[0], pat[0]) and _match(pat[1:], segs[1:]) -def _map_one(rel: str, subjects: list[Subject], shared: list[str], impact: Impact) -> None: +Reach = tuple[list[tuple[str, ...]], list[tuple[str, ...]]] # (skill directories, suite files) by resolved location + + +def _reach(subject: Subject, root: Path) -> Reach: + """Where a subject's hashes read, as repository-relative segments, through links as well as by declared path: + a symlinked skill directory or suite file maps a change at its target to the subject.""" + root_real = _real(root) + skill_roots: list[tuple[str, ...]] = [_segs(subject.path)] if subject.kind == "skill" else [] + suite_roots: list[tuple[str, ...]] = [] + if root_real is None: + return skill_roots, suite_roots + real_skill = _inside(root / subject.path, root_real) if subject.kind == "skill" else None + if real_skill is not None: + skill_roots.append(real_skill) + if subject.manifest is not None: # the repo subject hashes its manifest through a link, so its target is covered + real_manifest = _inside(root / subject.manifest, root_real) + if real_manifest is not None: + suite_roots.append(real_manifest) + for rel, path in _suite_files(root, subject.suite_globs).items(): + for _, entry in _walk(path, rel): + real = _inside(entry, root_real) + if real is not None: + suite_roots.append(real) + return skill_roots, suite_roots + + +def _inside(path: Path, root_real: Path) -> tuple[str, ...] | None: + """The segments of ``path``'s resolved location under the repository root; None when it is the root or outside it.""" + real = _real(path) + if real is None or real == root_real or not real.is_relative_to(root_real): + return None + return tuple(real.relative_to(root_real).as_posix().split("/")) + + +def _under(segs: tuple[str, ...], base: tuple[str, ...]) -> bool: + return segs[:len(base)] == base + + +def _in_suite(subject: Subject, segs: tuple[str, ...], reach: Reach) -> bool: + return any(_claims(g, segs) for g in subject.eval_globs) or any(_under(segs, r) for r in reach[1]) + + +def _map_one(rel: str, subjects: list[Subject], reach: dict[str, Reach], shared: list[str], impact: Impact) -> None: segs = tuple(rel.split("/")) is_shared = any(_claims(g, segs) for g in shared) - suites = [s.id for s in subjects if any(_claims(g, segs) for g in s.eval_globs)] - skill = _skill_owner(subjects, segs) - if skill is not None and any(_claims(g, segs) for g in skill.suite_globs): - skill = None # the skill's own suite holds this file: it is eval, and skill_sha leaves it out + suites = [s.id for s in subjects if _in_suite(s, segs, reach[s.id])] + # a skill whose own suite holds this file gets nothing: it is eval, and skill_sha leaves it out + skills = [s for s in _skill_owners(subjects, reach, segs) if not _in_suite(s, segs, reach[s.id])] if is_shared: impact.shared.append(rel) for sid in suites: impact.suites.setdefault(sid, []).append(rel) - if skill is not None: + for skill in skills: impact.skills.setdefault(skill.id, []).append(rel) - if not (is_shared or suites or skill is not None): + if not (is_shared or suites or skills): impact.unclaimed.append(rel) -def _skill_owner(subjects: list[Subject], segs: tuple[str, ...]) -> Subject | None: - """The skill whose directory holds ``segs``; the deepest one when skills nest.""" - owners = [s for s in subjects if s.kind == "skill" and segs[:len(_segs(s.path))] == _segs(s.path)] - return max(owners, key=lambda s: len(_segs(s.path)), default=None) +def _skill_owners(subjects: list[Subject], reach: dict[str, Reach], segs: tuple[str, ...]) -> list[Subject]: + """The skills whose directory (declared or resolved) holds ``segs``: the deepest ones when skills nest. Two subjects + that alias one directory both own its files, since both skill_sha values cover them.""" + depth = max( + (len(root) for s in subjects if s.kind == "skill" for root in reach[s.id][0] if _under(segs, root)), + default=0, + ) + return [ + s for s in subjects + if s.kind == "skill" and any(len(root) == depth and _under(segs, root) for root in reach[s.id][0]) + ] if depth else [] def _segs(path: str) -> tuple[str, ...]: @@ -282,12 +340,32 @@ def _resolve(data: dict[str, Any], root: Path) -> list[Subject]: return sorted(subjects, key=lambda s: s.id) +def _check_nesting(subjects: list[Subject], root: Path) -> None: + """A skill whose resolved directory lies inside another skill's, but whose declared path does not, is refused: the + outer skill's hash covers it, and a change to it could not be attributed to one owner. Skills that alias one + directory are both owners, so they are allowed.""" + real = {s.id: _real(root / s.path) for s in subjects if s.kind == "skill"} + for s in subjects: + s_real = real.get(s.id) if s.kind == "skill" else None + if s_real is None: + continue + for t in subjects: + t_real = real.get(t.id) if t.kind == "skill" and t is not s else None + if t_real is None or t_real == s_real: + continue + if s_real.is_relative_to(t_real) and not _within(s.path, t.path): + raise ConfigError( + f"subject {s.id}: its directory {s.path} is inside the skill {t.path} through a link; " + "a skill reached by a link into another skill is not supported" + ) + + def _skill_dirs(data: dict[str, Any], root: Path) -> list[str]: """Repository-relative directories matched by ``defaults.skills``; files do not count.""" rels: set[str] = set() for pattern in _as_list((data.get("defaults") or {}).get("skills")): - for match in root.glob(_glob(pattern, "defaults.skills")): - if match.is_dir(): + for match in _glob_paths(root, _glob(pattern, "defaults.skills")): + if match.is_dir(): # an OSError here is turned into ConfigError by the caller rels.add(match.relative_to(root).as_posix()) return sorted(rels) @@ -334,12 +412,66 @@ def _suite_globs(spec: Any, *, where: str, skill: str | None) -> tuple[str, ...] return tuple(dict.fromkeys(globs)) +def _unreadable(exc: OSError) -> ConfigError: + """A ConfigError naming the path that could not be read, so an unreadable directory is a config problem, not a + traceback out of the loader.""" + return ConfigError(f"cannot read {exc.filename or 'part of the repository'}: {exc.strerror or exc}") + + +def _listing(base: Path) -> list[tuple[str, bool, bool]]: + """(name, is_dir, is_symlink) of each entry of ``base`` that can be read; an unreadable one is left out.""" + out: list[tuple[str, bool, bool]] = [] + try: + with os.scandir(base) as it: + for entry in it: + try: + out.append((entry.name, entry.is_dir(), entry.is_symlink())) + except OSError: + continue + except OSError: + pass + return out + + +def _glob_walk(base: Path, segs: tuple[str, ...], i: int, found: set[Path]) -> None: + if i == len(segs): + found.add(base) + return + seg = segs[i] + if seg == "**": + _glob_walk(base, segs, i + 1, found) + for name, is_dir, is_link in _listing(base): + if is_dir: # follows symlinks: a linked directory is matched, but not descended + _glob_walk(base / name, segs, i + 1 if is_link else i, found) + return + wild = any(c in seg for c in "*?[") # matched by exact name against the listing: case-sensitive everywhere + for name, _, _ in _listing(base): + if fnmatch.fnmatchcase(name, seg) if wild else name == seg: + _glob_walk(base / name, segs, i + 1, found) + + +def _glob_paths(root: Path, pattern: str) -> list[Path]: + """The paths ``pattern`` matches under ``root``. Every segment, literal ones included, is matched against the names + in the directory listing, case-sensitively on every platform: ``Path.glob`` folds case on Windows, and 3.11 has no + ``case_sensitive`` switch, so a suite would differ between OSes and from impact. ``**`` is a whole segment matching + zero or more directories. It does not recurse through a symlinked directory: the link counts as one directory for the + segments after it, so ``**/x`` reaches ``link/x`` but not ``link/sub/x``; a second ``**`` placed after a link can still + descend it, so ``**/**/x`` is not guaranteed to match as ``**/x`` does. Unlike ``Path.glob`` on some versions, a + linked directory is matched by ``**`` itself on every version. A directory that cannot be listed contributes no + matches.""" + found: set[Path] = set() + _glob_walk(root, tuple(pattern.split("/")), 0, found) + return sorted(found) + + def _suite_files(root: Path, globs: tuple[str, ...]) -> dict[str, Path]: + """The suite's matches by repository-relative path. A match under another match is left out: that match's walk + already covers it, so ``{skill}/evals/**`` hashes the same whether ``**`` yields files too (3.13+) or not (3.11/3.12).""" found: dict[str, Path] = {} for pattern in globs: - for match in sorted(root.glob(pattern)): + for match in _glob_paths(root, pattern): found.setdefault(match.relative_to(root).as_posix(), match) - return found + return {rel: path for rel, path in found.items() if not any(other != rel and _within(rel, other) for other in found)} def _digest_files(files: dict[str, Path]) -> str: @@ -357,6 +489,14 @@ def _digest_files(files: dict[str, Path]) -> str: return "sha256:" + h.hexdigest() +def _real(path: Path) -> Path | None: + """The resolved path, or None when it cannot be resolved: a symlink loop (3.11 and 3.12 raise RuntimeError for it).""" + try: + return path.resolve() + except (OSError, RuntimeError): + return None + + def _walk(path: Path, rel: str) -> list[tuple[str, Path]]: """Entries under ``path`` (or ``path`` itself): regular files and symlinks. Symlinks are followed into their directories; a directory is visited once by its resolved path, so a cycle ends.""" @@ -367,8 +507,8 @@ def visit(entry: Path, name: str) -> None: if entry.is_symlink(): found.append((name, entry)) if entry.is_dir(): - real = entry.resolve() - if real in seen: + real = _real(entry) + if real is None or real in seen: return seen.add(real) for child in sorted(entry.iterdir()): @@ -386,30 +526,70 @@ def _within(rel: str, base: str) -> bool: def _check_suite(subject: Subject, root: Path) -> None: - """A skill's suite claims whole directories of its skill, never single files of it (SKILL.md, scripts, references).""" + """A skill's suite claims whole directories of its skill, never single files of it (SKILL.md, scripts, references), + and no link in it reaches into its skill or to an ancestor of it. Declared paths and resolved links are both checked.""" if subject.kind != "skill": return matches = _suite_files(root, subject.suite_globs) - real_skill = (root / subject.path).resolve() + real_skill = _real(root / subject.path) + if real_skill is None: + raise ConfigError(f"subject {subject.id}: {subject.path} is a link that does not resolve") for rel, path in matches.items(): - if rel == subject.path: - raise ConfigError(f"subject {subject.id}: its suite matches the whole skill directory {subject.path}") - if subject.path.startswith(rel + "/"): - raise ConfigError(f"subject {subject.id}: its suite {rel} contains the skill directory {subject.path}") - if path.is_dir() and real_skill.is_relative_to(path.resolve()): - raise ConfigError( - f"subject {subject.id}: its directory {rel} links to its skill or an ancestor of it, " - "so the suite would hold the skill's own files" - ) - for name, entry in _walk(path, rel): - if entry.is_symlink() and entry.resolve().is_relative_to(real_skill): - raise ConfigError( - f"subject {subject.id}: its suite holds {name}, a link into its skill {subject.path}; " - "a suite holds its own files, not links into the skill" - ) + _check_match(subject, rel, path, real_skill) + _check_claims(subject, matches, real_skill) + + +def _into_skill(path: Path, real_skill: Path) -> bool: + real = _real(path) + return real is not None and real.is_relative_to(real_skill) + + +def _check_match(subject: Subject, rel: str, path: Path, real_skill: Path) -> None: + if rel == subject.path: + raise ConfigError(f"subject {subject.id}: its suite matches the whole skill directory {subject.path}") + if subject.path.startswith(rel + "/"): + raise ConfigError(f"subject {subject.id}: its suite {rel} contains the skill directory {subject.path}") + real = _real(path) + if real is None: + raise ConfigError(f"subject {subject.id}: its suite holds {rel}, a link that loops; a suite holds real files") + if path.is_dir() and real_skill.is_relative_to(real): + raise ConfigError( + f"subject {subject.id}: its directory {rel} links to its skill or an ancestor of it, " + "so the suite would hold the skill's own files" + ) + for name, entry in _walk(path, rel): + if entry.is_symlink(): + _check_link(subject, name, entry, real_skill) + + +def _check_link(subject: Subject, name: str, entry: Path, real_skill: Path) -> None: + target = _real(entry) + if target is None: + raise ConfigError(f"subject {subject.id}: its suite holds {name}, a link that loops; a suite holds real files") + if target.is_relative_to(real_skill): + raise ConfigError( + f"subject {subject.id}: its suite holds {name}, a link into its skill {subject.path}; " + "a suite holds its own files, not links into the skill" + ) + if real_skill.is_relative_to(target): + raise ConfigError( + f"subject {subject.id}: its suite holds {name}, a link to {subject.path} or an ancestor of it, " + "so the suite would hold the skill's own files" + ) + + +def _check_claims(subject: Subject, matches: dict[str, Path], real_skill: Path) -> None: dirs = [rel for rel, path in matches.items() if path.is_dir() and _within(rel, subject.path)] for rel, path in matches.items(): - if path.is_dir() or not _within(rel, subject.path) or any(_within(rel, d) for d in dirs): + into = _into_skill(path, real_skill) + if path.is_dir() and not _within(rel, subject.path) and into: + how = "links into" if path.is_symlink() else "is inside the real directory of" + raise ConfigError( + f"subject {subject.id}: its directory {rel} {how} its skill {subject.path}, " + "so the skill's hash would cover the suite's files" + ) + inside = _within(rel, subject.path) or into + if path.is_dir() or not inside or any(_within(rel, d) for d in dirs): continue raise ConfigError( f"subject {subject.id}: its suite claims the file {rel} of its skill, not a whole directory; " diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index 371f9150f..b6df5d99b 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -6,6 +6,7 @@ from __future__ import annotations import os +import re from pathlib import Path import pytest @@ -115,7 +116,7 @@ def test_an_invalid_config_is_refused_with_the_file_and_the_reason(tmp_path, bod root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body}) with pytest.raises(ConfigError, match=needle) as err: load_repo_config(root) - assert ".github/dokimi/config.yaml" in str(err.value) + assert str(Path(".github/dokimi/config.yaml")) in str(err.value) def test_a_valid_full_config_loads_with_plan_budget_and_baseline(tmp_path): @@ -579,6 +580,7 @@ def test_a_budget_that_is_not_a_finite_number_is_refused(tmp_path, key, value): load_repo_config(root) +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_an_eval_that_is_a_symlink_hashes_the_file_it_points_to(tmp_path): import os @@ -595,6 +597,7 @@ def test_an_eval_that_is_a_symlink_hashes_the_file_it_points_to(tmp_path): assert _subjects(root)["skill/a"].suite_hash != before +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_an_eval_directory_that_is_a_symlink_hashes_the_files_it_reaches(tmp_path): import os @@ -611,6 +614,7 @@ def test_an_eval_directory_that_is_a_symlink_hashes_the_files_it_reaches(tmp_pat assert _subjects(root)["skill/a"].suite_hash != before +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_an_evals_dir_that_links_to_its_own_skill_is_refused(tmp_path): import os @@ -637,7 +641,9 @@ def test_a_budget_with_a_huge_exponent_is_refused_with_a_short_message(tmp_path) @pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -@pytest.mark.parametrize(("link", "target"), [("evals/skill_link.md", "../SKILL.md"), ("evals/up", "..")]) +@pytest.mark.parametrize( + ("link", "target"), [("evals/skill_link.md", "../SKILL.md"), ("evals/up", ".."), ("evals/root", "../../..")] +) def test_a_suite_link_into_its_skill_is_refused(tmp_path, link, target): body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n" files = {".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "a\n", "skills/a/evals/e.json": "{}\n"} @@ -645,3 +651,153 @@ def test_a_suite_link_into_its_skill_is_refused(tmp_path, link, target): os.symlink(target, root / "skills/a" / link) with pytest.raises(ConfigError, match=r"skill/a.*link"): load_repo_config(root).subjects(hashes=False) + + +# --- links and globs as the reviews found them ------------------------------------------------------ + + +def test_an_explicit_config_at_a_bracketed_path_claims_its_own_change_only(tmp_path): + name = "ci/config[1].yaml" + root = _tree(tmp_path / "repo", {name: HEADER + "defaults:\n skills: skills/*\n", "skills/a/SKILL.md": "a\n", "ci/config1.yaml": "x\n"}) + impact = load_repo_config(root, explicit=root / name).subjects_for_changed([name, "ci/config1.yaml"]) + assert impact.shared == [name] + assert impact.unclaimed == ["ci/config1.yaml"] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_change_to_a_symlinked_skills_target_maps_to_that_skill(tmp_path): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": BESIDE_CONFIG, "vendor/a/SKILL.md": "# a\n"}) + (root / "skills").mkdir() + os.symlink(root / "vendor" / "a", root / "skills" / "a", target_is_directory=True) + impact = load_repo_config(root).subjects_for_changed(["vendor/a/SKILL.md"]) + assert impact.skills == {"skill/a": ["vendor/a/SKILL.md"]} + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_two_skills_that_alias_one_directory_both_own_its_change(tmp_path): + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": BESIDE_CONFIG, "skills/b/SKILL.md": "# b\n"}) + os.symlink("b", root / "skills" / "a", target_is_directory=True) + cfg = load_repo_config(root) + assert len({s.skill_sha for s in cfg.subjects()}) == 1 # both hash the same tree + impact = cfg.subjects_for_changed(["skills/b/SKILL.md"]) + assert impact.skills == {"skill/a": ["skills/b/SKILL.md"], "skill/b": ["skills/b/SKILL.md"]} + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_skill_linked_into_another_skills_directory_is_refused(tmp_path): + body = HEADER + "subjects:\n - id: skill/q\n path: vendor/q\n - id: skill/r\n path: skills/r\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "vendor/q/SKILL.md": "q\n", + "vendor/q/inner/SKILL.md": "i\n", + }) + (root / "skills").mkdir() + os.symlink(root / "vendor/q/inner", root / "skills/r", target_is_directory=True) + with pytest.raises(ConfigError, match=r"skill/r.*inside the skill vendor/q"): + load_repo_config(root).subjects(hashes=False) + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_change_to_a_repo_manifest_reached_through_a_link_maps_to_the_subject(tmp_path): + body = HEADER + "subjects:\n - id: repo\n kind: repo\n manifest: dokimi.toml\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, "pyproject.toml": "[project]\n"}) + os.symlink("pyproject.toml", root / "dokimi.toml") + impact = load_repo_config(root).subjects_for_changed(["pyproject.toml"]) + assert impact.suites == {"repo": ["pyproject.toml"]} + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_change_to_a_suite_file_reached_through_a_link_maps_to_the_suite(tmp_path): + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": BESIDE_CONFIG, + "skills/a/SKILL.md": "a\n", + "shared/q.json": "[1]\n", + "skills/a/evals/.keep": "", + }) + os.symlink("../../../shared/q.json", root / "skills/a/evals/q.json") + impact = load_repo_config(root).subjects_for_changed(["shared/q.json"]) + assert impact.suites == {"skill/a": ["shared/q.json"]} + assert impact.unclaimed == [] + + +def test_a_suite_that_ends_in_double_star_hashes_like_its_directory(tmp_path): + files = {"skills/a/SKILL.md": "a\n", "skills/a/evals/q.json": "{}\n", "skills/a/evals/sub/b.json": "{}\n"} + head = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: " + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": head + "skills/a/evals\n", **files}) + dir_hash = _subjects(root)["skill/a"].suite_hash + (root / ".github/dokimi/config.yaml").write_text(head + "skills/a/evals/**\n", encoding="utf-8") + assert _subjects(root)["skill/a"].suite_hash == dir_hash + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_symlink_loop_in_a_suite_is_a_config_error_naming_the_link(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "a\n", "skills/a/evals/q.json": "{}\n"}) + os.symlink("loop", root / "skills/a/evals/loop") + with pytest.raises(ConfigError, match=r"skill/a.*loop"): + load_repo_config(root).subjects(hashes=False) + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_double_star_reaches_a_linked_directory_one_step_only(tmp_path): + from dokimi.repo_config import _glob_paths + + root = _tree(tmp_path / "repo", {"dir/sub/x": "x\n", "dir/x": "x\n"}) + os.symlink("dir", root / "link") + found = sorted(p.relative_to(root).as_posix() for p in _glob_paths(root, "**/x")) + assert found == ["dir/sub/x", "dir/x", "link/x"] + + +@pytest.mark.skipif(os.name == "nt" or os.geteuid() == 0, reason="permissions are not enforced on Windows or for root") +def test_an_unreadable_suite_directory_is_a_config_error_naming_it(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "a\n", "skills/a/evals/q.json": "{}\n"}) + evals = root / "skills/a/evals" + evals.chmod(0) + try: + with pytest.raises(ConfigError, match=r"cannot read .*evals"): + load_repo_config(root).subjects() + finally: + evals.chmod(0o755) + + +@pytest.mark.skipif(os.name == "nt" or os.geteuid() == 0, reason="permissions are not enforced on Windows or for root") +def test_an_unreadable_directory_under_a_skills_glob_matches_nothing(tmp_path): + body = HEADER + "defaults:\n skills: locked/skills\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, "locked/skills/a/SKILL.md": "a\n"}) + locked = root / "locked" + locked.chmod(0) + try: + assert load_repo_config(root).subjects(hashes=False) == [] + finally: + locked.chmod(0o755) + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +@pytest.mark.parametrize( + ("suite", "link", "target", "files"), + [ + ( + "evaluation/linked/SKILL.md", + "evaluation/linked", + "../skills/a", + {"skills/a/SKILL.md": "a\n", "skills/a/evals/e.json": "{}\n", "evaluation/.keep": ""}, + ), + ( + "vendor/a/SKILL.md", + "skills/a", + "../vendor/a", + {"vendor/a/SKILL.md": "a\n", "vendor/a/evals/e.json": "{}\n", "evaluation/.keep": ""}, + ), + ], +) +def test_a_suite_file_reached_through_a_link_into_its_skill_is_refused(tmp_path, suite, link, target, files): + body = HEADER + f"subjects:\n - id: skill/a\n path: skills/a\n suite: {suite}\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, **files}) + (root / link).parent.mkdir(parents=True, exist_ok=True) + os.symlink(target, root / link, target_is_directory=True) + with pytest.raises(ConfigError, match=rf"skill/a.*{re.escape(suite)}"): + load_repo_config(root).subjects(hashes=False) From b4504b0a733822de7e4670b31be6251cba788534 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 22:59:13 -0700 Subject: [PATCH 15/36] Thread: PRRT_kwDOVBg7g86yVRTx: already fixed in 72eb3bf0 Signed-off-by: Nikolay Petrov From c80ce227267bb6026335bb53d9b35ab94f356539 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 00:17:27 -0700 Subject: [PATCH 16/36] fix(config): shared config at its link and target, suite directories, undecodable names - The config file is shared under its declared path and under the file it resolves to, so a config linked from elsewhere in the repository counts at either path. - A suite's reach includes the directories it walks, so a deleted file under a directory that still exists maps to the suite, not the skill. - tree_digest encodes names and link targets with os.fsencode, so an undecodable POSIX name or link target digests instead of raising UnicodeEncodeError. Valid names keep the same bytes. Tests: red on b4504b0a for the three, green here. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 25 ++++++++++++++++--------- dokimi/dokimi/utils.py | 8 +++++--- dokimi/tests/test_repo_config.py | 27 +++++++++++++++++++++++++++ dokimi/tests/test_tree_digest.py | 16 ++++++++++++++++ 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 73a7c95d4..2835eabf4 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -221,12 +221,16 @@ def _as_list(value: Any) -> list[str]: def _own_path(config: RepoConfig) -> list[str]: - """The config file is shared: a change to it reruns the smoke set. Empty when it sits outside the root. - It is a literal path, but ``shared`` is matched as a glob, so its metacharacters are escaped.""" - try: - return [glob.escape(config.path.resolve().relative_to(config.root.resolve()).as_posix())] - except ValueError: - return [] + """The config file is shared: a change to it reruns the smoke set. Both its declared path and the file it + resolves to, so a config linked from elsewhere in the repository counts at either. A path outside the root is + skipped. Each is a literal path, but ``shared`` is matched as a glob, so its metacharacters are escaped.""" + found: list[str] = [] + for path, root in ((config.path, config.root), (config.path.resolve(), config.root.resolve())): + try: + found.append(glob.escape(path.relative_to(root).as_posix())) + except ValueError: + pass + return list(dict.fromkeys(found)) def _claims(pattern: str, segs: tuple[str, ...]) -> bool: @@ -262,7 +266,7 @@ def _reach(subject: Subject, root: Path) -> Reach: if real_manifest is not None: suite_roots.append(real_manifest) for rel, path in _suite_files(root, subject.suite_globs).items(): - for _, entry in _walk(path, rel): + for _, entry in _walk(path, rel, dirs=True): real = _inside(entry, root_real) if real is not None: suite_roots.append(real) @@ -497,8 +501,9 @@ def _real(path: Path) -> Path | None: return None -def _walk(path: Path, rel: str) -> list[tuple[str, Path]]: - """Entries under ``path`` (or ``path`` itself): regular files and symlinks. Symlinks are followed into their +def _walk(path: Path, rel: str, *, dirs: bool = False) -> list[tuple[str, Path]]: + """Entries under ``path`` (or ``path`` itself): regular files and symlinks, and with ``dirs`` the directories + too, so a deleted file under a directory that still exists maps to it. Symlinks are followed into their directories; a directory is visited once by its resolved path, so a cycle ends.""" found: list[tuple[str, Path]] = [] seen: set[Path] = set() @@ -507,6 +512,8 @@ def visit(entry: Path, name: str) -> None: if entry.is_symlink(): found.append((name, entry)) if entry.is_dir(): + if dirs: + found.append((name, entry)) real = _real(entry) if real is None or real in seen: return diff --git a/dokimi/dokimi/utils.py b/dokimi/dokimi/utils.py index cceb8a720..d8b168f05 100644 --- a/dokimi/dokimi/utils.py +++ b/dokimi/dokimi/utils.py @@ -109,13 +109,15 @@ def tree_digest(root: Path, *, exclude: Iterable[str] = ()) -> str: rel = "." if path == root else path.relative_to(root).as_posix() if any(rel == e or rel.startswith(e + "/") for e in skip): continue + # names and link targets are bytes on POSIX and may hold undecodable bytes: encode them as the OS does + name = os.fsencode(rel) if path.is_symlink(): - h.update(f"L {rel}\0{os.readlink(path)}\0".encode()) + h.update(b"L " + name + b"\0" + os.fsencode(os.readlink(path)) + b"\0") elif path.is_file(): - h.update(f"F {rel}\0{int(os.access(path, os.X_OK))}\0".encode()) + h.update(b"F " + name + f"\0{int(os.access(path, os.X_OK))}\0".encode()) h.update(hashlib.sha256(path.read_bytes()).digest()) elif path.is_dir(): - h.update(f"D {rel}\0".encode()) + h.update(b"D " + name + b"\0") return h.hexdigest() diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index b6df5d99b..16d4fdf6b 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -723,6 +723,33 @@ def test_a_change_to_a_suite_file_reached_through_a_link_maps_to_the_suite(tmp_p assert impact.unclaimed == [] +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_config_reached_through_a_link_is_shared_at_its_declared_path_and_its_target(tmp_path): + root = _tree(tmp_path / "repo", {"ci/config.yaml": BESIDE_CONFIG, "ci/other.yaml": "x\n"}) + (root / ".github/dokimi").mkdir(parents=True) + os.symlink("../../ci/config.yaml", root / ".github/dokimi/config.yaml") + impact = load_repo_config(root, explicit=root / ".github/dokimi/config.yaml").subjects_for_changed( + [".github/dokimi/config.yaml", "ci/config.yaml", "ci/other.yaml"]) + assert sorted(impact.shared) == [".github/dokimi/config.yaml", "ci/config.yaml"] + assert impact.unclaimed == ["ci/other.yaml"] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_deleted_file_in_a_linked_suite_directory_still_maps_to_the_suite(tmp_path): + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": BESIDE_CONFIG, + "skills/.keep": "", + "vendor/a/SKILL.md": "a\n", + "vendor/a/evals/.keep": "", + "vendor/a/evals/q.json": "[1]\n", + }) + os.symlink("../vendor/a", root / "skills/a") + (root / "vendor/a/evals/q.json").unlink() + impact = load_repo_config(root).subjects_for_changed(["vendor/a/evals/q.json"]) + assert impact.suites == {"skill/a": ["vendor/a/evals/q.json"]} + assert impact.skills == {} and impact.unclaimed == [] + + def test_a_suite_that_ends_in_double_star_hashes_like_its_directory(tmp_path): files = {"skills/a/SKILL.md": "a\n", "skills/a/evals/q.json": "{}\n", "skills/a/evals/sub/b.json": "{}\n"} head = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: " diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py index 929a98ee4..47b40c953 100644 --- a/dokimi/tests/test_tree_digest.py +++ b/dokimi/tests/test_tree_digest.py @@ -97,3 +97,19 @@ def test_a_file_root_digests_as_itself(tmp_path): def test_the_digest_is_bare_hex(tmp_path): digest = tree_digest(_write(tmp_path / "t", {"a": "a\n"})) assert len(digest) == 64 and all(c in "0123456789abcdef" for c in digest) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file names and link targets are bytes") +def test_an_undecodable_name_and_link_target_digest_without_error(tmp_path): + root = tmp_path / "tree" + root.mkdir() + fd = os.open(os.fsencode(root) + b"/bad-\xff", os.O_CREAT | os.O_WRONLY, 0o644) + os.close(fd) + os.symlink(b"target-\xff", os.fsencode(root / "link")) + first = tree_digest(root) + other = tmp_path / "other" + other.mkdir() + fd = os.open(os.fsencode(other) + b"/bad-\xfe", os.O_CREAT | os.O_WRONLY, 0o644) + os.close(fd) + os.symlink(b"target-\xff", os.fsencode(other / "link")) + assert first != tree_digest(other) From 5a23ae06babdc98c73a4c9d49cc6028da8c902db Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 09:42:50 -0800 Subject: [PATCH 17/36] fix(config): claim a suite after its directory is deleted, a link on the way, and undecodable names - A skill reached through a link keeps its suite globs rewritten to the resolved directory, so deleting the evals directory still routes a change there to the suite instead of the skill. - A symlink on the declared way to a suite file, a manifest or a skill directory maps a change to that link itself, so retargeting it moves the subject it feeds. - Suite digests encode names and link targets with os.fsencode, so an undecodable byte no longer raises UnicodeEncodeError. Refs PRRT_kwDOVBg7g86yYKWh, PRRT_kwDOVBg7g86yYKWl, PRRT_kwDOVBg7g86yYKWo Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 72 +++++++++++++++++++++++-------- dokimi/tests/test_repo_config.py | 73 ++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 18 deletions(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 2835eabf4..107591e6d 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -247,30 +247,62 @@ def _match(pat: tuple[str, ...], segs: tuple[str, ...]) -> bool: return bool(segs) and fnmatch.fnmatchcase(segs[0], pat[0]) and _match(pat[1:], segs[1:]) -Reach = tuple[list[tuple[str, ...]], list[tuple[str, ...]]] # (skill directories, suite files) by resolved location +@dataclass(frozen=True) +class Reach: + """Where a subject's hashes read, by repository-relative segments. + + ``skills``: skill directories, declared and resolved (a change under one moves skill_sha). + ``suites``: suite directories and files, reached through links (a change under one moves suite_hash). + ``links``, ``skill_links``: symlinks on the declared way to a suite read or to a skill directory, matched + exactly: retargeting one moves the hash, but a change to a file beside it does not. + ``globs``: the eval globs, with a skill's resolved directory in place of its declared one, so a suite still + claims its files after the link's target loses them (a deleted eval has no match to find).""" + + skills: list[tuple[str, ...]] + suites: list[tuple[str, ...]] + links: list[tuple[str, ...]] + skill_links: list[tuple[str, ...]] + globs: tuple[str, ...] + + +def _link_ancestors(rel: str, root: Path) -> list[tuple[str, ...]]: + """The proper ancestors of ``rel`` (repository-relative) that are symbolic links under ``root``.""" + parts = rel.split("/") + return [tuple(parts[:k]) for k in range(1, len(parts)) if root.joinpath(*parts[:k]).is_symlink()] def _reach(subject: Subject, root: Path) -> Reach: """Where a subject's hashes read, as repository-relative segments, through links as well as by declared path: - a symlinked skill directory or suite file maps a change at its target to the subject.""" + a symlinked skill directory or suite file maps a change at its target to the subject, and a symlink on the way + to any of them maps a change to the link itself.""" root_real = _real(root) - skill_roots: list[tuple[str, ...]] = [_segs(subject.path)] if subject.kind == "skill" else [] - suite_roots: list[tuple[str, ...]] = [] + skills: list[tuple[str, ...]] = [_segs(subject.path)] if subject.kind == "skill" else [] + suites: list[tuple[str, ...]] = [] + links: list[tuple[str, ...]] = [] + skill_links: list[tuple[str, ...]] = [] + globs = subject.eval_globs if root_real is None: - return skill_roots, suite_roots + return Reach(skills, suites, links, skill_links, globs) real_skill = _inside(root / subject.path, root_real) if subject.kind == "skill" else None if real_skill is not None: - skill_roots.append(real_skill) + skills.append(real_skill) + declared, resolved = glob.escape(subject.path), glob.escape("/".join(real_skill)) + if resolved != declared: # a linked skill: its suite globs, rewritten to where the files live + moved = [resolved + g[len(declared):] for g in globs if g == declared or g.startswith(declared + "/")] + globs = (*globs, *moved) + skill_links.extend(_link_ancestors(subject.path, root)) if subject.manifest is not None: # the repo subject hashes its manifest through a link, so its target is covered real_manifest = _inside(root / subject.manifest, root_real) if real_manifest is not None: - suite_roots.append(real_manifest) + suites.append(real_manifest) + links.extend(_link_ancestors(subject.manifest, root)) for rel, path in _suite_files(root, subject.suite_globs).items(): + links.extend(_link_ancestors(rel, root)) for _, entry in _walk(path, rel, dirs=True): real = _inside(entry, root_real) if real is not None: - suite_roots.append(real) - return skill_roots, suite_roots + suites.append(real) + return Reach(skills, suites, links, skill_links, globs) def _inside(path: Path, root_real: Path) -> tuple[str, ...] | None: @@ -286,7 +318,8 @@ def _under(segs: tuple[str, ...], base: tuple[str, ...]) -> bool: def _in_suite(subject: Subject, segs: tuple[str, ...], reach: Reach) -> bool: - return any(_claims(g, segs) for g in subject.eval_globs) or any(_under(segs, r) for r in reach[1]) + return (any(_claims(g, segs) for g in reach.globs) or any(_under(segs, r) for r in reach.suites) + or segs in reach.links) def _map_one(rel: str, subjects: list[Subject], reach: dict[str, Reach], shared: list[str], impact: Impact) -> None: @@ -307,15 +340,18 @@ def _map_one(rel: str, subjects: list[Subject], reach: dict[str, Reach], shared: def _skill_owners(subjects: list[Subject], reach: dict[str, Reach], segs: tuple[str, ...]) -> list[Subject]: """The skills whose directory (declared or resolved) holds ``segs``: the deepest ones when skills nest. Two subjects - that alias one directory both own its files, since both skill_sha values cover them.""" + that alias one directory both own its files, since both skill_sha values cover them. A symlink on a skill's way + is owned by that skill, whatever is beside it, since retargeting it changes the directory the skill hashes.""" depth = max( - (len(root) for s in subjects if s.kind == "skill" for root in reach[s.id][0] if _under(segs, root)), + (len(root) for s in subjects if s.kind == "skill" for root in reach[s.id].skills if _under(segs, root)), default=0, ) - return [ - s for s in subjects - if s.kind == "skill" and any(len(root) == depth and _under(segs, root) for root in reach[s.id][0]) - ] if depth else [] + owned = { + s.id for s in subjects + if s.kind == "skill" and depth and any(len(root) == depth and _under(segs, root) for root in reach[s.id].skills) + } + owned |= {s.id for s in subjects if s.kind == "skill" and segs in reach[s.id].skill_links} + return [s for s in subjects if s.id in owned] def _segs(path: str) -> tuple[str, ...]: @@ -485,11 +521,11 @@ def _digest_files(files: dict[str, Path]) -> str: for rel in sorted(files): for name, path in _walk(files[rel], rel): if path.is_symlink(): - h.update(f"{name}\0-> {os.readlink(path)}\n".encode()) + h.update(os.fsencode(f"{name}\0-> {os.readlink(path)}\n")) # names may hold undecodable bytes if path.is_dir(): continue # a linked directory: its files are walked and hashed one by one target = path.resolve() if path.exists() else path - h.update(f"{name}\0{tree_digest(target)}\n".encode()) + h.update(os.fsencode(f"{name}\0{tree_digest(target)}\n")) return "sha256:" + h.hexdigest() diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index 16d4fdf6b..14757b321 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -828,3 +828,76 @@ def test_a_suite_file_reached_through_a_link_into_its_skill_is_refused(tmp_path, os.symlink(target, root / link, target_is_directory=True) with pytest.raises(ConfigError, match=rf"skill/a.*{re.escape(suite)}"): load_repo_config(root).subjects(hashes=False) + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_linked_skill_still_claims_its_evals_after_their_whole_directory_is_deleted(tmp_path): + import shutil + + files = { + ".github/dokimi/config.yaml": BESIDE_CONFIG, + "vendor/a/SKILL.md": "# a\n", + "vendor/a/evals/q.json": "[1]\n", + } + root = _tree(tmp_path / "repo", files) + (root / "skills").mkdir() + os.symlink("../vendor/a", root / "skills" / "a") + shutil.rmtree(root / "vendor/a/evals") + impact = load_repo_config(root).subjects_for_changed(["vendor/a/evals/q.json"]) + assert impact.suites == {"skill/a": ["vendor/a/evals/q.json"]} + assert impact.skills == {} and impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_retargeting_a_link_on_the_way_to_a_suite_file_claims_the_link_itself(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/task.json\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "one/task.json": "[1]\n", + "two/task.json": "[2]\n", + }) + os.makedirs(root / "evaluation") + os.symlink("../one", root / "evaluation" / "current") + before = _subjects(root)["skill/a"].suite_hash + (root / "evaluation" / "current").unlink() + os.symlink("../two", root / "evaluation" / "current") + assert _subjects(root)["skill/a"].suite_hash != before + impact = load_repo_config(root).subjects_for_changed(["evaluation/current"]) + assert impact.suites == {"skill/a": ["evaluation/current"]} + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_retargeting_a_link_above_a_skill_claims_the_skill_by_its_link(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: linked/a\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "real1/a/SKILL.md": "# a\n", + "real2/a/SKILL.md": "# b\n", + }) + os.symlink("real1", root / "linked") + before = _subjects(root)["skill/a"].skill_sha + (root / "linked").unlink() + os.symlink("real2", root / "linked") + assert _subjects(root)["skill/a"].skill_sha != before + impact = load_repo_config(root).subjects_for_changed(["linked", "linked-sibling/x"]) + assert impact.skills == {"skill/a": ["linked"]} + assert impact.unclaimed == ["linked-sibling/x"] + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file names and link targets are bytes") +def test_undecodable_names_and_link_targets_under_a_suite_load_and_digest(tmp_path): + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": BESIDE_CONFIG, + "skills/a/SKILL.md": "# a\n", + }) + evals = root / "skills" / "a" / "evals" + evals.mkdir() + fd = os.open(os.fsencode(evals) + b"/bad-\xff", os.O_CREAT | os.O_WRONLY, 0o644) + os.close(fd) + os.symlink(b"../../../shared/target-\xff", os.fsencode(evals / "link")) # a dangling link out of the skill + subject = _subjects(root)["skill/a"] + assert subject.suite_hash and subject.suite_hash.startswith("sha256:") + impact = load_repo_config(root).subjects_for_changed(["skills/a/evals/bad-\udcff"]) + assert impact.suites == {"skill/a": ["skills/a/evals/bad-\udcff"]} From 1cb3efef54f803a83070d4e10b4264d544788762 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 10:20:33 -0800 Subject: [PATCH 18/36] fix(config): portable skill_sha and claims through chained links - tree_digest reads the exec bit from the git index when the file is tracked, so a checkout with a different umask or core.fileMode does not move skill_sha. Untracked files keep the disk bit. - Symlinks inside the tree hash their target as a relative path from the link, so the same tree digests the same at another path. - Reach follows every link on the way to a skill, suite file or manifest (_hops), so a change to a link on that chain claims the subject, and a link inside the skill that a suite reaches through is claimed by the skill too. - _digest_files hashes each hop line, so suite_hash moves when a link on the way changes. Covered by red-then-green tests in test_repo_config.py and test_tree_digest.py. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 97 ++++++++++++++++++++++++++------ dokimi/dokimi/utils.py | 59 ++++++++++++++++++- dokimi/tests/test_repo_config.py | 84 ++++++++++++++++++++++++++- dokimi/tests/test_tree_digest.py | 26 +++++++++ 4 files changed, 245 insertions(+), 21 deletions(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 107591e6d..b24624c9f 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -29,7 +29,7 @@ import yaml from dokimi.artifacts import ArtifactValidationError, validate_artifact -from dokimi.utils import tree_digest +from dokimi.utils import link_text, tree_digest KIND = "dokimi_config" CONFIG_CANDIDATES = (".github/dokimi/config.yaml", ".dokimi/config.yaml") @@ -265,10 +265,52 @@ class Reach: globs: tuple[str, ...] -def _link_ancestors(rel: str, root: Path) -> list[tuple[str, ...]]: - """The proper ancestors of ``rel`` (repository-relative) that are symbolic links under ``root``.""" - parts = rel.split("/") - return [tuple(parts[:k]) for k in range(1, len(parts)) if root.joinpath(*parts[:k]).is_symlink()] +def _declared_links(path: Path, root: Path) -> list[tuple[str, ...]]: + """The links on the declared way to ``path`` (a path under ``root``, as written), the path itself included when it is + one: the components that are symbolic links, not the links met by following them.""" + parts = path.relative_to(root).parts + return [parts[:k] for k in range(1, len(parts) + 1) if root.joinpath(*parts[:k]).is_symlink()] + + +def _hops(path: Path, root: Path, root_real: Path) -> list[tuple[str, ...]]: + """The repository-relative links met resolving ``path`` (a path under ``root``, as written): each link on its + declared way, and each link on the way to every target a link points to, until the path is resolved or leaves the + repository. A link's location is its real one, as git names it. A loop is cut after a fixed number of hops.""" + pending = list(path.relative_to(root).parts) + here: tuple[str, ...] = () # the real directory the next component is looked up in + found: dict[tuple[str, ...], None] = {} + hops = 0 + while pending: + part = pending.pop(0) + if part in ("", "."): + continue + if part == "..": + if not here: + break + here = here[:-1] + continue + link = root_real.joinpath(*here, part) + if not link.is_symlink(): + here = (*here, part) + continue + hops += 1 + if hops > 256: + break + found[(*here, part)] = None + target = os.readlink(link) + if os.path.isabs(target): + for anchor in (root_real, root): # written as the resolved or the given spelling of the repository + try: + pending = [*Path(os.path.normpath(target)).relative_to(anchor).parts, *pending] + break + except ValueError: + continue + else: + break # an absolute target outside the repository: no more of the way is ours + here = () + else: + pending = [*Path(target).parts, *pending] + return list(found) def _reach(subject: Subject, root: Path) -> Reach: @@ -290,21 +332,33 @@ def _reach(subject: Subject, root: Path) -> Reach: if resolved != declared: # a linked skill: its suite globs, rewritten to where the files live moved = [resolved + g[len(declared):] for g in globs if g == declared or g.startswith(declared + "/")] globs = (*globs, *moved) - skill_links.extend(_link_ancestors(subject.path, root)) + skill_links.extend(_hops(root / subject.path, root, root_real)) if subject.manifest is not None: # the repo subject hashes its manifest through a link, so its target is covered real_manifest = _inside(root / subject.manifest, root_real) if real_manifest is not None: suites.append(real_manifest) - links.extend(_link_ancestors(subject.manifest, root)) + links.extend(_hops(root / subject.manifest, root, root_real)) for rel, path in _suite_files(root, subject.suite_globs).items(): - links.extend(_link_ancestors(rel, root)) for _, entry in _walk(path, rel, dirs=True): - real = _inside(entry, root_real) - if real is not None: - suites.append(real) + _suite_entry(entry, root, root_real, real_skill, suites, links, skill_links) return Reach(skills, suites, links, skill_links, globs) +def _suite_entry(entry: Path, root: Path, root_real: Path, real_skill: tuple[str, ...] | None, + suites: list[tuple[str, ...]], links: list[tuple[str, ...]], + skill_links: list[tuple[str, ...]]) -> None: + """Add one suite file's claims: its own location and the links on the way to it, and a link inside the skill + that the suite reaches through (skill_sha covers it, so the skill claims it too).""" + declared_links = set(_declared_links(entry, root)) + for hop in _hops(entry, root, root_real): + links.append(hop) + if real_skill is not None and _under(hop, real_skill) and hop not in declared_links: + skill_links.append(hop) + real = _inside(entry, root_real) + if real is not None: + suites.append(real) + + def _inside(path: Path, root_real: Path) -> tuple[str, ...] | None: """The segments of ``path``'s resolved location under the repository root; None when it is the root or outside it.""" real = _real(path) @@ -327,7 +381,10 @@ def _map_one(rel: str, subjects: list[Subject], reach: dict[str, Reach], shared: is_shared = any(_claims(g, segs) for g in shared) suites = [s.id for s in subjects if _in_suite(s, segs, reach[s.id])] # a skill whose own suite holds this file gets nothing: it is eval, and skill_sha leaves it out - skills = [s for s in _skill_owners(subjects, reach, segs) if not _in_suite(s, segs, reach[s.id])] + skills = [ + s for s in _skill_owners(subjects, reach, segs) + if not _in_suite(s, segs, reach[s.id]) or segs in reach[s.id].skill_links + ] if is_shared: impact.shared.append(rel) for sid in suites: @@ -514,14 +571,20 @@ def _suite_files(root: Path, globs: tuple[str, ...]) -> dict[str, Path]: return {rel: path for rel, path in found.items() if not any(other != rel and _within(rel, other) for other in found)} -def _digest_files(files: dict[str, Path]) -> str: +def _digest_files(files: dict[str, Path], root: Path) -> str: """One digest over the suite's files. A directory is walked file by file; a symlink counts by its target text - (as tree_digest does) and by what it points to, so retargeting a link moves the hash even between identical trees.""" + (as tree_digest does) and by what it points to, so retargeting a link moves the hash even between identical trees. + Every link met on the way to a file counts too (a link above the file, or one on the way to its target), so + retargeting a link on the way to a suite file moves the hash even when the files behind it are identical.""" h = hashlib.sha256() + root_real = _real(root) or root for rel in sorted(files): for name, path in _walk(files[rel], rel): + for hop in _hops(path, root, root_real): + text = link_text(root.joinpath(*hop), root) # the same spelling of the tree as the symlink lines below + h.update(os.fsencode(f"link {'/'.join(hop)}\0-> {text}\n")) if path.is_symlink(): - h.update(os.fsencode(f"{name}\0-> {os.readlink(path)}\n")) # names may hold undecodable bytes + h.update(os.fsencode(f"{name}\0-> {link_text(path, root)}\n")) # names may hold undecodable bytes if path.is_dir(): continue # a linked directory: its files are walked and hashed one by one target = path.resolve() if path.exists() else path @@ -645,7 +708,7 @@ def _hashed(subject: Subject, root: Path, all_subjects: list[Subject]) -> Subjec if subject.kind == "repo": assert subject.manifest is not None # set by _subject for kind repo files.setdefault(subject.manifest, root / subject.manifest) - return replace(subject, suite_hash=_digest_files(files)) + return replace(subject, suite_hash=_digest_files(files, root)) inside = [rel[len(subject.path) + 1:] for rel in files if _within(rel, subject.path) and rel != subject.path] # a nested skill is its own subject: its files belong to its own skill_sha, not to the outer one nested = [ @@ -655,4 +718,4 @@ def _hashed(subject: Subject, root: Path, all_subjects: list[Subject]) -> Subjec ] # A skill directory that is a symlink is its target's content (a suite match is hashed by its link instead). skill_sha = "sha256:" + tree_digest((root / subject.path).resolve(), exclude=[*inside, *nested]) - return replace(subject, skill_sha=skill_sha, suite_hash=_digest_files(files)) + return replace(subject, skill_sha=skill_sha, suite_hash=_digest_files(files, root)) diff --git a/dokimi/dokimi/utils.py b/dokimi/dokimi/utils.py index d8b168f05..4e09d2a62 100644 --- a/dokimi/dokimi/utils.py +++ b/dokimi/dokimi/utils.py @@ -5,6 +5,7 @@ import json import os import re +import stat from pathlib import Path from typing import Iterable, Tuple, Dict, Any @@ -94,6 +95,48 @@ def digest_text(text: str) -> str: return "sha256:" + hashlib.sha256((text or "").encode("utf-8", errors="surrogatepass")).hexdigest() +def link_text(link: Path, root: Path) -> str: + """A symlink's target as the digests hold it: as written, except an absolute target inside ``root``, which is + written relative to the link. So a tree digests the same at another checkout path (the link's text is the same).""" + target = os.readlink(link) + if not os.path.isabs(target): + return target + target = os.path.normpath(target) + # the tree may be named by a path with links in it: match the target under the name as given and as resolved + # (a link that is the root itself is not resolved, or its own target would count as inside it) + anchors = [os.fspath(root)] if root.is_symlink() else [os.fspath(root), os.path.realpath(root)] + for anchor in anchors: + try: + inside = os.path.commonpath([target, anchor]) == anchor + except ValueError: # different drives, or a relative path mixed in + continue + if inside: + here = os.path.join(anchor, os.path.dirname(link.relative_to(root))) + return os.path.relpath(target, here).replace(os.sep, "/") + return target + + +def _tracked_modes(base: Path, spec: str) -> dict[str, bool]: + """The executable bit git records (mode 100755) for each file ``spec`` names under ``base``, keyed by the path + relative to ``base``. Empty when git cannot say (no git, or ``base`` is not in a repository).""" + from dokimi.process import Env, run + + # GIT_* variables (a hook's GIT_DIR or GIT_INDEX_FILE) would point git at some other repository's index + env = Env.only(allow=[k for k in os.environ if not k.startswith("GIT_")]) + try: + done = run(["git", "-C", os.fspath(base), "ls-files", "--stage", "-z", "--", spec], timeout=60, env=env) + except OSError: + return {} + if not done.ok: + return {} + modes: dict[str, bool] = {} + for record in done.stdout_bytes.split(b"\0"): + meta, tab, name = record.partition(b"\t") + if tab: + modes[os.fsdecode(name)] = meta.split()[0] == b"100755" + return modes + + def tree_digest(root: Path, *, exclude: Iterable[str] = ()) -> str: """sha256 (bare hex) over every path, mode bit and byte under ``root``; a link by its target. @@ -101,9 +144,16 @@ def tree_digest(root: Path, *, exclude: Iterable[str] = ()) -> str: (by its target, dangling or not; it is not walked). Entries whose path, relative to ``root``, equals one of ``exclude`` or sits under it are left out, so a skill's digest can leave its eval directory out. Output is deterministic: sorted walk, no timestamps. + + The same tree digests the same on every OS: a file's mode bit is the one git records (the index, so a + Windows checkout with no exec bit still matches), and an untracked file takes its owner's exec bit + from the disk. A link inside ``root`` with an absolute target is hashed by a relative target (see + ``link_text``); one pointing outside ``root`` is hashed as written. """ skip = tuple(e.strip("/") for e in exclude) entries = [root] if root.is_symlink() or root.is_file() else sorted(root.rglob("*")) + base, spec = (root.parent, root.name) if root.is_file() else (root, ".") + tracked = _tracked_modes(base, spec) if any(p.is_file() and not p.is_symlink() for p in entries) else {} h = hashlib.sha256() for path in entries: rel = "." if path == root else path.relative_to(root).as_posix() @@ -112,9 +162,14 @@ def tree_digest(root: Path, *, exclude: Iterable[str] = ()) -> str: # names and link targets are bytes on POSIX and may hold undecodable bytes: encode them as the OS does name = os.fsencode(rel) if path.is_symlink(): - h.update(b"L " + name + b"\0" + os.fsencode(os.readlink(path)) + b"\0") + h.update(b"L " + name + b"\0" + os.fsencode(link_text(path, root)) + b"\0") elif path.is_file(): - h.update(b"F " + name + f"\0{int(os.access(path, os.X_OK))}\0".encode()) + key = path.relative_to(base).as_posix() + if key in tracked: + executable = tracked[key] + else: + executable = bool(os.lstat(path).st_mode & stat.S_IXUSR) + h.update(b"F " + name + f"\0{int(executable)}\0".encode()) h.update(hashlib.sha256(path.read_bytes()).digest()) elif path.is_dir(): h.update(b"D " + name + b"\0") diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index 14757b321..9914ce5c8 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -854,8 +854,8 @@ def test_retargeting_a_link_on_the_way_to_a_suite_file_claims_the_link_itself(tm root = _tree(tmp_path / "repo", { ".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "# a\n", - "one/task.json": "[1]\n", - "two/task.json": "[2]\n", + "one/task.json": "[]\n", # identical bytes: only the link's target tells the two apart + "two/task.json": "[]\n", }) os.makedirs(root / "evaluation") os.symlink("../one", root / "evaluation" / "current") @@ -901,3 +901,83 @@ def test_undecodable_names_and_link_targets_under_a_suite_load_and_digest(tmp_pa assert subject.suite_hash and subject.suite_hash.startswith("sha256:") impact = load_repo_config(root).subjects_for_changed(["skills/a/evals/bad-\udcff"]) assert impact.suites == {"skill/a": ["skills/a/evals/bad-\udcff"]} + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_link_on_the_way_to_a_suite_files_target_claims_the_suite(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: skills/a/evals/**\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "shared/one/q.json": "[]\n", # identical bytes in both: the chain alone tells them apart + "shared/two/q.json": "[]\n", + }) + os.makedirs(root / "skills" / "a" / "evals") + os.symlink("../../../shared/current/q.json", root / "skills" / "a" / "evals" / "q.json") + os.symlink("one", root / "shared" / "current") + before = _subjects(root)["skill/a"].suite_hash + (root / "shared" / "current").unlink() + os.symlink("two", root / "shared" / "current") + assert _subjects(root)["skill/a"].suite_hash != before + impact = load_repo_config(root).subjects_for_changed(["shared/current", "shared/current-sibling/x"]) + assert impact.suites == {"skill/a": ["shared/current"]} + assert impact.unclaimed == ["shared/current-sibling/x"] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_link_on_the_way_to_a_skill_claims_the_skill(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: linked/a\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "real1/a/SKILL.md": "# a\n", + "real2/a/SKILL.md": "# b\n", + }) + os.symlink("hop", root / "linked") + os.symlink("real1", root / "hop") + before = _subjects(root)["skill/a"].skill_sha + (root / "hop").unlink() + os.symlink("real2", root / "hop") + assert _subjects(root)["skill/a"].skill_sha != before + impact = load_repo_config(root).subjects_for_changed(["hop"]) + assert impact.skills == {"skill/a": ["hop"]} + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_link_on_the_way_to_a_manifest_claims_the_subject(tmp_path): + body = HEADER + "subjects:\n - id: repo\n kind: repo\n manifest: dokimi.toml\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, "one/pyproject.toml": "[project]\n"}) + os.symlink("one", root / "current") + os.symlink("current/pyproject.toml", root / "dokimi.toml") + before = _subjects(root)["repo"].suite_hash + (root / "current").unlink() + os.symlink("two", root / "current") + (root / "two").mkdir() + (root / "two" / "pyproject.toml").write_text("[project]\n") + assert _subjects(root)["repo"].suite_hash != before + impact = load_repo_config(root).subjects_for_changed(["current"]) + assert impact.suites == {"repo": ["current"]} + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_link_inside_the_skill_that_a_suite_file_reaches_through_is_claimed_by_both(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: skills/a/evals/**\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "shared/one.md": "same\n", # identical bytes in both: only the link tells them apart + "shared/two.md": "same\n", + }) + os.makedirs(root / "skills" / "a" / "refs") + os.makedirs(root / "skills" / "a" / "evals") + os.symlink("../../../shared/one.md", root / "skills" / "a" / "refs" / "l") + os.symlink("../refs/l", root / "skills" / "a" / "evals" / "q.json") + before = _subjects(root)["skill/a"] + (root / "skills" / "a" / "refs" / "l").unlink() + os.symlink("../../../shared/two.md", root / "skills" / "a" / "refs" / "l") + after = _subjects(root)["skill/a"] + assert after.skill_sha != before.skill_sha and after.suite_hash != before.suite_hash + impact = load_repo_config(root).subjects_for_changed(["skills/a/refs/l"]) + assert impact.skills == {"skill/a": ["skills/a/refs/l"]} + assert impact.suites == {"skill/a": ["skills/a/refs/l"]} + assert impact.unclaimed == [] diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py index 47b40c953..7931ae179 100644 --- a/dokimi/tests/test_tree_digest.py +++ b/dokimi/tests/test_tree_digest.py @@ -6,6 +6,8 @@ from __future__ import annotations import os +import shutil +import subprocess from pathlib import Path import pytest @@ -113,3 +115,27 @@ def test_an_undecodable_name_and_link_target_digest_without_error(tmp_path): os.close(fd) os.symlink(b"target-\xff", os.fsencode(other / "link")) assert first != tree_digest(other) + + +@pytest.mark.skipif(shutil.which("git") is None, reason="the mode bit comes from git's index; needs git") +def test_the_executable_bit_is_the_one_git_records_not_the_disk_one(tmp_path): + root = _write(tmp_path / "t", {"run.sh": "echo\n"}) + (root / "run.sh").chmod(0o755) + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "add", "run.sh"], check=True) + before = tree_digest(root) + (root / "run.sh").chmod(0o644) # a Windows checkout has no exec bit on disk; git still records 100755 + assert tree_digest(root) == before + + +@symlinks +def test_an_absolute_link_inside_the_tree_digests_the_same_at_another_path(tmp_path): + a = _write(tmp_path / "a", {"q.json": "[]\n", "other.json": "[1]\n"}) + b = _write(tmp_path / "b", {"q.json": "[]\n", "other.json": "[1]\n"}) + os.symlink(a / "q.json", a / "current") # an absolute target: the checkout path must not reach the digest + os.symlink(b / "q.json", b / "current") + assert tree_digest(a) == tree_digest(b) + before = tree_digest(a) + (a / "current").unlink() + os.symlink(a / "other.json", a / "current") + assert tree_digest(a) != before From 8f23573dea7c8cdd219ffdc2ef96bd995159b383 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 11:35:51 -0800 Subject: [PATCH 19/36] fix(config): keep ".." after links, pin digest order and mode lookup - link_text keeps ".." after an in-tree link, so retargeting through it moves the digest - tree_digest sorts entries by path parts, the same order on every OS - _tracked_modes uses --literal-pathspecs and no capture limit - the config's links, and the links on the way to a literal suite path that matches nothing now, are claimed; a link a suite matches is not a skill link - _check_nesting refuses a nested skill that does not resolve inside the outer one Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 55 +++++++++-- dokimi/dokimi/utils.py | 26 ++++-- dokimi/tests/test_repo_config_links.py | 121 +++++++++++++++++++++++++ dokimi/tests/test_tree_digest.py | 53 +++++++++++ 4 files changed, 238 insertions(+), 17 deletions(-) create mode 100644 dokimi/tests/test_repo_config_links.py diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index b24624c9f..41931e5c2 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -230,6 +230,13 @@ def _own_path(config: RepoConfig) -> list[str]: found.append(glob.escape(path.relative_to(root).as_posix())) except ValueError: pass + # and every link on the way to it, as written: retargeting an intermediate link moves the config too + root_real = _real(config.root) + try: + hops = _hops(config.path, config.root, root_real) if root_real is not None else [] + except ValueError: # the config is not under its root as written + hops = [] + found.extend(glob.escape("/".join(hop)) for hop in hops) return list(dict.fromkeys(found)) @@ -338,21 +345,45 @@ def _reach(subject: Subject, root: Path) -> Reach: if real_manifest is not None: suites.append(real_manifest) links.extend(_hops(root / subject.manifest, root, root_real)) - for rel, path in _suite_files(root, subject.suite_globs).items(): + matches = _suite_files(root, subject.suite_globs) + for rel, path in matches.items(): for _, entry in _walk(path, rel, dirs=True): - _suite_entry(entry, root, root_real, real_skill, suites, links, skill_links) + _suite_entry(entry, root, root_real, real_skill, suites, links, skill_links, matches) + globs = (*globs, *_unnamed_literals(subject.suite_globs, matches, root, root_real, real_skill, + suites, links, skill_links)) return Reach(skills, suites, links, skill_links, globs) +def _unnamed_literals(literals: tuple[str, ...], matches: dict[str, Path], root: Path, root_real: Path, + real_skill: tuple[str, ...] | None, suites: list[tuple[str, ...]], + links: list[tuple[str, ...]], skill_links: list[tuple[str, ...]]) -> tuple[str, ...]: + """A literal suite path that names nothing now (its file deleted, or a link on the way retargeted where the file + is absent) still claims the links on its way and the place it resolves to, so the change maps to the suite. + Returns the escaped glob of each such place that resolves inside the repo.""" + globs: list[str] = [] + for literal in literals: + if literal in matches or any(c in literal for c in "*?["): + continue + entry = root.joinpath(*literal.split("/")) + _suite_entry(entry, root, root_real, real_skill, suites, links, skill_links, matches) + real = _inside(entry, root_real) + if real is not None: + globs.append(glob.escape("/".join(real))) + return tuple(globs) + + def _suite_entry(entry: Path, root: Path, root_real: Path, real_skill: tuple[str, ...] | None, suites: list[tuple[str, ...]], links: list[tuple[str, ...]], - skill_links: list[tuple[str, ...]]) -> None: + skill_links: list[tuple[str, ...]], matches: dict[str, Path]) -> None: """Add one suite file's claims: its own location and the links on the way to it, and a link inside the skill - that the suite reaches through (skill_sha covers it, so the skill claims it too).""" + that the suite reaches through (skill_sha covers it, so the skill claims it too). A link the suite matches is + left out of skill_sha, so it is not a skill link; ``matches`` holds the suite's matched paths.""" declared_links = set(_declared_links(entry, root)) for hop in _hops(entry, root, root_real): links.append(hop) - if real_skill is not None and _under(hop, real_skill) and hop not in declared_links: + rel = "/".join(hop) + in_suite = any(_within(rel, m) for m in matches) + if real_skill is not None and _under(hop, real_skill) and hop not in declared_links and not in_suite: skill_links.append(hop) real = _inside(entry, root_real) if real is not None: @@ -438,9 +469,10 @@ def _resolve(data: dict[str, Any], root: Path) -> list[Subject]: def _check_nesting(subjects: list[Subject], root: Path) -> None: - """A skill whose resolved directory lies inside another skill's, but whose declared path does not, is refused: the - outer skill's hash covers it, and a change to it could not be attributed to one owner. Skills that alias one - directory are both owners, so they are allowed.""" + """A skill whose resolved directory lies inside another skill's is refused unless its declared path lies inside the + other's declared path and resolves to the same place inside it: the outer skill's hash covers the inner's resolved + directory, and a change to it could not be attributed to one owner. Skills that alias one directory are both owners, + so they are allowed.""" real = {s.id: _real(root / s.path) for s in subjects if s.kind == "skill"} for s in subjects: s_real = real.get(s.id) if s.kind == "skill" else None @@ -450,7 +482,12 @@ def _check_nesting(subjects: list[Subject], root: Path) -> None: t_real = real.get(t.id) if t.kind == "skill" and t is not s else None if t_real is None or t_real == s_real: continue - if s_real.is_relative_to(t_real) and not _within(s.path, t.path): + if not s_real.is_relative_to(t_real): + continue + # the outer hash leaves out the inner's declared directory only, so the inner must resolve to that same + # place inside the outer (a link inside the outer that the inner reaches would stay in the outer's hash) + declared = s.path[len(t.path) + 1:] if _within(s.path, t.path) else None + if declared is None or s_real.relative_to(t_real).as_posix() != declared: raise ConfigError( f"subject {s.id}: its directory {s.path} is inside the skill {t.path} through a link; " "a skill reached by a link into another skill is not supported" diff --git a/dokimi/dokimi/utils.py b/dokimi/dokimi/utils.py index 4e09d2a62..44ec09525 100644 --- a/dokimi/dokimi/utils.py +++ b/dokimi/dokimi/utils.py @@ -97,23 +97,28 @@ def digest_text(text: str) -> str: def link_text(link: Path, root: Path) -> str: """A symlink's target as the digests hold it: as written, except an absolute target inside ``root``, which is - written relative to the link. So a tree digests the same at another checkout path (the link's text is the same).""" + written relative to the link. So a tree digests the same at another checkout path (the link's text is the same). + A ``..`` in such a target is kept as written: after a link the kernel resolves it from the link's target, so + collapsing it lexically would hash two different files alike.""" target = os.readlink(link) if not os.path.isabs(target): return target - target = os.path.normpath(target) # the tree may be named by a path with links in it: match the target under the name as given and as resolved # (a link that is the root itself is not resolved, or its own target would count as inside it) anchors = [os.fspath(root)] if root.is_symlink() else [os.fspath(root), os.path.realpath(root)] for anchor in anchors: try: - inside = os.path.commonpath([target, anchor]) == anchor + inside = os.path.commonpath([target, anchor]) == anchor and target.startswith(anchor) except ValueError: # different drives, or a relative path mixed in continue if inside: - here = os.path.join(anchor, os.path.dirname(link.relative_to(root))) - return os.path.relpath(target, here).replace(os.sep, "/") - return target + rest = [part for part in target[len(anchor):].split(os.sep) if part not in ("", ".")] + depth = len(link.relative_to(root).parent.parts) # the link's directory, as the text is read from there + if ".." in rest: + return "/".join([".."] * depth + rest) or "." + here = os.path.join(anchor, *link.relative_to(root).parent.parts) + return os.path.relpath(os.path.join(anchor, *rest), here).replace(os.sep, "/") + return target # outside the tree: as written def _tracked_modes(base: Path, spec: str) -> dict[str, bool]: @@ -124,7 +129,9 @@ def _tracked_modes(base: Path, spec: str) -> dict[str, bool]: # GIT_* variables (a hook's GIT_DIR or GIT_INDEX_FILE) would point git at some other repository's index env = Env.only(allow=[k for k in os.environ if not k.startswith("GIT_")]) try: - done = run(["git", "-C", os.fspath(base), "ls-files", "--stage", "-z", "--", spec], timeout=60, env=env) + # literal: the name is a path, not a pattern; no capture limit: a large tree's listing must be read whole + done = run(["git", "--literal-pathspecs", "-C", os.fspath(base), "ls-files", "--stage", "-z", "--", spec], + timeout=60, env=env, capture_limit=None) except OSError: return {} if not done.ok: @@ -151,7 +158,10 @@ def tree_digest(root: Path, *, exclude: Iterable[str] = ()) -> str: ``link_text``); one pointing outside ``root`` is hashed as written. """ skip = tuple(e.strip("/") for e in exclude) - entries = [root] if root.is_symlink() or root.is_file() else sorted(root.rglob("*")) + # sorted by the parts of the relative path, compared as code points: the same order on every OS (Path + # ordering folds case on Windows) + entries = ([root] if root.is_symlink() or root.is_file() + else sorted(root.rglob("*"), key=lambda p: p.relative_to(root).parts)) base, spec = (root.parent, root.name) if root.is_file() else (root, ".") tracked = _tracked_modes(base, spec) if any(p.is_file() and not p.is_symlink() for p in entries) else {} h = hashlib.sha256() diff --git a/dokimi/tests/test_repo_config_links.py b/dokimi/tests/test_repo_config_links.py new file mode 100644 index 000000000..05cf09823 --- /dev/null +++ b/dokimi/tests/test_repo_config_links.py @@ -0,0 +1,121 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""dokimi_config.v1: links on the way to the config, literal suite paths, nested skills and link chains.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from dokimi.repo_config import ConfigError, load_repo_config + +HEADER = "schema_version: dokimi_config.v1\n" + + +def _tree(root: Path, files: dict[str, str]) -> Path: + """A repository at ``root`` (it always has a ``.git``) holding ``files``; returns the resolved root.""" + (root / ".git").mkdir(parents=True, exist_ok=True) + for rel, text in files.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return root.resolve() + + +def _subjects(root: Path): + return {s.id: s for s in load_repo_config(root).subjects()} + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_retargeting_a_link_on_the_way_to_the_config_moves_the_smoke_set(tmp_path): + root = _tree(tmp_path / "repo", { + "one/config.yaml": HEADER + "subjects: []\n", + "two/config.yaml": HEADER + "subjects: []\n", + }) + os.makedirs(root / ".github" / "dokimi") + os.makedirs(root / "ci") + os.symlink("../one", root / "ci" / "current") + os.symlink("../../ci/current/config.yaml", root / ".github" / "dokimi" / "config.yaml") + (root / "ci" / "current").unlink() + os.symlink("../two", root / "ci" / "current") + impact = load_repo_config(root).subjects_for_changed(["ci/current"]) + assert impact.shared == ["ci/current"] + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_retargeting_a_link_to_a_suite_file_that_is_missing_there_moves_the_suite(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/task.json\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "one/task.json": "[]\n", + "two/other.json": "[]\n", # no task.json here: the suite is empty after the retarget + }) + os.makedirs(root / "evaluation") + os.symlink("../one", root / "evaluation" / "current") + before = _subjects(root)["skill/a"].suite_hash + (root / "evaluation" / "current").unlink() + os.symlink("../two", root / "evaluation" / "current") + assert _subjects(root)["skill/a"].suite_hash != before + impact = load_repo_config(root).subjects_for_changed(["evaluation/current"]) + assert impact.suites == {"skill/a": ["evaluation/current"]} + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_deleting_the_file_a_linked_literal_suite_names_still_claims_it(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/task.json\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "one/task.json": "[]\n", + }) + os.makedirs(root / "evaluation") + os.symlink("../one", root / "evaluation" / "current") + before = _subjects(root)["skill/a"].suite_hash + (root / "one" / "task.json").unlink() + assert _subjects(root)["skill/a"].suite_hash != before + impact = load_repo_config(root).subjects_for_changed(["one/task.json"]) + assert impact.suites == {"skill/a": ["one/task.json"]} + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_nested_skill_linked_to_a_sibling_inside_the_outer_skill_is_refused(tmp_path): + body = HEADER + ("subjects:\n - id: skill/outer\n path: skills/outer\n" + " - id: skill/inner\n path: skills/outer/inner\n") + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/outer/SKILL.md": "# outer\n", + "skills/outer/actual/SKILL.md": "# actual\n", + }) + os.symlink(root / "skills/outer/actual", root / "skills/outer/inner", target_is_directory=True) + with pytest.raises(ConfigError, match=r"skill/inner.*not supported"): + load_repo_config(root).subjects(hashes=False) + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_link_inside_a_suite_reached_through_another_link_is_not_a_skill_link(tmp_path): + # skill_sha leaves the suite's files out, so retargeting a link inside the suite must not claim the skill + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: skills/a/evals/**\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "x/one.md": "same\n", + "x/two.md": "same\n", + }) + os.makedirs(root / "skills" / "a" / "evals") + os.symlink("../../../x/one.md", root / "skills" / "a" / "evals" / "alias") + os.symlink("alias", root / "skills" / "a" / "evals" / "q.json") + before = _subjects(root)["skill/a"] + (root / "skills" / "a" / "evals" / "alias").unlink() + os.symlink("../../../x/two.md", root / "skills" / "a" / "evals" / "alias") + after = _subjects(root)["skill/a"] + assert after.skill_sha == before.skill_sha and after.suite_hash != before.suite_hash + impact = load_repo_config(root).subjects_for_changed(["skills/a/evals/alias"]) + assert impact.skills == {} + assert impact.suites == {"skill/a": ["skills/a/evals/alias"]} diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py index 7931ae179..fa924b560 100644 --- a/dokimi/tests/test_tree_digest.py +++ b/dokimi/tests/test_tree_digest.py @@ -5,6 +5,7 @@ from __future__ import annotations +import hashlib import os import shutil import subprocess @@ -139,3 +140,55 @@ def test_an_absolute_link_inside_the_tree_digests_the_same_at_another_path(tmp_p (a / "current").unlink() os.symlink(a / "other.json", a / "current") assert tree_digest(a) != before + + +@symlinks +def test_a_dotdot_after_a_link_is_kept_so_retargeting_through_it_moves_the_digest(tmp_path): + # root/hop/../x reaches /outside/x when hop is a link; collapsing the ".." would make it root/x + root = _write(tmp_path / "repo", {"x": "x\n"}) + outside = tmp_path / "outside" / "sub" + outside.mkdir(parents=True) + os.symlink(outside, root / "hop") + os.symlink(root / "hop" / ".." / "x", root / "current") + before = tree_digest(root) + (root / "current").unlink() + os.symlink(root / "x", root / "current") + assert tree_digest(root) != before + + +def test_a_tracked_file_whose_name_has_brackets_takes_its_mode_from_the_index(tmp_path): + # ls-files reads the name as a glob unless it is literal: [1] would match nothing and the disk bit would count + root = _write(tmp_path / "t", {"question[1].json": "[]\n"}) + (root / "question[1].json").chmod(0o755) + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "add", "question[1].json"], check=True) + before = tree_digest(root / "question[1].json") # a file root: git is asked for its name as the pathspec + (root / "question[1].json").chmod(0o644) + assert tree_digest(root / "question[1].json") == before + + +def test_a_tracked_mode_past_the_capture_limit_still_comes_from_the_index(tmp_path): + # git's listing of a large tree passes 1 MiB; the middle of it was replaced by a marker, so entries there + # fell back to the disk bit + names = [f"f{i:05d}-" + "x" * 200 for i in range(7000)] + root = _write(tmp_path / "t", {name: "a\n" for name in names}) + target = names[3500] + (root / target).chmod(0o755) + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "add", "."], check=True) + before = tree_digest(root) + (root / target).chmod(0o644) + assert tree_digest(root) == before + + +def test_mixed_case_names_are_ordered_by_code_point_on_every_os(tmp_path): + # Path sorting folds case on Windows, so "a.txt" sorted before "B.txt" there and after it on POSIX + root = _write(tmp_path / "t", {"B.txt": "2\n", "a.txt": "1\n", "a/nested.txt": "3\n"}) + expected = hashlib.sha256() + for rel in sorted(["B.txt", "a.txt", "a", "a/nested.txt"], key=lambda r: tuple(r.split("/"))): + if (root / rel).is_dir(): + expected.update(b"D " + rel.encode() + b"\0") + continue + expected.update(b"F " + rel.encode() + b"\0" + b"0\0") + expected.update(hashlib.sha256((root / rel).read_bytes()).digest()) + assert tree_digest(root) == expected.hexdigest() From f4d6cc73eaac2b9cdd1abe46db1046d74af940e8 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sun, 1 Nov 2026 20:12:06 +0000 Subject: [PATCH 20/36] Keep .. after a link in an absolute link target when finding hops Signed-off-by: Anton Volkov --- dokimi/dokimi/repo_config.py | 2 +- dokimi/tests/test_repo_config_links.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 41931e5c2..16cda0d0e 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -308,7 +308,7 @@ def _hops(path: Path, root: Path, root_real: Path) -> list[tuple[str, ...]]: if os.path.isabs(target): for anchor in (root_real, root): # written as the resolved or the given spelling of the repository try: - pending = [*Path(os.path.normpath(target)).relative_to(anchor).parts, *pending] + pending = [*Path(target).relative_to(anchor).parts, *pending] break except ValueError: continue diff --git a/dokimi/tests/test_repo_config_links.py b/dokimi/tests/test_repo_config_links.py index 05cf09823..f64f9425d 100644 --- a/dokimi/tests/test_repo_config_links.py +++ b/dokimi/tests/test_repo_config_links.py @@ -66,6 +66,30 @@ def test_retargeting_a_link_to_a_suite_file_that_is_missing_there_moves_the_suit assert impact.unclaimed == [] +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_dotdot_after_a_link_in_an_absolute_target_keeps_that_link_in_reach(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/q.json\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "one/q.json": "[]\n", + "one/sub/keep": "x\n", + "two/q.json": "[]\n", + "two/sub/keep": "x\n", + }) + os.makedirs(root / "evaluation") + os.makedirs(root / "shared") + os.symlink("../one/sub", root / "shared" / "current") + os.symlink(root / "shared" / "current" / ".." / "q.json", root / "evaluation" / "q.json") + before = _subjects(root)["skill/a"].suite_hash + (root / "shared" / "current").unlink() + os.symlink("../two/sub", root / "shared" / "current") + assert _subjects(root)["skill/a"].suite_hash != before + impact = load_repo_config(root).subjects_for_changed(["shared/current"]) + assert impact.suites == {"skill/a": ["shared/current"]} + assert impact.unclaimed == [] + + @pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_deleting_the_file_a_linked_literal_suite_names_still_claims_it(tmp_path): body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/task.json\n" From b109fc3894b5e846d8e9980547d9e005c3accd0e Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sun, 1 Nov 2026 20:12:39 +0000 Subject: [PATCH 21/36] Claim links on the way to a wildcard suite that matches nothing Signed-off-by: Anton Volkov --- dokimi/dokimi/repo_config.py | 18 ++++++++++-- dokimi/tests/test_repo_config_links.py | 38 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 16cda0d0e..1f858d3d9 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -359,12 +359,24 @@ def _unnamed_literals(literals: tuple[str, ...], matches: dict[str, Path], root: links: list[tuple[str, ...]], skill_links: list[tuple[str, ...]]) -> tuple[str, ...]: """A literal suite path that names nothing now (its file deleted, or a link on the way retargeted where the file is absent) still claims the links on its way and the place it resolves to, so the change maps to the suite. - Returns the escaped glob of each such place that resolves inside the repo.""" + A glob that matches nothing claims the links on the way to its literal prefix, and its own pattern under the + place the prefix resolves to. Returns the escaped glob of each such place that resolves inside the repo.""" globs: list[str] = [] for literal in literals: - if literal in matches or any(c in literal for c in "*?["): + if literal in matches: continue - entry = root.joinpath(*literal.split("/")) + segs = literal.split("/") + wild = next((i for i, s in enumerate(segs) if any(c in s for c in "*?[")), None) + if wild is not None: # a glob that matches nothing claims the links on the way to its literal prefix + if wild == 0 or _glob_paths(root, literal): + continue + prefix = root.joinpath(*segs[:wild]) + links.extend(_hops(prefix, root, root_real)) + real = _inside(prefix, root_real) + if real is not None: + globs.append("/".join([glob.escape("/".join(real)), *segs[wild:]])) + continue + entry = root.joinpath(*segs) _suite_entry(entry, root, root_real, real_skill, suites, links, skill_links, matches) real = _inside(entry, root_real) if real is not None: diff --git a/dokimi/tests/test_repo_config_links.py b/dokimi/tests/test_repo_config_links.py index f64f9425d..678bd41b3 100644 --- a/dokimi/tests/test_repo_config_links.py +++ b/dokimi/tests/test_repo_config_links.py @@ -66,6 +66,44 @@ def test_retargeting_a_link_to_a_suite_file_that_is_missing_there_moves_the_suit assert impact.unclaimed == [] +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_retargeting_a_link_to_a_wildcard_suite_that_matches_nothing_there_moves_the_suite(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/*.json\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "one/task.json": "[]\n", + "two/other.md": "x\n", # no JSON here: the suite is empty after the retarget + }) + os.makedirs(root / "evaluation") + os.symlink("../one", root / "evaluation" / "current") + before = _subjects(root)["skill/a"].suite_hash + (root / "evaluation" / "current").unlink() + os.symlink("../two", root / "evaluation" / "current") + assert _subjects(root)["skill/a"].suite_hash != before + impact = load_repo_config(root).subjects_for_changed(["evaluation/current"]) + assert impact.suites == {"skill/a": ["evaluation/current"]} + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_deleting_the_only_file_a_wildcard_suite_matches_still_claims_it(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/*.json\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "one/task.json": "[]\n", + }) + os.makedirs(root / "evaluation") + os.symlink("../one", root / "evaluation" / "current") + before = _subjects(root)["skill/a"].suite_hash + (root / "one" / "task.json").unlink() + assert _subjects(root)["skill/a"].suite_hash != before + impact = load_repo_config(root).subjects_for_changed(["one/task.json"]) + assert impact.suites == {"skill/a": ["one/task.json"]} + assert impact.unclaimed == [] + + @pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_a_dotdot_after_a_link_in_an_absolute_target_keeps_that_link_in_reach(tmp_path): body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/q.json\n" From 90422e1848c539e6104e20f7aae7e267f1c1afe1 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sun, 1 Nov 2026 20:12:44 +0000 Subject: [PATCH 22/36] Order directory children by name in the suite walk Signed-off-by: Anton Volkov --- dokimi/dokimi/repo_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 1f858d3d9..c397134a0 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -666,7 +666,7 @@ def visit(entry: Path, name: str) -> None: if real is None or real in seen: return seen.add(real) - for child in sorted(entry.iterdir()): + for child in sorted(entry.iterdir(), key=lambda p: p.name): visit(child, f"{name}/{child.name}") elif entry.is_file() and not entry.is_symlink(): found.append((name, entry)) From 809282b85d6555235c8af08c79d494130e625086 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sun, 1 Nov 2026 20:13:12 +0000 Subject: [PATCH 23/36] Keep a trailing slash and a root self-link in link_text Signed-off-by: Anton Volkov --- dokimi/dokimi/utils.py | 7 +++++-- dokimi/tests/test_tree_digest.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/dokimi/dokimi/utils.py b/dokimi/dokimi/utils.py index 44ec09525..175a65b7a 100644 --- a/dokimi/dokimi/utils.py +++ b/dokimi/dokimi/utils.py @@ -112,12 +112,15 @@ def link_text(link: Path, root: Path) -> str: except ValueError: # different drives, or a relative path mixed in continue if inside: + if link == root and target == anchor: + return target # the root links to itself, a loop: "." would read as a link to its own directory rest = [part for part in target[len(anchor):].split(os.sep) if part not in ("", ".")] depth = len(link.relative_to(root).parent.parts) # the link's directory, as the text is read from there + slash = "/" if target.endswith((os.sep, os.sep + ".")) else "" # "x/" and "x/." need a directory if ".." in rest: - return "/".join([".."] * depth + rest) or "." + return "/".join([".."] * depth + rest) + slash or "." here = os.path.join(anchor, *link.relative_to(root).parent.parts) - return os.path.relpath(os.path.join(anchor, *rest), here).replace(os.sep, "/") + return os.path.relpath(os.path.join(anchor, *rest), here).replace(os.sep, "/") + slash return target # outside the tree: as written diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py index fa924b560..f49b10896 100644 --- a/dokimi/tests/test_tree_digest.py +++ b/dokimi/tests/test_tree_digest.py @@ -156,6 +156,25 @@ def test_a_dotdot_after_a_link_is_kept_so_retargeting_through_it_moves_the_diges assert tree_digest(root) != before +@symlinks +def test_an_absolute_link_ending_in_a_slash_digests_differently_from_one_without(tmp_path): + # root/q.json/ and root/q.json/. do not resolve for a regular file; they must not digest as root/q.json + plain, slash, dot = (_write(tmp_path / n, {"q.json": "[]\n"}) for n in ("plain", "slash", "dot")) + os.symlink(f"{plain}/q.json", plain / "current") + os.symlink(f"{slash}/q.json/", slash / "current") + os.symlink(f"{dot}/q.json/.", dot / "current") + assert tree_digest(slash) != tree_digest(plain) + assert tree_digest(dot) != tree_digest(plain) + + +@symlinks +def test_a_root_link_to_itself_digests_differently_from_a_link_to_dot(tmp_path): + loop, dot = tmp_path / "loop", tmp_path / "dot" + os.symlink(loop, loop) + os.symlink(".", dot) + assert tree_digest(loop) != tree_digest(dot) + + def test_a_tracked_file_whose_name_has_brackets_takes_its_mode_from_the_index(tmp_path): # ls-files reads the name as a glob unless it is literal: [1] would match nothing and the disk bit would count root = _write(tmp_path / "t", {"question[1].json": "[]\n"}) From 94f43acd93dff57b9e11321f0b3811ef48da0056 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 13:06:20 -0800 Subject: [PATCH 24/36] fix(config): name suites by their resolved spelling, claim wildcard prefixes, keep root and Windows link targets - A suite reached through a linked skill is also matched by where its files resolve, so a link inside it is not reported as a skill link. - A wildcard suite claims the links on the way to its literal prefix and its own pattern under the place the prefix resolves to, also when other files still match. - A root symlink keeps its target as written, so a link to a descendant of itself no longer digests like the relative one. - Absolute link targets lose the Windows extended-length prefix before the containment check. - The tests that chmod a tracked file set the index exec bit explicitly (update-index --chmod=+x), so they hold on Windows too. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 28 ++++++++--- dokimi/dokimi/utils.py | 16 ++++++- dokimi/tests/test_repo_config_links.py | 64 +++++++++++++++++++++++++- dokimi/tests/test_tree_digest.py | 39 ++++++++++++++++ 4 files changed, 137 insertions(+), 10 deletions(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index c397134a0..ed7c430ba 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -346,10 +346,11 @@ def _reach(subject: Subject, root: Path) -> Reach: suites.append(real_manifest) links.extend(_hops(root / subject.manifest, root, root_real)) matches = _suite_files(root, subject.suite_globs) + named = {**matches, **_resolved_spellings(matches, root_real)} # each match by its spelling, and as it resolves for rel, path in matches.items(): for _, entry in _walk(path, rel, dirs=True): - _suite_entry(entry, root, root_real, real_skill, suites, links, skill_links, matches) - globs = (*globs, *_unnamed_literals(subject.suite_globs, matches, root, root_real, real_skill, + _suite_entry(entry, root, root_real, real_skill, suites, links, skill_links, named) + globs = (*globs, *_unnamed_literals(subject.suite_globs, named, root, root_real, real_skill, suites, links, skill_links)) return Reach(skills, suites, links, skill_links, globs) @@ -359,16 +360,17 @@ def _unnamed_literals(literals: tuple[str, ...], matches: dict[str, Path], root: links: list[tuple[str, ...]], skill_links: list[tuple[str, ...]]) -> tuple[str, ...]: """A literal suite path that names nothing now (its file deleted, or a link on the way retargeted where the file is absent) still claims the links on its way and the place it resolves to, so the change maps to the suite. - A glob that matches nothing claims the links on the way to its literal prefix, and its own pattern under the - place the prefix resolves to. Returns the escaped glob of each such place that resolves inside the repo.""" + A glob claims the links on the way to its literal prefix and its own pattern under the place the prefix resolves + to, whether or not it still matches files (deleting one of several matches moves the suite too). + Returns the escaped glob of each such place that resolves inside the repo.""" globs: list[str] = [] for literal in literals: if literal in matches: continue segs = literal.split("/") wild = next((i for i, s in enumerate(segs) if any(c in s for c in "*?[")), None) - if wild is not None: # a glob that matches nothing claims the links on the way to its literal prefix - if wild == 0 or _glob_paths(root, literal): + if wild is not None: # a glob claims the links on the way to its literal prefix + if wild == 0: continue prefix = root.joinpath(*segs[:wild]) links.extend(_hops(prefix, root, root_real)) @@ -384,12 +386,24 @@ def _unnamed_literals(literals: tuple[str, ...], matches: dict[str, Path], root: return tuple(globs) +def _resolved_spellings(matches: dict[str, Path], root_real: Path) -> dict[str, Path]: + """Each match again, spelled by where its directory resolves: a suite reached through a linked skill is matched + under the link's spelling, and its files are hashed where they live.""" + spelled: dict[str, Path] = {} + for path in matches.values(): + segs = _inside(path.parent, root_real) + if segs is not None: + spelled["/".join((*segs, path.name))] = path + return spelled + + def _suite_entry(entry: Path, root: Path, root_real: Path, real_skill: tuple[str, ...] | None, suites: list[tuple[str, ...]], links: list[tuple[str, ...]], skill_links: list[tuple[str, ...]], matches: dict[str, Path]) -> None: """Add one suite file's claims: its own location and the links on the way to it, and a link inside the skill that the suite reaches through (skill_sha covers it, so the skill claims it too). A link the suite matches is - left out of skill_sha, so it is not a skill link; ``matches`` holds the suite's matched paths.""" + left out of skill_sha, so it is not a skill link; ``matches`` holds the suite's matched paths, under their + declared and their resolved spellings.""" declared_links = set(_declared_links(entry, root)) for hop in _hops(entry, root, root_real): links.append(hop) diff --git a/dokimi/dokimi/utils.py b/dokimi/dokimi/utils.py index 175a65b7a..63340e935 100644 --- a/dokimi/dokimi/utils.py +++ b/dokimi/dokimi/utils.py @@ -95,6 +95,17 @@ def digest_text(text: str) -> str: return "sha256:" + hashlib.sha256((text or "").encode("utf-8", errors="surrogatepass")).hexdigest() +def _plain_path(text: str) -> str: + r"""A Windows absolute path without its extended-length prefix. os.readlink gives an absolute link target as + \\?\C:\... (or \\?\UNC\...), while the anchors are plain: the two read as different drives. + Any other text is returned unchanged.""" + if text.startswith("\\\\?\\UNC\\"): + return "\\\\" + text[len("\\\\?\\UNC\\"):] + if text.startswith("\\\\?\\"): + return text[len("\\\\?\\"):] + return text + + def link_text(link: Path, root: Path) -> str: """A symlink's target as the digests hold it: as written, except an absolute target inside ``root``, which is written relative to the link. So a tree digests the same at another checkout path (the link's text is the same). @@ -103,6 +114,9 @@ def link_text(link: Path, root: Path) -> str: target = os.readlink(link) if not os.path.isabs(target): return target + target = _plain_path(target) + if link == root: # the root is hashed by its target, not walked: kept as written, so a loop through it stays a loop + return target # the tree may be named by a path with links in it: match the target under the name as given and as resolved # (a link that is the root itself is not resolved, or its own target would count as inside it) anchors = [os.fspath(root)] if root.is_symlink() else [os.fspath(root), os.path.realpath(root)] @@ -112,8 +126,6 @@ def link_text(link: Path, root: Path) -> str: except ValueError: # different drives, or a relative path mixed in continue if inside: - if link == root and target == anchor: - return target # the root links to itself, a loop: "." would read as a link to its own directory rest = [part for part in target[len(anchor):].split(os.sep) if part not in ("", ".")] depth = len(link.relative_to(root).parent.parts) # the link's directory, as the text is read from there slash = "/" if target.endswith((os.sep, os.sep + ".")) else "" # "x/" and "x/." need a directory diff --git a/dokimi/tests/test_repo_config_links.py b/dokimi/tests/test_repo_config_links.py index 678bd41b3..68e7cb883 100644 --- a/dokimi/tests/test_repo_config_links.py +++ b/dokimi/tests/test_repo_config_links.py @@ -10,7 +10,7 @@ import pytest -from dokimi.repo_config import ConfigError, load_repo_config +from dokimi.repo_config import ConfigError, _walk, load_repo_config HEADER = "schema_version: dokimi_config.v1\n" @@ -181,3 +181,65 @@ def test_a_link_inside_a_suite_reached_through_another_link_is_not_a_skill_link( impact = load_repo_config(root).subjects_for_changed(["skills/a/evals/alias"]) assert impact.skills == {} assert impact.suites == {"skill/a": ["skills/a/evals/alias"]} + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_deleting_one_file_a_wildcard_suite_matches_among_others_still_claims_it(tmp_path): + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/*.json\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "one/task.json": "[]\n", + "one/keep.json": "[]\n", + }) + os.makedirs(root / "evaluation") + os.symlink("../one", root / "evaluation" / "current") + before = _subjects(root)["skill/a"].suite_hash + (root / "one" / "task.json").unlink() + assert _subjects(root)["skill/a"].suite_hash != before + impact = load_repo_config(root).subjects_for_changed(["one/task.json"]) + assert impact.suites == {"skill/a": ["one/task.json"]} + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_link_inside_a_suite_under_a_linked_skill_is_not_a_skill_link(tmp_path): + # skills/a -> vendor/a: the suite is spelled under skills/a but its files live under vendor/a. The eval link + # is in the suite, so skill_sha leaves it out, whichever spelling the walk reached it by + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: skills/a/evals/**\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "vendor/a/SKILL.md": "# a\n", + "x/one.md": "same\n", + "x/two.md": "same\n", + }) + os.makedirs(root / "skills") + os.symlink("../vendor/a", root / "skills" / "a") + os.makedirs(root / "vendor" / "a" / "evals") + os.symlink("../../../x/one.md", root / "vendor" / "a" / "evals" / "q.json") + before = _subjects(root)["skill/a"] + (root / "vendor" / "a" / "evals" / "q.json").unlink() + os.symlink("../../../x/two.md", root / "vendor" / "a" / "evals" / "q.json") + after = _subjects(root)["skill/a"] + assert after.skill_sha == before.skill_sha and after.suite_hash != before.suite_hash + impact = load_repo_config(root).subjects_for_changed(["vendor/a/evals/q.json"]) + assert impact.skills == {} + assert impact.suites == {"skill/a": ["vendor/a/evals/q.json"]} + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_mixed_case_directory_aliases_are_walked_in_code_point_order_on_every_os(tmp_path): + # Path ordering folds case on Windows, where "a" sorts before "B"; POSIX sorts "B" first. The walk takes the first + # alias of a shared directory it meets, so the suite must name the same alias on every OS: children by name + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "shared/q.json": "[]\n", + }) + os.makedirs(root / "evaluation") + os.symlink("../shared", root / "evaluation" / "B") + os.symlink("../shared", root / "evaluation" / "a") + names = [name for name, _ in _walk(root / "evaluation", "evaluation")] + assert "evaluation/B/q.json" in names + assert "evaluation/a/q.json" not in names diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py index f49b10896..b9dcdaa2c 100644 --- a/dokimi/tests/test_tree_digest.py +++ b/dokimi/tests/test_tree_digest.py @@ -26,6 +26,11 @@ def _write(root: Path, files: dict[str, str]) -> Path: return root +def _track_executable(root: Path, rel: str) -> None: + """Record ``rel`` as 100755 in the index whatever the disk says (chmod sets no exec bit on Windows).""" + subprocess.run(["git", "-C", str(root), "update-index", "--chmod=+x", "--", rel], check=True) + + def test_the_same_content_at_another_path_digests_the_same(tmp_path): a = _write(tmp_path / "a", {"SKILL.md": "x\n", "scripts/run.py": "print(1)\n"}) b = _write(tmp_path / "b", {"SKILL.md": "x\n", "scripts/run.py": "print(1)\n"}) @@ -124,6 +129,7 @@ def test_the_executable_bit_is_the_one_git_records_not_the_disk_one(tmp_path): (root / "run.sh").chmod(0o755) subprocess.run(["git", "init", "-q", str(root)], check=True) subprocess.run(["git", "-C", str(root), "add", "run.sh"], check=True) + _track_executable(root, "run.sh") # chmod does not set the bit on Windows: the index must say 100755 on every OS before = tree_digest(root) (root / "run.sh").chmod(0o644) # a Windows checkout has no exec bit on disk; git still records 100755 assert tree_digest(root) == before @@ -175,12 +181,44 @@ def test_a_root_link_to_itself_digests_differently_from_a_link_to_dot(tmp_path): assert tree_digest(loop) != tree_digest(dot) +@symlinks +def test_a_root_link_to_a_descendant_of_itself_digests_differently_from_a_relative_one(tmp_path): + # root -> root/child loops through the root; root -> child is the sibling directory. Both would read "child" + root = tmp_path / "link" + os.symlink(root / "child", root) + looping = tree_digest(root) + root.unlink() + os.symlink("child", root) + assert tree_digest(root) != looping + + +def test_a_windows_extended_path_prefix_is_read_as_the_plain_path(): + # os.readlink on Windows returns the \\?\ form of an absolute link target; the containment check needs the plain one + from dokimi.utils import _plain_path + + assert _plain_path("\\\\?\\C:\\checkout\\tree\\q.json") == "C:\\checkout\\tree\\q.json" + assert _plain_path("\\\\?\\UNC\\srv\\share\\q.json") == "\\\\srv\\share\\q.json" + assert _plain_path("C:\\checkout\\tree\\q.json") == "C:\\checkout\\tree\\q.json" + assert _plain_path("q.json") == "q.json" + + +@pytest.mark.skipif(os.name != "nt", reason="the extended-path prefix is what Windows reads an absolute link back as") +@symlinks +def test_an_absolute_link_inside_the_tree_digests_the_same_at_another_path_on_windows(tmp_path): + a = _write(tmp_path / "a", {"q.json": "[]\n"}) + b = _write(tmp_path / "b", {"q.json": "[]\n"}) + os.symlink(a / "q.json", a / "current") + os.symlink(b / "q.json", b / "current") + assert tree_digest(a) == tree_digest(b) + + def test_a_tracked_file_whose_name_has_brackets_takes_its_mode_from_the_index(tmp_path): # ls-files reads the name as a glob unless it is literal: [1] would match nothing and the disk bit would count root = _write(tmp_path / "t", {"question[1].json": "[]\n"}) (root / "question[1].json").chmod(0o755) subprocess.run(["git", "init", "-q", str(root)], check=True) subprocess.run(["git", "-C", str(root), "add", "question[1].json"], check=True) + _track_executable(root, "question[1].json") before = tree_digest(root / "question[1].json") # a file root: git is asked for its name as the pathspec (root / "question[1].json").chmod(0o644) assert tree_digest(root / "question[1].json") == before @@ -195,6 +233,7 @@ def test_a_tracked_mode_past_the_capture_limit_still_comes_from_the_index(tmp_pa (root / target).chmod(0o755) subprocess.run(["git", "init", "-q", str(root)], check=True) subprocess.run(["git", "-C", str(root), "add", "."], check=True) + _track_executable(root, target) before = tree_digest(root) (root / target).chmod(0o644) assert tree_digest(root) == before From 54ffa033c898da31cd33b2f3bcc3489da01e92a6 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sun, 1 Nov 2026 22:32:19 +0000 Subject: [PATCH 25/36] Let the Windows extended-path digest test run where symlinks work Signed-off-by: Anton Volkov --- dokimi/tests/test_tree_digest.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py index b9dcdaa2c..5259313da 100644 --- a/dokimi/tests/test_tree_digest.py +++ b/dokimi/tests/test_tree_digest.py @@ -203,12 +203,14 @@ def test_a_windows_extended_path_prefix_is_read_as_the_plain_path(): @pytest.mark.skipif(os.name != "nt", reason="the extended-path prefix is what Windows reads an absolute link back as") -@symlinks def test_an_absolute_link_inside_the_tree_digests_the_same_at_another_path_on_windows(tmp_path): a = _write(tmp_path / "a", {"q.json": "[]\n"}) b = _write(tmp_path / "b", {"q.json": "[]\n"}) - os.symlink(a / "q.json", a / "current") - os.symlink(b / "q.json", b / "current") + try: + os.symlink(a / "q.json", a / "current") + os.symlink(b / "q.json", b / "current") + except OSError: + pytest.skip("creating symlinks needs privileges on Windows") assert tree_digest(a) == tree_digest(b) From 7b121d8043607b9538f3a30ca7042121e058c5fc Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 17:20:49 -0800 Subject: [PATCH 26/36] fix(config): address the round-8 review of the link and suite walks - a link a wildcard suite walks through claims its change even after its target loses the match (repo_glob.glob_links, in a new module that keeps repo_config under the length cap) - a skill that resolves to the repository root is a ConfigError - an absolute link target with the Windows extended-length prefix is matched as the plain path - a suite glob that cannot read a directory on its way is a ConfigError; skills globs still match nothing - a target whose ".." leads out of the tree is hashed as written; Windows link containment ignores case - the executable-bit tests assert that the index alone moves the digest, with the disk bit held still Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 66 ++++--------------- dokimi/dokimi/repo_glob.py | 80 ++++++++++++++++++++++++ dokimi/dokimi/utils.py | 10 ++- dokimi/tests/test_import_architecture.py | 2 +- dokimi/tests/test_repo_config_globs.py | 53 ++++++++++++++++ dokimi/tests/test_repo_config_links.py | 46 ++++++++++++++ dokimi/tests/test_tree_digest.py | 58 +++++++++++++---- 7 files changed, 249 insertions(+), 66 deletions(-) create mode 100644 dokimi/dokimi/repo_glob.py create mode 100644 dokimi/tests/test_repo_config_globs.py diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index ed7c430ba..c09b74746 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -29,7 +29,8 @@ import yaml from dokimi.artifacts import ArtifactValidationError, validate_artifact -from dokimi.utils import link_text, tree_digest +from dokimi.repo_glob import glob_links as _glob_links, glob_paths as _glob_paths +from dokimi.utils import _plain_path, link_text, tree_digest KIND = "dokimi_config" CONFIG_CANDIDATES = (".github/dokimi/config.yaml", ".dokimi/config.yaml") @@ -191,8 +192,9 @@ def load_repo_config(start: Path | None = None, *, explicit: Path | None = None) def _norm(value: str) -> str | None: - """A repository-relative POSIX path or glob, or None when it is empty, absolute or climbs out.""" - norm = PurePosixPath(value.replace("\\", "/")).as_posix() + """A repository-relative POSIX path or glob, or None when it is empty, absolute or climbs out. git names changed + files with "/" on every OS; a backslash is a separator only on Windows, and an ordinary name character elsewhere.""" + norm = PurePosixPath(value.replace("\\", "/") if os.sep == "\\" else value).as_posix() # A drive (C:, C:skill) makes Path join discard the root on Windows, so it is refused like an absolute path. if norm in ("", ".") or norm.startswith("/") or DRIVE.match(norm) or ".." in norm.split("/"): return None @@ -304,7 +306,7 @@ def _hops(path: Path, root: Path, root_real: Path) -> list[tuple[str, ...]]: if hops > 256: break found[(*here, part)] = None - target = os.readlink(link) + target = _plain_path(os.readlink(link)) # Windows reads an absolute target back as \\?\C:\... if os.path.isabs(target): for anchor in (root_real, root): # written as the resolved or the given spelling of the repository try: @@ -369,11 +371,12 @@ def _unnamed_literals(literals: tuple[str, ...], matches: dict[str, Path], root: continue segs = literal.split("/") wild = next((i for i, s in enumerate(segs) if any(c in s for c in "*?[")), None) - if wild is not None: # a glob claims the links on the way to its literal prefix + if wild is not None: # a glob claims each link its walk reads through, and its own pattern where its prefix resolves + for link in _glob_links(root, literal): + links.extend(_hops(link, root, root_real)) if wild == 0: continue prefix = root.joinpath(*segs[:wild]) - links.extend(_hops(prefix, root, root_real)) real = _inside(prefix, root_real) if real is not None: globs.append("/".join([glob.escape("/".join(real)), *segs[wild:]])) @@ -578,58 +581,12 @@ def _unreadable(exc: OSError) -> ConfigError: return ConfigError(f"cannot read {exc.filename or 'part of the repository'}: {exc.strerror or exc}") -def _listing(base: Path) -> list[tuple[str, bool, bool]]: - """(name, is_dir, is_symlink) of each entry of ``base`` that can be read; an unreadable one is left out.""" - out: list[tuple[str, bool, bool]] = [] - try: - with os.scandir(base) as it: - for entry in it: - try: - out.append((entry.name, entry.is_dir(), entry.is_symlink())) - except OSError: - continue - except OSError: - pass - return out - - -def _glob_walk(base: Path, segs: tuple[str, ...], i: int, found: set[Path]) -> None: - if i == len(segs): - found.add(base) - return - seg = segs[i] - if seg == "**": - _glob_walk(base, segs, i + 1, found) - for name, is_dir, is_link in _listing(base): - if is_dir: # follows symlinks: a linked directory is matched, but not descended - _glob_walk(base / name, segs, i + 1 if is_link else i, found) - return - wild = any(c in seg for c in "*?[") # matched by exact name against the listing: case-sensitive everywhere - for name, _, _ in _listing(base): - if fnmatch.fnmatchcase(name, seg) if wild else name == seg: - _glob_walk(base / name, segs, i + 1, found) - - -def _glob_paths(root: Path, pattern: str) -> list[Path]: - """The paths ``pattern`` matches under ``root``. Every segment, literal ones included, is matched against the names - in the directory listing, case-sensitively on every platform: ``Path.glob`` folds case on Windows, and 3.11 has no - ``case_sensitive`` switch, so a suite would differ between OSes and from impact. ``**`` is a whole segment matching - zero or more directories. It does not recurse through a symlinked directory: the link counts as one directory for the - segments after it, so ``**/x`` reaches ``link/x`` but not ``link/sub/x``; a second ``**`` placed after a link can still - descend it, so ``**/**/x`` is not guaranteed to match as ``**/x`` does. Unlike ``Path.glob`` on some versions, a - linked directory is matched by ``**`` itself on every version. A directory that cannot be listed contributes no - matches.""" - found: set[Path] = set() - _glob_walk(root, tuple(pattern.split("/")), 0, found) - return sorted(found) - - def _suite_files(root: Path, globs: tuple[str, ...]) -> dict[str, Path]: """The suite's matches by repository-relative path. A match under another match is left out: that match's walk already covers it, so ``{skill}/evals/**`` hashes the same whether ``**`` yields files too (3.13+) or not (3.11/3.12).""" found: dict[str, Path] = {} for pattern in globs: - for match in _glob_paths(root, pattern): + for match in _glob_paths(root, pattern, strict=True): found.setdefault(match.relative_to(root).as_posix(), match) return {rel: path for rel, path in found.items() if not any(other != rel and _within(rel, other) for other in found)} @@ -703,6 +660,9 @@ def _check_suite(subject: Subject, root: Path) -> None: real_skill = _real(root / subject.path) if real_skill is None: raise ConfigError(f"subject {subject.id}: {subject.path} is a link that does not resolve") + real_root = _real(root) + if real_root is not None and os.path.normcase(os.fspath(real_skill)) == os.path.normcase(os.fspath(real_root)): + raise ConfigError(f"subject {subject.id}: {subject.path} resolves to the repository root; a skill is a directory inside it") for rel, path in matches.items(): _check_match(subject, rel, path, real_skill) _check_claims(subject, matches, real_skill) diff --git a/dokimi/dokimi/repo_glob.py b/dokimi/dokimi/repo_glob.py new file mode 100644 index 000000000..c568e572f --- /dev/null +++ b/dokimi/dokimi/repo_glob.py @@ -0,0 +1,80 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Glob matching for repository suites and skill directories, walking the tree by segment: symbolic links are +followed as directories, and an unreadable directory either matches nothing or, in strict mode, is a ConfigError.""" + +from __future__ import annotations + +import errno +import fnmatch +import os +from pathlib import Path + + +_MISSING = (errno.ENOENT, errno.ENOTDIR, errno.ELOOP) # a path that is not there, or a link that loops: no entries + + +def _listing(base: Path, *, strict: bool = False) -> list[tuple[str, bool, bool]]: + """(name, is_dir, is_symlink) of each entry of ``base`` that can be read. An unreadable one is left out, unless + ``strict``: then its error is raised (a suite must not hash as if its files were absent). A missing ``base`` has + no entries either way.""" + out: list[tuple[str, bool, bool]] = [] + try: + with os.scandir(base) as it: + for entry in it: + try: + out.append((entry.name, entry.is_dir(), entry.is_symlink())) + except OSError as exc: + if strict and exc.errno not in _MISSING: + raise + except OSError as exc: + if strict and exc.errno not in _MISSING: + raise + return out + + +def _glob_walk(base: Path, segs: tuple[str, ...], i: int, found: set[Path], *, strict: bool, + links: set[Path]) -> None: + """Match ``segs[i:]`` under ``base``, adding each match to ``found`` and each symbolic link a segment must read + through to ``links`` (matched or not: a retargeted link may leave the pattern nothing to match).""" + if i == len(segs): + found.add(base) + return + seg = segs[i] + more = i + 1 < len(segs) # a link is read through only when a segment follows it + if seg == "**": + _glob_walk(base, segs, i + 1, found, strict=strict, links=links) + for name, is_dir, is_link in _listing(base, strict=strict): + if is_link and more: + links.add(base / name) + if is_dir: # follows symlinks: a linked directory is matched, but not descended + _glob_walk(base / name, segs, i + 1 if is_link else i, found, strict=strict, links=links) + return + wild = any(c in seg for c in "*?[") # matched by exact name against the listing: case-sensitive everywhere + for name, _, is_link in _listing(base, strict=strict): + if fnmatch.fnmatchcase(name, seg) if wild else name == seg: + if is_link and more: + links.add(base / name) + _glob_walk(base / name, segs, i + 1, found, strict=strict, links=links) + + +def glob_links(root: Path, pattern: str) -> list[Path]: + """The symbolic links a walk of ``pattern`` reads through, matched or not, unreadable directories raising.""" + links: set[Path] = set() + _glob_walk(root, tuple(pattern.split("/")), 0, set(), strict=True, links=links) + return sorted(links) + + +def glob_paths(root: Path, pattern: str, *, strict: bool = False) -> list[Path]: + """The paths ``pattern`` matches under ``root``. Every segment, literal ones included, is matched against the names + in the directory listing, case-sensitively on every platform: ``Path.glob`` folds case on Windows, and 3.11 has no + ``case_sensitive`` switch, so a suite would differ between OSes and from impact. ``**`` is a whole segment matching + zero or more directories. It does not recurse through a symlinked directory: the link counts as one directory for the + segments after it, so ``**/x`` reaches ``link/x`` but not ``link/sub/x``; a second ``**`` placed after a link can still + descend it, so ``**/**/x`` is not guaranteed to match as ``**/x`` does. Unlike ``Path.glob`` on some versions, a + linked directory is matched by ``**`` itself on every version. A directory that cannot be listed contributes no + matches, unless ``strict``: then its error is raised.""" + found: set[Path] = set() + _glob_walk(root, tuple(pattern.split("/")), 0, found, strict=strict, links=set()) + return sorted(found) diff --git a/dokimi/dokimi/utils.py b/dokimi/dokimi/utils.py index 63340e935..64c079dcc 100644 --- a/dokimi/dokimi/utils.py +++ b/dokimi/dokimi/utils.py @@ -121,8 +121,11 @@ def link_text(link: Path, root: Path) -> str: # (a link that is the root itself is not resolved, or its own target would count as inside it) anchors = [os.fspath(root)] if root.is_symlink() else [os.fspath(root), os.path.realpath(root)] for anchor in anchors: + # Windows folds case in paths (and drive letters), so the comparison is on the normalized spelling; the target + # keeps its own spelling in the text it returns + key, key_anchor = os.path.normcase(target), os.path.normcase(anchor) try: - inside = os.path.commonpath([target, anchor]) == anchor and target.startswith(anchor) + inside = os.path.commonpath([key, key_anchor]) == key_anchor and key.startswith(key_anchor) except ValueError: # different drives, or a relative path mixed in continue if inside: @@ -130,6 +133,11 @@ def link_text(link: Path, root: Path) -> str: depth = len(link.relative_to(root).parent.parts) # the link's directory, as the text is read from there slash = "/" if target.endswith((os.sep, os.sep + ".")) else "" # "x/" and "x/." need a directory if ".." in rest: + # a target whose ".." leads out of the tree (and does not come back in) is external: hashed as written, + # so two trees whose external targets differ do not digest alike + lexical = os.path.normcase(os.path.normpath(target)) + if lexical != key_anchor and not lexical.startswith(key_anchor.rstrip(os.sep) + os.sep): + return target return "/".join([".."] * depth + rest) + slash or "." here = os.path.join(anchor, *link.relative_to(root).parent.parts) return os.path.relpath(os.path.join(anchor, *rest), here).replace(os.sep, "/") + slash diff --git a/dokimi/tests/test_import_architecture.py b/dokimi/tests/test_import_architecture.py index 02716ceee..9de19e3fa 100644 --- a/dokimi/tests/test_import_architecture.py +++ b/dokimi/tests/test_import_architecture.py @@ -63,7 +63,7 @@ # ingest, static metrics, gates. ("components", ( "dokimi.agent_profiles", "dokimi.gate", "dokimi.ingest", "dokimi.mcp", - "dokimi.metrics", "dokimi.repo_config", "dokimi.skills", + "dokimi.metrics", "dokimi.repo_config", "dokimi.repo_glob", "dokimi.skills", )), # What everything shares: defaults, registries, artifact and record I/O, the LLM and # process chokepoints, console output. diff --git a/dokimi/tests/test_repo_config_globs.py b/dokimi/tests/test_repo_config_globs.py new file mode 100644 index 000000000..c76646279 --- /dev/null +++ b/dokimi/tests/test_repo_config_globs.py @@ -0,0 +1,53 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""dokimi_config.v1: glob walks of suites (unreadable directories) and names with backslashes.""" + +from __future__ import annotations + +import os + +import pytest + +from dokimi.repo_config import ConfigError, load_repo_config + +HEADER = "schema_version: dokimi_config.v1\n" + + +def _tree(root, files): + """A repository at ``root`` (it always has a ``.git``) holding ``files``; returns the resolved root.""" + (root / ".git").mkdir(parents=True, exist_ok=True) + for rel, text in files.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return root.resolve() + + +@pytest.mark.skipif(os.name == "nt" or os.geteuid() == 0, reason="permissions are not enforced on Windows or for root") +def test_an_unreadable_directory_on_the_way_of_a_suite_glob_is_a_config_error(tmp_path): + # unlike a skills glob, a suite that cannot see its files must not hash as if it had none + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/locked/tasks/*.json\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "a\n", + "evaluation/locked/tasks/q.json": "{}\n", + }) + locked = root / "evaluation" / "locked" + locked.chmod(0) + try: + with pytest.raises(ConfigError, match=r"cannot read .*locked"): + load_repo_config(root).subjects() + finally: + locked.chmod(0o755) + + +def test_a_backslash_in_a_posix_file_name_is_part_of_the_name(tmp_path): + # git reports paths with "/" on every OS; on POSIX a backslash is an ordinary character of a file name + if os.name == "nt": + pytest.skip("on Windows a backslash separates path parts") + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "a\n"}) + impact = load_repo_config(root).subjects_for_changed(["skills/a/evals\\q.json"]) + assert impact.unclaimed == [] + assert impact.skills == {"skill/a": ["skills/a/evals\\q.json"]} diff --git a/dokimi/tests/test_repo_config_links.py b/dokimi/tests/test_repo_config_links.py index 68e7cb883..5ed15d9ad 100644 --- a/dokimi/tests/test_repo_config_links.py +++ b/dokimi/tests/test_repo_config_links.py @@ -243,3 +243,49 @@ def test_mixed_case_directory_aliases_are_walked_in_code_point_order_on_every_os names = [name for name, _ in _walk(root / "evaluation", "evaluation")] assert "evaluation/B/q.json" in names assert "evaluation/a/q.json" not in names + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_link_met_by_a_wildcard_suite_still_claims_it_after_its_target_loses_the_match(tmp_path): + # evaluation/**/task.json walks through evaluation/link; retargeting the link to a directory without task.json + # leaves the glob with no match, so only the walk that met the link can still claim it + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/**/task.json\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "one/task.json": "[]\n", + "two/readme.md": "x\n", + }) + os.makedirs(root / "evaluation") + os.symlink("../one", root / "evaluation" / "link") + os.remove(root / "evaluation" / "link") + os.symlink("../two", root / "evaluation" / "link") + impact = load_repo_config(root).subjects_for_changed(["evaluation/link"]) + assert impact.unclaimed == [] + assert impact.suites == {"skill/a": ["evaluation/link"]} + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_skill_linked_to_the_repository_root_is_a_config_error(tmp_path): + # its hash would cover the whole repository, and no change to a file could be traced back to the skill + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n" + root = _tree(tmp_path / "repo", {".github/dokimi/config.yaml": body, "README.md": "x\n"}) + os.makedirs(root / "skills") + os.symlink("..", root / "skills" / "a") + with pytest.raises(ConfigError, match="repository root"): + load_repo_config(root).subjects() + + +def test_a_link_target_with_the_windows_extended_prefix_is_matched_as_the_plain_path(tmp_path, monkeypatch): + # os.readlink returns \\?\ paths for absolute links on Windows; the hop must still be found inside the repository + from dokimi import repo_config + + root = _tree(tmp_path / "repo", {"shared/v1/q.json": "[]\n"}) + os.symlink(root / "shared" / "v1", root / "mid") + os.symlink(root / "mid", root / "current") # its target is absolute, and a link on the way to a file in the repo + real_readlink = os.readlink + monkeypatch.setattr( + os, "readlink", + lambda p: "\\\\?\\" + real_readlink(p) if os.fspath(p).endswith("current") else real_readlink(p), + ) + assert repo_config._hops(root / "current" / "q.json", root, root) == [("current",), ("mid",)] diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py index 5259313da..dd7bdad9a 100644 --- a/dokimi/tests/test_tree_digest.py +++ b/dokimi/tests/test_tree_digest.py @@ -28,7 +28,12 @@ def _write(root: Path, files: dict[str, str]) -> Path: def _track_executable(root: Path, rel: str) -> None: """Record ``rel`` as 100755 in the index whatever the disk says (chmod sets no exec bit on Windows).""" - subprocess.run(["git", "-C", str(root), "update-index", "--chmod=+x", "--", rel], check=True) + _index_mode(root, rel, "+x") + + +def _index_mode(root: Path, rel: str, change: str) -> None: + """Set the index's executable bit for ``rel`` (``+x`` or ``-x``) and leave the disk alone.""" + subprocess.run(["git", "-C", str(root), "update-index", f"--chmod={change}", "--", rel], check=True) def test_the_same_content_at_another_path_digests_the_same(tmp_path): @@ -126,13 +131,15 @@ def test_an_undecodable_name_and_link_target_digest_without_error(tmp_path): @pytest.mark.skipif(shutil.which("git") is None, reason="the mode bit comes from git's index; needs git") def test_the_executable_bit_is_the_one_git_records_not_the_disk_one(tmp_path): root = _write(tmp_path / "t", {"run.sh": "echo\n"}) - (root / "run.sh").chmod(0o755) + (root / "run.sh").chmod(0o644) # the disk bit stays put: a change to the index alone must move the digest subprocess.run(["git", "init", "-q", str(root)], check=True) subprocess.run(["git", "-C", str(root), "add", "run.sh"], check=True) _track_executable(root, "run.sh") # chmod does not set the bit on Windows: the index must say 100755 on every OS - before = tree_digest(root) - (root / "run.sh").chmod(0o644) # a Windows checkout has no exec bit on disk; git still records 100755 - assert tree_digest(root) == before + before = tree_digest(root, exclude=[".git"]) # .git holds the index itself, which changes with it + (root / "run.sh").chmod(0o755) # a Windows checkout has no exec bit on disk; git still records 100755 + assert tree_digest(root, exclude=[".git"]) == before + _index_mode(root, "run.sh", "-x") + assert tree_digest(root, exclude=[".git"]) != before @symlinks @@ -217,13 +224,15 @@ def test_an_absolute_link_inside_the_tree_digests_the_same_at_another_path_on_wi def test_a_tracked_file_whose_name_has_brackets_takes_its_mode_from_the_index(tmp_path): # ls-files reads the name as a glob unless it is literal: [1] would match nothing and the disk bit would count root = _write(tmp_path / "t", {"question[1].json": "[]\n"}) - (root / "question[1].json").chmod(0o755) + (root / "question[1].json").chmod(0o644) # the disk bit stays put, so only the index can tell the modes apart subprocess.run(["git", "init", "-q", str(root)], check=True) subprocess.run(["git", "-C", str(root), "add", "question[1].json"], check=True) _track_executable(root, "question[1].json") before = tree_digest(root / "question[1].json") # a file root: git is asked for its name as the pathspec - (root / "question[1].json").chmod(0o644) + (root / "question[1].json").chmod(0o755) assert tree_digest(root / "question[1].json") == before + _index_mode(root, "question[1].json", "-x") + assert tree_digest(root / "question[1].json") != before def test_a_tracked_mode_past_the_capture_limit_still_comes_from_the_index(tmp_path): @@ -232,13 +241,15 @@ def test_a_tracked_mode_past_the_capture_limit_still_comes_from_the_index(tmp_pa names = [f"f{i:05d}-" + "x" * 200 for i in range(7000)] root = _write(tmp_path / "t", {name: "a\n" for name in names}) target = names[3500] - (root / target).chmod(0o755) + (root / target).chmod(0o644) # the disk bit stays put: the index alone decides the digest subprocess.run(["git", "init", "-q", str(root)], check=True) subprocess.run(["git", "-C", str(root), "add", "."], check=True) _track_executable(root, target) - before = tree_digest(root) - (root / target).chmod(0o644) - assert tree_digest(root) == before + before = tree_digest(root, exclude=[".git"]) + (root / target).chmod(0o755) + assert tree_digest(root, exclude=[".git"]) == before + _index_mode(root, target, "-x") + assert tree_digest(root, exclude=[".git"]) != before def test_mixed_case_names_are_ordered_by_code_point_on_every_os(tmp_path): @@ -252,3 +263,28 @@ def test_mixed_case_names_are_ordered_by_code_point_on_every_os(tmp_path): expected.update(b"F " + rel.encode() + b"\0" + b"0\0") expected.update(hashlib.sha256((root / rel).read_bytes()).digest()) assert tree_digest(root) == expected.hexdigest() + + +@symlinks +def test_an_absolute_link_whose_dotdot_leaves_the_tree_is_hashed_as_written(tmp_path): + # /one/tree/../external names /one/external; written relative, the link would read the same in a copy at /two + # and hide that its target lies elsewhere + roots = {} + for name in ("one", "two"): + root = _write(tmp_path / name / "tree", {"SKILL.md": "x\n"}) + _write(tmp_path / name / "external", {"q.json": "[]\n"}) + os.symlink(f"{root}/../external/q.json", root / "link.json") + roots[name] = root + assert tree_digest(roots["one"]) != tree_digest(roots["two"]) + + +@pytest.mark.skipif(os.name != "nt", reason="drive letters and directory names fold case on Windows only") +def test_an_absolute_link_inside_the_tree_matches_whatever_the_case_of_its_target(tmp_path): + a = _write(tmp_path / "a", {"q.json": "[]\n"}) + b = _write(tmp_path / "b", {"q.json": "[]\n"}) + try: + os.symlink(str(a / "q.json").lower(), a / "current") # the same file, spelled in another case + os.symlink(b / "q.json", b / "current") + except OSError: + pytest.skip("creating symlinks needs privileges on Windows") + assert tree_digest(a) == tree_digest(b) From ef9a0b177cf950366a5b130f68588e59925eb447 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 18:26:28 -0800 Subject: [PATCH 27/36] fix(config): keep the glob and digest walks of a suite or skill complete - a link met by a wildcard suite keeps the pattern's rest under its target, so a deleted file there still claims - a self-referential link is listed, so the suite refuses it (3.13+ resolves a loop to itself: stat tells it apart) - ** does not match a regular file as its zero-directory case - the skill digest walks strictly: an unreadable directory is a ConfigError, not left out of skill_sha - the symlink test that creates links is skipped on Windows Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 19 ++++++++++--- dokimi/dokimi/repo_glob.py | 23 +++++++++------- dokimi/dokimi/utils.py | 27 +++++++++++++++++-- dokimi/tests/test_repo_config_globs.py | 28 +++++++++++++++++++ dokimi/tests/test_repo_config_links.py | 37 ++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 15 deletions(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index c09b74746..9c4e8dd09 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -15,6 +15,7 @@ from __future__ import annotations +import errno import fnmatch import glob import hashlib @@ -372,8 +373,11 @@ def _unnamed_literals(literals: tuple[str, ...], matches: dict[str, Path], root: segs = literal.split("/") wild = next((i for i, s in enumerate(segs) if any(c in s for c in "*?[")), None) if wild is not None: # a glob claims each link its walk reads through, and its own pattern where its prefix resolves - for link in _glob_links(root, literal): + for link, rest in _glob_links(root, literal): links.extend(_hops(link, root, root_real)) + real_link = _inside(link, root_real) # the rest of the pattern, under the place the link resolves to + if real_link is not None and rest: + globs.append("/".join([glob.escape("/".join(real_link)), *rest])) if wild == 0: continue prefix = root.joinpath(*segs[:wild]) @@ -613,11 +617,18 @@ def _digest_files(files: dict[str, Path], root: Path) -> str: def _real(path: Path) -> Path | None: - """The resolved path, or None when it cannot be resolved: a symlink loop (3.11 and 3.12 raise RuntimeError for it).""" + """The resolved path, or None when it cannot be resolved: a symlink loop (3.11 and 3.12 raise RuntimeError for it; + 3.13 and later resolve it to itself, so the loop is told apart by stat, which fails with ELOOP).""" try: - return path.resolve() + real = path.resolve() except (OSError, RuntimeError): return None + try: + path.stat() + except OSError as exc: + if exc.errno == errno.ELOOP: + return None + return real def _walk(path: Path, rel: str, *, dirs: bool = False) -> list[tuple[str, Path]]: @@ -740,5 +751,5 @@ def _hashed(subject: Subject, root: Path, all_subjects: list[Subject]) -> Subjec if s.kind == "skill" and s.path != subject.path and _within(s.path, subject.path) ] # A skill directory that is a symlink is its target's content (a suite match is hashed by its link instead). - skill_sha = "sha256:" + tree_digest((root / subject.path).resolve(), exclude=[*inside, *nested]) + skill_sha = "sha256:" + tree_digest((root / subject.path).resolve(), exclude=[*inside, *nested], strict=True) return replace(subject, skill_sha=skill_sha, suite_hash=_digest_files(files, root)) diff --git a/dokimi/dokimi/repo_glob.py b/dokimi/dokimi/repo_glob.py index c568e572f..999ecf3ee 100644 --- a/dokimi/dokimi/repo_glob.py +++ b/dokimi/dokimi/repo_glob.py @@ -26,7 +26,9 @@ def _listing(base: Path, *, strict: bool = False) -> list[tuple[str, bool, bool] try: out.append((entry.name, entry.is_dir(), entry.is_symlink())) except OSError as exc: - if strict and exc.errno not in _MISSING: + if exc.errno == errno.ELOOP: # a link that loops: still an entry, so a suite refuses it + out.append((entry.name, False, True)) + elif strict and exc.errno not in _MISSING: raise except OSError as exc: if strict and exc.errno not in _MISSING: @@ -35,19 +37,21 @@ def _listing(base: Path, *, strict: bool = False) -> list[tuple[str, bool, bool] def _glob_walk(base: Path, segs: tuple[str, ...], i: int, found: set[Path], *, strict: bool, - links: set[Path]) -> None: + links: set[tuple[Path, tuple[str, ...]]]) -> None: """Match ``segs[i:]`` under ``base``, adding each match to ``found`` and each symbolic link a segment must read - through to ``links`` (matched or not: a retargeted link may leave the pattern nothing to match).""" + through to ``links``, with the segments left to match under the link's target (matched or not: a retargeted + link may leave the pattern nothing to match).""" if i == len(segs): found.add(base) return seg = segs[i] more = i + 1 < len(segs) # a link is read through only when a segment follows it if seg == "**": - _glob_walk(base, segs, i + 1, found, strict=strict, links=links) + if more or not base.is_file(): # ** matches directories only: a file is not a zero-directory match + _glob_walk(base, segs, i + 1, found, strict=strict, links=links) for name, is_dir, is_link in _listing(base, strict=strict): if is_link and more: - links.add(base / name) + links.add((base / name, segs[i + 1:])) if is_dir: # follows symlinks: a linked directory is matched, but not descended _glob_walk(base / name, segs, i + 1 if is_link else i, found, strict=strict, links=links) return @@ -55,13 +59,14 @@ def _glob_walk(base: Path, segs: tuple[str, ...], i: int, found: set[Path], *, s for name, _, is_link in _listing(base, strict=strict): if fnmatch.fnmatchcase(name, seg) if wild else name == seg: if is_link and more: - links.add(base / name) + links.add((base / name, segs[i + 1:])) _glob_walk(base / name, segs, i + 1, found, strict=strict, links=links) -def glob_links(root: Path, pattern: str) -> list[Path]: - """The symbolic links a walk of ``pattern`` reads through, matched or not, unreadable directories raising.""" - links: set[Path] = set() +def glob_links(root: Path, pattern: str) -> list[tuple[Path, tuple[str, ...]]]: + """The symbolic links a walk of ``pattern`` reads through, matched or not, unreadable directories raising, each + with the segments the pattern still has under its target (a link met by the last segment has none).""" + links: set[tuple[Path, tuple[str, ...]]] = set() _glob_walk(root, tuple(pattern.split("/")), 0, set(), strict=True, links=links) return sorted(links) diff --git a/dokimi/dokimi/utils.py b/dokimi/dokimi/utils.py index 64c079dcc..147dae888 100644 --- a/dokimi/dokimi/utils.py +++ b/dokimi/dokimi/utils.py @@ -167,7 +167,28 @@ def _tracked_modes(base: Path, spec: str) -> dict[str, bool]: return modes -def tree_digest(root: Path, *, exclude: Iterable[str] = ()) -> str: +def _entries(root: Path, skip: tuple[str, ...], *, strict: bool) -> list[Path]: + """Every entry under ``root``, directories included, a symlink as itself and never descended into. Entries under + a ``skip`` prefix are not read. Non-strict is ``rglob``, which skips a directory it cannot read (and the files + behind it); strict raises that error, so a digest cannot leave out what it could not read.""" + if not strict: + return list(root.rglob("*")) + found: list[Path] = [] + pending = [root] + while pending: + with os.scandir(pending.pop()) as it: + for entry in it: + path = Path(entry.path) + rel = path.relative_to(root).as_posix() + if any(rel == e or rel.startswith(e + "/") for e in skip): + continue + found.append(path) + if entry.is_dir(follow_symlinks=False): + pending.append(path) + return found + + +def tree_digest(root: Path, *, exclude: Iterable[str] = (), strict: bool = False) -> str: """sha256 (bare hex) over every path, mode bit and byte under ``root``; a link by its target. A file ``root`` digests as itself (its path is ``.``), and so does a symlink ``root`` @@ -179,12 +200,14 @@ def tree_digest(root: Path, *, exclude: Iterable[str] = ()) -> str: Windows checkout with no exec bit still matches), and an untracked file takes its owner's exec bit from the disk. A link inside ``root`` with an absolute target is hashed by a relative target (see ``link_text``); one pointing outside ``root`` is hashed as written. + + ``strict``: a directory under ``root`` that cannot be read raises its OSError (the default skips it silently). """ skip = tuple(e.strip("/") for e in exclude) # sorted by the parts of the relative path, compared as code points: the same order on every OS (Path # ordering folds case on Windows) entries = ([root] if root.is_symlink() or root.is_file() - else sorted(root.rglob("*"), key=lambda p: p.relative_to(root).parts)) + else sorted(_entries(root, skip, strict=strict), key=lambda p: p.relative_to(root).parts)) base, spec = (root.parent, root.name) if root.is_file() else (root, ".") tracked = _tracked_modes(base, spec) if any(p.is_file() and not p.is_symlink() for p in entries) else {} h = hashlib.sha256() diff --git a/dokimi/tests/test_repo_config_globs.py b/dokimi/tests/test_repo_config_globs.py index c76646279..032314176 100644 --- a/dokimi/tests/test_repo_config_globs.py +++ b/dokimi/tests/test_repo_config_globs.py @@ -51,3 +51,31 @@ def test_a_backslash_in_a_posix_file_name_is_part_of_the_name(tmp_path): impact = load_repo_config(root).subjects_for_changed(["skills/a/evals\\q.json"]) assert impact.unclaimed == [] assert impact.skills == {"skill/a": ["skills/a/evals\\q.json"]} + + +def test_a_regular_file_followed_by_a_double_star_is_not_a_match(tmp_path): + # ** matches directories only, so the zero-directory branch does not accept the file itself + from dokimi.repo_glob import glob_paths + + (tmp_path / "evaluation").mkdir() + (tmp_path / "evaluation" / "q.json").write_text("[]\n", encoding="utf-8") + assert glob_paths(tmp_path, "evaluation/q.json/**") == [] + assert glob_paths(tmp_path, "evaluation/**") == [tmp_path / "evaluation"] + + +@pytest.mark.skipif(os.name == "nt" or os.geteuid() == 0, reason="needs POSIX permissions and a non-root user") +def test_an_unreadable_directory_inside_a_skill_is_a_config_error_not_left_out_of_skill_sha(tmp_path): + # Path.rglob suppresses permission errors, so edits behind the directory would not move skill_sha + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "skills/a/scripts/run.py": "print(1)\n", + }) + scripts = root / "skills" / "a" / "scripts" + scripts.chmod(0) + try: + with pytest.raises(ConfigError, match="scripts"): + load_repo_config(root).subjects() + finally: + scripts.chmod(0o755) diff --git a/dokimi/tests/test_repo_config_links.py b/dokimi/tests/test_repo_config_links.py index 5ed15d9ad..8b2ccfb0c 100644 --- a/dokimi/tests/test_repo_config_links.py +++ b/dokimi/tests/test_repo_config_links.py @@ -276,6 +276,7 @@ def test_a_skill_linked_to_the_repository_root_is_a_config_error(tmp_path): load_repo_config(root).subjects() +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_a_link_target_with_the_windows_extended_prefix_is_matched_as_the_plain_path(tmp_path, monkeypatch): # os.readlink returns \\?\ paths for absolute links on Windows; the hop must still be found inside the repository from dokimi import repo_config @@ -289,3 +290,39 @@ def test_a_link_target_with_the_windows_extended_prefix_is_matched_as_the_plain_ lambda p: "\\\\?\\" + real_readlink(p) if os.fspath(p).endswith("current") else real_readlink(p), ) assert repo_config._hops(root / "current" / "q.json", root, root) == [("current",), ("mid",)] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +@pytest.mark.parametrize("glob", ["evaluation/*/task.json", "evaluation/**/task.json"]) +def test_deleting_the_file_a_link_met_by_a_wildcard_suite_led_to_still_claims_it(tmp_path, glob): + # the walk met evaluation/current and matched one/task.json through it; the file is gone now, so only the + # remaining pattern at the link's target (one/task.json) can still claim the deletion + body = HEADER + f"subjects:\n - id: skill/a\n path: skills/a\n suite: {glob}\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + "one/task.json": "[]\n", + "one/readme.md": "x\n", + }) + os.makedirs(root / "evaluation") + os.symlink("../one", root / "evaluation" / "current") + os.remove(root / "one" / "task.json") + impact = load_repo_config(root).subjects_for_changed(["one/task.json"]) + assert impact.unclaimed == [] + assert "skill/a" in impact.suites + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +@pytest.mark.parametrize("suite", ["evaluation/*", "evaluation/loop"]) +def test_a_self_referential_link_a_suite_names_is_a_config_error_not_dropped(tmp_path, suite): + # entry.is_dir() raises ELOOP for evaluation/loop -> loop; the entry must still be listed, so the suite + # validation refuses it rather than accepting an empty suite + body = HEADER + f"subjects:\n - id: skill/a\n path: skills/a\n suite: {suite}\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "# a\n", + }) + os.makedirs(root / "evaluation") + os.symlink("loop", root / "evaluation" / "loop") + with pytest.raises(ConfigError, match="loop"): + load_repo_config(root).subjects() From 4b1892185c50143c3a82f750e087c66ee1d7d4b5 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 20:29:32 -0800 Subject: [PATCH 28/36] fix(config): a terminal ** matches directories only, not FIFOs or sockets Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_glob.py | 2 +- dokimi/tests/test_repo_config_globs.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/repo_glob.py b/dokimi/dokimi/repo_glob.py index 999ecf3ee..e4e5cecfa 100644 --- a/dokimi/dokimi/repo_glob.py +++ b/dokimi/dokimi/repo_glob.py @@ -47,7 +47,7 @@ def _glob_walk(base: Path, segs: tuple[str, ...], i: int, found: set[Path], *, s seg = segs[i] more = i + 1 < len(segs) # a link is read through only when a segment follows it if seg == "**": - if more or not base.is_file(): # ** matches directories only: a file is not a zero-directory match + if more or base.is_dir(): # terminal ** matches directories only _glob_walk(base, segs, i + 1, found, strict=strict, links=links) for name, is_dir, is_link in _listing(base, strict=strict): if is_link and more: diff --git a/dokimi/tests/test_repo_config_globs.py b/dokimi/tests/test_repo_config_globs.py index 032314176..cca8bd33b 100644 --- a/dokimi/tests/test_repo_config_globs.py +++ b/dokimi/tests/test_repo_config_globs.py @@ -63,6 +63,15 @@ def test_a_regular_file_followed_by_a_double_star_is_not_a_match(tmp_path): assert glob_paths(tmp_path, "evaluation/**") == [tmp_path / "evaluation"] +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="needs POSIX special files") +def test_a_fifo_followed_by_a_double_star_is_not_a_match(tmp_path): + from dokimi.repo_glob import glob_paths + + (tmp_path / "evaluation").mkdir() + os.mkfifo(tmp_path / "evaluation" / "q") + assert glob_paths(tmp_path, "evaluation/q/**") == [] + + @pytest.mark.skipif(os.name == "nt" or os.geteuid() == 0, reason="needs POSIX permissions and a non-root user") def test_an_unreadable_directory_inside_a_skill_is_a_config_error_not_left_out_of_skill_sha(tmp_path): # Path.rglob suppresses permission errors, so edits behind the directory would not move skill_sha From dbce8d4ff6df15a7cb2ffd914c0a8964eea6d1be Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Mon, 2 Nov 2026 10:46:47 +0000 Subject: [PATCH 29/36] Hash a manifest under the suite directory once Signed-off-by: Anton Volkov --- dokimi/dokimi/repo_config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 9c4e8dd09..1746804fd 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -741,7 +741,8 @@ def _hashed(subject: Subject, root: Path, all_subjects: list[Subject]) -> Subjec files = _suite_files(root, subject.suite_globs) if subject.kind == "repo": assert subject.manifest is not None # set by _subject for kind repo - files.setdefault(subject.manifest, root / subject.manifest) + if not any(_within(subject.manifest, rel) for rel in files): # a suite directory already walks it + files.setdefault(subject.manifest, root / subject.manifest) return replace(subject, suite_hash=_digest_files(files, root)) inside = [rel[len(subject.path) + 1:] for rel in files if _within(rel, subject.path) and rel != subject.path] # a nested skill is its own subject: its files belong to its own skill_sha, not to the outer one From 171da878e50bd8e0d884e16c59d2fd4d417bbdd2 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Mon, 2 Nov 2026 10:46:47 +0000 Subject: [PATCH 30/36] Skip the two git-index mode tests when git is missing Signed-off-by: Anton Volkov --- dokimi/tests/test_tree_digest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py index dd7bdad9a..dc3486e78 100644 --- a/dokimi/tests/test_tree_digest.py +++ b/dokimi/tests/test_tree_digest.py @@ -221,6 +221,7 @@ def test_an_absolute_link_inside_the_tree_digests_the_same_at_another_path_on_wi assert tree_digest(a) == tree_digest(b) +@pytest.mark.skipif(shutil.which("git") is None, reason="the mode bit comes from git's index; needs git") def test_a_tracked_file_whose_name_has_brackets_takes_its_mode_from_the_index(tmp_path): # ls-files reads the name as a glob unless it is literal: [1] would match nothing and the disk bit would count root = _write(tmp_path / "t", {"question[1].json": "[]\n"}) @@ -235,6 +236,7 @@ def test_a_tracked_file_whose_name_has_brackets_takes_its_mode_from_the_index(tm assert tree_digest(root / "question[1].json") != before +@pytest.mark.skipif(shutil.which("git") is None, reason="the mode bit comes from git's index; needs git") def test_a_tracked_mode_past_the_capture_limit_still_comes_from_the_index(tmp_path): # git's listing of a large tree passes 1 MiB; the middle of it was replaced by a marker, so entries there # fell back to the disk bit From f431c95867ffb91b8df2e1adddbe7b286c9c5f5a Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Mon, 2 Nov 2026 08:31:25 -0800 Subject: [PATCH 31/36] fix(repo): close the link, suite and index gaps in the repo_config loader - A skill reached through a link outside the repository, or a link inside the skill on the way to its suite, now claims the skill for impact. - A deleted link on the way to a suite claims the suite (ancestor of a gone path only, so existing paths are unchanged). - Suite link targets are hashed per matched directory, so retargeting between empty directories moves suite_hash. - A suite reached through a link into its own skill is refused. - Declared case is kept when the disk differs (Windows case folding). - Mode bits come from one git index read per subjects() call, not one git process per file; files missing from the index still ask git. - tree_digest no longer follows symlinks to check is_file (unreadable link targets digest by their text). - Move symlink helpers to dokimi/repo_links.py for the file-length cap. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 213 +++++++++-------------- dokimi/dokimi/repo_links.py | 120 +++++++++++++ dokimi/dokimi/utils.py | 24 ++- dokimi/tests/test_import_architecture.py | 2 +- dokimi/tests/test_repo_config_links.py | 140 +++++++++++++++ dokimi/tests/test_tree_digest.py | 18 ++ 6 files changed, 383 insertions(+), 134 deletions(-) create mode 100644 dokimi/dokimi/repo_links.py diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 1746804fd..d5cd5d342 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -15,7 +15,6 @@ from __future__ import annotations -import errno import fnmatch import glob import hashlib @@ -25,13 +24,14 @@ from decimal import Decimal, InvalidOperation from dataclasses import dataclass, field, replace from pathlib import Path, PurePosixPath -from typing import Any, Iterable +from typing import Any, Iterable, Mapping import yaml from dokimi.artifacts import ArtifactValidationError, validate_artifact from dokimi.repo_glob import glob_links as _glob_links, glob_paths as _glob_paths -from dokimi.utils import _plain_path, link_text, tree_digest +from dokimi.repo_links import _declared_links, _hops, _inside, _real, _under, _walk, _within +from dokimi.utils import _tracked_modes, link_text, tree_digest KIND = "dokimi_config" CONFIG_CANDIDATES = (".github/dokimi/config.yaml", ".dokimi/config.yaml") @@ -93,7 +93,10 @@ def subjects(self, *, hashes: bool = True) -> list[Subject]: _check_nesting(resolved, self.root) for subject in resolved: _check_suite(subject, self.root) - return [_hashed(s, self.root, resolved) for s in resolved] if hashes else resolved + if not hashes: + return resolved + modes = _tracked_modes(self.root, ".") # the index is read once for every subject's suite files + return [_hashed(s, self.root, resolved, modes) for s in resolved] except OSError as exc: raise _unreadable(exc) from exc @@ -109,7 +112,7 @@ def subjects_for_changed(self, paths: Iterable[str]) -> Impact: if rel is None: impact.unclaimed.append(str(raw)) continue - _map_one(rel, subjects, reach, shared, impact) + _map_one(rel, subjects, reach, shared, impact, gone=not os.path.lexists(self.root / rel)) return impact except OSError as exc: raise _unreadable(exc) from exc @@ -275,54 +278,6 @@ class Reach: globs: tuple[str, ...] -def _declared_links(path: Path, root: Path) -> list[tuple[str, ...]]: - """The links on the declared way to ``path`` (a path under ``root``, as written), the path itself included when it is - one: the components that are symbolic links, not the links met by following them.""" - parts = path.relative_to(root).parts - return [parts[:k] for k in range(1, len(parts) + 1) if root.joinpath(*parts[:k]).is_symlink()] - - -def _hops(path: Path, root: Path, root_real: Path) -> list[tuple[str, ...]]: - """The repository-relative links met resolving ``path`` (a path under ``root``, as written): each link on its - declared way, and each link on the way to every target a link points to, until the path is resolved or leaves the - repository. A link's location is its real one, as git names it. A loop is cut after a fixed number of hops.""" - pending = list(path.relative_to(root).parts) - here: tuple[str, ...] = () # the real directory the next component is looked up in - found: dict[tuple[str, ...], None] = {} - hops = 0 - while pending: - part = pending.pop(0) - if part in ("", "."): - continue - if part == "..": - if not here: - break - here = here[:-1] - continue - link = root_real.joinpath(*here, part) - if not link.is_symlink(): - here = (*here, part) - continue - hops += 1 - if hops > 256: - break - found[(*here, part)] = None - target = _plain_path(os.readlink(link)) # Windows reads an absolute target back as \\?\C:\... - if os.path.isabs(target): - for anchor in (root_real, root): # written as the resolved or the given spelling of the repository - try: - pending = [*Path(target).relative_to(anchor).parts, *pending] - break - except ValueError: - continue - else: - break # an absolute target outside the repository: no more of the way is ours - here = () - else: - pending = [*Path(target).parts, *pending] - return list(found) - - def _reach(subject: Subject, root: Path) -> Reach: """Where a subject's hashes read, as repository-relative segments, through links as well as by declared path: a symlinked skill directory or suite file maps a change at its target to the subject, and a symlink on the way @@ -336,13 +291,14 @@ def _reach(subject: Subject, root: Path) -> Reach: if root_real is None: return Reach(skills, suites, links, skill_links, globs) real_skill = _inside(root / subject.path, root_real) if subject.kind == "skill" else None + if subject.kind == "skill": # the links on the way to the skill directory, whether it resolves inside the repository or not + skill_links.extend(_hops(root / subject.path, root, root_real)) if real_skill is not None: skills.append(real_skill) declared, resolved = glob.escape(subject.path), glob.escape("/".join(real_skill)) if resolved != declared: # a linked skill: its suite globs, rewritten to where the files live moved = [resolved + g[len(declared):] for g in globs if g == declared or g.startswith(declared + "/")] globs = (*globs, *moved) - skill_links.extend(_hops(root / subject.path, root, root_real)) if subject.manifest is not None: # the repo subject hashes its manifest through a link, so its target is covered real_manifest = _inside(root / subject.manifest, root_real) if real_manifest is not None: @@ -386,6 +342,12 @@ def _unnamed_literals(literals: tuple[str, ...], matches: dict[str, Path], root: globs.append("/".join([glob.escape("/".join(real)), *segs[wild:]])) continue entry = root.joinpath(*segs) + if not _declared_links(entry, root): + # no link on the way: the literal is its own place, as declared. Resolving it would take the spelling on + # disk, which a case-folding filesystem (Windows) returns for a name that differs only in case. + suites.append(tuple(segs)) + globs.append(glob.escape("/".join(segs))) + continue _suite_entry(entry, root, root_real, real_skill, suites, links, skill_links, matches) real = _inside(entry, root_real) if real is not None: @@ -411,43 +373,47 @@ def _suite_entry(entry: Path, root: Path, root_real: Path, real_skill: tuple[str that the suite reaches through (skill_sha covers it, so the skill claims it too). A link the suite matches is left out of skill_sha, so it is not a skill link; ``matches`` holds the suite's matched paths, under their declared and their resolved spellings.""" - declared_links = set(_declared_links(entry, root)) for hop in _hops(entry, root, root_real): links.append(hop) - rel = "/".join(hop) - in_suite = any(_within(rel, m) for m in matches) - if real_skill is not None and _under(hop, real_skill) and hop not in declared_links and not in_suite: + in_suite = any(_within("/".join(hop), m) for m in matches) + if real_skill is not None and _under(hop, real_skill) and not in_suite: skill_links.append(hop) real = _inside(entry, root_real) if real is not None: suites.append(real) -def _inside(path: Path, root_real: Path) -> tuple[str, ...] | None: - """The segments of ``path``'s resolved location under the repository root; None when it is the root or outside it.""" - real = _real(path) - if real is None or real == root_real or not real.is_relative_to(root_real): - return None - return tuple(real.relative_to(root_real).as_posix().split("/")) +def _in_suite(subject: Subject, segs: tuple[str, ...], reach: Reach, gone: bool = False) -> bool: + return (any(_claims(g, segs) or (gone and _ancestor_of(g, segs)) for g in reach.globs) + or any(_under(segs, r) for r in reach.suites) or segs in reach.links) -def _under(segs: tuple[str, ...], base: tuple[str, ...]) -> bool: - return segs[:len(base)] == base +def _ancestor_of(pattern: str, segs: tuple[str, ...]) -> bool: + """True when ``segs`` is a strict ancestor of a path the glob ``pattern`` matches. Only asked of a changed path that + is gone from disk: a link on the way to a suite that was deleted still changes what the suite reads, and no link + is left to find, so the change claims the suite. (A ``**`` leading the pattern can match under any path.)""" + return _ancestor(tuple(pattern.split("/")), segs) -def _in_suite(subject: Subject, segs: tuple[str, ...], reach: Reach) -> bool: - return (any(_claims(g, segs) for g in reach.globs) or any(_under(segs, r) for r in reach.suites) - or segs in reach.links) +def _ancestor(pat: tuple[str, ...], segs: tuple[str, ...]) -> bool: + if not segs: + return bool(pat) + if not pat: + return False + if pat[0] == "**": + return True # a ** below here may match anything under the path + return fnmatch.fnmatchcase(segs[0], pat[0]) and _ancestor(pat[1:], segs[1:]) -def _map_one(rel: str, subjects: list[Subject], reach: dict[str, Reach], shared: list[str], impact: Impact) -> None: +def _map_one(rel: str, subjects: list[Subject], reach: dict[str, Reach], shared: list[str], impact: Impact, + *, gone: bool = False) -> None: segs = tuple(rel.split("/")) is_shared = any(_claims(g, segs) for g in shared) - suites = [s.id for s in subjects if _in_suite(s, segs, reach[s.id])] + suites = [s.id for s in subjects if _in_suite(s, segs, reach[s.id], gone)] # a skill whose own suite holds this file gets nothing: it is eval, and skill_sha leaves it out skills = [ s for s in _skill_owners(subjects, reach, segs) - if not _in_suite(s, segs, reach[s.id]) or segs in reach[s.id].skill_links + if not _in_suite(s, segs, reach[s.id], gone) or segs in reach[s.id].skill_links ] if is_shared: impact.shared.append(rel) @@ -595,71 +561,46 @@ def _suite_files(root: Path, globs: tuple[str, ...]) -> dict[str, Path]: return {rel: path for rel, path in found.items() if not any(other != rel and _within(rel, other) for other in found)} -def _digest_files(files: dict[str, Path], root: Path) -> str: +def _digest_files(files: dict[str, Path], root: Path, modes: Mapping[str, bool] | None = None) -> str: """One digest over the suite's files. A directory is walked file by file; a symlink counts by its target text (as tree_digest does) and by what it points to, so retargeting a link moves the hash even between identical trees. - Every link met on the way to a file counts too (a link above the file, or one on the way to its target), so - retargeting a link on the way to a suite file moves the hash even when the files behind it are identical.""" + Every link met on the way to a match or a file counts too (a link above it, or one on the way to its target), so + retargeting a link on the way to a suite moves the hash even when the files behind it are identical, or there are + none. ``modes`` are the index's executable bits keyed by repository-relative path, read once by the caller.""" h = hashlib.sha256() root_real = _real(root) or root - for rel in sorted(files): - for name, path in _walk(files[rel], rel): - for hop in _hops(path, root, root_real): + seen: set[tuple[str, ...]] = set() # a link on the way to several matches is hashed once + + def hop_lines(path: Path) -> None: + for hop in _hops(path, root, root_real): + if hop not in seen: + seen.add(hop) text = link_text(root.joinpath(*hop), root) # the same spelling of the tree as the symlink lines below h.update(os.fsencode(f"link {'/'.join(hop)}\0-> {text}\n")) + + for rel in sorted(files): + hop_lines(files[rel]) # a directory match with no file under it reads its links too + for name, path in _walk(files[rel], rel): + hop_lines(path) if path.is_symlink(): h.update(os.fsencode(f"{name}\0-> {link_text(path, root)}\n")) # names may hold undecodable bytes if path.is_dir(): continue # a linked directory: its files are walked and hashed one by one target = path.resolve() if path.exists() else path - h.update(os.fsencode(f"{name}\0{tree_digest(target)}\n")) + h.update(os.fsencode(f"{name}\0{tree_digest(target, tracked=_file_modes(target, root_real, modes))}\n")) return "sha256:" + h.hexdigest() -def _real(path: Path) -> Path | None: - """The resolved path, or None when it cannot be resolved: a symlink loop (3.11 and 3.12 raise RuntimeError for it; - 3.13 and later resolve it to itself, so the loop is told apart by stat, which fails with ELOOP).""" - try: - real = path.resolve() - except (OSError, RuntimeError): +def _file_modes(target: Path, root_real: Path, modes: Mapping[str, bool] | None) -> dict[str, bool] | None: + """The index's executable bit for one file, keyed as tree_digest keys a file it digests by itself. None when the + index was not read, or the file is not in the repository's index (a submodule's, say): git is asked about it then.""" + if modes is None: return None try: - path.stat() - except OSError as exc: - if exc.errno == errno.ELOOP: - return None - return real - - -def _walk(path: Path, rel: str, *, dirs: bool = False) -> list[tuple[str, Path]]: - """Entries under ``path`` (or ``path`` itself): regular files and symlinks, and with ``dirs`` the directories - too, so a deleted file under a directory that still exists maps to it. Symlinks are followed into their - directories; a directory is visited once by its resolved path, so a cycle ends.""" - found: list[tuple[str, Path]] = [] - seen: set[Path] = set() - - def visit(entry: Path, name: str) -> None: - if entry.is_symlink(): - found.append((name, entry)) - if entry.is_dir(): - if dirs: - found.append((name, entry)) - real = _real(entry) - if real is None or real in seen: - return - seen.add(real) - for child in sorted(entry.iterdir(), key=lambda p: p.name): - visit(child, f"{name}/{child.name}") - elif entry.is_file() and not entry.is_symlink(): - found.append((name, entry)) - - visit(path, rel) - return sorted(found) - - -def _within(rel: str, base: str) -> bool: - """True when ``rel`` is ``base`` or sits under it (repository-relative paths).""" - return rel == base or rel.startswith(base + "/") + key = target.relative_to(root_real).as_posix() + except ValueError: + return None + return {target.name: modes[key]} if key in modes else None def _check_suite(subject: Subject, root: Path) -> None: @@ -675,7 +616,7 @@ def _check_suite(subject: Subject, root: Path) -> None: if real_root is not None and os.path.normcase(os.fspath(real_skill)) == os.path.normcase(os.fspath(real_root)): raise ConfigError(f"subject {subject.id}: {subject.path} resolves to the repository root; a skill is a directory inside it") for rel, path in matches.items(): - _check_match(subject, rel, path, real_skill) + _check_match(subject, rel, path, real_skill, root) _check_claims(subject, matches, real_skill) @@ -684,7 +625,7 @@ def _into_skill(path: Path, real_skill: Path) -> bool: return real is not None and real.is_relative_to(real_skill) -def _check_match(subject: Subject, rel: str, path: Path, real_skill: Path) -> None: +def _check_match(subject: Subject, rel: str, path: Path, real_skill: Path, root: Path) -> None: if rel == subject.path: raise ConfigError(f"subject {subject.id}: its suite matches the whole skill directory {subject.path}") if subject.path.startswith(rel + "/"): @@ -700,6 +641,26 @@ def _check_match(subject: Subject, rel: str, path: Path, real_skill: Path) -> No for name, entry in _walk(path, rel): if entry.is_symlink(): _check_link(subject, name, entry, real_skill) + # a link on the way to the match is read by the suite as much as one inside it, so the same rule applies to it, + # except the links on the skill's own way (a skill that is itself a link into the repository) + _check_hops(subject, rel, path, real_skill, root) + + +def _check_hops(subject: Subject, rel: str, path: Path, real_skill: Path, root: Path) -> None: + """A link on the way to the match that leads into the skill (or to an ancestor of it) is refused as one inside.""" + root_real = _real(root) + if root_real is None: + return + own_way = set(_hops(root / subject.path, root, root_real)) + for hop in _hops(path, root, root_real): + target = _real(root.joinpath(*hop)) + if hop in own_way or target is None: + continue + if target.is_relative_to(real_skill) or real_skill.is_relative_to(target): + raise ConfigError( + f"subject {subject.id}: its suite holds {rel}, reached through the link {'/'.join(hop)}, which links " + f"into its skill {subject.path} or to an ancestor of it; a suite holds its own files, not links into the skill" + ) def _check_link(subject: Subject, name: str, entry: Path, real_skill: Path) -> None: @@ -737,13 +698,13 @@ def _check_claims(subject: Subject, matches: dict[str, Path], real_skill: Path) ) -def _hashed(subject: Subject, root: Path, all_subjects: list[Subject]) -> Subject: +def _hashed(subject: Subject, root: Path, all_subjects: list[Subject], modes: Mapping[str, bool] | None) -> Subject: files = _suite_files(root, subject.suite_globs) if subject.kind == "repo": assert subject.manifest is not None # set by _subject for kind repo if not any(_within(subject.manifest, rel) for rel in files): # a suite directory already walks it files.setdefault(subject.manifest, root / subject.manifest) - return replace(subject, suite_hash=_digest_files(files, root)) + return replace(subject, suite_hash=_digest_files(files, root, modes)) inside = [rel[len(subject.path) + 1:] for rel in files if _within(rel, subject.path) and rel != subject.path] # a nested skill is its own subject: its files belong to its own skill_sha, not to the outer one nested = [ @@ -753,4 +714,4 @@ def _hashed(subject: Subject, root: Path, all_subjects: list[Subject]) -> Subjec ] # A skill directory that is a symlink is its target's content (a suite match is hashed by its link instead). skill_sha = "sha256:" + tree_digest((root / subject.path).resolve(), exclude=[*inside, *nested], strict=True) - return replace(subject, skill_sha=skill_sha, suite_hash=_digest_files(files, root)) + return replace(subject, skill_sha=skill_sha, suite_hash=_digest_files(files, root, modes)) diff --git a/dokimi/dokimi/repo_links.py b/dokimi/dokimi/repo_links.py new file mode 100644 index 000000000..110d24fed --- /dev/null +++ b/dokimi/dokimi/repo_links.py @@ -0,0 +1,120 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Symlink-aware reads of a repository for dokimi_config: where a path resolves, the links on its declared way and on +the way to a target, and the entries under a directory. Paths are repository-relative segment tuples where they are +returned, so they match the impact a changed file maps to.""" + +from __future__ import annotations + +import errno +import os +from pathlib import Path + +from dokimi.utils import _plain_path + + +def _real(path: Path) -> Path | None: + """The resolved path, or None when it cannot be resolved: a symlink loop (3.11 and 3.12 raise RuntimeError for it; + 3.13 and later resolve it to itself, so the loop is told apart by stat, which fails with ELOOP).""" + try: + real = path.resolve() + except (OSError, RuntimeError): + return None + try: + path.stat() + except OSError as exc: + if exc.errno == errno.ELOOP: + return None + return real + + +def _walk(path: Path, rel: str, *, dirs: bool = False) -> list[tuple[str, Path]]: + """Entries under ``path`` (or ``path`` itself): regular files and symlinks, and with ``dirs`` the directories + too, so a deleted file under a directory that still exists maps to it. Symlinks are followed into their + directories; a directory is visited once by its resolved path, so a cycle ends.""" + found: list[tuple[str, Path]] = [] + seen: set[Path] = set() + + def visit(entry: Path, name: str) -> None: + if entry.is_symlink(): + found.append((name, entry)) + if entry.is_dir(): + if dirs: + found.append((name, entry)) + real = _real(entry) + if real is None or real in seen: + return + seen.add(real) + for child in sorted(entry.iterdir(), key=lambda p: p.name): + visit(child, f"{name}/{child.name}") + elif entry.is_file() and not entry.is_symlink(): + found.append((name, entry)) + + visit(path, rel) + return sorted(found) + + +def _within(rel: str, base: str) -> bool: + """True when ``rel`` is ``base`` or sits under it (repository-relative paths).""" + return rel == base or rel.startswith(base + "/") + + +def _under(segs: tuple[str, ...], base: tuple[str, ...]) -> bool: + return segs[:len(base)] == base + + +def _inside(path: Path, root_real: Path) -> tuple[str, ...] | None: + """The segments of ``path``'s resolved location under the repository root; None when it is the root or outside it.""" + real = _real(path) + if real is None or real == root_real or not real.is_relative_to(root_real): + return None + return tuple(real.relative_to(root_real).as_posix().split("/")) + + +def _declared_links(path: Path, root: Path) -> list[tuple[str, ...]]: + """The links on the declared way to ``path`` (a path under ``root``, as written), the path itself included when it is + one: the components that are symbolic links, not the links met by following them.""" + parts = path.relative_to(root).parts + return [parts[:k] for k in range(1, len(parts) + 1) if root.joinpath(*parts[:k]).is_symlink()] + + +def _hops(path: Path, root: Path, root_real: Path) -> list[tuple[str, ...]]: + """The repository-relative links met resolving ``path`` (a path under ``root``, as written): each link on its + declared way, and each link on the way to every target a link points to, until the path is resolved or leaves the + repository. A link's location is its real one, as git names it. A loop is cut after a fixed number of hops.""" + pending = list(path.relative_to(root).parts) + here: tuple[str, ...] = () # the real directory the next component is looked up in + found: dict[tuple[str, ...], None] = {} + hops = 0 + while pending: + part = pending.pop(0) + if part in ("", "."): + continue + if part == "..": + if not here: + break + here = here[:-1] + continue + link = root_real.joinpath(*here, part) + if not link.is_symlink(): + here = (*here, part) + continue + hops += 1 + if hops > 256: + break + found[(*here, part)] = None + target = _plain_path(os.readlink(link)) # Windows reads an absolute target back as \\?\C:\... + if os.path.isabs(target): + for anchor in (root_real, root): # written as the resolved or the given spelling of the repository + try: + pending = [*Path(target).relative_to(anchor).parts, *pending] + break + except ValueError: + continue + else: + break # an absolute target outside the repository: no more of the way is ours + here = () + else: + pending = [*Path(target).parts, *pending] + return list(found) diff --git a/dokimi/dokimi/utils.py b/dokimi/dokimi/utils.py index 147dae888..8bd9ca48b 100644 --- a/dokimi/dokimi/utils.py +++ b/dokimi/dokimi/utils.py @@ -7,7 +7,7 @@ import re import stat from pathlib import Path -from typing import Iterable, Tuple, Dict, Any +from typing import Any, Dict, Iterable, Mapping, Tuple from dokimi.defaults import bedrock_profile from dokimi.exit_codes import EXIT_USAGE @@ -188,7 +188,8 @@ def _entries(root: Path, skip: tuple[str, ...], *, strict: bool) -> list[Path]: return found -def tree_digest(root: Path, *, exclude: Iterable[str] = (), strict: bool = False) -> str: +def tree_digest(root: Path, *, exclude: Iterable[str] = (), strict: bool = False, + tracked: Mapping[str, bool] | None = None) -> str: """sha256 (bare hex) over every path, mode bit and byte under ``root``; a link by its target. A file ``root`` digests as itself (its path is ``.``), and so does a symlink ``root`` @@ -202,14 +203,23 @@ def tree_digest(root: Path, *, exclude: Iterable[str] = (), strict: bool = False ``link_text``); one pointing outside ``root`` is hashed as written. ``strict``: a directory under ``root`` that cannot be read raises its OSError (the default skips it silently). + ``tracked``: the executable bits git records, keyed by path relative to ``root`` (to its parent for a file root), + as :func:`_tracked_modes` returns them; None reads them from git. A caller digesting many files reads them once. """ skip = tuple(e.strip("/") for e in exclude) + # a symlink is never looked through here: its target may be unreadable (or gone), and it digests by its text + is_link = root.is_symlink() + as_file = not is_link and root.is_file() # sorted by the parts of the relative path, compared as code points: the same order on every OS (Path # ordering folds case on Windows) - entries = ([root] if root.is_symlink() or root.is_file() + entries = ([root] if is_link or as_file else sorted(_entries(root, skip, strict=strict), key=lambda p: p.relative_to(root).parts)) - base, spec = (root.parent, root.name) if root.is_file() else (root, ".") - tracked = _tracked_modes(base, spec) if any(p.is_file() and not p.is_symlink() for p in entries) else {} + base, spec = (root.parent, root.name) if as_file else (root, ".") + modes: Mapping[str, bool] + if tracked is None: + modes = _tracked_modes(base, spec) if any(not p.is_symlink() and p.is_file() for p in entries) else {} + else: # the caller read the index already: keyed as below, by the path relative to ``base`` + modes = tracked h = hashlib.sha256() for path in entries: rel = "." if path == root else path.relative_to(root).as_posix() @@ -221,8 +231,8 @@ def tree_digest(root: Path, *, exclude: Iterable[str] = (), strict: bool = False h.update(b"L " + name + b"\0" + os.fsencode(link_text(path, root)) + b"\0") elif path.is_file(): key = path.relative_to(base).as_posix() - if key in tracked: - executable = tracked[key] + if key in modes: + executable = modes[key] else: executable = bool(os.lstat(path).st_mode & stat.S_IXUSR) h.update(b"F " + name + f"\0{int(executable)}\0".encode()) diff --git a/dokimi/tests/test_import_architecture.py b/dokimi/tests/test_import_architecture.py index 9de19e3fa..0c7164813 100644 --- a/dokimi/tests/test_import_architecture.py +++ b/dokimi/tests/test_import_architecture.py @@ -63,7 +63,7 @@ # ingest, static metrics, gates. ("components", ( "dokimi.agent_profiles", "dokimi.gate", "dokimi.ingest", "dokimi.mcp", - "dokimi.metrics", "dokimi.repo_config", "dokimi.repo_glob", "dokimi.skills", + "dokimi.metrics", "dokimi.repo_config", "dokimi.repo_glob", "dokimi.repo_links", "dokimi.skills", )), # What everything shares: defaults, registries, artifact and record I/O, the LLM and # process chokepoints, console output. diff --git a/dokimi/tests/test_repo_config_links.py b/dokimi/tests/test_repo_config_links.py index 8b2ccfb0c..37c8cf5f4 100644 --- a/dokimi/tests/test_repo_config_links.py +++ b/dokimi/tests/test_repo_config_links.py @@ -6,10 +6,13 @@ from __future__ import annotations import os +import shutil +import subprocess from pathlib import Path import pytest +from dokimi import repo_config, repo_links from dokimi.repo_config import ConfigError, _walk, load_repo_config HEADER = "schema_version: dokimi_config.v1\n" @@ -326,3 +329,140 @@ def test_a_self_referential_link_a_suite_names_is_a_config_error_not_dropped(tmp os.symlink("loop", root / "evaluation" / "loop") with pytest.raises(ConfigError, match="loop"): load_repo_config(root).subjects() + + +SKILL_A = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: {suite}\n" + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_link_on_the_way_to_a_skill_inside_its_suite_moves_the_skill_too(tmp_path): + # skills/a/refs/current -> common/one: skill_sha covers the link (the suite matches only the tasks under it) + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": SKILL_A.format(suite="skills/a/refs/current/tasks"), + "skills/a/SKILL.md": "a\n", + "common/one/tasks/q.json": "[]\n", + "common/two/tasks/q.json": "[]\n", + }) + os.makedirs(root / "skills" / "a" / "refs") + os.symlink("../../../common/one", root / "skills" / "a" / "refs" / "current") + before = _subjects(root)["skill/a"] + (root / "skills" / "a" / "refs" / "current").unlink() + os.symlink("../../../common/two", root / "skills" / "a" / "refs" / "current") + after = _subjects(root)["skill/a"] + assert after.skill_sha != before.skill_sha and after.suite_hash != before.suite_hash + impact = load_repo_config(root).subjects_for_changed(["skills/a/refs/current"]) + assert impact.skills == {"skill/a": ["skills/a/refs/current"]} + assert impact.suites == {"skill/a": ["skills/a/refs/current"]} + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_retargeting_a_linked_skill_outside_the_repository_claims_the_skill(tmp_path): + ext = tmp_path / "ext" + for name in ("one", "two"): + (ext / name / "a").mkdir(parents=True) + (ext / name / "a" / "SKILL.md").write_text(f"{name}\n", encoding="utf-8") + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": HEADER + "subjects:\n - id: skill/a\n path: linked/a\n suite: evaluation/*.json\n", + "evaluation/x.json": "[]\n", + }) + os.symlink(ext / "one", root / "linked") + before = _subjects(root)["skill/a"] + (root / "linked").unlink() + os.symlink(ext / "two", root / "linked") + after = _subjects(root)["skill/a"] + assert after.skill_sha != before.skill_sha + impact = load_repo_config(root).subjects_for_changed(["linked"]) + assert impact.skills == {"skill/a": ["linked"]} and impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +@pytest.mark.parametrize("suite", ["evaluation/current/task.json", "evaluation/current/*.json", "evaluation/*/task.json"]) +def test_a_deleted_link_on_the_way_to_a_suite_still_claims_the_suite(tmp_path, suite): + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": SKILL_A.format(suite=suite), + "skills/a/SKILL.md": "a\n", + "one/task.json": "[]\n", + }) + os.makedirs(root / "evaluation") + os.symlink("../one", root / "evaluation" / "current") + assert _subjects(root)["skill/a"].suite_hash + (root / "evaluation" / "current").unlink() + impact = load_repo_config(root).subjects_for_changed(["evaluation/current"]) + assert impact.suites == {"skill/a": ["evaluation/current"]} + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_retargeting_a_link_between_empty_directories_moves_the_suite_hash(tmp_path): + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": SKILL_A.format(suite="evaluation/current/tasks"), + "skills/a/SKILL.md": "a\n", + }) + os.makedirs(root / "one" / "tasks") # empty: the match is a directory with no file under it + os.makedirs(root / "two" / "tasks") + os.makedirs(root / "evaluation") + os.symlink("../one", root / "evaluation" / "current") + before = _subjects(root)["skill/a"].suite_hash + (root / "evaluation" / "current").unlink() + os.symlink("../two", root / "evaluation" / "current") + assert _subjects(root)["skill/a"].suite_hash != before + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_suite_reached_through_a_link_into_its_own_skill_is_refused(tmp_path): + # skills/a/evals -> actual, and the suite is skills/a/evals/tasks: the tasks are the skill's own files + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": SKILL_A.format(suite="skills/a/evals/tasks"), + "skills/a/SKILL.md": "a\n", + "skills/a/actual/tasks/q.json": "[]\n", + }) + os.symlink("actual", root / "skills" / "a" / "evals") + with pytest.raises(ConfigError, match=r"skill/a.*skills/a/evals/tasks.*skills/a/evals"): + load_repo_config(root).subjects(hashes=False) + + +def test_a_case_that_differs_only_from_the_name_on_disk_is_the_declared_suite_place(tmp_path, monkeypatch): + # a case-folding filesystem (Windows) resolves skills/a/evals to skills/a/EVALS; the suite is still the declared place + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": HEADER + "subjects:\n - id: skill/a\n path: skills/a\n", + "skills/a/SKILL.md": "a\n", + "skills/a/EVALS/q.json": "[]\n", + }) + expected = load_repo_config(root).subjects_for_changed(["skills/a/EVALS/q.json"]) + expected_hashes = {s.id: s.suite_hash for s in load_repo_config(root).subjects()} + real = repo_links._real + + def folded(path): # what Windows returns for the declared name: the spelling on disk + return real(path.parent / "EVALS") if path.name == "evals" else real(path) + + monkeypatch.setattr(repo_links, "_real", folded) + monkeypatch.setattr(repo_config, "_real", folded) + assert load_repo_config(root).subjects_for_changed(["skills/a/EVALS/q.json"]) == expected + assert {s.id: s.suite_hash for s in load_repo_config(root).subjects()} == expected_hashes + assert expected.skills == {"skill/a": ["skills/a/EVALS/q.json"]} and expected.suites == {} + + +@pytest.mark.skipif(shutil.which("git") is None, reason="the executable bits come from git's index; needs git") +def test_a_suite_of_many_files_reads_the_git_index_once(tmp_path, monkeypatch): + # each eval file used to start its own git ls-files: one per file, for every subject's suite hash + from dokimi import repo_config, utils + + calls: list[Path] = [] + read = utils._tracked_modes + + def counting(base, spec): + calls.append(base) + return read(base, spec) + + monkeypatch.setattr(utils, "_tracked_modes", counting) + monkeypatch.setattr(repo_config, "_tracked_modes", counting) + files = {"skills/a/SKILL.md": "a\n", **{f"evaluation/t{i}/q.json": "[]\n" for i in range(30)}} + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/*/q.json\n", + **files, + }) + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "add", "-A"], check=True) + subjects = load_repo_config(root).subjects() + assert subjects[0].suite_hash and len(calls) <= 2 # the skill's own tree and the index, once each diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py index dc3486e78..c3e1d8d31 100644 --- a/dokimi/tests/test_tree_digest.py +++ b/dokimi/tests/test_tree_digest.py @@ -290,3 +290,21 @@ def test_an_absolute_link_inside_the_tree_matches_whatever_the_case_of_its_targe except OSError: pytest.skip("creating symlinks needs privileges on Windows") assert tree_digest(a) == tree_digest(b) + + +@pytest.mark.skipif(os.name == "nt", reason="a directory's mode bits do not stop reads on Windows") +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="root reads any directory: the test cannot fail there") +def test_a_readable_link_to_an_unreadable_target_digests_by_its_text(tmp_path): + # the link is readable, but its target's directory has no search permission: the link is hashed by its text, not read + private = tmp_path / "private" + private.mkdir() + (private / "q.json").write_text("[]\n", encoding="utf-8") + root = _write(tmp_path / "t", {"x.json": "[]\n"}) + os.symlink(private / "q.json", root / "link.json") + os.chmod(private, 0) + try: + assert tree_digest(root / "link.json") + assert tree_digest(root) + finally: + os.chmod(private, 0o755) + From f618329984447908dddb89f188f388af5724fb7e Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Mon, 2 Nov 2026 08:33:58 -0800 Subject: [PATCH 32/36] fix(repo): untracked suite files do not start a git process each A suite file the git index does not hold (untracked, or in a tree with no repository) asked git for its mode bit one file at a time. Such a file now takes its disk bit, as tree_digest already does for untracked files, so subjects() reads the index once for every suite file. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 8 +++++--- dokimi/tests/test_repo_config_links.py | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index d5cd5d342..505c51afc 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -593,14 +593,16 @@ def hop_lines(path: Path) -> None: def _file_modes(target: Path, root_real: Path, modes: Mapping[str, bool] | None) -> dict[str, bool] | None: """The index's executable bit for one file, keyed as tree_digest keys a file it digests by itself. None when the - index was not read, or the file is not in the repository's index (a submodule's, say): git is asked about it then.""" + index was not read (tree_digest then asks git). A file the index does not hold (untracked, outside the repository) + gives no bit and digests by its disk bit, as tree_digest does for an untracked file; asking git for each one is + the per-file process this avoids.""" if modes is None: return None try: key = target.relative_to(root_real).as_posix() except ValueError: - return None - return {target.name: modes[key]} if key in modes else None + return {} + return {target.name: modes[key]} if key in modes else {} def _check_suite(subject: Subject, root: Path) -> None: diff --git a/dokimi/tests/test_repo_config_links.py b/dokimi/tests/test_repo_config_links.py index 37c8cf5f4..07903dcb3 100644 --- a/dokimi/tests/test_repo_config_links.py +++ b/dokimi/tests/test_repo_config_links.py @@ -444,7 +444,8 @@ def folded(path): # what Windows returns for the declared name: the spelling on @pytest.mark.skipif(shutil.which("git") is None, reason="the executable bits come from git's index; needs git") -def test_a_suite_of_many_files_reads_the_git_index_once(tmp_path, monkeypatch): +@pytest.mark.parametrize("added", [True, False], ids=["tracked", "untracked"]) +def test_a_suite_of_many_files_reads_the_git_index_once(tmp_path, monkeypatch, added): # each eval file used to start its own git ls-files: one per file, for every subject's suite hash from dokimi import repo_config, utils @@ -463,6 +464,7 @@ def counting(base, spec): **files, }) subprocess.run(["git", "init", "-q", str(root)], check=True) - subprocess.run(["git", "-C", str(root), "add", "-A"], check=True) + if added: + subprocess.run(["git", "-C", str(root), "add", "-A"], check=True) subjects = load_repo_config(root).subjects() assert subjects[0].suite_hash and len(calls) <= 2 # the skill's own tree and the index, once each From 9510ce7b702e1c36a1ffb8e8c5054d3b050bc31c Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Mon, 2 Nov 2026 09:01:34 -0800 Subject: [PATCH 33/36] test(tree-digest): restore owner-only mode on the private dir (bandit B103) Signed-off-by: Nikolay Petrov --- dokimi/tests/test_tree_digest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py index c3e1d8d31..a8eadd120 100644 --- a/dokimi/tests/test_tree_digest.py +++ b/dokimi/tests/test_tree_digest.py @@ -306,5 +306,5 @@ def test_a_readable_link_to_an_unreadable_target_digests_by_its_text(tmp_path): assert tree_digest(root / "link.json") assert tree_digest(root) finally: - os.chmod(private, 0o755) + os.chmod(private, 0o700) # owner access back so tmp_path can be removed From d9284d6350d8789e049ec51c946c2416df3376e3 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Mon, 2 Nov 2026 10:02:19 -0800 Subject: [PATCH 34/36] fix(config): v1 accepts only whole-skill links; map deleted links and nested modes The v1 rule: a skill may be a link to a place inside the repository; any other link on the way to or inside a skill is a ConfigError. So is a link the walk of a suite glob reads through, matched or not (so the same config is refused whether or not the suite has a match yet), and a link inside a suite match. A link is not attributed beyond the paths it names, so the impact map does not guess at it. - A deleted path above a suite glob keeps the skill too (skill_sha hashed the link), so a deleted link claims both impact.skills and impact.suites. - A file of a nested checkout takes its executable bit from that repository's index, not the disk, when the outer index does not hold it. The tests that asserted link semantics now beyond v1 are removed; the new tests/test_repo_config_links_v1.py covers the refusals and the accepted whole-skill link. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repo_config.py | 75 +++++- dokimi/tests/test_repo_config.py | 183 +------------- dokimi/tests/test_repo_config_links.py | 282 ---------------------- dokimi/tests/test_repo_config_links_v1.py | 183 ++++++++++++++ 4 files changed, 258 insertions(+), 465 deletions(-) create mode 100644 dokimi/tests/test_repo_config_links_v1.py diff --git a/dokimi/dokimi/repo_config.py b/dokimi/dokimi/repo_config.py index 505c51afc..2b5aee292 100644 --- a/dokimi/dokimi/repo_config.py +++ b/dokimi/dokimi/repo_config.py @@ -93,6 +93,7 @@ def subjects(self, *, hashes: bool = True) -> list[Subject]: _check_nesting(resolved, self.root) for subject in resolved: _check_suite(subject, self.root) + _check_links_v1(subject, self.root) if not hashes: return resolved modes = _tracked_modes(self.root, ".") # the index is read once for every subject's suite files @@ -414,6 +415,7 @@ def _map_one(rel: str, subjects: list[Subject], reach: dict[str, Reach], shared: skills = [ s for s in _skill_owners(subjects, reach, segs) if not _in_suite(s, segs, reach[s.id], gone) or segs in reach[s.id].skill_links + or _gone_above_suite(reach[s.id], segs, gone) ] if is_shared: impact.shared.append(rel) @@ -425,6 +427,12 @@ def _map_one(rel: str, subjects: list[Subject], reach: dict[str, Reach], shared: impact.unclaimed.append(rel) +def _gone_above_suite(reach: Reach, segs: tuple[str, ...], gone: bool) -> bool: + """A deleted path above a suite's glob that is not itself a suite path: it may have been a link inside the skill, + which skill_sha hashed, so the skill keeps the change as well as the suite (the glob's own matches stay the suite's).""" + return gone and any(_ancestor_of(g, segs) and not _claims(g, segs) for g in reach.globs) + + def _skill_owners(subjects: list[Subject], reach: dict[str, Reach], segs: tuple[str, ...]) -> list[Subject]: """The skills whose directory (declared or resolved) holds ``segs``: the deepest ones when skills nest. Two subjects that alias one directory both own its files, since both skill_sha values cover them. A symlink on a skill's way @@ -602,7 +610,20 @@ def _file_modes(target: Path, root_real: Path, modes: Mapping[str, bool] | None) key = target.relative_to(root_real).as_posix() except ValueError: return {} - return {target.name: modes[key]} if key in modes else {} + if key in modes: + return {target.name: modes[key]} + # the outer index does not hold a file of a nested checkout: its own repository records the bit, so git is asked + return None if _in_nested_repo(target, root_real) else {} + + +def _in_nested_repo(target: Path, root_real: Path) -> bool: + """True when a directory between ``target`` and the repository root holds a ``.git`` of its own.""" + for parent in target.parents: + if parent == root_real or not parent.is_relative_to(root_real): + return False + if (parent / ".git").exists(): + return True + return False def _check_suite(subject: Subject, root: Path) -> None: @@ -622,6 +643,58 @@ def _check_suite(subject: Subject, root: Path) -> None: _check_claims(subject, matches, real_skill) +def _check_links_v1(subject: Subject, root: Path) -> None: + """The v1 link rule: the only link a subject may hold is its own skill directory, when that links to a place + inside the repository. Any other link on the way to the skill, inside it, on the way to a suite match or inside one + is a ConfigError, so the impact map never has to attribute a link (a link's retargeting or deletion is not + attributed beyond the paths it names). Widening the rule later is free; narrowing it would break configs.""" + root_real = _real(root) + if root_real is None: + return + own_way = _check_skill_links(subject, root, root_real) if subject.kind == "skill" else set() + _check_suite_links(subject, root, root_real, own_way) + + +def _check_skill_links(subject: Subject, root: Path, root_real: Path) -> set[tuple[str, ...]]: + """The links on the way to the skill and inside it; returns the skill's own hop, the only one allowed.""" + segs = tuple(subject.path.split("/")) + hops = _hops(root / subject.path, root, root_real) + for hop in hops: + if hop != segs: + raise ConfigError( + f"subject {subject.id}: {subject.path} is reached through the link {'/'.join(hop)}; " + "only the skill directory itself may be a link, and only to a place inside the repository" + ) + if segs in hops and _inside(root / subject.path, root_real) is None: + raise ConfigError(f"subject {subject.id}: its skill directory {subject.path} links outside the repository") + for name, entry in _walk(root / subject.path, subject.path): + if entry.is_symlink() and name != subject.path: + raise ConfigError(f"subject {subject.id}: {name} is a link inside its skill; a skill holds real files in v1") + return {segs} + + +def _check_suite_links(subject: Subject, root: Path, root_real: Path, own_way: set[tuple[str, ...]]) -> None: + """The links on the way to a suite and inside its matches; only the skill's own hop may be on the way. Every link + a glob's walk reads through is checked, matched or not, so a link is refused whether or not the suite has a match.""" + for pattern in subject.suite_globs: + for link, _rest in _glob_links(root, pattern): + _check_way(subject, pattern, _hops(link, root, root_real), own_way) + for rel, path in _suite_files(root, subject.suite_globs).items(): + _check_way(subject, rel, _hops(path, root, root_real), own_way) + for name, entry in _walk(path, rel): + if entry.is_symlink(): + raise ConfigError(f"subject {subject.id}: its suite holds {name}, a link; a suite holds real files in v1") + + +def _check_way(subject: Subject, rel: str, hops: list[tuple[str, ...]], own_way: set[tuple[str, ...]]) -> None: + for hop in hops: + if hop not in own_way: + raise ConfigError( + f"subject {subject.id}: its suite {rel} is reached through the link {'/'.join(hop)}; " + "a suite holds real files and directories in v1" + ) + + def _into_skill(path: Path, real_skill: Path) -> bool: real = _real(path) return real is not None and real.is_relative_to(real_skill) diff --git a/dokimi/tests/test_repo_config.py b/dokimi/tests/test_repo_config.py index 9914ce5c8..6cf2bb992 100644 --- a/dokimi/tests/test_repo_config.py +++ b/dokimi/tests/test_repo_config.py @@ -399,37 +399,6 @@ def test_a_manifest_with_brackets_in_its_name_is_claimed_as_written(tmp_path): assert impact.unclaimed == ["manifests/repo1.yaml"] -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_retargeting_a_matched_dangling_symlink_moves_suite_hash(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/*\n" - files = {".github/dokimi/config.yaml": body, "skills/a/SKILL.md": "# a\n", "evaluation/keep.txt": "k\n"} - root = _tree(tmp_path / "repo", files) - link = root / "evaluation" / "task" - os.symlink("missing-a", link) - before = _subjects(root)["skill/a"].suite_hash - link.unlink() - os.symlink("missing-b", link) - assert _subjects(root)["skill/a"].suite_hash != before - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_retargeting_a_matched_directory_symlink_moves_suite_hash(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/*\n" - files = { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "one/q.json": "[]\n", - "two/q.json": "[]\n", - } - root = _tree(tmp_path / "repo", files) - (root / "evaluation").mkdir() - os.symlink(root / "one", root / "evaluation" / "linked", target_is_directory=True) - before = _subjects(root)["skill/a"].suite_hash - (root / "evaluation" / "linked").unlink() - os.symlink(root / "two", root / "evaluation" / "linked", target_is_directory=True) - assert _subjects(root)["skill/a"].suite_hash != before - - @pytest.mark.parametrize( ("body", "where"), [ @@ -580,40 +549,6 @@ def test_a_budget_that_is_not_a_finite_number_is_refused(tmp_path, key, value): load_repo_config(root) -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_an_eval_that_is_a_symlink_hashes_the_file_it_points_to(tmp_path): - import os - - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "a\n", - "shared/q.json": "[1]\n", - "skills/a/evals/.keep": "", - }) - os.symlink("../../../shared/q.json", root / "skills/a/evals/q.json") - before = _subjects(root)["skill/a"].suite_hash - (root / "shared/q.json").write_text("[2]\n", encoding="utf-8") - assert _subjects(root)["skill/a"].suite_hash != before - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_an_eval_directory_that_is_a_symlink_hashes_the_files_it_reaches(tmp_path): - import os - - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "a\n", - "common/q.json": "[1]\n", - "skills/a/evals/.keep": "", - }) - os.symlink("../../../common", root / "skills/a/evals/shared") - before = _subjects(root)["skill/a"].suite_hash - (root / "common/q.json").write_text("[2]\n", encoding="utf-8") - assert _subjects(root)["skill/a"].suite_hash != before - - @pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_an_evals_dir_that_links_to_its_own_skill_is_refused(tmp_path): import os @@ -709,20 +644,6 @@ def test_a_change_to_a_repo_manifest_reached_through_a_link_maps_to_the_subject( assert impact.unclaimed == [] -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_a_change_to_a_suite_file_reached_through_a_link_maps_to_the_suite(tmp_path): - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": BESIDE_CONFIG, - "skills/a/SKILL.md": "a\n", - "shared/q.json": "[1]\n", - "skills/a/evals/.keep": "", - }) - os.symlink("../../../shared/q.json", root / "skills/a/evals/q.json") - impact = load_repo_config(root).subjects_for_changed(["shared/q.json"]) - assert impact.suites == {"skill/a": ["shared/q.json"]} - assert impact.unclaimed == [] - - @pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_a_config_reached_through_a_link_is_shared_at_its_declared_path_and_its_target(tmp_path): root = _tree(tmp_path / "repo", {"ci/config.yaml": BESIDE_CONFIG, "ci/other.yaml": "x\n"}) @@ -848,46 +769,8 @@ def test_a_linked_skill_still_claims_its_evals_after_their_whole_directory_is_de assert impact.skills == {} and impact.unclaimed == [] -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_retargeting_a_link_on_the_way_to_a_suite_file_claims_the_link_itself(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/task.json\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "one/task.json": "[]\n", # identical bytes: only the link's target tells the two apart - "two/task.json": "[]\n", - }) - os.makedirs(root / "evaluation") - os.symlink("../one", root / "evaluation" / "current") - before = _subjects(root)["skill/a"].suite_hash - (root / "evaluation" / "current").unlink() - os.symlink("../two", root / "evaluation" / "current") - assert _subjects(root)["skill/a"].suite_hash != before - impact = load_repo_config(root).subjects_for_changed(["evaluation/current"]) - assert impact.suites == {"skill/a": ["evaluation/current"]} - assert impact.unclaimed == [] - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_retargeting_a_link_above_a_skill_claims_the_skill_by_its_link(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: linked/a\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "real1/a/SKILL.md": "# a\n", - "real2/a/SKILL.md": "# b\n", - }) - os.symlink("real1", root / "linked") - before = _subjects(root)["skill/a"].skill_sha - (root / "linked").unlink() - os.symlink("real2", root / "linked") - assert _subjects(root)["skill/a"].skill_sha != before - impact = load_repo_config(root).subjects_for_changed(["linked", "linked-sibling/x"]) - assert impact.skills == {"skill/a": ["linked"]} - assert impact.unclaimed == ["linked-sibling/x"] - - @pytest.mark.skipif(os.name == "nt", reason="POSIX file names and link targets are bytes") -def test_undecodable_names_and_link_targets_under_a_suite_load_and_digest(tmp_path): +def test_undecodable_names_under_a_suite_load_and_digest(tmp_path): root = _tree(tmp_path / "repo", { ".github/dokimi/config.yaml": BESIDE_CONFIG, "skills/a/SKILL.md": "# a\n", @@ -896,53 +779,12 @@ def test_undecodable_names_and_link_targets_under_a_suite_load_and_digest(tmp_pa evals.mkdir() fd = os.open(os.fsencode(evals) + b"/bad-\xff", os.O_CREAT | os.O_WRONLY, 0o644) os.close(fd) - os.symlink(b"../../../shared/target-\xff", os.fsencode(evals / "link")) # a dangling link out of the skill subject = _subjects(root)["skill/a"] assert subject.suite_hash and subject.suite_hash.startswith("sha256:") impact = load_repo_config(root).subjects_for_changed(["skills/a/evals/bad-\udcff"]) assert impact.suites == {"skill/a": ["skills/a/evals/bad-\udcff"]} -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_a_link_on_the_way_to_a_suite_files_target_claims_the_suite(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: skills/a/evals/**\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "shared/one/q.json": "[]\n", # identical bytes in both: the chain alone tells them apart - "shared/two/q.json": "[]\n", - }) - os.makedirs(root / "skills" / "a" / "evals") - os.symlink("../../../shared/current/q.json", root / "skills" / "a" / "evals" / "q.json") - os.symlink("one", root / "shared" / "current") - before = _subjects(root)["skill/a"].suite_hash - (root / "shared" / "current").unlink() - os.symlink("two", root / "shared" / "current") - assert _subjects(root)["skill/a"].suite_hash != before - impact = load_repo_config(root).subjects_for_changed(["shared/current", "shared/current-sibling/x"]) - assert impact.suites == {"skill/a": ["shared/current"]} - assert impact.unclaimed == ["shared/current-sibling/x"] - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_a_link_on_the_way_to_a_skill_claims_the_skill(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: linked/a\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "real1/a/SKILL.md": "# a\n", - "real2/a/SKILL.md": "# b\n", - }) - os.symlink("hop", root / "linked") - os.symlink("real1", root / "hop") - before = _subjects(root)["skill/a"].skill_sha - (root / "hop").unlink() - os.symlink("real2", root / "hop") - assert _subjects(root)["skill/a"].skill_sha != before - impact = load_repo_config(root).subjects_for_changed(["hop"]) - assert impact.skills == {"skill/a": ["hop"]} - assert impact.unclaimed == [] - - @pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_a_link_on_the_way_to_a_manifest_claims_the_subject(tmp_path): body = HEADER + "subjects:\n - id: repo\n kind: repo\n manifest: dokimi.toml\n" @@ -958,26 +800,3 @@ def test_a_link_on_the_way_to_a_manifest_claims_the_subject(tmp_path): impact = load_repo_config(root).subjects_for_changed(["current"]) assert impact.suites == {"repo": ["current"]} - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_a_link_inside_the_skill_that_a_suite_file_reaches_through_is_claimed_by_both(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: skills/a/evals/**\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "shared/one.md": "same\n", # identical bytes in both: only the link tells them apart - "shared/two.md": "same\n", - }) - os.makedirs(root / "skills" / "a" / "refs") - os.makedirs(root / "skills" / "a" / "evals") - os.symlink("../../../shared/one.md", root / "skills" / "a" / "refs" / "l") - os.symlink("../refs/l", root / "skills" / "a" / "evals" / "q.json") - before = _subjects(root)["skill/a"] - (root / "skills" / "a" / "refs" / "l").unlink() - os.symlink("../../../shared/two.md", root / "skills" / "a" / "refs" / "l") - after = _subjects(root)["skill/a"] - assert after.skill_sha != before.skill_sha and after.suite_hash != before.suite_hash - impact = load_repo_config(root).subjects_for_changed(["skills/a/refs/l"]) - assert impact.skills == {"skill/a": ["skills/a/refs/l"]} - assert impact.suites == {"skill/a": ["skills/a/refs/l"]} - assert impact.unclaimed == [] diff --git a/dokimi/tests/test_repo_config_links.py b/dokimi/tests/test_repo_config_links.py index 07903dcb3..e16e1d5f4 100644 --- a/dokimi/tests/test_repo_config_links.py +++ b/dokimi/tests/test_repo_config_links.py @@ -49,106 +49,6 @@ def test_retargeting_a_link_on_the_way_to_the_config_moves_the_smoke_set(tmp_pat assert impact.unclaimed == [] -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_retargeting_a_link_to_a_suite_file_that_is_missing_there_moves_the_suite(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/task.json\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "one/task.json": "[]\n", - "two/other.json": "[]\n", # no task.json here: the suite is empty after the retarget - }) - os.makedirs(root / "evaluation") - os.symlink("../one", root / "evaluation" / "current") - before = _subjects(root)["skill/a"].suite_hash - (root / "evaluation" / "current").unlink() - os.symlink("../two", root / "evaluation" / "current") - assert _subjects(root)["skill/a"].suite_hash != before - impact = load_repo_config(root).subjects_for_changed(["evaluation/current"]) - assert impact.suites == {"skill/a": ["evaluation/current"]} - assert impact.unclaimed == [] - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_retargeting_a_link_to_a_wildcard_suite_that_matches_nothing_there_moves_the_suite(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/*.json\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "one/task.json": "[]\n", - "two/other.md": "x\n", # no JSON here: the suite is empty after the retarget - }) - os.makedirs(root / "evaluation") - os.symlink("../one", root / "evaluation" / "current") - before = _subjects(root)["skill/a"].suite_hash - (root / "evaluation" / "current").unlink() - os.symlink("../two", root / "evaluation" / "current") - assert _subjects(root)["skill/a"].suite_hash != before - impact = load_repo_config(root).subjects_for_changed(["evaluation/current"]) - assert impact.suites == {"skill/a": ["evaluation/current"]} - assert impact.unclaimed == [] - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_deleting_the_only_file_a_wildcard_suite_matches_still_claims_it(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/*.json\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "one/task.json": "[]\n", - }) - os.makedirs(root / "evaluation") - os.symlink("../one", root / "evaluation" / "current") - before = _subjects(root)["skill/a"].suite_hash - (root / "one" / "task.json").unlink() - assert _subjects(root)["skill/a"].suite_hash != before - impact = load_repo_config(root).subjects_for_changed(["one/task.json"]) - assert impact.suites == {"skill/a": ["one/task.json"]} - assert impact.unclaimed == [] - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_a_dotdot_after_a_link_in_an_absolute_target_keeps_that_link_in_reach(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/q.json\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "one/q.json": "[]\n", - "one/sub/keep": "x\n", - "two/q.json": "[]\n", - "two/sub/keep": "x\n", - }) - os.makedirs(root / "evaluation") - os.makedirs(root / "shared") - os.symlink("../one/sub", root / "shared" / "current") - os.symlink(root / "shared" / "current" / ".." / "q.json", root / "evaluation" / "q.json") - before = _subjects(root)["skill/a"].suite_hash - (root / "shared" / "current").unlink() - os.symlink("../two/sub", root / "shared" / "current") - assert _subjects(root)["skill/a"].suite_hash != before - impact = load_repo_config(root).subjects_for_changed(["shared/current"]) - assert impact.suites == {"skill/a": ["shared/current"]} - assert impact.unclaimed == [] - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_deleting_the_file_a_linked_literal_suite_names_still_claims_it(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/task.json\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "one/task.json": "[]\n", - }) - os.makedirs(root / "evaluation") - os.symlink("../one", root / "evaluation" / "current") - before = _subjects(root)["skill/a"].suite_hash - (root / "one" / "task.json").unlink() - assert _subjects(root)["skill/a"].suite_hash != before - impact = load_repo_config(root).subjects_for_changed(["one/task.json"]) - assert impact.suites == {"skill/a": ["one/task.json"]} - assert impact.unclaimed == [] - - @pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_a_nested_skill_linked_to_a_sibling_inside_the_outer_skill_is_refused(tmp_path): body = HEADER + ("subjects:\n - id: skill/outer\n path: skills/outer\n" @@ -163,73 +63,6 @@ def test_a_nested_skill_linked_to_a_sibling_inside_the_outer_skill_is_refused(tm load_repo_config(root).subjects(hashes=False) -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_a_link_inside_a_suite_reached_through_another_link_is_not_a_skill_link(tmp_path): - # skill_sha leaves the suite's files out, so retargeting a link inside the suite must not claim the skill - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: skills/a/evals/**\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "x/one.md": "same\n", - "x/two.md": "same\n", - }) - os.makedirs(root / "skills" / "a" / "evals") - os.symlink("../../../x/one.md", root / "skills" / "a" / "evals" / "alias") - os.symlink("alias", root / "skills" / "a" / "evals" / "q.json") - before = _subjects(root)["skill/a"] - (root / "skills" / "a" / "evals" / "alias").unlink() - os.symlink("../../../x/two.md", root / "skills" / "a" / "evals" / "alias") - after = _subjects(root)["skill/a"] - assert after.skill_sha == before.skill_sha and after.suite_hash != before.suite_hash - impact = load_repo_config(root).subjects_for_changed(["skills/a/evals/alias"]) - assert impact.skills == {} - assert impact.suites == {"skill/a": ["skills/a/evals/alias"]} - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_deleting_one_file_a_wildcard_suite_matches_among_others_still_claims_it(tmp_path): - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/current/*.json\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "one/task.json": "[]\n", - "one/keep.json": "[]\n", - }) - os.makedirs(root / "evaluation") - os.symlink("../one", root / "evaluation" / "current") - before = _subjects(root)["skill/a"].suite_hash - (root / "one" / "task.json").unlink() - assert _subjects(root)["skill/a"].suite_hash != before - impact = load_repo_config(root).subjects_for_changed(["one/task.json"]) - assert impact.suites == {"skill/a": ["one/task.json"]} - assert impact.unclaimed == [] - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_a_link_inside_a_suite_under_a_linked_skill_is_not_a_skill_link(tmp_path): - # skills/a -> vendor/a: the suite is spelled under skills/a but its files live under vendor/a. The eval link - # is in the suite, so skill_sha leaves it out, whichever spelling the walk reached it by - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: skills/a/evals/**\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "vendor/a/SKILL.md": "# a\n", - "x/one.md": "same\n", - "x/two.md": "same\n", - }) - os.makedirs(root / "skills") - os.symlink("../vendor/a", root / "skills" / "a") - os.makedirs(root / "vendor" / "a" / "evals") - os.symlink("../../../x/one.md", root / "vendor" / "a" / "evals" / "q.json") - before = _subjects(root)["skill/a"] - (root / "vendor" / "a" / "evals" / "q.json").unlink() - os.symlink("../../../x/two.md", root / "vendor" / "a" / "evals" / "q.json") - after = _subjects(root)["skill/a"] - assert after.skill_sha == before.skill_sha and after.suite_hash != before.suite_hash - impact = load_repo_config(root).subjects_for_changed(["vendor/a/evals/q.json"]) - assert impact.skills == {} - assert impact.suites == {"skill/a": ["vendor/a/evals/q.json"]} - - @pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_mixed_case_directory_aliases_are_walked_in_code_point_order_on_every_os(tmp_path): # Path ordering folds case on Windows, where "a" sorts before "B"; POSIX sorts "B" first. The walk takes the first @@ -248,26 +81,6 @@ def test_mixed_case_directory_aliases_are_walked_in_code_point_order_on_every_os assert "evaluation/a/q.json" not in names -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_a_link_met_by_a_wildcard_suite_still_claims_it_after_its_target_loses_the_match(tmp_path): - # evaluation/**/task.json walks through evaluation/link; retargeting the link to a directory without task.json - # leaves the glob with no match, so only the walk that met the link can still claim it - body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/**/task.json\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "one/task.json": "[]\n", - "two/readme.md": "x\n", - }) - os.makedirs(root / "evaluation") - os.symlink("../one", root / "evaluation" / "link") - os.remove(root / "evaluation" / "link") - os.symlink("../two", root / "evaluation" / "link") - impact = load_repo_config(root).subjects_for_changed(["evaluation/link"]) - assert impact.unclaimed == [] - assert impact.suites == {"skill/a": ["evaluation/link"]} - - @pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_a_skill_linked_to_the_repository_root_is_a_config_error(tmp_path): # its hash would cover the whole repository, and no change to a file could be traced back to the skill @@ -295,26 +108,6 @@ def test_a_link_target_with_the_windows_extended_prefix_is_matched_as_the_plain_ assert repo_config._hops(root / "current" / "q.json", root, root) == [("current",), ("mid",)] -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -@pytest.mark.parametrize("glob", ["evaluation/*/task.json", "evaluation/**/task.json"]) -def test_deleting_the_file_a_link_met_by_a_wildcard_suite_led_to_still_claims_it(tmp_path, glob): - # the walk met evaluation/current and matched one/task.json through it; the file is gone now, so only the - # remaining pattern at the link's target (one/task.json) can still claim the deletion - body = HEADER + f"subjects:\n - id: skill/a\n path: skills/a\n suite: {glob}\n" - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": body, - "skills/a/SKILL.md": "# a\n", - "one/task.json": "[]\n", - "one/readme.md": "x\n", - }) - os.makedirs(root / "evaluation") - os.symlink("../one", root / "evaluation" / "current") - os.remove(root / "one" / "task.json") - impact = load_repo_config(root).subjects_for_changed(["one/task.json"]) - assert impact.unclaimed == [] - assert "skill/a" in impact.suites - - @pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") @pytest.mark.parametrize("suite", ["evaluation/*", "evaluation/loop"]) def test_a_self_referential_link_a_suite_names_is_a_config_error_not_dropped(tmp_path, suite): @@ -334,81 +127,6 @@ def test_a_self_referential_link_a_suite_names_is_a_config_error_not_dropped(tmp SKILL_A = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: {suite}\n" -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_a_link_on_the_way_to_a_skill_inside_its_suite_moves_the_skill_too(tmp_path): - # skills/a/refs/current -> common/one: skill_sha covers the link (the suite matches only the tasks under it) - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": SKILL_A.format(suite="skills/a/refs/current/tasks"), - "skills/a/SKILL.md": "a\n", - "common/one/tasks/q.json": "[]\n", - "common/two/tasks/q.json": "[]\n", - }) - os.makedirs(root / "skills" / "a" / "refs") - os.symlink("../../../common/one", root / "skills" / "a" / "refs" / "current") - before = _subjects(root)["skill/a"] - (root / "skills" / "a" / "refs" / "current").unlink() - os.symlink("../../../common/two", root / "skills" / "a" / "refs" / "current") - after = _subjects(root)["skill/a"] - assert after.skill_sha != before.skill_sha and after.suite_hash != before.suite_hash - impact = load_repo_config(root).subjects_for_changed(["skills/a/refs/current"]) - assert impact.skills == {"skill/a": ["skills/a/refs/current"]} - assert impact.suites == {"skill/a": ["skills/a/refs/current"]} - assert impact.unclaimed == [] - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_retargeting_a_linked_skill_outside_the_repository_claims_the_skill(tmp_path): - ext = tmp_path / "ext" - for name in ("one", "two"): - (ext / name / "a").mkdir(parents=True) - (ext / name / "a" / "SKILL.md").write_text(f"{name}\n", encoding="utf-8") - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": HEADER + "subjects:\n - id: skill/a\n path: linked/a\n suite: evaluation/*.json\n", - "evaluation/x.json": "[]\n", - }) - os.symlink(ext / "one", root / "linked") - before = _subjects(root)["skill/a"] - (root / "linked").unlink() - os.symlink(ext / "two", root / "linked") - after = _subjects(root)["skill/a"] - assert after.skill_sha != before.skill_sha - impact = load_repo_config(root).subjects_for_changed(["linked"]) - assert impact.skills == {"skill/a": ["linked"]} and impact.unclaimed == [] - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -@pytest.mark.parametrize("suite", ["evaluation/current/task.json", "evaluation/current/*.json", "evaluation/*/task.json"]) -def test_a_deleted_link_on_the_way_to_a_suite_still_claims_the_suite(tmp_path, suite): - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": SKILL_A.format(suite=suite), - "skills/a/SKILL.md": "a\n", - "one/task.json": "[]\n", - }) - os.makedirs(root / "evaluation") - os.symlink("../one", root / "evaluation" / "current") - assert _subjects(root)["skill/a"].suite_hash - (root / "evaluation" / "current").unlink() - impact = load_repo_config(root).subjects_for_changed(["evaluation/current"]) - assert impact.suites == {"skill/a": ["evaluation/current"]} - assert impact.unclaimed == [] - - -@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") -def test_retargeting_a_link_between_empty_directories_moves_the_suite_hash(tmp_path): - root = _tree(tmp_path / "repo", { - ".github/dokimi/config.yaml": SKILL_A.format(suite="evaluation/current/tasks"), - "skills/a/SKILL.md": "a\n", - }) - os.makedirs(root / "one" / "tasks") # empty: the match is a directory with no file under it - os.makedirs(root / "two" / "tasks") - os.makedirs(root / "evaluation") - os.symlink("../one", root / "evaluation" / "current") - before = _subjects(root)["skill/a"].suite_hash - (root / "evaluation" / "current").unlink() - os.symlink("../two", root / "evaluation" / "current") - assert _subjects(root)["skill/a"].suite_hash != before - - @pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") def test_a_suite_reached_through_a_link_into_its_own_skill_is_refused(tmp_path): # skills/a/evals -> actual, and the suite is skills/a/evals/tasks: the tasks are the skill's own files diff --git a/dokimi/tests/test_repo_config_links_v1.py b/dokimi/tests/test_repo_config_links_v1.py new file mode 100644 index 000000000..f291fddfd --- /dev/null +++ b/dokimi/tests/test_repo_config_links_v1.py @@ -0,0 +1,183 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""dokimi_config.v1 link rule: a skill may be a link to a place inside the repository; any other link on the way to +or inside a skill or suite is a ConfigError, and the deletion of a link is still mapped to the skill and the suite.""" + +from __future__ import annotations + +import os +import shutil +import subprocess + +import pytest + +from dokimi.repo_config import ConfigError, load_repo_config + +HEADER = "schema_version: dokimi_config.v1\n" +SKILL_A = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: {suite}\n" +symlinks = pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") + + +def _tree(root, files): + """A repository at ``root`` (it always has a ``.git``) holding ``files``; returns the resolved root.""" + (root / ".git").mkdir(parents=True, exist_ok=True) + for rel, text in files.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return root.resolve() + + +def _subjects(root): + return {s.id: s for s in load_repo_config(root).subjects()} + + +def _git(cwd, *args): + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) + + +@symlinks +@pytest.mark.parametrize("case", ["suite_link", "suite_file_link", "skill_inside_link", "skill_way_link", + "suite_way_link", "skill_outside_link", "repo_suite_link"]) +def test_a_link_other_than_a_whole_skill_directory_is_a_config_error(tmp_path, case): + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": SKILL_A.format(suite="evaluation/current/*.json"), + "skills/a/SKILL.md": "# a\n", + "one/task.json": "[]\n", + "real/a/SKILL.md": "# a\n", + "shared/evaluation/q.json": "[]\n", + "shared/x": "x\n", + }) + if case == "suite_link": # the suite's directory is a link to another directory + os.makedirs(root / "evaluation") + os.symlink("../one", root / "evaluation" / "current") + elif case == "suite_file_link": # a suite match that is itself a link to a file + os.makedirs(root / "evaluation" / "current") + os.symlink("../../shared/x", root / "evaluation" / "current" / "task.json") + elif case == "skill_inside_link": # a link inside the skill + os.makedirs(root / "skills" / "a" / "refs") + os.symlink("../../../shared", root / "skills" / "a" / "refs" / "current") + elif case == "skill_way_link": # the skill is reached through a link that is not the skill itself + os.rename(root / "skills", root / "real-skills") + os.symlink("real-skills", root / "skills") + elif case == "suite_way_link": # the suite's file is reached through a link above it + (root / ".github/dokimi/config.yaml").write_text( + SKILL_A.format(suite="evaluation/q.json"), encoding="utf-8") + os.symlink("shared/evaluation", root / "evaluation") + elif case == "skill_outside_link": # the skill directory links outside the repository + outside = root.parent / "outside-a" + outside.mkdir() + (outside / "SKILL.md").write_text("# a\n", encoding="utf-8") + (root / "skills" / "a").rename(root.parent / "moved-a") + os.symlink(outside, root / "skills" / "a") + elif case == "repo_suite_link": # a repository subject whose suite directory is a link + (root / ".github/dokimi/config.yaml").write_text( + HEADER + "subjects:\n - id: repo\n kind: repo\n manifest: README.md\n suite: docs/**\n", + encoding="utf-8") + (root / "README.md").write_text("# r\n", encoding="utf-8") + os.makedirs(root / "real-docs") + (root / "real-docs" / "d.md").write_text("d\n", encoding="utf-8") + os.symlink("real-docs", root / "docs") + with pytest.raises(ConfigError, match="link"): + load_repo_config(root).subjects() + + +@symlinks +@pytest.mark.parametrize("suite", ["evaluation/current/task.json", "evaluation/*/task.json", "evaluation/**/task.json"]) +@pytest.mark.parametrize("target_present", [True, False]) +def test_a_link_on_the_way_to_a_suite_is_refused_whether_or_not_its_file_exists(tmp_path, suite, target_present): + """The config must not depend on the disk: a link the suite's walk reads through is refused with no match under it, + whether the link is named literally or met by a wildcard.""" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": SKILL_A.format(suite=suite), + "skills/a/SKILL.md": "# a\n", + "one/keep": "x\n", + }) + os.makedirs(root / "evaluation") + os.symlink("../one", root / "evaluation" / "current") + if target_present: + (root / "one" / "task.json").write_text("[]\n", encoding="utf-8") + with pytest.raises(ConfigError, match="reached through the link evaluation/current"): + load_repo_config(root).subjects() + + +@symlinks +def test_a_skill_directory_that_links_inside_the_repository_is_accepted(tmp_path): + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": SKILL_A.format(suite="skills/a/evals/**"), + "real/a/SKILL.md": "# a\n", + "real/a/evals/q.json": "[]\n", + }) + os.makedirs(root / "skills") + os.symlink("../real/a", root / "skills" / "a") + subject = _subjects(root)["skill/a"] + assert subject.suite_hash and subject.skill_sha + impact = load_repo_config(root).subjects_for_changed(["real/a/SKILL.md"]) + assert impact.skills == {"skill/a": ["real/a/SKILL.md"]} + assert impact.unclaimed == [] + + +@symlinks +@pytest.mark.parametrize("suite", ["evaluation/current/task.json", "evaluation/current/*.json", "evaluation/*/task.json"]) +def test_a_link_on_the_way_to_a_suite_that_is_gone_still_claims_the_suite(tmp_path, suite): + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": SKILL_A.format(suite=suite), + "skills/a/SKILL.md": "a\n", + }) + os.makedirs(root / "evaluation") # the link that was here is deleted: nothing is on disk at the path now + impact = load_repo_config(root).subjects_for_changed(["evaluation/current"]) + assert impact.suites == {"skill/a": ["evaluation/current"]} + assert impact.unclaimed == [] + + +@symlinks +def test_a_link_inside_a_skill_that_is_deleted_claims_the_skill_and_the_suite(tmp_path): + """The link ``skills/a/refs/current`` lived in the skill and was the way to the suite; with it gone, the change + still moves both: skill_sha hashed the link, and the suite's glob runs through its path.""" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": SKILL_A.format(suite="skills/a/refs/current/tasks/**"), + "skills/a/SKILL.md": "# a\n", + "skills/a/refs/keep": "x\n", + }) + impact = load_repo_config(root).subjects_for_changed(["skills/a/refs/current"]) + assert impact.skills == {"skill/a": ["skills/a/refs/current"]} + assert impact.suites == {"skill/a": ["skills/a/refs/current"]} + assert impact.unclaimed == [] + + +def test_a_real_suite_whose_only_file_is_deleted_still_claims_the_suite(tmp_path): + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": SKILL_A.format(suite="evaluation/current/*.json"), + "skills/a/SKILL.md": "# a\n", + "evaluation/current/task.json": "[]\n", + }) + before = _subjects(root)["skill/a"].suite_hash + (root / "evaluation" / "current" / "task.json").unlink() + assert _subjects(root)["skill/a"].suite_hash != before + impact = load_repo_config(root).subjects_for_changed(["evaluation/current/task.json"]) + assert impact.suites == {"skill/a": ["evaluation/current/task.json"]} + assert impact.unclaimed == [] + + +@pytest.mark.skipif(os.name == "nt", reason="the executable bit is not kept on Windows") +def test_a_file_of_a_nested_checkout_takes_its_mode_from_that_repository(tmp_path): + """A file of a nested checkout is not in the outer index, so its mode is the nested repository's index bit, not the + disk bit. The index says executable and the disk does not: chmod on disk must not move the suite hash. The nested + .git/index bytes are left alone, so only the mode can move the hash.""" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": SKILL_A.format(suite="evaluation/**"), + "skills/a/SKILL.md": "# a\n", + }) + shutil.rmtree(root / ".git") + _git(root, "init", "-q") # a real outer repository: its index holds the skill files, not evaluation/ + _git(root, "add", "--", ".github", "skills") + nested = root / "evaluation" / "nested" + nested.mkdir(parents=True) + (nested / "q.sh").write_text("echo\n", encoding="utf-8") + _git(nested, "init", "-q") + _git(nested, "add", "q.sh") + _git(nested, "update-index", "--chmod=+x", "q.sh") + before = _subjects(root)["skill/a"].suite_hash + os.chmod(nested / "q.sh", 0o755) + assert _subjects(root)["skill/a"].suite_hash == before From a04689b5f1863939da850d71f46bb93a2adcc738 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Mon, 2 Nov 2026 11:00:03 -0800 Subject: [PATCH 35/36] test(config): owner-only exec bit in the nested-repo mode test (bandit B103) Signed-off-by: Nikolay Petrov --- dokimi/tests/test_repo_config_links_v1.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dokimi/tests/test_repo_config_links_v1.py b/dokimi/tests/test_repo_config_links_v1.py index f291fddfd..8dfbf756c 100644 --- a/dokimi/tests/test_repo_config_links_v1.py +++ b/dokimi/tests/test_repo_config_links_v1.py @@ -179,5 +179,5 @@ def test_a_file_of_a_nested_checkout_takes_its_mode_from_that_repository(tmp_pat _git(nested, "add", "q.sh") _git(nested, "update-index", "--chmod=+x", "q.sh") before = _subjects(root)["skill/a"].suite_hash - os.chmod(nested / "q.sh", 0o755) + os.chmod(nested / "q.sh", 0o700) assert _subjects(root)["skill/a"].suite_hash == before From 1d06ed24ad3d190f87248cff17db30b49956d737 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Mon, 2 Nov 2026 16:25:49 -0800 Subject: [PATCH 36/36] fix(config): follow a link's way back into the repository, read suite metadata lazily - _hops walks at the real place in the filesystem, so a ".." that leaves the repository and comes back in still reaches (and records) the links on the way; an absolute target is walked from the filesystem root as the kernel does. - link_text folds a ".." that leaves the tree and re-enters it (tree/../tree/x), so the checkout's directory name no longer reaches the digest; a ".." after a link is kept. - A glob reads an entry's directory metadata only when it descends into it: an unrelated link to a locked directory no longer fails a literal suite. - A ** walk does not record a link to a regular file as a link of the suite. - The changelog no longer advertises suite links: v1 refuses them. Signed-off-by: Nikolay Petrov --- ...261030_180000_napetrov_feat-repo-config.md | 3 +- dokimi/dokimi/repo_glob.py | 20 ++++++++--- dokimi/dokimi/repo_links.py | 33 ++++++++----------- dokimi/dokimi/utils.py | 23 +++++++++++++ dokimi/tests/test_repo_config_globs.py | 33 +++++++++++++++++++ dokimi/tests/test_repo_config_links_v1.py | 17 ++++++++++ dokimi/tests/test_tree_digest.py | 19 +++++++++++ 7 files changed, 124 insertions(+), 24 deletions(-) diff --git a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md index 34457fafd..bc3b417ae 100644 --- a/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md +++ b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md @@ -18,5 +18,6 @@ SPDX-License-Identifier: Apache-2.0 (`SKILL.md`, a script, a reference, one eval file) is refused at load, naming the subject and the file. A link in a suite that resolves into its skill is refused too. An explicit subject whose id is taken by a default skill at another directory is refused instead of silently replacing it. `baseline.store: false` reads as `off`, and a budget number that YAML reads as text (`1e3`) is refused with the plain decimal or the `1.0e+3` spelling, and a `.nan` or `.inf` budget is refused. A suite directory is - hashed file by file, following symlinks; a link also counts by its target text, so an edit to a shared eval, or a retarget of its link, moves `suite_hash`. Nothing reads the file yet: the commands that use it come later, + hashed file by file. In v1 a link is refused in a suite and on the way to one (the only link a subject may hold is + its skill directory, when that links inside the repository), so its retargeting is never attributed to the suite. Nothing reads the file yet: the commands that use it come later, and existing `.agent-eval/` lookups are unchanged. diff --git a/dokimi/dokimi/repo_glob.py b/dokimi/dokimi/repo_glob.py index e4e5cecfa..31d86e7c6 100644 --- a/dokimi/dokimi/repo_glob.py +++ b/dokimi/dokimi/repo_glob.py @@ -15,14 +15,18 @@ _MISSING = (errno.ENOENT, errno.ENOTDIR, errno.ELOOP) # a path that is not there, or a link that loops: no entries -def _listing(base: Path, *, strict: bool = False) -> list[tuple[str, bool, bool]]: +def _listing(base: Path, *, strict: bool = False, dirs: bool = True) -> list[tuple[str, bool, bool]]: """(name, is_dir, is_symlink) of each entry of ``base`` that can be read. An unreadable one is left out, unless ``strict``: then its error is raised (a suite must not hash as if its files were absent). A missing ``base`` has - no entries either way.""" + no entries either way. ``is_dir`` is read only when ``dirs``: a name the caller does not descend into costs no + metadata, so a link it never reads (one into a locked directory) cannot fail the walk.""" out: list[tuple[str, bool, bool]] = [] try: with os.scandir(base) as it: for entry in it: + if not dirs: + out.append((entry.name, False, entry.is_symlink())) + continue try: out.append((entry.name, entry.is_dir(), entry.is_symlink())) except OSError as exc: @@ -36,6 +40,14 @@ def _listing(base: Path, *, strict: bool = False) -> list[tuple[str, bool, bool] return out +def _regular_link(path: Path) -> bool: + """True for a symbolic link to a regular file: a walk never reads through one, so it is not a link of a suite.""" + try: + return path.is_file() + except OSError: + return False + + def _glob_walk(base: Path, segs: tuple[str, ...], i: int, found: set[Path], *, strict: bool, links: set[tuple[Path, tuple[str, ...]]]) -> None: """Match ``segs[i:]`` under ``base``, adding each match to ``found`` and each symbolic link a segment must read @@ -50,13 +62,13 @@ def _glob_walk(base: Path, segs: tuple[str, ...], i: int, found: set[Path], *, s if more or base.is_dir(): # terminal ** matches directories only _glob_walk(base, segs, i + 1, found, strict=strict, links=links) for name, is_dir, is_link in _listing(base, strict=strict): - if is_link and more: + if is_link and more and not _regular_link(base / name): links.add((base / name, segs[i + 1:])) if is_dir: # follows symlinks: a linked directory is matched, but not descended _glob_walk(base / name, segs, i + 1 if is_link else i, found, strict=strict, links=links) return wild = any(c in seg for c in "*?[") # matched by exact name against the listing: case-sensitive everywhere - for name, _, is_link in _listing(base, strict=strict): + for name, _, is_link in _listing(base, strict=strict, dirs=False): # only a matched name is looked up further if fnmatch.fnmatchcase(name, seg) if wild else name == seg: if is_link and more: links.add((base / name, segs[i + 1:])) diff --git a/dokimi/dokimi/repo_links.py b/dokimi/dokimi/repo_links.py index 110d24fed..186c98969 100644 --- a/dokimi/dokimi/repo_links.py +++ b/dokimi/dokimi/repo_links.py @@ -81,10 +81,12 @@ def _declared_links(path: Path, root: Path) -> list[tuple[str, ...]]: def _hops(path: Path, root: Path, root_real: Path) -> list[tuple[str, ...]]: """The repository-relative links met resolving ``path`` (a path under ``root``, as written): each link on its - declared way, and each link on the way to every target a link points to, until the path is resolved or leaves the - repository. A link's location is its real one, as git names it. A loop is cut after a fixed number of hops.""" + declared way, and each link on the way to every target a link points to, until the path is resolved. A link's + location is its real one, as git names it. A loop is cut after a fixed number of hops. The walk is kept at its real + place in the filesystem, so a ``..`` may leave the repository and come back into it: a link met on the way back is + still recorded, and a link outside the repository is followed but not returned.""" pending = list(path.relative_to(root).parts) - here: tuple[str, ...] = () # the real directory the next component is looked up in + here = root_real # the real directory the next component is looked up in found: dict[tuple[str, ...], None] = {} hops = 0 while pending: @@ -92,29 +94,22 @@ def _hops(path: Path, root: Path, root_real: Path) -> list[tuple[str, ...]]: if part in ("", "."): continue if part == "..": - if not here: - break - here = here[:-1] + here = here.parent # the directory holding ``here`` is real, so its parent is the kernel's parent continue - link = root_real.joinpath(*here, part) + link = here / part if not link.is_symlink(): - here = (*here, part) + here = link continue hops += 1 if hops > 256: break - found[(*here, part)] = None + if link.is_relative_to(root_real): + found[tuple(link.relative_to(root_real).parts)] = None target = _plain_path(os.readlink(link)) # Windows reads an absolute target back as \\?\C:\... if os.path.isabs(target): - for anchor in (root_real, root): # written as the resolved or the given spelling of the repository - try: - pending = [*Path(target).relative_to(anchor).parts, *pending] - break - except ValueError: - continue - else: - break # an absolute target outside the repository: no more of the way is ours - here = () + anchor, *names = Path(target).parts # walked from the filesystem root, as the kernel walks it + pending = [*names, *pending] + here = Path(anchor) else: - pending = [*Path(target).parts, *pending] + pending = [*Path(target).parts, *pending] # a relative target is read from the link's directory: here return list(found) diff --git a/dokimi/dokimi/utils.py b/dokimi/dokimi/utils.py index 8bd9ca48b..7d011d56f 100644 --- a/dokimi/dokimi/utils.py +++ b/dokimi/dokimi/utils.py @@ -106,6 +106,22 @@ def _plain_path(text: str) -> str: return text +def _settled(target: str) -> str: + """An absolute ``target`` with each ``..`` that follows a real directory folded into it, as the kernel reads the + path. A ``..`` that follows a link is kept: the kernel resolves it from the link's target, which a fold would miss.""" + anchor, *names = Path(target).parts + kept: list[str] = [] + for name in names: + if name == "..": + top = kept[-1] if kept else None + here = os.path.join(anchor, *kept) + if top not in (None, "..") and os.path.isdir(here) and not os.path.islink(here): + kept.pop() + continue + kept.append(name) + return os.path.join(anchor, *kept) + + def link_text(link: Path, root: Path) -> str: """A symlink's target as the digests hold it: as written, except an absolute target inside ``root``, which is written relative to the link. So a tree digests the same at another checkout path (the link's text is the same). @@ -138,6 +154,13 @@ def link_text(link: Path, root: Path) -> str: lexical = os.path.normcase(os.path.normpath(target)) if lexical != key_anchor and not lexical.startswith(key_anchor.rstrip(os.sep) + os.sep): return target + # a ".." that leaves the tree and comes back in (tree/../tree/x) is folded, so the name of the checkout's + # directory does not reach the digest; the ".." after a link stays, as the kernel reads it + settled = _settled(target) + key_settled = os.path.normcase(settled) + if key_settled != key_anchor and not key_settled.startswith(key_anchor.rstrip(os.sep) + os.sep): + return target + rest = [part for part in settled[len(anchor):].split(os.sep) if part not in ("", ".")] return "/".join([".."] * depth + rest) + slash or "." here = os.path.join(anchor, *link.relative_to(root).parent.parts) return os.path.relpath(os.path.join(anchor, *rest), here).replace(os.sep, "/") + slash diff --git a/dokimi/tests/test_repo_config_globs.py b/dokimi/tests/test_repo_config_globs.py index cca8bd33b..033937766 100644 --- a/dokimi/tests/test_repo_config_globs.py +++ b/dokimi/tests/test_repo_config_globs.py @@ -88,3 +88,36 @@ def test_an_unreadable_directory_inside_a_skill_is_a_config_error_not_left_out_o load_repo_config(root).subjects() finally: scripts.chmod(0o755) + + +@pytest.mark.skipif(os.name == "nt" or os.geteuid() == 0, reason="permissions are not enforced on Windows or for root") +def test_an_unreadable_link_beside_a_literal_suite_file_is_not_read(tmp_path): + # a literal suite path reads only its own names: an unrelated link to a locked directory is never stat'ed + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/task.json\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "a\n", + "evaluation/task.json": "[]\n", + "locked/dir/q.json": "{}\n", + }) + os.symlink("../locked/dir", root / "evaluation" / "unrelated") + locked = root / "locked" + locked.chmod(0) + try: + assert "skill/a" in {s.id for s in load_repo_config(root).subjects()} + finally: + locked.chmod(0o755) + + +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs privileges on Windows") +def test_a_link_to_a_regular_file_beside_a_double_star_suite_is_not_a_suite_link(tmp_path): + # ** reads through directory links only: a link to a regular file is never read, so it is no link of the suite + body = HEADER + "subjects:\n - id: skill/a\n path: skills/a\n suite: evaluation/**/task.json\n" + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": body, + "skills/a/SKILL.md": "a\n", + "evaluation/task.json": "[]\n", + "README.md": "readme\n", + }) + os.symlink("../README.md", root / "evaluation" / "readme.md") + assert "skill/a" in {s.id for s in load_repo_config(root).subjects()} diff --git a/dokimi/tests/test_repo_config_links_v1.py b/dokimi/tests/test_repo_config_links_v1.py index 8dfbf756c..414c08334 100644 --- a/dokimi/tests/test_repo_config_links_v1.py +++ b/dokimi/tests/test_repo_config_links_v1.py @@ -160,6 +160,7 @@ def test_a_real_suite_whose_only_file_is_deleted_still_claims_the_suite(tmp_path assert impact.unclaimed == [] +@pytest.mark.skipif(shutil.which("git") is None, reason="needs git") @pytest.mark.skipif(os.name == "nt", reason="the executable bit is not kept on Windows") def test_a_file_of_a_nested_checkout_takes_its_mode_from_that_repository(tmp_path): """A file of a nested checkout is not in the outer index, so its mode is the nested repository's index bit, not the @@ -181,3 +182,19 @@ def test_a_file_of_a_nested_checkout_takes_its_mode_from_that_repository(tmp_pat before = _subjects(root)["skill/a"].suite_hash os.chmod(nested / "q.sh", 0o700) assert _subjects(root)["skill/a"].suite_hash == before + + +@symlinks +@pytest.mark.parametrize("form", ["relative", "absolute"]) +def test_a_skill_link_whose_way_leaves_the_repository_and_comes_back_names_the_middle_link(tmp_path, form): + # skills/a -> ../../repo/mid: the way leaves the repository and re-enters it at mid, a link v1 refuses + root = _tree(tmp_path / "repo", { + ".github/dokimi/config.yaml": HEADER + "subjects:\n - id: skill/a\n path: skills/a\n", + "real/a/SKILL.md": "# a\n", + }) + os.symlink("real/a", root / "mid") + target = f"../../{root.name}/mid" if form == "relative" else f"{root}/../{root.name}/mid" + (root / "skills").mkdir() + os.symlink(target, root / "skills" / "a") + with pytest.raises(ConfigError, match="reached through the link mid"): + load_repo_config(root).subjects() diff --git a/dokimi/tests/test_tree_digest.py b/dokimi/tests/test_tree_digest.py index a8eadd120..f0d9f5593 100644 --- a/dokimi/tests/test_tree_digest.py +++ b/dokimi/tests/test_tree_digest.py @@ -308,3 +308,22 @@ def test_a_readable_link_to_an_unreadable_target_digests_by_its_text(tmp_path): finally: os.chmod(private, 0o700) # owner access back so tmp_path can be removed + + +@symlinks +def test_an_absolute_link_that_leaves_the_tree_and_comes_back_digests_the_same_at_another_path(tmp_path): + # /tmp/a/../a/q.json re-enters the tree at its own q.json: the checkout's name must not reach the digest + roots = {name: _write(tmp_path / name, {"q.json": "[]\n"}) for name in ("a", "b")} + for name, root in roots.items(): + os.symlink(f"{root}/../{name}/q.json", root / "current") + assert tree_digest(roots["a"]) == tree_digest(roots["b"]) + + +@symlinks +def test_a_dotdot_after_a_missing_directory_is_kept_so_the_link_does_not_digest_as_its_folded_target(tmp_path): + # root/gone/../x is ENOENT while gone is absent (a dangling link), not root/x: the two must not digest alike + dangling = _write(tmp_path / "a", {"x": "x\n"}) + os.symlink(f"{dangling}/gone/../x", dangling / "current") + direct = _write(tmp_path / "b", {"x": "x\n"}) + os.symlink(f"{direct}/x", direct / "current") + assert tree_digest(dangling) != tree_digest(direct)