Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
0afe66d
feat(catalog): bundle-file-unreferenced, one warn per skill for bundl…
napetrov Nov 9, 2026
61e3a52
fix(catalog): bundle-file-unreferenced does not open a named pipe
napetrov Nov 10, 2026
fbbda30
fix(catalog): a file name inside a longer name does not name the shor…
napetrov Nov 10, 2026
b21da2d
fix(catalog): test files outside a tests folder are not counted
napetrov Nov 10, 2026
c0c1d26
fix(catalog): a skipped document that SKILL.md names is still read
napetrov Nov 10, 2026
c72c3d3
fix(catalog): decode the links of a read document as SKILL.md's are
napetrov Nov 10, 2026
3e67f8f
Merge main into the PR branch, resolving validate.py
napetrov Nov 10, 2026
f81f2ed
Address review: a name followed by a dotted suffix does not name the …
napetrov Nov 10, 2026
93200bc
Address review: a folder written from the skill root names its files
napetrov Nov 10, 2026
4f010b9
Address review: a folder linked from a nested document names only its…
napetrov Nov 10, 2026
cc078d5
Address review: a skill-dir link in a nested document resolves from t…
napetrov Nov 10, 2026
9c7434d
Address review: a bundle over the file cap is a checkout and is not c…
napetrov Nov 10, 2026
7a803a0
bundle-file-unreferenced: a folder at the end of a sentence names its…
napetrov Nov 10, 2026
4f58c73
Thread: PRR_kwDOVBg7g88AAAABXBtM2g: already fixed in 93200bc35 and cc…
napetrov Nov 10, 2026
ada9d8b
Merge branch 'main' of https://github.com/intel/dokimi into HEAD
napetrov Nov 11, 2026
d10c819
bundle-file-unreferenced: a truncated walk or a named document past t…
napetrov Nov 11, 2026
ea94ed7
bundle-file-unreferenced: a plain folder mention in a nested document…
napetrov Nov 11, 2026
c3be91e
bundle-file-unreferenced: the document search is its own function
napetrov Nov 11, 2026
bcf6e35
Move the path prefix comment above _PATH_PREFIX
antonwolfy Nov 11, 2026
cd26489
List every bundle-file-unreferenced exclusion in the docs
antonwolfy Nov 11, 2026
e6ab3e8
Merge branch 'main' of https://github.com/intel/dokimi into pr-addres…
napetrov Nov 11, 2026
a41af7c
Count named documents in test folders; no claim on unreadable ones
napetrov Nov 11, 2026
2d1777b
Time only the bundle search in the timing test, in CPU time
napetrov Nov 11, 2026
72b0970
Merge branch 'main' of https://github.com/intel/dokimi into pr-addres…
napetrov Nov 12, 2026
a9f441c
Address review: list the skip conditions of the bundle check
napetrov Nov 12, 2026
d51328d
Name the bundled files in the script tests' SKILL.md
napetrov Nov 12, 2026
d174325
Thread: PRR_kwDOVBg7g88AAAABXhnXZg: already fixed in a9f441ceb1
napetrov Nov 12, 2026
86a92f3
Merge branch 'main' of https://github.com/intel/dokimi into pr-addres…
napetrov Nov 13, 2026
446e43c
Address review: a document named by a hidden file name is read
napetrov Nov 13, 2026
5c5e3ae
Address review: pin the read-cap guard on named documents
napetrov Nov 13, 2026
0eb6759
Address review: a folder written with a backslash names its files
napetrov Nov 13, 2026
503c9e4
Address review: a folder link with a title or brackets names its folder
napetrov Nov 13, 2026
195fbf1
Address review: the bundle walk lists its files in path order
napetrov Nov 13, 2026
a62b2ef
Address review: drop the scratch corpus figures from the module docst…
napetrov Nov 13, 2026
5228ca2
Thread: PRRT_kwDOVBg7g862mN3b: already fixed in a9f441ceb1
napetrov Nov 13, 2026
bb11659
Address review: a backslash folder in a nested document names its own…
napetrov Nov 13, 2026
9e535a8
Address review: a reference definition with a title names its folder
napetrov Nov 13, 2026
8bc0f18
Merge branch 'main' of https://github.com/intel/dokimi into pr-addres…
napetrov Nov 13, 2026
e993313
Address self-review: a nested document in a folder with capitals name…
napetrov Nov 13, 2026
9bc964f
fix(catalog): bundle-file-unreferenced reads backslash and fragment f…
vchamarthi Nov 13, 2026
de4a9b4
Merge branch 'main' of https://github.com/intel/dokimi into feat/bund…
napetrov Nov 14, 2026
13f8a60
Address review: escapes, a skill-dir root link, and edge pins
napetrov Nov 14, 2026
e92451c
Address self-review: pin the separator reading with a name that can c…
napetrov Nov 14, 2026
3341461
Address test run: path-missing spans also get the unreferenced warning
napetrov Nov 14, 2026
0d83bf9
Address self-review: name the Windows-path test for the tilde it pins
napetrov Nov 14, 2026
a96b4d0
Pin the escaped asterisk in the bundle-file-unreferenced test
antonwolfy Nov 14, 2026
00ae79b
Pin the decoding of a skill-root link in the unreferenced test
antonwolfy Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- **`catalog check` counts bundled files the skill never names.** A script or document next to `SKILL.md` that neither `SKILL.md` nor a document it names mentions (by path, file name or folder, case ignored) is counted in one new `warn` per skill, `bundle-file-unreferenced`, with the count and the first three paths. Data, images, licence and readme files, `_`-prefixed helpers, tests and vendored folders are not counted. Leave the code out with `--ignore bundle-file-unreferenced`.
9 changes: 8 additions & 1 deletion dokimi/docs/external-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,14 @@ Spanish, French, German, Portuguese, Russian, Chinese, Japanese and Korean, and
of them covers (Arabic, Thai, Hebrew, …) is not checked: it gets a `note` (`description-when-not-checked`), so not checked does not read as a pass: its row says `note:` instead of `ok`, and the counts line `1 ok (1 not checked)`. A Latin-script language without a list of its own
(Polish, Dutch, Turkish, Vietnamese) is checked against these cues. Two skills in different paths with the same
directory name and description but different `SKILL.md` text are a `warn` on both
(`mirror-drift`), naming the other copies. Skills with
(`mirror-drift`), naming the other copies. A bundled script or document (`.py`, `.sh`, `.js`,
`.ts`, `.md`, `.txt` and similar) that neither `SKILL.md` nor a document it names ever names, by
path, file name or folder, is counted in one `warn` per skill (`bundle-file-unreferenced`): the agent
finds it only by browsing. Data, images, licence, readme, changelog and `AGENTS.md`-style files,
`_`-prefixed helpers, tests and `evals/` folders, hidden and vendored folders, and nested skills are
not counted, nor is a skill at the root of the scanned checkout. The check then gives no count and no finding, and
says nothing about it, when the search cannot see every name: a bundle over 2000 files, more than 50 named
documents to read, a named document over 256 KiB, not UTF-8, a symlink or a FIFO, a folder that cannot be listed. Skills with
`disable-model-invocation: true` are exempt from the description checks.

