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 00000000..34457faf --- /dev/null +++ b/dokimi/changelog.d/20261030_180000_napetrov_feat-repo-config.md @@ -0,0 +1,22 @@ + + +### 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 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. 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, + and existing `.agent-eval/` lookups are unchanged. diff --git a/dokimi/docs/reference/cli-interface.json b/dokimi/docs/reference/cli-interface.json index 92f8caa8..4928f8ba 100644 --- a/dokimi/docs/reference/cli-interface.json +++ b/dokimi/docs/reference/cli-interface.json @@ -13143,6 +13143,7 @@ "'answers'", "'arms'", "'cli_routes'", + "'dokimi_config'", "'effective_instructions'", "'eval'", "'eval_coverage'", diff --git a/dokimi/dokimi/artifacts.py b/dokimi/dokimi/artifacts.py index fe128e8f..a02dd9ab 100644 --- a/dokimi/dokimi/artifacts.py +++ b/dokimi/dokimi/artifacts.py @@ -14,8 +14,9 @@ ``result`` (one status per planned cell), ``repo_budget`` (what a repository puts into an agent's context), ``effective_instructions`` (the effective-instructions/v0 receipt of a reach probe), ``need_graph`` and ``eval_coverage`` (a frozen need graph and an -eval set's coverage of it) and ``task_inspect`` (which eval-task format each -path under a tree is). This module stamps each with a +eval set's coverage of it), ``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. @@ -63,6 +64,7 @@ "task_inspect": "task_inspect.v1", "redteam": "redteam.v1", "cli_routes": "cli_routes.v1", + "dokimi_config": "dokimi_config.v1", "need_graph": "need_graph.v1", "eval_coverage": "eval_coverage.v1", } diff --git a/dokimi/dokimi/commands/repo_run.py b/dokimi/dokimi/commands/repo_run.py index 8c81f085..cd910ecf 100644 --- a/dokimi/dokimi/commands/repo_run.py +++ b/dokimi/dokimi/commands/repo_run.py @@ -20,6 +20,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): @@ -59,14 +60,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) @@ -95,24 +96,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 00000000..2b5aee29 --- /dev/null +++ b/dokimi/dokimi/repo_config.py @@ -0,0 +1,792 @@ +# 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 glob +import hashlib +import os +import math +import re +from decimal import Decimal, InvalidOperation +from dataclasses import dataclass, field, replace +from pathlib import Path, PurePosixPath +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.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") +DEFAULT_SUITE = "{skill}/evals" +SKILL_PLACEHOLDER = "{skill}" +DRIVE = re.compile(r"[A-Za-z]:") + + +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 the manifest, escaped to a literal.""" + return self.suite_globs + ((glob.escape(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. + 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) + _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 + return [_hashed(s, self.root, resolved, modes) for s in 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.""" + 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, gone=not os.path.lexists(self.root / rel)) + return impact + except OSError as exc: + raise _unreadable(exc) from exc + + +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") + _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, 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: + number = Decimal(text) + except InvalidOperation: + continue # text that is no number at all: the schema refuses it as a type error + 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: + """: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. 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 + 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 _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 [] + 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. 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 + # 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)) + + +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:]) + + +@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 _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 + to any of them maps a change to the link itself.""" + root_real = _real(root) + 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 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) + 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(_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, 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) + + +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. + 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 claims each link its walk reads through, and its own pattern where its prefix resolves + 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]) + real = _inside(prefix, root_real) + if real is not None: + 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: + globs.append(glob.escape("/".join(real))) + 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, under their + declared and their resolved spellings.""" + for hop in _hops(entry, root, root_real): + links.append(hop) + 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 _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 _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 _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, + *, 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], 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], 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) + for sid in suites: + impact.suites.setdefault(sid, []).append(rel) + for skill in skills: + impact.skills.setdefault(skill.id, []).append(rel) + if not (is_shared or suites or skills): + 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 + 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].skills if _under(segs, root)), + default=0, + ) + 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, ...]: + 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_paths = {s.path for s in explicit} + 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) + + +def _check_nesting(subjects: list[Subject], root: Path) -> None: + """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 + 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 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" + ) + + +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 _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) + + +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(_glob((text.replace(SKILL_PLACEHOLDER, glob.escape(skill)) if skill else text), where)) + 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 _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, 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)} + + +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 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 + 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, tracked=_file_modes(target, root_real, modes))}\n")) + return "sha256:" + h.hexdigest() + + +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 (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 {} + 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: + """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 = _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, root) + _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) + + +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 + "/"): + 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) + # 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: + 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(): + 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; " + f"claim the directory instead, for example {subject.path}/evals/**" + ) + + +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, 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 = [ + 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, *nested], strict=True) + return replace(subject, skill_sha=skill_sha, suite_hash=_digest_files(files, root, modes)) diff --git a/dokimi/dokimi/repo_glob.py b/dokimi/dokimi/repo_glob.py new file mode 100644 index 00000000..e4e5cecf --- /dev/null +++ b/dokimi/dokimi/repo_glob.py @@ -0,0 +1,85 @@ +# 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 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: + raise + return out + + +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 + 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 == "**": + 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: + 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): + if fnmatch.fnmatchcase(name, seg) if wild else name == seg: + if is_link and more: + 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[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) + + +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/repo_links.py b/dokimi/dokimi/repo_links.py new file mode 100644 index 00000000..110d24fe --- /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/runs/kinds.py b/dokimi/dokimi/runs/kinds.py index 4ceb7202..c5dd4d95 100644 --- a/dokimi/dokimi/runs/kinds.py +++ b/dokimi/dokimi/runs/kinds.py @@ -69,6 +69,7 @@ class KindFields: counts=("receipts",)), "task_inspect": KindFields(subject=("root",), counts=("items",)), "cli_routes": KindFields(counts=("roots", "leaves", "tombstones")), + "dokimi_config": KindFields(subject=("subjects[].id",), counts=("subjects", "shared")), "redteam": KindFields(subject=("questions_path",), model=("judge_model",), counts=("violations", "questions")), "need_graph": KindFields(subject=("artifact_name",), counts=("nodes", "edges")), diff --git a/dokimi/dokimi/schemas/dokimi_config.v1.json b/dokimi/dokimi/schemas/dokimi_config.v1.json new file mode 100644 index 00000000..97dd0f4a --- /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 .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, + "$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": {"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/dokimi/utils.py b/dokimi/dokimi/utils.py index 56caceca..8bd9ca48 100644 --- a/dokimi/dokimi/utils.py +++ b/dokimi/dokimi/utils.py @@ -1,11 +1,13 @@ # Copyright (C) 2026 Intel Corporation # SPDX-License-Identifier: Apache-2.0 +import hashlib import json import os import re +import stat from pathlib import Path -from typing import Tuple, Dict, Any +from typing import Any, Dict, Iterable, Mapping, Tuple from dokimi.defaults import bedrock_profile from dokimi.exit_codes import EXIT_USAGE @@ -93,6 +95,153 @@ 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). + 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 = _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)] + 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([key, key_anchor]) == key_anchor and key.startswith(key_anchor) + except ValueError: # different drives, or a relative path mixed in + continue + if inside: + 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: + # 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 + return target # outside the tree: as written + + +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: + # 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: + 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 _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, + 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`` + (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. + + ``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 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 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() + 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(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 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()) + h.update(hashlib.sha256(path.read_bytes()).digest()) + elif path.is_dir(): + h.update(b"D " + name + b"\0") + 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 00000000..1d36898b --- /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 969eef92..0c716481 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.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.py b/dokimi/tests/test_repo_config.py new file mode 100644 index 00000000..6cf2bb99 --- /dev/null +++ b/dokimi/tests/test_repo_config.py @@ -0,0 +1,802 @@ +# 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 + +import os +import re +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 str(Path(".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"), + (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): + 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" + + +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_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, + "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"]} + + +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.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) + + +@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 + + +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}/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_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 "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" + 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 + + +@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) + + +@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) + + +@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 + + 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) + + +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", ".."), ("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"} + 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) + + +# --- 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_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: " + 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) + + +@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="POSIX file names and link targets are bytes") +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", + }) + 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) + 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_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"]} + diff --git a/dokimi/tests/test_repo_config_globs.py b/dokimi/tests/test_repo_config_globs.py new file mode 100644 index 00000000..cca8bd33 --- /dev/null +++ b/dokimi/tests/test_repo_config_globs.py @@ -0,0 +1,90 @@ +# 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"]} + + +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(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 + 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 new file mode 100644 index 00000000..e16e1d5f --- /dev/null +++ b/dokimi/tests/test_repo_config_links.py @@ -0,0 +1,188 @@ +# 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 +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" + + +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_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_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 + + +@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() + + +@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 + + 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",)] + + +@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() + + +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_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") +@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 + + 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) + 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 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 00000000..8dfbf756 --- /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", 0o700) + assert _subjects(root)["skill/a"].suite_hash == before diff --git a/dokimi/tests/test_runs_scan.py b/dokimi/tests/test_runs_scan.py index 12e3a2f8..8716df19 100644 --- a/dokimi/tests/test_runs_scan.py +++ b/dokimi/tests/test_runs_scan.py @@ -50,6 +50,7 @@ "effective_instructions.v1": ({}, "toy", "m", "2026-10-31T00:00:00+00:00"), "task_inspect.v1": ({}, "tests/fixtures/task_formats", None, None), "cli_routes.v1": ({}, None, None, None), + "dokimi_config.v1": ({}, "skill/onetbb, repo", None, None), "redteam.v1": ({}, "questions.json", None, None), "need_graph.v1": ({}, "dpnp-io", None, None), "eval_coverage.v1": ({}, "dpnp-io", None, None), diff --git a/dokimi/tests/test_runs_show.py b/dokimi/tests/test_runs_show.py index 243e6ebd..059a950a 100644 --- a/dokimi/tests/test_runs_show.py +++ b/dokimi/tests/test_runs_show.py @@ -43,6 +43,7 @@ "eval_coverage.v1": {"gaps": 1, "evals": 2, "problems": 0}, "need_graph.v1": {"nodes": 3, "edges": 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 00000000..a8eadd12 --- /dev/null +++ b/dokimi/tests/test_tree_digest.py @@ -0,0 +1,310 @@ +# 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 + +import hashlib +import os +import shutil +import subprocess +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(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + 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).""" + _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): + 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) + 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"}) + 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) + + +@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) + + +@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(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, 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 +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 + + +@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 + + +@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) + + +@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") +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"}) + 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) + + +@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"}) + (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(0o755) + assert tree_digest(root / "question[1].json") == before + _index_mode(root, "question[1].json", "-x") + 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 + 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(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, 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): + # 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() + + +@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) + + +@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, 0o700) # owner access back so tmp_path can be removed +