stack: SKILL.md-Links auf repo-root-relative Pfade umgestellt, docs verify/instructions verify pruefen Linkziele
Files changed: - CHANGES.md - VERSION - instructions/CONTRACT.md - instructions/dev/doc-pull-through.md - instructions/dev/stack-close/SKILL.md - instructions/dev/stack-dev/SKILL.md - instructions/wiki-ingest/SKILL.md - instructions/wiki-lint/SKILL.md - instructions/wiki-manage/SKILL.md - instructions/wiki-query/SKILL.md - instructions/wiki-status/SKILL.md - tools/CONTRACT.md - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/instructions_cmd.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_instructions_cmd.py
This commit is contained in:
1 parent
dc688e5726
commit
0fb8fd6122
16 files changed
+442
-78
No files matched your search
@@ -32,6 +32,18 @@ A sixth checks a *reference* rather than a copy: no document `dist export`
|
||||
ships may cite an issue number, because the board those numbers live on
|
||||
exists only in the origin repo.
|
||||
|
||||
A seventh checks the other half of the same reference problem: every relative
|
||||
markdown link in a reference file - `toc.target_files()`'s scope, the same one
|
||||
the table-of-contents check uses - must resolve to a file that actually
|
||||
exists. A link with the wrong `../` count is invisible to every check above:
|
||||
it is present, it names an existing command or contract by title, and nothing
|
||||
renders it to notice the target is unreachable. The complementary half - that
|
||||
`instructions/<name>/SKILL.md` never carries a relative markdown link at all,
|
||||
because `instructions sync` copies it to a different depth than its links
|
||||
assume - is `instructions verify`'s job, not this one, since that module
|
||||
already owns the Skill/Instruction split (`skill_dirs()` vs
|
||||
`instruction_files()`).
|
||||
|
||||
Everything here is a hard oracle: a set comparison or a regex, no judgment.
|
||||
Content quality of the contracts themselves stays with the LLM.
|
||||
"""
|
||||
@@ -44,7 +56,7 @@ from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config, conventions, kb_collections, toc, version as version_mod
|
||||
from chemenu import config, conventions, kb_collections, markdown_code, toc, version as version_mod
|
||||
from chemenu.commands import dist_cmd
|
||||
from chemenu.commands._util import fail, rel_path, success
|
||||
|
||||
@@ -452,6 +464,61 @@ def check_toc_regions() -> list[str]:
|
||||
return issues
|
||||
|
||||
|
||||
# A markdown link, `[text](target)`. The target excludes `)` and whitespace -
|
||||
# the same restriction every link in this repo's own instructions already
|
||||
# follows; a target needing either would need CommonMark's <angle-bracket>
|
||||
# escaping, which nothing here uses.
|
||||
MARKDOWN_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
||||
|
||||
|
||||
def is_external_or_anchor(target: str) -> bool:
|
||||
"""A link this check does not resolve as a filesystem path: an absolute
|
||||
URL, a `mailto:`, or a pure in-page `#anchor`.
|
||||
|
||||
Public (not `_`-prefixed): `instructions_cmd.check_skill_reference_paths`
|
||||
imports this alongside `MARKDOWN_LINK_RE` rather than keeping a second
|
||||
copy - the two checks classify the same link shape, just over different
|
||||
file sets (AGENTS.md invariant 8)."""
|
||||
return target.startswith(("http://", "https://", "mailto:", "#"))
|
||||
|
||||
|
||||
def check_reference_targets() -> list[str]:
|
||||
"""Every relative markdown link in a reference file resolves to a real file.
|
||||
|
||||
Scoped to `toc.target_files()` - AGENTS.md, the stage and collection
|
||||
contracts, and every flat `instructions/**.md` file - the same scope the
|
||||
table-of-contents check uses. That scope already excludes `SKILL.md`
|
||||
(banned from carrying a markdown link at all - `instructions verify`'s
|
||||
`check_skill_reference_paths`), `commonplace/` (vendored, not stack
|
||||
material) and `raw/`/`kb/` page content (data, not documentation) beyond
|
||||
the two files that are themselves reference material.
|
||||
|
||||
A target's `#anchor` suffix is stripped before resolving - CommonMark
|
||||
anchors are not filesystem paths, and nothing here renders one to notice
|
||||
a stale one anyway. Code fences and inline code spans are masked first
|
||||
(`markdown_code.strip_code_spans`), so a passage that shows link syntax
|
||||
as an example is not mistaken for a real reference.
|
||||
"""
|
||||
issues = []
|
||||
for path in toc.target_files():
|
||||
text = path.read_text(encoding="utf-8")
|
||||
masked = markdown_code.strip_code_spans(text)
|
||||
for line_number, masked_line in enumerate(masked.splitlines(), start=1):
|
||||
for match in MARKDOWN_LINK_RE.finditer(masked_line):
|
||||
target = match.group(1)
|
||||
if is_external_or_anchor(target):
|
||||
continue
|
||||
target_path = target.split("#", 1)[0]
|
||||
if not target_path:
|
||||
continue
|
||||
if not (path.parent / target_path).resolve().exists():
|
||||
issues.append(
|
||||
f"{rel_path(path)}:{line_number} links to `{target}`, which does not "
|
||||
"resolve to an existing file"
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def command_table_free_readmes() -> list[Path]:
|
||||
"""Every README that must not carry a copy of the command table.
|
||||
|
||||
@@ -768,7 +835,7 @@ def check_breaking_change_for_boundary() -> list[str]:
|
||||
|
||||
@app.command("verify")
|
||||
def verify():
|
||||
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, version/changelog agreement, and issue references in shipped documents."""
|
||||
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, version/changelog agreement, issue references, and link targets in shipped documents."""
|
||||
issues = (
|
||||
check_cli_readme()
|
||||
+ check_readmes_have_no_command_table()
|
||||
@@ -780,6 +847,7 @@ def verify():
|
||||
+ check_breaking_change_for_boundary()
|
||||
+ check_no_issue_references()
|
||||
+ check_toc_regions()
|
||||
+ check_reference_targets()
|
||||
)
|
||||
|
||||
if issues:
|
||||
@@ -791,7 +859,8 @@ def verify():
|
||||
f"{len(STAGE_CONTRACTS)} stage contract(s) present, no legacy type blocks, "
|
||||
f"{len(IGNORE_CANARIES)} ignore canaries clear, "
|
||||
f"no issue references in {len(shipped_prose())} shipped document(s), "
|
||||
f"tables of contents current on {len(toc.target_files())} reference file(s), "
|
||||
f"tables of contents current and every link resolving on "
|
||||
f"{len(toc.target_files())} reference file(s), "
|
||||
f"{version_mod.CHANGES_FILENAME} documents version "
|
||||
f"{(config.ROOT / version_mod.VERSION_FILENAME).read_text(encoding='utf-8').strip()}."
|
||||
)
|
||||
|
||||
@@ -20,6 +20,19 @@ Both target directories are gitignored. A fresh clone has no skills until `sync`
|
||||
runs; `instructions/bootstrap.md` is the procedure, and `verify` says so rather
|
||||
than reporting an error when *every* copy is missing, because that is the
|
||||
expected state of a clean checkout rather than a fault.
|
||||
|
||||
The copy is also a different depth than the source, and without the sibling
|
||||
files a relative link might expect - a plain `shutil.copytree` per skill
|
||||
directory, not a mirror of the whole `instructions/` tree. A relative markdown
|
||||
link correct at `instructions/<name>/SKILL.md` therefore resolves to a
|
||||
different, usually nonexistent, file in the published copy the harness
|
||||
actually reads. `verify` forbids the shape outright
|
||||
(`check_skill_reference_paths`) rather than checking depth arithmetic, and a
|
||||
`SKILL.md` writes an outbound reference as a repo-root-relative plain path
|
||||
instead - see instructions/CONTRACT.md § "A skill's outbound reference is a
|
||||
plain path, not a link". `docs_verify.check_reference_targets` is the
|
||||
complementary check, over the flat instructions and contracts that are still
|
||||
allowed to link normally because nothing ever copies them elsewhere.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -31,8 +44,8 @@ from pathlib import Path
|
||||
import typer
|
||||
import yaml
|
||||
|
||||
from chemenu import config
|
||||
from chemenu.commands import dist_cmd
|
||||
from chemenu import config, markdown_code
|
||||
from chemenu.commands import dist_cmd, docs_verify
|
||||
from chemenu.commands._util import fail, rel_path, success
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
@@ -313,6 +326,45 @@ def dev_only_forbidden_references(instructions_dir: Path | None = None) -> set[s
|
||||
return referenced
|
||||
|
||||
|
||||
def check_skill_reference_paths() -> list[str]:
|
||||
"""No `SKILL.md` may carry a relative markdown link.
|
||||
|
||||
`sync` copies each skill directory verbatim into `.agents/skills/<name>/`
|
||||
and `.claude/skills/<name>/` - a different depth than
|
||||
`instructions/<name>/SKILL.md` itself, and without the sibling files a
|
||||
relative link might expect. A markdown link that resolves correctly at
|
||||
the source (`../session-setup.md`, `../../kb/CONTRACT.md`) resolves to a
|
||||
different, usually nonexistent, file once copied: the number of `../`
|
||||
segments that reaches a target from `instructions/<name>/` does not reach
|
||||
the same target from `.claude/skills/<name>/`.
|
||||
|
||||
So a `SKILL.md` never writes an outbound reference as a relative markdown
|
||||
link - it names the target as a repo-root-relative plain path instead
|
||||
(`` `instructions/session-setup.md` ``, not
|
||||
`[session-setup.md](../session-setup.md)`). See instructions/CONTRACT.md
|
||||
§ "A skill's outbound reference is a plain path, not a link" for why that
|
||||
form survives the copy unchanged.
|
||||
`docs_verify.check_reference_targets` is the complementary check, over the
|
||||
flat instructions and contracts that are still allowed to link normally
|
||||
because nothing ever copies them elsewhere."""
|
||||
issues: list[str] = []
|
||||
for source in skill_dirs():
|
||||
path = source / SKILL_FILE
|
||||
text = path.read_text(encoding="utf-8")
|
||||
masked = markdown_code.strip_code_spans(text)
|
||||
for line_number, masked_line in enumerate(masked.splitlines(), start=1):
|
||||
for match in docs_verify.MARKDOWN_LINK_RE.finditer(masked_line):
|
||||
target = match.group(1)
|
||||
if docs_verify.is_external_or_anchor(target):
|
||||
continue
|
||||
issues.append(
|
||||
f"{rel_path(path)}:{line_number} carries a relative markdown link to "
|
||||
f"`{target}` - `instructions sync` copies this file to a different depth, "
|
||||
"so write the target as a plain repo-root-relative path instead"
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@app.command("sync")
|
||||
def sync(
|
||||
force: bool = typer.Option(
|
||||
@@ -351,7 +403,7 @@ def sync(
|
||||
|
||||
@app.command("verify")
|
||||
def verify():
|
||||
"""Check instructions/ against its type, and every published copy against its source."""
|
||||
"""Check instructions/ against its type, that no skill carries a relative markdown link, and every published copy against its source."""
|
||||
sources = skill_dirs()
|
||||
instructions = instruction_files()
|
||||
if not sources and not instructions:
|
||||
@@ -398,7 +450,11 @@ def verify():
|
||||
if not frontmatter.get("description"):
|
||||
issues.append(f"{source.name}: SKILL.md is missing (or has an empty) `description`")
|
||||
|
||||
# 3. Published copies match their sources. Missing *everywhere* is a clean
|
||||
# 3. No skill carries a relative markdown link - see
|
||||
# check_skill_reference_paths's own docstring for why the copy breaks it.
|
||||
issues.extend(check_skill_reference_paths())
|
||||
|
||||
# 4. Published copies match their sources. Missing *everywhere* is a clean
|
||||
# checkout, not a fault - say what to run instead of reporting drift.
|
||||
expected = len(sources) * len(target_dirs())
|
||||
missing = 0
|
||||
@@ -419,7 +475,7 @@ def verify():
|
||||
if missing and not bootstrap_needed:
|
||||
issues.append(f"{missing} published copy/copies missing - run `wikitool instructions sync`")
|
||||
|
||||
# 4. An instruction nothing loads is inert. Nothing else would report it -
|
||||
# 5. An instruction nothing loads is inert. Nothing else would report it -
|
||||
# unless it is `manual: true`, which inverts the rule over a narrower
|
||||
# haystack: that instruction must not be linked from AGENTS.md or a
|
||||
# skill (automatic pickup), though a CONTRACT.md mentioning it by name
|
||||
@@ -441,7 +497,7 @@ def verify():
|
||||
"Link it from a skill, a contract, AGENTS.md, or CLAUDE.md, or delete it."
|
||||
)
|
||||
|
||||
# 5. instructions/dev/ is a hard boundary: `dist export` prunes it whole,
|
||||
# 6. instructions/dev/ is a hard boundary: `dist export` prunes it whole,
|
||||
# so nothing outside it may depend on something inside it staying
|
||||
# around in a distributed instance. See dev_only_forbidden_references's
|
||||
# docstring for the dist:strip exemption.
|
||||
|
||||
Reference in new issue
Block a user