The scan also reads the scripts a skill bundles: each `.sh`, `.bash` and `.py` file, and each file that
Expand Down
284 changes: 284 additions & 0 deletions dokimi/dokimi/external/bundle_refs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0

"""``bundle-file-unreferenced``: bundled scripts and documents that ``SKILL.md`` never names.

An agent reads ``SKILL.md`` and opens the files it names; a bundled file it is not pointed at is
found only by browsing the directory. One ``warn`` per skill in :mod:`dokimi.external.validate`,
with the count and the first :data:`EXAMPLES_SHOWN` paths.

Only code and documents count (:data:`COUNTED_SUFFIXES`): data, images and fonts are read by the
scripts, not by the agent. A file is named when ``SKILL.md`` holds its path relative to the skill,
its file name, or the path of a folder above it named alone (``references/`` covers every file under
it; ``references/a.md`` covers only ``a.md``), case ignored; a named document is read too, so the
files it names count as named, unless it is a changelog, contributing or notice file, which names
nothing here. Licence, readme and changelog files, ``AGENTS.md``, ``__init__.py`` and other
``_``-prefixed helpers, tests, hidden and vendored folders, and a nested skill's folder are left out
of the count; a document in a test or hidden folder that ``SKILL.md`` names is still read. A skill at
the root of a scanned checkout is not checked, since its bundle is the rest of that checkout.
"""

