Skip to content

feat(catalog): corpus mode for catalog scan (#413 A3, A4, A6, A7) - #414

Open
napetrov wants to merge 4 commits into
intel:mainfrom
napetrov:feat/catalog-corpus-mode
Open

napetrov wants to merge 4 commits into
intel:mainfrom
napetrov:feat/catalog-corpus-mode

Conversation

@napetrov

@napetrov napetrov commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Corpus mode for dokimi catalog scan, for large skill catalogs (#413, items A3, A4, A6, A7):

  • --per-skill reviews each skill alone, so the cross-catalog checks (duplicate, name-conflict, mirror-drift, description-near-duplicate) do not fire between unrelated skills.
  • A SKILL.md nested in another skill's directory is marked nested_in (JSON) and noted (text); --skip-nested leaves it out.
  • dokimi.external.review_skill_dir(path) -> SkillReview is the public per-skill review.
  • With --per-skill: --jsonl PATH streams one row per skill, --jobs N reviews in parallel, --resume skips skills already in PATH. --jsonl, --jobs, --resume need --per-skill; misuse exits 2.

Docs: docs/external-content.md section 1; changelog fragment added. --jobs was already on the flag allowlist; the new spellings have allowlist rows with a reason.

Evidence

  • make ci-local --offline on the head: 0 failed, 19 passed, 13 skipped.
  • 29 new tests in four files (test_catalog_per_skill, test_catalog_review_dir, test_catalog_nested, test_catalog_jsonl), plus the import-cycle and flag-contract gates.
  • Mutations on the new code (nested mark, resume skip, per-skill cross checks) each fail the new tests; --jobs ordering is not caught by any test (known gap).
  • Local self-review (pr-review, local mode): one blocking finding (--resume --skip-nested counted a nested row from an earlier run), fixed in 2795d517 with a regression test.

Overlap: commands/catalog.py is also moved by #396; the change there is small.

Part of #413

🤖 Generated with Claude Code

- --per-skill reviews each skill alone, so the cross-catalog checks
  (duplicate, name-conflict, mirror-drift, description-near-duplicate)
  do not fire between unrelated skills.
- A SKILL.md nested in another skill's directory is marked nested_in
  (JSON) and noted (text); --skip-nested leaves it out.
- review_skill_dir(path) -> SkillReview is the public per-skill review.
- With --per-skill: --jsonl PATH streams one row per skill, --jobs N
  reviews in parallel, --resume skips skills already in PATH.
  --jsonl/--jobs/--resume need --per-skill; misuse exits 2.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…lier run wrote

Prior rows are counted only when their skill is in this run's selection,
so --skip-nested hides a nested error row written before, and a skill
removed from the catalog no longer counts.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@napetrov
napetrov requested a review from a team as a code owner November 6, 2026 04:48
Copilot AI balanced review requested due to automatic review settings November 6, 2026 04:48
@exai-bot

exai-bot commented Nov 6, 2026 •

Copy link
Copy Markdown

✅ OpenCodeReview: Review complete: 0 finding(s) across 12 selected item(s).

CI at 6f5ddcf: 15 passing, 2 skipped.

Review details
  • Commit reviewed: 6f5ddcf against merge base 040f86d
  • Coverage: 12 files in 3 groups
  • Engine: OpenCodeReview v1.13.5-exai.3.d7112371, us.openai.gpt-6.1-sol
  • Usage: 364k tokens (241k from cache), 44s
  • Tool calls: 17 (12 file_read, 3 code_search, 1 file_read_diff, 1 pr_threads)
  • Catalog scanning and output behavior: 5 files
  • External catalog review integration: 3 files
  • Catalog corpus documentation and CLI contract: 4 files

from dokimi.external import nested_in_bundles, read_skill_manifest

try:
skills = read_skill_manifest(root)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

performance · medium
Must-fix — Impact: JSONL mode still retains every skill body in memory before writing its first result, undermining the million-skill corpus use case. Trigger: --per-skill --jsonl on a large catalog (for example, 1M skills with 10 KB bodies requires roughly 10 GB for body text alone). Evidence: read_skill_manifest eagerly calls load_skill for each entry, SkillCatalogEntry retains Skill.body, and pairs/todo keep these entries alive throughout review; --resume also loads bodies for skills it will skip. Discover lightweight paths/manifest metadata first and load each selected skill inside the review worker. Workaround: partition the catalog into smaller scans. Severity: medium because memory exhaustion blocks large corpus scans, the explicit scale targeted by #413 A7.

if not path.is_file():
return []
try:
lines = path.read_text(encoding="utf-8").splitlines()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix — Impact: --resume cannot recover earlier complete rows after an interrupted non-ASCII write. Trigger: a killed run leaves the last line ending partway through a multibyte UTF-8 character. Evidence: write() uses ensure_ascii=False, but read_text raises UnicodeDecodeError before the incomplete-line repair loop; the documented half-written-line recovery therefore does not run. Read and split bytes first, preserving validated complete lines and dropping the incomplete trailing line. Workaround: manually remove the incomplete trailing line. Severity: medium because this blocks the supported interruption-recovery feature for a bounded input case.

Comment on lines +138 to +140
if isinstance(row, dict):
rows.append(row)
kept.append(line)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix — Impact: malformed resume input crashes with a traceback rather than a usage error, and may be rewritten before validation. Trigger: run catalog scan --root ROOT --per-skill --jsonl out.jsonl --resume with out.jsonl containing {"name":"alpha"}. Evidence: any JSON object is accepted here; _row_key then indexes the missing relpath. Objects with a matching relpath but missing/invalid state or error similarly fail in _Tally.add. Validate the required row fields and state values before accepting or repairing the file, and report invalid complete records with usage_exit. Workaround: remove the invalid record or regenerate without --resume. Severity justification: medium because a bounded invalid-input case prevents the supported resume operation and bypasses its diagnostic handling.

if isinstance(row, dict):
rows.append(row)
kept.append(line)
if len(kept) != len(lines):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix — Impact: resuming can merge two rows into an invalid JSONL line, losing both on the next resume. Trigger: PATH ends with a complete JSON object but no trailing newline, and another skill remains to review. Evidence: splitlines() accepts the final object, this condition skips repair, and append mode writes the next object directly after it. Normalize the final newline before appending and add a regression test. Workaround: manually add a newline before --resume. Severity: medium because this causes bounded loss of persisted scan results.

return failed


def _check_scan_flags(args: argparse.Namespace) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix — Impact: a successful tasks-only scan silently ignores the requested JSONL output and resume/parallel options. Trigger: catalog scan --root TASK_CATALOG --kind tasks --per-skill --jsonl out.jsonl (also with --resume or --jobs 2). Evidence: this validation accepts the combination, but cmd_catalog_scan only calls _scan_skills for skills/all; _scan_tasks never creates the JSONL file or applies those options. Reject skill-only options with --kind tasks using a usage error. Workaround: use --kind skills or all when requesting corpus output. Severity justification: medium because an accepted command omits its explicitly requested artifact, a bounded supported-contract failure.

@codecov-commenter

codecov-commenter commented Nov 6, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 92.82051% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.78%. Comparing base (7fa98b9) to head (6f5ddcf).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
dokimi/dokimi/commands/catalog_scan.py 92.18% 6 Missing and 4 partials ⚠️
dokimi/dokimi/external/catalog_review.py 89.74% 2 Missing and 2 partials ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@           Coverage Diff            @@
##             main     #414    +/-   ##
========================================
  Coverage   94.78%   94.78%            
========================================
  Files         353      356     +3     
  Lines       46527    46754   +227     
  Branches     7967     8003    +36     
========================================
+ Hits        44100    44317   +217     
- Misses       1540     1545     +5     
- Partials      887      892     +5     
Files with missing lines Coverage Δ
dokimi/dokimi/commands/catalog.py 98.73% <100.00%> (+0.83%) ⬆️
dokimi/dokimi/external/__init__.py 100.00% <100.00%> (ø)
dokimi/dokimi/external/catalog_review.py 89.74% <89.74%> (ø)
dokimi/dokimi/commands/catalog_scan.py 92.18% <92.18%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

5 open issues
What changed in this PR

Adds “corpus mode” to dokimi catalog scan so large catalogs can be scanned skill-by-skill (optionally streaming to JSONL, parallelized, and resumable), while also exposing a public per-skill review API and nested-skill marking.

Changes:

  • Add --per-skill scan mode with --jsonl, --jobs, --resume, and --skip-nested support and flag-contract validation.
  • Introduce dokimi.external.review_skill_dir() and nested_in_bundles() to support per-skill review and nested-skill detection.
  • Add docs/changelog updates and new test coverage for per-skill behavior, nested marking, and JSONL/resume.
File Description
dokimi/​dokimi/​commands/​catalog.py Wires new per-skill scan mode into catalog scan, adds flag-contract checks, and refactors shared row printing/building.
dokimi/​dokimi/​commands/​catalog_scan.py Implements per-skill scan execution, JSONL streaming/resume, multiprocessing workers, and nested filtering/marking.
dokimi/​dokimi/​external/​catalog_review.py Adds public review_skill_dir() API and nested-in-bundles detection helpers.
dokimi/​dokimi/​external/​__init__.py Exports the new external APIs via __all__.
dokimi/​tests/​test_catalog_review_dir.py Tests the public review_skill_dir() behavior and export surface.
dokimi/​tests/​test_catalog_per_skill.py Verifies cross-catalog checks are suppressed in per-skill mode while per-skill checks still run.
dokimi/​tests/​test_catalog_nested.py Tests nested skill marking, skipping behavior, and parity across scan modes.
dokimi/​tests/​test_catalog_jsonl.py Tests JSONL streaming, ordering with jobs, resume behavior, and invalid flag combinations/exits.
dokimi/​tests/​data/​cli_flags_allowlist.txt Allowlists new CLI flags with rationale.
dokimi/​docs/​reference/​cli-interface.json Updates CLI reference snapshot with new flags.
dokimi/​docs/​external-content.md Documents corpus/per-skill scan behavior, nested marking, and flag constraints.
dokimi/​changelog.d/​20261105_120000_napetrov_catalog-corpus-mode.md Adds changelog entry describing the new corpus mode/features.

Review effort: Lite

Comment on lines +127 to +144
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError) as exc:
usage_exit(f"cannot read --jsonl {path} to resume: {exc}")
rows: list[dict] = []
kept: list[str] = []
for line in lines:
try:
row = json.loads(line)
except ValueError:
continue
if isinstance(row, dict):
rows.append(row)
kept.append(line)
if len(kept) != len(lines):
tmp = path.with_name(path.name + ".part")
tmp.write_text("".join(line + "\n" for line in kept), encoding="utf-8")
tmp.replace(path)
usage_exit("--jsonl writes one row per skill to a file; drop --format json")
if args.resume and not args.jsonl:
usage_exit("--resume needs --jsonl: the rows it skips are read from that file")
if (args.jsonl or args.jobs > 1) and not args.per_skill:
Comment on lines +190 to +210
tally = _Tally(args.strict)
prior = sink.prior if sink is not None else []
# Only the rows of the skills this run selects count: --skip-nested must hide a nested row an
# earlier run wrote, and a skill removed from the catalog is not part of this scan.
selected = {_entry_key(entry) for entry, _ in pairs}
prior = [row for row in prior if _row_key(row) in selected]
for row in prior:
tally.add(row)
done = {_row_key(row) for row in prior}
todo = [pair for pair in pairs if _entry_key(pair[0]) not in done]
for row in _review_rows(todo, args.jobs):
tally.add(row)
if sink is not None:
sink.write(row)
else:
report["skills"].append(row)
if sink is not None:
sink.close()
if not as_json:
_print_text(root, declared, tally, len(prior), jsonl, report["skills"])
return tally.failed, declared
Comment on lines +190 to +210
tally = _Tally(args.strict)
prior = sink.prior if sink is not None else []
# Only the rows of the skills this run selects count: --skip-nested must hide a nested row an
# earlier run wrote, and a skill removed from the catalog is not part of this scan.
selected = {_entry_key(entry) for entry, _ in pairs}
prior = [row for row in prior if _row_key(row) in selected]
for row in prior:
tally.add(row)
done = {_row_key(row) for row in prior}
todo = [pair for pair in pairs if _entry_key(pair[0]) not in done]
for row in _review_rows(todo, args.jobs):
tally.add(row)
if sink is not None:
sink.write(row)
else:
report["skills"].append(row)
if sink is not None:
sink.close()
if not as_json:
_print_text(root, declared, tally, len(prior), jsonl, report["skills"])
return tally.failed, declared
Comment on lines +65 to +73
def test_more_jobs_write_the_same_rows_in_the_same_order(tmp_path, capsys):
_catalog(tmp_path)
one = tmp_path / "jobs1.jsonl"
many = tmp_path / "jobs3.jsonl"

_run(["--root", str(tmp_path), "--per-skill", "--jsonl", str(one), "--jobs", "1"], capsys)
_run(["--root", str(tmp_path), "--per-skill", "--jsonl", str(many), "--jobs", "3"], capsys)

assert _rows(many) == _rows(one)
…le on error

Python 3.14 makes forkserver the Linux default; its socket tripped the test
socket guard, and the failure left the --jsonl file open.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
skill_dir = Path(path)
if not skill_dir.is_dir():
raise ValueError(f"not a skill directory: {skill_dir}")
own = [e for e in _scan_skill_files(skill_dir) if e.path is not None and _same_dir(e.path, skill_dir)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

performance · medium
Must-fix — Impact: the single-directory API loads and retains every descendant skill body before discarding them, so reviewing one outer skill can consume memory proportional to an entire nested corpus rather than that skill. Trigger: call review_skill_dir(outer) with many large nested SKILL.md files (e.g. 100,000 descendants with 10 KB bodies retains roughly 1 GB of body text). Evidence: _scan_skill_files walks all descendant SKILL.md files, calls load_skill on each, and returns a fully materialized list; this comprehension filters only after that work completes. The nested test checks findings but does not catch descendant loading. Workaround: review a standalone copy without nested skills. Severity justification: medium because this causes a material, bounded performance/memory failure of the new public API. Load only skill_dir / 'SKILL.md' into a SkillCatalogEntry with source_root=skill_dir, preserving loader-error handling, and add a test proving nested SKILL.md files are not loaded.

Regenerate docs/reference/cli-interface.json with the corpus-mode flags on catalog check.
Name catalog check (the intel#396 path) in the PR's changelog fragment, docstrings,
flag allowlist comments and test argv.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
@xaleryb

xaleryb commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

6f5ddcf: merged main after #396. The CLI snapshot is regenerated, so the five new flags sit on catalog check. The changelog fragment, the catalog_scan.py docstrings, the flags allowlist and the new tests' argv now say catalog check; on the merged tree, catalog scan exits 2.

@antonwolfy antonwolfy left a comment

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.

Review at 6f5ddcf. The per-skill review, the nested marking and the flag contract work as described. The problems are in the --resume path and at the edges of the flag checks: seven inline points, four of them bugs I reproduced. I would fix those four before merge.

What I ran, on a Linux review host at 6f5ddcf:

  • make ci-local: 29 passed, 1 failed, 7 skipped; 15,889 tests; changed-line coverage 95%. The failure is the test step: two tests hit the 60 s timeout and one slow-hook test took 1.9 s against a 1 s limit, while other jobs shared the machine. All four of those tests pass when run alone (174 passed), and none touches this PR's files. I do not count it against the PR.
  • Reproductions for the code findings, with the LLM transport mocked.
  • A mutation run on the four new test files: 61 mutants, 27 pass the full suite. Most change text or counts; the ones that decide an exit code or a selection are the --strict and path-less-key points above.
  • All three non-merge commits carry a Signed-off-by.

Checked, no issues

  • Flag contract: --jsonl with --format json, --resume without --jsonl, and --jsonl or --jobs 2 without --per-skill exit 2.
  • Nested marking: a root SKILL.md encloses nothing, symlinked skill directories are not walked, --skip-nested also drops the skill from the declared count.
  • Jobs: --jobs 2 gives the same rows and exit code as --jobs 1 under spawn, including a load-error skill.
  • Housekeeping: the five flags are in the CLI snapshot and the allowlist with reasons, the changelog fragment is in place, and mkdocs build passes.
Bot points I checked that no longer apply at this head (5)
  • Copilot's point on reading the whole JSONL into memory does not apply as a defect: the file holds one small row per skill, and the loaded skill bodies from the point above are the larger cost.
  • Copilot's point on --jobs 1 without --per-skill does not apply: 1 is the default, so the parser cannot tell it from an absent flag, and the run is the same as without it.
  • Copilot's point on sink.close() not being in a finally does not apply: the loop in scan_per_skill is wrapped in try/finally that closes the sink (catalog_scan.py:202-211).
  • Copilot's second point on sink.close() repeats the first and does not apply for the same reason: the close sits in the finally block.
  • exai-bot's point on review_skill_dir retaining nested skill bodies does not apply: it does load every descendant SKILL.md to pick the first, which costs time, but nothing is kept after the call and a broken nested skill does not change the outer result.

if isinstance(row, dict):
rows.append(row)
kept.append(line)
if len(kept) != len(lines):

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.

I agree with exai-bot here; --resume glues the next row onto a last line that has no trailing newline.

_load_prior rewrites the file only when a line was dropped (len(kept) != len(lines)). A last line that parses but lacks \n is kept as it is, and path.open("a") at :157 appends right after it. Repro: three skills alpha, bravo, charlie; keep only the first row of the --jsonl file, without its newline; run again with --resume. The file is then:

  • line 0: {"name": "alpha", ... }{"name": "bravo", ...} (does not parse)
  • line 1: the charlie row

The run prints 3 ok and exits 0. The next --resume drops line 0 as unparseable, so alpha and bravo are reviewed again, and any other consumer of the file reads a corrupt line.

Suggestion: after the loop, rewrite the file whenever it does not end in \n (or write "\n" before the first append in that case), and add a test with a complete last row and no newline.

continue
if isinstance(row, dict):
rows.append(row)
kept.append(line)

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.

I agree with exai-bot here; a complete JSON object that is not a row crashes --resume with a traceback.

_load_prior keeps any dict. With out.jsonl holding {"name":"alpha"}, --per-skill --jsonl out.jsonl --resume dies at _row_key (catalog_scan.py:86) with KeyError: 'relpath'. A row with "state": "weird" dies the same way at _Tally.add (:120, KeyError: 'weird'). A file from an older version or another tool is enough.

Suggestion: keep a row only if relpath and name are present and state is one of ok, warn, error. For a complete line that fails this check, usage_exit naming the file and the line number, rather than dropping it.

if not path.is_file():
return []
try:
lines = path.read_text(encoding="utf-8").splitlines()

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.

I agree with exai-bot here; a run killed inside a multibyte character cannot be resumed.

write() uses ensure_ascii=False (:162), so non-ASCII text from a description or a message reaches the file as UTF-8. A kill can cut a character in half. Repro: keep one complete row, append the byte 0xc3 (first byte of é), run --resume. Output, exit 2: cannot read --jsonl o.jsonl to resume: 'utf-8' codec can't decode byte 0xc3 in position 341: unexpected end of data. The half-written-line recovery in the docstring never runs.

Suggestion: read bytes, split on b"\n", decode each line on its own, and treat a line that fails to decode as the half-written one.

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.

One more failure from the same ensure_ascii=False: a directory name that is not valid UTF-8 crashes --jsonl with UnicodeEncodeError: surrogates not allowed, while --format json escapes it. Reading bytes line by line would not fix that case. json.dumps(row) with the default ensure_ascii fixes both: every line is ASCII, so a kill cannot cut a character in half.

usage_exit("--jsonl writes one row per skill to a file; drop --format json")
if args.resume and not args.jsonl:
usage_exit("--resume needs --jsonl: the rows it skips are read from that file")
if (args.jsonl or args.jobs > 1) and not args.per_skill:

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.

I agree with exai-bot here; --kind tasks accepts the per-skill flags and ignores them.

_check_scan_flags does not look at --kind, and _scan_skills is only called for skills and all. Repro: catalog check --root <root> --kind tasks --per-skill --jsonl d.jsonl prints # tasks (0 declared ...), exits 0 and creates no d.jsonl. A CI step that then reads d.jsonl finds no file, or treats a missing file as no findings.

Suggestion: if args.kind == "tasks" and (args.per_skill or args.jsonl or args.resume or args.skip_nested or args.jobs > 1): usage_exit("--per-skill, --skip-nested, --jsonl, --jobs and --resume apply to skills; drop them or use --kind skills|all").

def _entry_key(entry) -> str:
from dokimi.external.catalog_review import entry_relpath

return entry_relpath(entry) or f"name:{entry.name}"

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.

--resume can skip a skill that was never reviewed: path-less entries share the key name:?.

A skills.yaml entry without a name gets the name ?, so every such entry has the resume key name:? (_entry_key, _row_key). Repro: a skills.yaml with two nameless entries (- {} and - {foo: 1}) among valid ones; scan with --per-skill --jsonl, cut the file after the first ? row, run again with --resume. The second run prints 6 declared ... 3 error; 3 resumed, where a full run prints 4 error. The second ? entry is counted as done and its error never appears. Entries that point at the same directory collide on the relpath the same way.

Suggestion: key path-less entries by their position in the manifest (for example entry:{index}), and key every row the same way so resume matches by position.


def add(self, row: dict) -> None:
self.counts[row["state"]] += 1
if row["error"] is not None or (self.strict and row["state"] == "error"):

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.

No test pins --strict on the per-skill path.

This line decides the exit code of a corpus scan: a spec error fails only under --strict. Mutation check: dropping the self.strict and clause, and making every error fail regardless of --strict, both pass the four new test files. The only --strict tests are the older ones on the non-per-skill path. A regression here makes --per-skill --strict exit 0 on spec errors, or exit 1 without --strict.

Suggestion: a test with one skill that has a spec error and no load error, run --per-skill --jsonl with and without --strict, expecting exit 0 and 1.

from dokimi.external import nested_in_bundles, read_skill_manifest

try:
skills = read_skill_manifest(root)

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.

I agree with exai-bot here; the module promises a million-skill corpus, but every skill body is loaded before the first row is written. This is from reading the code, not a run.

read_skill_manifest (external/catalog.py:113, :231) calls load_skill for every entry and keeps the result in SkillCatalogEntry.skill. pairs, todo and the worker arguments then hold all of them for the whole scan. --resume loads the skills it will skip, too. --jsonl bounds the output, not the memory. The docstring at :7 says a corpus of a million skills can be scanned one row at a time.

Suggestion: either list the skill paths first and load each skill inside _review_row, or drop the million-skill wording from the docstring and docs/external-content.md and say the whole catalog is held in memory.

@vlad-perevezentsev vlad-perevezentsev left a comment

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.

Review at 6f5ddcf. The per-skill review and the nested marking work, and the default scan gives the same rows as before plus relpath and nested_in. The --resume path is not ready yet: the file can disagree with the counts, and some files it should recover crash or are refused. Details are in the line comments.

Agreed with @exai-bot: the corpus is still loaded whole before the first row; review_skill_dir loads every nested skill to review one; a foreign row crashes --resume with KeyError; a last row with no newline gets glued to the next one; --kind tasks ignores --jsonl.

Agreed with @antonwolfy: path-less entries share the resume key name:?; no test pins --strict on the per-skill path. On the cut UTF-8 line I added a second case and a simpler fix in his thread.

Agreed with @Copilot that _load_prior reads the whole file, a minor cost next to the skill bodies. The sink.close() threads are fixed by the try/finally in 05a8b29, and --jobs 1 without --per-skill is the default, so it changes nothing.

What I ran, at 6f5ddcf:

  • make ci-local --offline: 0 failed, 19 passed, 13 skipped (network and Docker jobs).
  • CLI repros on scratch catalogs: --resume --skip-nested leaves the nested row in the file while printing 1 declared; {"name":"alpha"} in the file gives KeyError: 'relpath'; a last line cut inside a UTF-8 character exits 2; a last row with no newline gets the next row glued to it; --kind tasks --per-skill --jsonl writes no file; a non-UTF-8 directory name crashes --jsonl with UnicodeEncodeError; a skills.yaml path through a symlinked skills/ gives nested_in: real_skills/a for a parent whose relpath is skills/a.

Checked, no issues

  • Flag contract: --jsonl with --format json, --resume without --jsonl and --jsonl without --per-skill all exit 2, and the tests cover each one.
  • Per-skill review: each skill goes through review_skills([entry]) alone, so the four cross-catalog checks cannot fire, and --jobs keeps the catalog order through imap. The spawn context runs _review_row at module level.
  • Symlinks: a SKILL.md symlinked out of the root does not crash nested_in_bundles, because the loader does not resolve skill.path.
  • Docs and changelog: the fragment is under Added and names every flag and the new API. The new files have the SPDX header, and the flags are in the allowlist and the CLI snapshot.

Comment on lines +194 to +201
# Only the rows of the skills this run selects count: --skip-nested must hide a nested row an
# earlier run wrote, and a skill removed from the catalog is not part of this scan.
selected = {_entry_key(entry) for entry, _ in pairs}
prior = [row for row in prior if _row_key(row) in selected]
for row in prior:
tally.add(row)
done = {_row_key(row) for row in prior}
todo = [pair for pair in pairs if _entry_key(pair[0]) not in done]

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.

--resume leaves the rows this run does not select in the --jsonl file, so the file no longer matches the printed counts.

A first run writes skills/a and skills/a/sub. Then --per-skill --skip-nested --jsonl s.jsonl --resume prints 1 declared ... 1 resumed and exits 0, but s.jsonl still holds both rows, the nested one included. A skill removed from the catalog keeps its row too. Whoever reads the file gets rows the scan says it left out. test_resume_with_skip_nested_... checks only stdout.

Suggestion: when prior loses rows here, rewrite the file with the kept ones (as _load_prior already does through .part), and assert in the test that the file has one row.

The cross-catalog checks (``duplicate``, ``name-conflict``, ``mirror-drift``,
``description-near-duplicate``) need every skill loaded at once. This mode gives them up,
so a corpus of a million skills can be scanned one row at a time: rows are written in
catalog order, a run killed midway keeps the rows it wrote, and ``--resume`` reviews only

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.

Nit: The docstring says rows are written in catalog order, but after --resume the new rows come after the old ones.

test_resume_reviews_only_the_skills_without_a_row itself expects alpha, charlie, delta, bravo.

Suggestion: say "in catalog order within one run; --resume appends".

"""
root_r = Path(root).resolve()
bundles: dict[Path, str] = {}
for entry in entries:

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.

Nit: nested_in is built from resolved paths, but a row's relpath is not resolved, so the two can differ.

With catalog_path: skills/a where skills is a symlink to real_skills, the outer row has relpath: skills/a and the inner row nested_in: real_skills/a, which matches no row.

Suggestion: build bundles from entry_relpath(entry) so both use the same form.

except ValueError as exc:
usage_exit(f"skills.yaml: {exc}")
pairs = catalog_scan.read_marked_skills(args, root)
if args.per_skill or args.jsonl:

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.

Nit: args.per_skill or args.jsonl can be just args.per_skill: _check_scan_flags already exits on --jsonl without --per-skill.

The default branch below also repeats the counting, the exit-1 rule and the header that _Tally and _print_text now do; using them here keeps the two modes from drifting apart.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants