Repository navigation
Conversation
`catalog scan` checked only that a skill loads. Each loaded skill is now also reviewed: the directory name must equal `name` and be kebab-case (at most 64 characters), and `description` must be at most 1024 characters (errors, exit 1). A missing `name` and a SKILL.md byte-identical to an earlier skill's are warnings naming the first copy. disable-model-invocation is shown as a note. `--format json` prints one object with a row per skill and task. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
`catalog scan` exited 1 on a skill spec error. Spec errors now print and exit 0; `--strict` makes them exit 1, as a load error does. Load errors still exit 1 always. A SKILL.md whose frontmatter is not valid YAML is reported as a `frontmatter-parse` error carrying the YAML message. The loader reads that as "no description", so the scan showed only a misleading missing description error before. The scan uses a new parse_frontmatter_strict path; parse_frontmatter is unchanged for other callers. Without a skills.yaml, the scan's discovery path now records a broken skill as an error row instead of stopping at the first one. validate.py is ruff-formatted; catalog.py only in the lines this branch added. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #163 +/- ##
========================================
Coverage 91.97% 91.97%
========================================
Files 223 224 +1
Lines 27709 27863 +154
Branches 4553 4574 +21
========================================
+ Hits 25486 25628 +142
- Misses 1574 1581 +7
- Partials 649 654 +5
🚀 New features to boost your workflow:
|
antonwolfy
left a comment
There was a problem hiding this comment.
Review at 1868f79. The loader changes and the exit-code split between load errors and --strict hold up. The problems are at the edges: one JSON crash, one false --strict failure, and tests that leave the name rules unpinned. Fix the first two inline comments before merge.
What I ran, on a Linux review host at 1868f79:
make ci-local: 6,897 tests passed, 21 skipped; the changed-line coverage step,schema-check, the changelog check, the wheel smoke tests andgitleakspass. Theruff,mypy,deptry,bandit,zizmor,pre-commit,mkdocsandpip-auditsteps could not run in my venv:make devstops at the tools install on this base, which #179 fixed onmain. Static checks, mypy and the strict docs build pass on GitHub's CI at this head.- Reproductions for the code findings, on fixture catalogs, with no model or network involved.
- A mutation run on
validate.pyandcommands/catalog.py: 80 mutants, 48 killed, 32 survive the 17 tests intest_catalog_validate.py(7 of the survivors are equivalent).
Test gaps (the code is correct today; these changes would pass the suite):
- The name rules beyond one title-case name are not pinned. These mutants of
validate.py:116all pass the suite:>to>=onNAME_MAX,NAME_MAX = 200, the length check removed, a regex that allowsa--b, a trailing-?,_allowed,fullmatchreplaced bymatch. A 65-character name,a--b,a-,a_bwould then beok. Suggestion: a parametrized test where name and directory match, so onlyname-formatfires:"a"*64and"a1-2b"give no finding;"a"*65,"a--b","a-","-a","a_b","a b"give["name-format"]. - Duplicate detection is not pinned to whole-file bytes and to the first copy. Hashing only the frontmatter, hashing stripped text, and "latest copy wins" all pass. The existing test changes the description, so the frontmatter differs. Suggestion: same frontmatter with a different body is not a duplicate; a trailing-newline difference is not a duplicate; three identical copies all name the first, which has
duplicate_of is None. stateprecedence anddisable-model-invocation: falseare untested. Swapping thewarnanderrorranks inSkillReview.statepasses, so a mirror with a bad name would bewarnand pass--strict. Reading the flag withbool(meta.get(...))passes, sofalsewould hide a skill. Suggestion: a bad-name skill that is also a mirror expectsstate == "error"and--strictexit 1; adisable-model-invocation: falseskill expects the flag False.- The JSON
tasksarray has no content test. Every JSON test uses--kind skillsor assertsreport["tasks"] == []. Renamingtrackorreason, mappingskippedtook, dropping the append, or printing the# tasksheader in JSON mode all pass. Suggestion: one--kind all --format jsonrun on a catalog with a loaded and a skipped task; parse the whole stdout and assert the task rows.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
antonwolfy
left a comment
There was a problem hiding this comment.
I pushed a commit per nit, up to 1ed1cfb, which fix the two nits from my review:
7e1d17e:dokimi/dokimi/external/catalog.py:108: catchOSErrorinstead ofFileNotFoundErrorin the SKILL.md scan and in_skill_entry(:204), keepingValueError.1ed1cfb:dokimi/docs/external-content.md:92: the example header now shows the ok/warn/error counts, and a sentence at:130says the YAML message is on thefrontmatter-parseline while the row's main line and JSONerrorstill say the description is missing.
Left as is:
- The four test-gap points are not labelled
Nit:orNon-blocking:in the review body, so they stay with the author.
make ci-local at 1ed1cfb: 20 passed, 8 failed (all exit 127, tools missing on the host), 7 skipped.
Not approving yet. Still open: the bug and the two risks from my review.
|
Test gaps from the review body, all added in 67b683e (
|
…or a root SKILL.md An unquoted YAML date as status crashed catalog scan --format json. A SKILL.md directly in the scan root was compared with the root directory name and failed --strict. Adds the review's mutation-gap tests. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
antonwolfy
left a comment
There was a problem hiding this comment.
Review at 67b683e. Both earlier threads are fixed and tested. One new crash in the duplicate check is worth fixing before merge, plus a test gap and a doc nit, three points in all, all inline.
What I ran, on a Linux review host at 67b683e:
make ci-local: 27 passed, 1 failed, 7 skipped. 6,933 tests passed, 1 skipped; the changed-line coverage step,schema-check,ruff,mypy,deptry,bandit,zizmor, the strict docs build, the wheel smoke tests andgitleakspass. The one failure ispip-auditon the lock files, which this PR does not touch.- Reproductions for the code finding, on fixture catalogs, with no model or network involved.
- A mutation run on
validate.pyandcommands/catalog.py: 56 mutants, 41 killed, 15 survive. The name-rule, duplicate, state and--strictmutants are all killed now. The survivors that matter are in the test-gap comment.
Checked, no issues
- JSON status: date, float and bool
statusvalues come out as strings in the skill and task rows, and--format jsonstdout parses as one object with and without--strict. - Root
SKILL.md: with a plain scan and withcatalog_path: .,name-mismatchis skipped andname-formatstill applies. - Exit codes: a non-UTF-8 file, a missing description and broken YAML each exit 1 as a load error. A spec error alone exits 0 without
--strict.
| review.content_sha256 = _skill_digest(skill) | ||
| original = first_by_digest.setdefault(review.content_sha256, skill) | ||
| if original is not skill: | ||
| where = original.path.parent.relative_to(entry.source_root).as_posix() |
There was a problem hiding this comment.
A duplicate skill crashes the whole scan with ValueError when --root is a symlink and a skills.yaml entry uses an absolute catalog_path into the real directory.
original.path.parent.relative_to(entry.source_root) compares literal paths. The loader accepts the entry because it compares resolved paths, so the first copy's path is under the real directory while source_root is the symlink.
Repro: realB/skills/a and realB/skills/b hold byte-identical SKILL.md files; linkB is a symlink to realB; realB/skills.yaml lists catalog_path: <abs path of realB>/skills/a and catalog_path: skills/b. catalog scan --root linkB --kind skills raises:
File ".../validate.py", line 189, in review_skills
ValueError: '.../realB/skills/a' is not in the subpath of '.../linkB'
No report is printed, not even for the skills that loaded. The trigger is narrow, but the failure is a traceback instead of a row.
The same literal comparison at :183 (skill.path.parent == entry.source_root) misses equivalent spellings: with catalog_path: sub/.. on a root SKILL.md named zzz, the row gets ERROR: name 'zzz' is not the directory name '..', while catalog_path: ./ is skipped correctly.
Suggestion: resolve both sides before comparing (skill.path.parent.resolve() == entry.source_root.resolve()), and in the duplicate branch fall back to str(original.path.parent) when relative_to raises ValueError.
There was a problem hiding this comment.
Fixed in 29e767f. The root check now compares resolved paths, and the duplicate branch uses a helper that tries the literal then the resolved relative path and falls back to the path as written, so it can no longer raise. Tests: test_a_duplicate_found_through_a_symlinked_root_is_a_row_not_a_traceback and test_a_root_skill_reached_by_an_equivalent_spelling_skips_the_directory_check (the sub/.. case); both fail on the old code.
| def test_skill_at_the_scan_root_is_not_compared_with_the_root_directory_name(tmp_path, capsys): | ||
| root = tmp_path / "checkout" | ||
| root.mkdir() | ||
| (root / "SKILL.md").write_text( | ||
| "---\nname: z\ndescription: Use for z.\n---\n# z\n", encoding="utf-8" | ||
| ) | ||
| report, code = _scan_json(root, capsys, strict=True) | ||
|
|
||
| assert code == 0 | ||
| assert report["skills"][0]["state"] == "ok" | ||
| assert report["skills"][0]["findings"] == [] | ||
|
|
There was a problem hiding this comment.
The root-skill test pins only the name-mismatch skip; the other root-specific code and the None guard on manifest_status can break with the suite green.
Mutation check, all pass the 33 tests in this file:
str(skill.status)without theNoneguard atcommands/catalog.py:31: every skill without a manifest status would show"None"in JSON. The date test only covers thestr()part. The same holds for the task row at:110.- At
validate.py:121, skipping thename-formatcheck whenat_rootis true: a rootSKILL.mdnamedZ_zwould beok. - The
at_rootbranch of thename-missingmessage atvalidate.py:109replaced by the directory-name text: the message would again cite the checkout directory name, which is what the fix removed.
Suggestion: extend this test, or add two, so the root SKILL.md has name: Z_z (expects ["name-format"] only) and another has no name (expects name-missing with no directory name in the message), and assert manifest_status is None for a skill with no manifest status in a JSON scan.
There was a problem hiding this comment.
Added in 29e767f: test_root_skill_still_gets_the_name_format_check (Z_z -> only name-format), test_root_skill_without_a_name_does_not_cite_the_checkout_directory, and test_a_skill_without_a_manifest_status_has_a_null_status_in_json / test_a_task_without_a_manifest_status_has_a_null_status_in_json. These pin existing behavior, so they pass on the current code; I did not re-run the mutation run.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
antonwolfy
left a comment
There was a problem hiding this comment.
I pushed a commit per nit, up to 9ec812d, which fix the nit from my review:
9ec812d:dokimi/docs/external-content.md:120: added that aSKILL.mddirectly in the scanned root is not compared with the root directory's name.
make ci-local at 9ec812d: 27 passed, 1 failed (pip-audit, same as at the reviewed head), 7 skipped.
GitHub's Tests fail at 9ec812d on every Python version, in test_growth_ratchet.py: commands/catalog.py::cmd_catalog_scan has complexity 21 against its baseline of 12, and external/catalog.py::read_skill_manifest has 11 with no baseline entry (the cap is 10). The commit changes only external-content.md. The ratchet came to main (#93) after this PR's earlier green run, and the PR's changes to those two functions take them past it. Splitting the two functions makes Tests green again.
Not approving yet. Still open: the bug and the risk from my review.
…er the complexity ratchet Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…ot crash the duplicate check; pin root-skill rules Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
CLI interface change detectedThis PR changes dokimi's user-facing CLI surface (commands and/or options): ~ `dokimi catalog scan` parameters changed:
+ added option `--format`
+ added option `--strict`Updated on every push; the |
Part of #4.
Summary
catalog scannow reviews each loaded skill against the Agent Skills spec, and the report is machine-readable.Spec errors are reported and do not change the exit code unless
--strictis given, so existing scripts keep their exit status. Broken YAML frontmatter is named (frontmatter-parse, with the YAML message) instead of showing as a missing description. Without askills.yaml, the scan now reviews everySKILL.mdunder the root, and a skill that does not load becomes an error row instead of aborting the scan.Evidence
The new tests live in
tests/test_catalog_validate.py(17 tests). The CLI-level tests call thecatalog scancommand, so they show the old behaviour directly.Local checks on the rebased head:
Merge Danger
Door: two-way. The new flags and the JSON object are additive, and the exit code without
--strictis unchanged for every case that exited 0 before. Reverting the commits restores the old scan.Blast Radius: low, confined to
catalog scan.Behaviour changes for callers:
--strict, a spec error prints as anERRORrow and the scan still exits 0. Users who want a failing gate must pass--strict.# skills (N declared under ...; x ok, y warn, z error), and finding lines are indented under their skill. Parsers of the old header line need updating.skills.yaml. It now enumerates everySKILL.mdunder the root (rglob), so vendored or mirrored copies appear as rows, and mirrors are reported aswarn.SKILL.mdwith invalid YAML exits 1, with or without--strict, as a load error does.parse_frontmatterkeeps its lenient behaviour; the strict variant is a new function used only by the validator.Checklist
pytestpassesruff,mypy, and schema checks passdocs/external-content.md(scan section) and the README step-1 command🤖 Generated with Claude Code