from __future__ import annotations

import os
import posixpath
import re
from pathlib import Path, PurePosixPath
from typing import Callable, Iterable, Optional

from dokimi.skills.loader import VENDORED_DIR_NAMES

from .command_checks import _is_skill_dir

COUNTED_SUFFIXES = frozenset(
{".py", ".sh", ".bash", ".zsh", ".ps1", ".js", ".mjs", ".cjs", ".ts", ".rb", ".md", ".mdx", ".txt"}
)
# Files no agent is meant to open for the task: licence and project paperwork, package markers.
SKIPPED_STEMS = frozenset({"license", "licence", "readme", "changelog", "notice", "copying", "authors", "contributing"})
SKIPPED_NAMES = frozenset({"__init__.py", "conftest.py", "requirements.txt", "agents.md", "claude.md"})
SKIPPED_DIRS = frozenset({"tests", "test", "__tests__", "evals"})
TEST_STEM = re.compile(r"test_.*|.*_test|.*\.(?:test|spec)") # test_build.py, build_test.py, build.test.js
WALK_MAX_FILES = 2000 # a skill with more files is a checkout, not a bundle: it is not checked
EXAMPLES_SHOWN = 3
DOC_SUFFIXES = frozenset({".md", ".mdx", ".txt"})
DOC_MAX_BYTES = 256 * 1024
DOCS_READ_MAX = 50
# Documents whose text names no file the agent needs for the task: a changelog or a notice that mentions a script does not.
NON_REFERENCE_STEMS = frozenset({"changelog", "contributing", "notice"})
# The destination of an inline folder link (``[x](scripts/)``): its folder is in ``links``, so the text is no mention.
_FOLDER_DEST = re.compile(
r"\]\([^)\n]*/(?:[#?][^)\n]*)?\)" # ``[x](scripts/)``, ``[x](scripts/#part)``
r"|\]\(\s*<?[^)\s<>]*/(?:[#?][^)\s<>]*)?>?(?:\s+(?:\"[^\"\n]*\"|'[^'\n]*'|\([^)\n]*\)))?\s*\)" # with a title, or in <...>
r"|^\s{0,3}\[[^\]\n]+\]:\s*<?[^\s<>]*/(?:[#?][^\s<>]*)?>?(?:[ \t]+(?:\"[^\"\n]*\"|'[^'\n]*'|\([^)\n]*\)))?[ \t]*$", # a reference definition ``[x]: scripts/``
re.MULTILINE,
)
SKILL_ROOT_LINK = "${claude_skill_dir}/" # lowercased: a link through it names a file from the skill root
# A path prefix that names the skill root or the current folder: ``./scripts/``, ``../scripts/``, ``${CLAUDE_SKILL_DIR}/scripts/``.
_PATH_PREFIX = re.compile(r"(?<![\w.-])(?:\$\{claude_skill_dir\}/|\.\.?/)")
# A backslash before ``_``, ``*`` or ``~`` may escape it (``my\_script.py``); see :func:`_matchable` for both readings.
_ESCAPED = re.compile(r"\\([_*~])")


def _walk(skill_dir: Path, *, counted: bool, failed: list[OSError]) -> list[str]:
"""The files under ``skill_dir`` an agent may open, relative and sorted, at most :data:`WALK_MAX_FILES` + 1.

One more than the cap tells the caller the bundle is over it. Past the cap the walk goes on
without collecting, since a stopped ``os.walk`` leaves its directory handles open.

``counted``: the files the warning counts. Otherwise every document, the skipped ones too (a
readme that names a script leads the agent to it). A folder that cannot be listed is added to ``failed``.
"""
found: list[str] = []
for dirpath, dirnames, filenames in os.walk(skill_dir, onerror=failed.append):
here = Path(dirpath)
dirnames[:] = sorted(
d for d in dirnames
if d not in VENDORED_DIR_NAMES and not _is_skill_dir(here / d)
and (not counted or (not d.startswith(".") and d.lower() not in SKIPPED_DIRS))
)
for name in sorted(filenames):
if len(found) > WALK_MAX_FILES:
continue
stem, suffix = os.path.splitext(name)
if (
(counted and name.startswith(".")) # a hidden file is no counted file; a document may be one
or suffix.lower() not in COUNTED_SUFFIXES
or (here == skill_dir and name == "SKILL.md")
or (counted and (
name.startswith("_") # a private helper (``_common.py``) scripts import
or stem.lower() in SKIPPED_STEMS
or name.lower() in SKIPPED_NAMES
or TEST_STEM.fullmatch(stem.lower())
))
):
continue
found.append((here / name).relative_to(skill_dir).as_posix())
return sorted(found) # path order: os.walk yields a folder's files before its subfolders'


_WORD = re.compile(r"\w")


def _mentions(needle: str, text: str, before: str) -> bool:
"""Whether ``needle`` occurs in ``text`` as a whole path or file name: not preceded by a word character or a
character of ``before``, and not followed by a word character, ``-``, or a dot and a word character (so
``build.py`` is not a part of ``rebuild.py``, and does not name ``build.py.bak``).

A search with ``str.find`` per occurrence, not a regex over the whole text for each candidate: a bundle's
documents can be megabytes long.
"""
i = text.find(needle)
while i != -1:
end = i + len(needle)
after = text[end:end + 1]
if (i == 0 or not (_WORD.match(text[i - 1]) or text[i - 1] in before)) and not (
_WORD.match(after) or after == "-" or (after == "." and _WORD.match(text[end + 1:end + 2]))
):
return True
i = text.find(needle, i + 1)
return False


def _target(folder: str, link: str) -> str:
"""The lowercased path, from the skill root, that a link in a document in ``folder`` names.

A link through ``${CLAUDE_SKILL_DIR}/`` is from the skill root whatever the document's folder.
"""
if link.lower().startswith(SKILL_ROOT_LINK):
link, folder = link[len(SKILL_ROOT_LINK):], "."
return posixpath.normpath(posixpath.join(folder, link)).lower()


def _named(rel: str, text: str, links: set[str]) -> bool:
"""Whether ``text`` names the file ``rel``: its path, its file name, or a folder above it named alone.
A link to the skill root (``./``) names every file.

Case is ignored (``FORMS.md`` names ``forms.md``). ``links`` are the link destinations of
``SKILL.md`` and of the documents read so far, as files from the skill root (escapes and
percent-encoding decoded), lowercased.
"""
parts = rel.lower().split("/")
if "." in links or "/".join(parts) in links or any("/".join(parts[:i]) in links for i in range(1, len(parts))):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A [back](../) link in a nested document names every bundled file. _target("references", "../") normalizes to ".", and this line reads "." in links as "the skill root is linked", which was meant for SKILL.md linking its own root.

Repro (SKILL.md: see references/g.md; references/g.md: [back](../); bundled scripts/run.py, named nowhere): unreferenced_message returns None. With [up](../) removed from the guide it returns 1 of 2 ... never named. A back-link at the end of a reference page is common, so it hides the warn for the whole skill.

Suggestion: treat "." as the root only for links from SKILL.md (pass linked for the root separately from the links of read documents), or only when the document's folder is ".". Add a test with a back-link in a nested document.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Waiting on a decision before I change this: should a [back](../) link in a nested document name every bundled file? It does today, since ../ from references/ is the skill root. Options: (a) only a root link in SKILL.md names every file; (b) keep it, and the pinned test test_a_nested_link_to_the_skill_root_names_the_files_under_it stays. My recommendation: (a).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for asking. Option (a): only a root link in SKILL.md names every bundled file; a root link in a nested document does not. Please change the pinned test test_a_nested_link_to_the_skill_root_names_the_files_under_it to match, and say in the docs and the changelog fragment that a root link in SKILL.md silences the check.

return True # a link to the file, or a folder link to an ancestor
if _mentions("/".join(parts), text, ".-") or _mentions(parts[-1], text, ".-"):
return True
# A folder named alone (``references/``), not as the start of another file's path (``scripts/a.py``).
return any(_mentions("/".join(parts[:i]) + "/", text, "./-") for i in range(1, len(parts)))
Comment thread
napetrov marked this conversation as resolved.
Resolved


_RELATIVE = re.compile(r"(?<![\w./-])((?:\.\.?/)+)")


def _from_folder(text: str, folder: str) -> str:
"""``text`` with its ``./`` and ``../`` paths resolved from the skill root, for a document in ``folder``.

In ``references/guide.md`` the words ``./scripts/`` name ``references/scripts/``, not the root's.
"""
if folder == ".":
return text

def resolve(match: re.Match[str]) -> str:
base = folder
for part in match.group(1).split("/")[:-1]:
if part == "..":
base = posixpath.dirname(base) or "."
return "" if base == "." else base + "/"

return _RELATIVE.sub(resolve, text)


def _matchable(text: str, folder: str = ".") -> str:
"""The text the names are looked up in, for a document in ``folder``: folder link destinations and the
``./``-style prefixes dropped.

A folder destination is in ``links`` as its folder, so ``[scripts](scripts/)`` in a nested document
does not name the skill-root ``scripts/``; ``./scripts/`` and ``${CLAUDE_SKILL_DIR}/scripts/`` in
the text name ``scripts/``. A file's name in a destination still names it, as in the text. A backslash
is read as a path separator, as a document written on Windows has it (``scripts\\``); before ``_``, ``*`` or
``~`` it is also read as an escape (``my\\_script.py``), and a name in either reading counts. After the folder
links are dropped: ``[x](scripts\\)`` is no link (``\\)`` escapes the parenthesis), so it is a mention.
"""
text = _FOLDER_DEST.sub("]", text)
escaped = _ESCAPED.sub(r"\1", text).replace("\\", "/") # my\_script.py names my_script.py
separated = text.replace("\\", "/") # scripts\_helper.py names scripts/_helper.py
both = escaped if escaped == separated else escaped + "\n" + separated # a name in either reading counts
return _PATH_PREFIX.sub("", _from_folder(both, folder))


def _read_doc(path: Path) -> Optional[str]:
"""The text of a named document: "" for a file that is not a document, None for a document that cannot be
read (a symlink, a FIFO or another non-file, over :data:`DOC_MAX_BYTES`, not UTF-8), whose names are unknown."""
if path.suffix.lower() not in DOC_SUFFIXES:
return ""
try:
if path.is_symlink() or not path.is_file() or path.stat().st_size > DOC_MAX_BYTES: # a FIFO blocks the read
Comment thread
napetrov marked this conversation as resolved.
Resolved
Comment thread
napetrov marked this conversation as resolved.
Resolved
return None
return path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return None


def _named_closure(
skill_dir: Path,
files: list[str],
docs: list[str],
text: str,
linked: set[str],
doc_links: Callable[[str], Iterable[Optional[str]]],
) -> tuple[set[str], bool]:
"""The bundled files that ``SKILL.md`` or a document it names mentions, followed from there, and whether
the search was incomplete: the document walk was truncated, a named document was past the read cap, or a
named document could not be read (its names are unknown).

``text`` holds the names of ``SKILL.md``; ``linked`` the names of its links and of each document read. Each
pass checks the candidates against the text not yet checked (``unseen``), and each document read adds to it.
"""
named_files: set[str] = set()
docs_read = 0
incomplete = len(docs) > WALK_MAX_FILES
candidates = list(dict.fromkeys(files + docs))
unseen = text # the text no candidate has been checked against yet: a candidate is only new against it
while True:
new = [rel for rel in candidates if rel not in named_files and _named(rel, unseen, linked)]
if not new:
break
unseen = ""
for rel in new:
named_files.add(rel)
if PurePosixPath(rel).stem.lower() in NON_REFERENCE_STEMS:
continue
if docs_read >= DOCS_READ_MAX:
incomplete = incomplete or PurePosixPath(rel).suffix.lower() in DOC_SUFFIXES
continue
more = _read_doc(skill_dir / rel)
if more is None: # named, but its text cannot be read: what it names is unknown
incomplete = True
continue
if more:
docs_read += 1
folder = PurePosixPath(rel).parent.as_posix().lower() # the text is lowercased: its folder must be too
unseen += "\n" + _matchable(more.lower(), folder)
linked.update(_target(folder, link) for link in doc_links(more) if link)
return named_files, incomplete


def unreferenced_message(
skill_path: Path,
links: Iterable[Optional[str]],
*,
at_root: bool,
doc_links: Callable[[str], Iterable[Optional[str]]],
) -> Optional[str]:
"""The ``bundle-file-unreferenced`` message for the skill whose ``SKILL.md`` is ``skill_path``, or None.

``links``: the files its Markdown links name (None for a link that names no file). ``doc_links``
gives the same for the Markdown of a document. A document ``SKILL.md`` names is read too, and so
on from there (at most :data:`DOCS_READ_MAX`): a script that only ``forms.md`` names is still
found by an agent that follows ``SKILL.md`` to ``forms.md``.
"""
if at_root:
return None
try:
text = _matchable(skill_path.read_text(encoding="utf-8").lower())
failed: list[OSError] = []
files = _walk(skill_path.parent, counted=True, failed=failed)
docs = _walk(skill_path.parent, counted=False, failed=failed)
except (OSError, UnicodeDecodeError):
return None
if failed: # a folder that cannot be listed may hold the document that names a file
return None
if len(files) > WALK_MAX_FILES: # a checkout, not a bundle: a partial list would be a wrong count
return None
linked = {_target(".", link) for link in links if link}
named_files, incomplete = _named_closure(skill_path.parent, files, docs, text, linked, doc_links)
if incomplete: # a name the search cannot see may be the one a script relies on: claim nothing
return None
Comment on lines +270 to +275

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the search is incomplete the check is skipped with no trace, and the docs do not say when.

A bundle over 2000 files, more than 50 named documents, or one named document that is over 256 KiB, not UTF-8 or a symlink turns the check off for the whole skill, and catalog check shows it as ok. For example, SKILL.md says it reads data/words.txt (a 350 KB word list): scripts/unused.py is no longer reported; with a 100-line list it is.

Suggestion: list these skip conditions next to the checkout-root one in external-content.md and the changelog, and treat a named .txt over the cap as data rather than an unread document.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a: Fixed in a9f441c: the docs list the skip conditions. b: Waiting on a decision before I change this: count a named .txt over 256 KiB as data, or keep claiming nothing? My recommendation: keep it, since a .txt that names a script would give a false warning. The thread stays open until then.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a: Already fixed in 5228ca2: external-content.md now lists the skip conditions (a9f441c). b: Keeping the 256 KiB read cap on a named document: a larger file is not read, and the docs list it as a skip condition.

missing = [rel for rel in files if rel not in named_files]
if not missing:
return None
shown = ", ".join(missing[:EXAMPLES_SHOWN]) + (", ..." if len(missing) > EXAMPLES_SHOWN else "")
return (
f"{len(missing)} of {len(files)} bundled scripts and documents are never named in SKILL.md "
f"or a document it names ({shown}); the agent finds them only by browsing. Name each one "
"where it is used, or remove it"
)
Loading
Loading