feat(kyberforge): ADR-0020 context contract for skills and agents #103
@@ -222,17 +222,21 @@ def read_text(path):
|
|||||||
# which is what a monorepo means,
|
# which is what a monorepo means,
|
||||||
# 2. the target's own apm package,
|
# 2. the target's own apm package,
|
||||||
# 3. the packages that package DECLARES in apm.yml dependencies.apm.
|
# 3. the packages that package DECLARES in apm.yml dependencies.apm.
|
||||||
# Deployed .claude/ and .agents/ trees are deliberately NOT consulted in that
|
# Deployed .claude/ and .agents/ trees are deliberately NOT consulted when the
|
||||||
# case. They are `apm install` output, gitignored, and present only on a machine
|
# root came from the plugins/ probe. They are `apm install` output, gitignored,
|
||||||
# that has run it: four cross-plugin targets in this repo (gitea-branches ->
|
# and present only on a machine that has run it: four cross-plugin targets in
|
||||||
# git-branches, gitea-branches -> git-history, gitea-issues -> git-branches,
|
# this repo (gitea-branches -> git-branches, gitea-branches -> git-history,
|
||||||
# gitea-workflow -> git-workflow) resolved through .claude/skills/ alone, so the
|
# gitea-issues -> git-branches, gitea-workflow -> git-workflow) resolved through
|
||||||
# same commit measured 2 dangling targets on a developer machine and 6 on a
|
# .claude/skills/ alone, so the same commit measured 2 dangling targets on a
|
||||||
# fresh clone. A gate shipping hot with no baseline cannot give two answers.
|
# developer machine and 6 on a fresh clone. A gate shipping hot with no baseline
|
||||||
|
# cannot give two answers.
|
||||||
#
|
#
|
||||||
# Deployed trees are used only when NO authoring root exists — the consumer
|
# Deployed trees ARE used when no plugin monorepo was found — whether the walk
|
||||||
# case, where the file being checked lives in or beside a deployed tree and
|
# landed on a bare .git ancestor or on nothing at all. That is the consumer
|
||||||
# there is no monorepo to read.
|
# case: the file being checked lives in or beside a deployed tree, inside an
|
||||||
|
# ordinary git repo, with no monorepo to read. The two cases are told apart by
|
||||||
|
# which probe matched, never by how many names a root contributed; see
|
||||||
|
# known_targets().
|
||||||
|
|
||||||
|
|
||||||
def _is_fs_root(path):
|
def _is_fs_root(path):
|
||||||
@@ -241,11 +245,17 @@ def _is_fs_root(path):
|
|||||||
|
|
||||||
def _collect_package(pkg_dir, names):
|
def _collect_package(pkg_dir, names):
|
||||||
"""Add every skill/agent name a package directory exposes, any layout."""
|
"""Add every skill/agent name a package directory exposes, any layout."""
|
||||||
|
# glob.escape() the DIRECTORY only. A checkout path containing `[`, `]`,
|
||||||
|
# `*` or `?` — a worktree named `feature[2]`, say — otherwise turns the
|
||||||
|
# whole pattern into a character class that matches nothing, and the
|
||||||
|
# resolver degrades to the "DID NOT RUN" INFO with rc=0 across every file
|
||||||
|
# in the tree. The wildcards in `sub` are the intended ones and stay raw.
|
||||||
|
safe_dir = glob.escape(pkg_dir)
|
||||||
for sub in ('.apm/skills/*/', 'skills/*/'):
|
for sub in ('.apm/skills/*/', 'skills/*/'):
|
||||||
for path in glob.glob(os.path.join(pkg_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
names.add(os.path.basename(path.rstrip('/')).lower())
|
names.add(os.path.basename(path.rstrip('/')).lower())
|
||||||
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
||||||
for path in glob.glob(os.path.join(pkg_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
base = os.path.basename(path)
|
base = os.path.basename(path)
|
||||||
if base.endswith('.agent.md'):
|
if base.endswith('.agent.md'):
|
||||||
base = base[:-len('.agent.md')]
|
base = base[:-len('.agent.md')]
|
||||||
@@ -277,28 +287,35 @@ def _apm_package_root(start_dir):
|
|||||||
def _authoring_root(start_dir):
|
def _authoring_root(start_dir):
|
||||||
"""Nearest ancestor that is a plugin monorepo, else the nearest .git tree.
|
"""Nearest ancestor that is a plugin monorepo, else the nearest .git tree.
|
||||||
|
|
||||||
|
Returns (root, matched_plugins_probe). The flag reports WHICH probe
|
||||||
|
matched: True for the plugins/*/.apm/{skills,agents} glob, False for the
|
||||||
|
.git fallback and for no match at all. known_targets() needs that
|
||||||
|
distinction — only a real plugins/ root makes the deployed trees
|
||||||
|
redundant, and a name-count delta cannot tell the two apart.
|
||||||
|
|
||||||
Two passes, not one interleaved walk: a nested .git (a submodule, a
|
Two passes, not one interleaved walk: a nested .git (a submodule, a
|
||||||
worktree of a sub-package) must not win over a real plugins/ root further
|
worktree of a sub-package) must not win over a real plugins/ root further
|
||||||
up. Both passes stop before the filesystem root for the same reason
|
up. Both passes stop before the filesystem root for the same reason
|
||||||
_apm_package_root does.
|
_apm_package_root does.
|
||||||
"""
|
"""
|
||||||
for probe in (
|
probes = (
|
||||||
lambda d: bool(glob.glob(os.path.join(d, 'plugins', '*', '.apm', 'skills'))
|
lambda d: bool(glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'skills'))
|
||||||
or glob.glob(os.path.join(d, 'plugins', '*', '.apm', 'agents'))),
|
or glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'agents'))),
|
||||||
lambda d: os.path.exists(os.path.join(d, '.git'))):
|
lambda d: os.path.exists(os.path.join(d, '.git')))
|
||||||
|
for index, probe in enumerate(probes):
|
||||||
current = os.path.abspath(start_dir)
|
current = os.path.abspath(start_dir)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if _is_fs_root(current):
|
if _is_fs_root(current):
|
||||||
break
|
break
|
||||||
if probe(current):
|
if probe(current):
|
||||||
return current
|
return current, index == 0
|
||||||
current = os.path.dirname(current)
|
current = os.path.dirname(current)
|
||||||
return None
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
def _collect_authoring_root(root, names):
|
def _collect_authoring_root(root, names):
|
||||||
"""Every plugin in the monorepo contributes its names."""
|
"""Every plugin in the monorepo contributes its names."""
|
||||||
for pkg in glob.glob(os.path.join(root, 'plugins', '*')):
|
for pkg in glob.glob(os.path.join(glob.escape(root), 'plugins', '*')):
|
||||||
if os.path.isdir(pkg):
|
if os.path.isdir(pkg):
|
||||||
_collect_package(pkg, names)
|
_collect_package(pkg, names)
|
||||||
|
|
||||||
@@ -362,7 +379,7 @@ def _declared_dependency_dirs(pkg_dir):
|
|||||||
def _deployed_roots(start_dir):
|
def _deployed_roots(start_dir):
|
||||||
""".claude/ and .agents/ trees above start_dir — what a host really sees.
|
""".claude/ and .agents/ trees above start_dir — what a host really sees.
|
||||||
|
|
||||||
Consulted ONLY when no authoring root exists; see the section header. The
|
Consulted ONLY when no plugin monorepo root was found; see the header. The
|
||||||
filesystem root is skipped for the same reason _apm_package_root skips it:
|
filesystem root is skipped for the same reason _apm_package_root skips it:
|
||||||
a stray /.claude/skills/ must not join every path's universe.
|
a stray /.claude/skills/ must not join every path's universe.
|
||||||
"""
|
"""
|
||||||
@@ -401,10 +418,22 @@ def known_targets(start_dir):
|
|||||||
for dep_dir in _declared_dependency_dirs(package):
|
for dep_dir in _declared_dependency_dirs(package):
|
||||||
_collect_package(dep_dir, names)
|
_collect_package(dep_dir, names)
|
||||||
|
|
||||||
root = _authoring_root(start)
|
# A .git ancestor is an authoring root only if it actually holds plugins.
|
||||||
|
# _authoring_root() falls back to the nearest .git, so it is truthy in ANY
|
||||||
|
# git repo; without the distinction that fallback wins in every consumer
|
||||||
|
# checkout, _collect_authoring_root() contributes nothing, and the deployed
|
||||||
|
# branch below is dead code in the exact case it exists for. So condition
|
||||||
|
# on WHICH probe matched, which _authoring_root() reports directly. A
|
||||||
|
# name-count delta looks equivalent and is not: _collect_authoring_root()
|
||||||
|
# re-collects the checked file's own plugin, whose names the blocks above
|
||||||
|
# already added, so a one-plugin monorepo shows a delta of zero and would
|
||||||
|
# wrongly reach for the deployed trees — including the user's global
|
||||||
|
# ~/.claude/skills, making the verdict depend on what happens to be
|
||||||
|
# installed (ADR-0020 lines 118-127).
|
||||||
|
root, root_has_plugins = _authoring_root(start)
|
||||||
if root:
|
if root:
|
||||||
_collect_authoring_root(root, names)
|
_collect_authoring_root(root, names)
|
||||||
else:
|
if not root_has_plugins:
|
||||||
for base in _deployed_roots(start):
|
for base in _deployed_roots(start):
|
||||||
_collect_package(base, names)
|
_collect_package(base, names)
|
||||||
return names
|
return names
|
||||||
@@ -510,11 +539,17 @@ MARKED_TARGET = r"(?:`/?(%s)`|(?<![\w./*-])/(%s)\b)" % (NAME_ANY, NAME_ANY)
|
|||||||
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
||||||
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
||||||
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
||||||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET)
|
# re.I on ALL of them, uniformly. The patterns are built from the same
|
||||||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET)
|
# lowercase NAME_* fragments, so half of them carrying the flag and half not
|
||||||
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET)
|
# meant `Skill-Audit` at the start of a boundary sentence was extracted by
|
||||||
|
# ROUTE_ANY but invisible to BACKTICK — contradicting normalize_target()'s own
|
||||||
|
# docstring, which exists precisely because extraction is case-insensitive and
|
||||||
|
# the universe is not.
|
||||||
|
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||||||
|
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||||||
|
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
||||||
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
||||||
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH)
|
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I)
|
||||||
# A boundary clause takes two shapes and BOTH count: the prose markers, and
|
# A boundary clause takes two shapes and BOTH count: the prose markers, and
|
||||||
# ADR-0020's compressed arrow form `Not <thing> -> <name>`.
|
# ADR-0020's compressed arrow form `Not <thing> -> <name>`.
|
||||||
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
|
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
|
||||||
@@ -681,8 +716,16 @@ def unresolved_targets(description, known):
|
|||||||
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
|
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
|
||||||
# be measured must never report green, so every caller of these two ERRORs on a
|
# be measured must never report green, so every caller of these two ERRORs on a
|
||||||
# miss instead of moving on.
|
# miss instead of moving on.
|
||||||
|
#
|
||||||
|
# The CLOSING marker is anchored at column 0 — deliberately NOT `[ \t]*---`.
|
||||||
|
# YAML block-scalar content must be indented deeper than its key, so an
|
||||||
|
# indented `---` inside a folded description is CONTENT; letting it close the
|
||||||
|
# frontmatter truncated the description mid-value and silently reclassified the
|
||||||
|
# rest as body, which is a vacuous green in both directions at once. Leading
|
||||||
|
# whitespace is still tolerated on the OPENING marker, where no such content
|
||||||
|
# can exist.
|
||||||
FRONTMATTER_RE = re.compile(
|
FRONTMATTER_RE = re.compile(
|
||||||
r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n[ \t]*---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
def strip_bom(text):
|
def strip_bom(text):
|
||||||
@@ -714,14 +757,26 @@ def description_value(fm_text):
|
|||||||
try:
|
try:
|
||||||
data = yaml.safe_load(fm_text)
|
data = yaml.safe_load(fm_text)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise FrontmatterError(re.sub(r'\s+', ' ', str(exc)).strip())
|
# Every FrontmatterError message is a COMPLETE clause, never a detail a
|
||||||
|
# caller wraps in one. Callers used to prefix a hard-coded "frontmatter
|
||||||
|
# is not valid YAML (...)", which is true only of this branch: the two
|
||||||
|
# type failures below come from frontmatter that parsed fine, and
|
||||||
|
# telling their author the YAML is invalid sends them hunting for a
|
||||||
|
# syntax error that is not there — on a blocking gate with no baseline.
|
||||||
|
raise FrontmatterError('frontmatter is not valid YAML (%s)'
|
||||||
|
% re.sub(r'\s+', ' ', str(exc)).strip())
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise FrontmatterError('frontmatter is not a YAML mapping')
|
raise FrontmatterError('frontmatter is not a YAML mapping')
|
||||||
value = data.get('description')
|
value = data.get('description')
|
||||||
if value is None:
|
if value is None:
|
||||||
return ''
|
return ''
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
value = str(value)
|
# NOT str()-coerced. `description: true` became the 4-character "True"
|
||||||
|
# and sailed through the 400-character gate; a list or mapping was
|
||||||
|
# measured as its Python repr. Neither is a description a host can
|
||||||
|
# preload, so this is a parse failure, reported as one.
|
||||||
|
raise FrontmatterError(
|
||||||
|
'description is a %s, not a string' % type(value).__name__)
|
||||||
return re.sub(r'\s+', ' ', value).strip()
|
return re.sub(r'\s+', ' ', value).strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -791,6 +846,18 @@ def mask_fenced(text):
|
|||||||
if (marker and marker[0] == fence[0] and len(marker) >= len(fence)
|
if (marker and marker[0] == fence[0] and len(marker) >= len(fence)
|
||||||
and not stripped.strip()[len(marker):].strip()):
|
and not stripped.strip()[len(marker):].strip()):
|
||||||
fence = None
|
fence = None
|
||||||
|
# An UNCLOSED fence has no cost-free answer, only a choice of which way to
|
||||||
|
# be wrong. Masking to end-of-body blanks the rest of the body, silently
|
||||||
|
# disabling the ERROR-tier references/ check and the gotcha counts.
|
||||||
|
# Returning the raw text instead exposes the unclosed example's own
|
||||||
|
# content, so a fenced example naming a nonexistent references/ file
|
||||||
|
# becomes a hard ERROR it would not have been had the fence been closed —
|
||||||
|
# confirmed, not hypothetical. The loud-false-positive direction is the one
|
||||||
|
# chosen: this script's rule is that a file it cannot measure must never
|
||||||
|
# report green, and masking-onward is exactly that failure. Both outcomes
|
||||||
|
# need an already-malformed file, and the false positive costs one fence.
|
||||||
|
if fence is not None:
|
||||||
|
return text
|
||||||
return ''.join(out)
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
@@ -869,12 +936,15 @@ def get_frontmatter_keys(fm):
|
|||||||
return keys
|
return keys
|
||||||
|
|
||||||
def agent_description(fm, local_fname):
|
def agent_description(fm, local_fname):
|
||||||
"""The folded description VALUE, or None if the frontmatter is not YAML."""
|
"""The folded description VALUE, or None if it could not be read."""
|
||||||
try:
|
try:
|
||||||
return description_value(fm)
|
return description_value(fm)
|
||||||
except FrontmatterError as exc:
|
except FrontmatterError as exc:
|
||||||
fail(f"frontmatter is not valid YAML ({exc}) — the ADR-0020 description and "
|
# `exc` carries the whole clause — invalid YAML, a non-mapping block, or
|
||||||
f"boundary-target gates could not run — {local_fname}")
|
# a description of the wrong type. Do not prefix a diagnosis here; the
|
||||||
|
# last one named a syntax error for two failures that have none.
|
||||||
|
fail(f"{exc} — the ADR-0020 description and boundary-target gates could "
|
||||||
|
f"not run — {local_fname}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def check_description_budget(value, local_fname):
|
def check_description_budget(value, local_fname):
|
||||||
@@ -944,11 +1014,31 @@ def check_boundary(value, fpath, local_fname):
|
|||||||
f"{local_fname}")
|
f"{local_fname}")
|
||||||
|
|
||||||
def extract_tools_list(fm):
|
def extract_tools_list(fm):
|
||||||
"""Extract tool names from the tools frontmatter field (space or comma separated)."""
|
"""Tool names from the `tools` field — inline scalar OR YAML block sequence.
|
||||||
val = extract_field(fm, 'tools')
|
|
||||||
if not val:
|
Read off the PARSED mapping, never off extract_field(). That function's
|
||||||
|
capture is newline-bounded on purpose (`[^\\S\\r\\n]*(.+)`), so a `tools:`
|
||||||
|
written as a block sequence — the shape Copilot agent files use — captured
|
||||||
|
nothing at all and the subagent-unavailable-tool check silently stopped
|
||||||
|
firing on exactly the files it was written for. Both spellings are legal
|
||||||
|
YAML, so both are read here.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(fm)
|
||||||
|
except Exception:
|
||||||
|
# Not this function's failure to report: the frontmatter's validity is
|
||||||
|
# decided (and failed) by agent_description() on the same text.
|
||||||
return set()
|
return set()
|
||||||
return set(re.split(r'[\s,]+', val.strip()))
|
if not isinstance(data, dict):
|
||||||
|
return set()
|
||||||
|
val = data.get('tools')
|
||||||
|
if isinstance(val, list):
|
||||||
|
items = [str(item).strip() for item in val]
|
||||||
|
elif isinstance(val, str):
|
||||||
|
items = re.split(r'[\s,]+', val.strip())
|
||||||
|
else:
|
||||||
|
return set()
|
||||||
|
return {item for item in items if item}
|
||||||
|
|
||||||
def is_copilot_cloud_ide(fpath):
|
def is_copilot_cloud_ide(fpath):
|
||||||
"""True if the file is a cloud/IDE Copilot agent (name is optional for these)."""
|
"""True if the file is a cloud/IDE Copilot agent (name is optional for these)."""
|
||||||
@@ -1063,6 +1153,15 @@ def check_apm_agent_file(fpath, allowlist, stem):
|
|||||||
fail(f"file is {exc}. Nothing could be measured, so this is a hard "
|
fail(f"file is {exc}. Nothing could be measured, so this is a hard "
|
||||||
f"failure, not a skip — {local_fname}")
|
f"failure, not a skip — {local_fname}")
|
||||||
return
|
return
|
||||||
|
except OSError as exc:
|
||||||
|
# A path that cannot be opened gets a FAIL line naming it, not a bare
|
||||||
|
# FileNotFoundError traceback. scripts/check-apm-agents-valid.sh takes
|
||||||
|
# this path for an agent file deleted from the worktree but still
|
||||||
|
# tracked in the index — a real, expected state, and the caller needs to
|
||||||
|
# be told which file, not handed an interpreter stack.
|
||||||
|
fail(f"could not be read ({exc.strerror or exc}): {fpath}. Nothing could "
|
||||||
|
f"be measured, so this is a hard failure, not a skip — {local_fname}")
|
||||||
|
return
|
||||||
|
|
||||||
fm, body = parse_frontmatter(content)
|
fm, body = parse_frontmatter(content)
|
||||||
if fm is None:
|
if fm is None:
|
||||||
@@ -1168,6 +1267,13 @@ def check_file(fpath, file_provider):
|
|||||||
fail(f"file is {exc}. Nothing could be measured, so this is a hard "
|
fail(f"file is {exc}. Nothing could be measured, so this is a hard "
|
||||||
f"failure, not a skip — {local_fname}")
|
f"failure, not a skip — {local_fname}")
|
||||||
return
|
return
|
||||||
|
except OSError as exc:
|
||||||
|
# Same reason as check_apm_agent_file's: a diagnostic naming the path
|
||||||
|
# beats a FileNotFoundError traceback. The counterpart is pre-checked at
|
||||||
|
# the bottom of this script, but agent_file itself never was.
|
||||||
|
fail(f"could not be read ({exc.strerror or exc}): {fpath}. Nothing could "
|
||||||
|
f"be measured, so this is a hard failure, not a skip — {local_fname}")
|
||||||
|
return
|
||||||
|
|
||||||
fm, body = parse_frontmatter(content)
|
fm, body = parse_frontmatter(content)
|
||||||
if fm is None:
|
if fm is None:
|
||||||
|
|||||||
@@ -148,17 +148,21 @@ def read_text(path):
|
|||||||
# which is what a monorepo means,
|
# which is what a monorepo means,
|
||||||
# 2. the target's own apm package,
|
# 2. the target's own apm package,
|
||||||
# 3. the packages that package DECLARES in apm.yml dependencies.apm.
|
# 3. the packages that package DECLARES in apm.yml dependencies.apm.
|
||||||
# Deployed .claude/ and .agents/ trees are deliberately NOT consulted in that
|
# Deployed .claude/ and .agents/ trees are deliberately NOT consulted when the
|
||||||
# case. They are `apm install` output, gitignored, and present only on a machine
|
# root came from the plugins/ probe. They are `apm install` output, gitignored,
|
||||||
# that has run it: four cross-plugin targets in this repo (gitea-branches ->
|
# and present only on a machine that has run it: four cross-plugin targets in
|
||||||
# git-branches, gitea-branches -> git-history, gitea-issues -> git-branches,
|
# this repo (gitea-branches -> git-branches, gitea-branches -> git-history,
|
||||||
# gitea-workflow -> git-workflow) resolved through .claude/skills/ alone, so the
|
# gitea-issues -> git-branches, gitea-workflow -> git-workflow) resolved through
|
||||||
# same commit measured 2 dangling targets on a developer machine and 6 on a
|
# .claude/skills/ alone, so the same commit measured 2 dangling targets on a
|
||||||
# fresh clone. A gate shipping hot with no baseline cannot give two answers.
|
# developer machine and 6 on a fresh clone. A gate shipping hot with no baseline
|
||||||
|
# cannot give two answers.
|
||||||
#
|
#
|
||||||
# Deployed trees are used only when NO authoring root exists — the consumer
|
# Deployed trees ARE used when no plugin monorepo was found — whether the walk
|
||||||
# case, where the file being checked lives in or beside a deployed tree and
|
# landed on a bare .git ancestor or on nothing at all. That is the consumer
|
||||||
# there is no monorepo to read.
|
# case: the file being checked lives in or beside a deployed tree, inside an
|
||||||
|
# ordinary git repo, with no monorepo to read. The two cases are told apart by
|
||||||
|
# which probe matched, never by how many names a root contributed; see
|
||||||
|
# known_targets().
|
||||||
|
|
||||||
|
|
||||||
def _is_fs_root(path):
|
def _is_fs_root(path):
|
||||||
@@ -167,11 +171,17 @@ def _is_fs_root(path):
|
|||||||
|
|
||||||
def _collect_package(pkg_dir, names):
|
def _collect_package(pkg_dir, names):
|
||||||
"""Add every skill/agent name a package directory exposes, any layout."""
|
"""Add every skill/agent name a package directory exposes, any layout."""
|
||||||
|
# glob.escape() the DIRECTORY only. A checkout path containing `[`, `]`,
|
||||||
|
# `*` or `?` — a worktree named `feature[2]`, say — otherwise turns the
|
||||||
|
# whole pattern into a character class that matches nothing, and the
|
||||||
|
# resolver degrades to the "DID NOT RUN" INFO with rc=0 across every file
|
||||||
|
# in the tree. The wildcards in `sub` are the intended ones and stay raw.
|
||||||
|
safe_dir = glob.escape(pkg_dir)
|
||||||
for sub in ('.apm/skills/*/', 'skills/*/'):
|
for sub in ('.apm/skills/*/', 'skills/*/'):
|
||||||
for path in glob.glob(os.path.join(pkg_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
names.add(os.path.basename(path.rstrip('/')).lower())
|
names.add(os.path.basename(path.rstrip('/')).lower())
|
||||||
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
||||||
for path in glob.glob(os.path.join(pkg_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
base = os.path.basename(path)
|
base = os.path.basename(path)
|
||||||
if base.endswith('.agent.md'):
|
if base.endswith('.agent.md'):
|
||||||
base = base[:-len('.agent.md')]
|
base = base[:-len('.agent.md')]
|
||||||
@@ -203,28 +213,35 @@ def _apm_package_root(start_dir):
|
|||||||
def _authoring_root(start_dir):
|
def _authoring_root(start_dir):
|
||||||
"""Nearest ancestor that is a plugin monorepo, else the nearest .git tree.
|
"""Nearest ancestor that is a plugin monorepo, else the nearest .git tree.
|
||||||
|
|
||||||
|
Returns (root, matched_plugins_probe). The flag reports WHICH probe
|
||||||
|
matched: True for the plugins/*/.apm/{skills,agents} glob, False for the
|
||||||
|
.git fallback and for no match at all. known_targets() needs that
|
||||||
|
distinction — only a real plugins/ root makes the deployed trees
|
||||||
|
redundant, and a name-count delta cannot tell the two apart.
|
||||||
|
|
||||||
Two passes, not one interleaved walk: a nested .git (a submodule, a
|
Two passes, not one interleaved walk: a nested .git (a submodule, a
|
||||||
worktree of a sub-package) must not win over a real plugins/ root further
|
worktree of a sub-package) must not win over a real plugins/ root further
|
||||||
up. Both passes stop before the filesystem root for the same reason
|
up. Both passes stop before the filesystem root for the same reason
|
||||||
_apm_package_root does.
|
_apm_package_root does.
|
||||||
"""
|
"""
|
||||||
for probe in (
|
probes = (
|
||||||
lambda d: bool(glob.glob(os.path.join(d, 'plugins', '*', '.apm', 'skills'))
|
lambda d: bool(glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'skills'))
|
||||||
or glob.glob(os.path.join(d, 'plugins', '*', '.apm', 'agents'))),
|
or glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'agents'))),
|
||||||
lambda d: os.path.exists(os.path.join(d, '.git'))):
|
lambda d: os.path.exists(os.path.join(d, '.git')))
|
||||||
|
for index, probe in enumerate(probes):
|
||||||
current = os.path.abspath(start_dir)
|
current = os.path.abspath(start_dir)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if _is_fs_root(current):
|
if _is_fs_root(current):
|
||||||
break
|
break
|
||||||
if probe(current):
|
if probe(current):
|
||||||
return current
|
return current, index == 0
|
||||||
current = os.path.dirname(current)
|
current = os.path.dirname(current)
|
||||||
return None
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
def _collect_authoring_root(root, names):
|
def _collect_authoring_root(root, names):
|
||||||
"""Every plugin in the monorepo contributes its names."""
|
"""Every plugin in the monorepo contributes its names."""
|
||||||
for pkg in glob.glob(os.path.join(root, 'plugins', '*')):
|
for pkg in glob.glob(os.path.join(glob.escape(root), 'plugins', '*')):
|
||||||
if os.path.isdir(pkg):
|
if os.path.isdir(pkg):
|
||||||
_collect_package(pkg, names)
|
_collect_package(pkg, names)
|
||||||
|
|
||||||
@@ -288,7 +305,7 @@ def _declared_dependency_dirs(pkg_dir):
|
|||||||
def _deployed_roots(start_dir):
|
def _deployed_roots(start_dir):
|
||||||
""".claude/ and .agents/ trees above start_dir — what a host really sees.
|
""".claude/ and .agents/ trees above start_dir — what a host really sees.
|
||||||
|
|
||||||
Consulted ONLY when no authoring root exists; see the section header. The
|
Consulted ONLY when no plugin monorepo root was found; see the header. The
|
||||||
filesystem root is skipped for the same reason _apm_package_root skips it:
|
filesystem root is skipped for the same reason _apm_package_root skips it:
|
||||||
a stray /.claude/skills/ must not join every path's universe.
|
a stray /.claude/skills/ must not join every path's universe.
|
||||||
"""
|
"""
|
||||||
@@ -327,10 +344,22 @@ def known_targets(start_dir):
|
|||||||
for dep_dir in _declared_dependency_dirs(package):
|
for dep_dir in _declared_dependency_dirs(package):
|
||||||
_collect_package(dep_dir, names)
|
_collect_package(dep_dir, names)
|
||||||
|
|
||||||
root = _authoring_root(start)
|
# A .git ancestor is an authoring root only if it actually holds plugins.
|
||||||
|
# _authoring_root() falls back to the nearest .git, so it is truthy in ANY
|
||||||
|
# git repo; without the distinction that fallback wins in every consumer
|
||||||
|
# checkout, _collect_authoring_root() contributes nothing, and the deployed
|
||||||
|
# branch below is dead code in the exact case it exists for. So condition
|
||||||
|
# on WHICH probe matched, which _authoring_root() reports directly. A
|
||||||
|
# name-count delta looks equivalent and is not: _collect_authoring_root()
|
||||||
|
# re-collects the checked file's own plugin, whose names the blocks above
|
||||||
|
# already added, so a one-plugin monorepo shows a delta of zero and would
|
||||||
|
# wrongly reach for the deployed trees — including the user's global
|
||||||
|
# ~/.claude/skills, making the verdict depend on what happens to be
|
||||||
|
# installed (ADR-0020 lines 118-127).
|
||||||
|
root, root_has_plugins = _authoring_root(start)
|
||||||
if root:
|
if root:
|
||||||
_collect_authoring_root(root, names)
|
_collect_authoring_root(root, names)
|
||||||
else:
|
if not root_has_plugins:
|
||||||
for base in _deployed_roots(start):
|
for base in _deployed_roots(start):
|
||||||
_collect_package(base, names)
|
_collect_package(base, names)
|
||||||
return names
|
return names
|
||||||
@@ -436,11 +465,17 @@ MARKED_TARGET = r"(?:`/?(%s)`|(?<![\w./*-])/(%s)\b)" % (NAME_ANY, NAME_ANY)
|
|||||||
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
||||||
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
||||||
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
||||||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET)
|
# re.I on ALL of them, uniformly. The patterns are built from the same
|
||||||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET)
|
# lowercase NAME_* fragments, so half of them carrying the flag and half not
|
||||||
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET)
|
# meant `Skill-Audit` at the start of a boundary sentence was extracted by
|
||||||
|
# ROUTE_ANY but invisible to BACKTICK — contradicting normalize_target()'s own
|
||||||
|
# docstring, which exists precisely because extraction is case-insensitive and
|
||||||
|
# the universe is not.
|
||||||
|
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||||||
|
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||||||
|
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
||||||
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
||||||
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH)
|
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I)
|
||||||
# A boundary clause takes two shapes and BOTH count: the prose markers, and
|
# A boundary clause takes two shapes and BOTH count: the prose markers, and
|
||||||
# ADR-0020's compressed arrow form `Not <thing> -> <name>`.
|
# ADR-0020's compressed arrow form `Not <thing> -> <name>`.
|
||||||
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
|
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
|
||||||
@@ -607,8 +642,16 @@ def unresolved_targets(description, known):
|
|||||||
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
|
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
|
||||||
# be measured must never report green, so every caller of these two ERRORs on a
|
# be measured must never report green, so every caller of these two ERRORs on a
|
||||||
# miss instead of moving on.
|
# miss instead of moving on.
|
||||||
|
#
|
||||||
|
# The CLOSING marker is anchored at column 0 — deliberately NOT `[ \t]*---`.
|
||||||
|
# YAML block-scalar content must be indented deeper than its key, so an
|
||||||
|
# indented `---` inside a folded description is CONTENT; letting it close the
|
||||||
|
# frontmatter truncated the description mid-value and silently reclassified the
|
||||||
|
# rest as body, which is a vacuous green in both directions at once. Leading
|
||||||
|
# whitespace is still tolerated on the OPENING marker, where no such content
|
||||||
|
# can exist.
|
||||||
FRONTMATTER_RE = re.compile(
|
FRONTMATTER_RE = re.compile(
|
||||||
r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n[ \t]*---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
def strip_bom(text):
|
def strip_bom(text):
|
||||||
@@ -640,14 +683,26 @@ def description_value(fm_text):
|
|||||||
try:
|
try:
|
||||||
data = yaml.safe_load(fm_text)
|
data = yaml.safe_load(fm_text)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise FrontmatterError(re.sub(r'\s+', ' ', str(exc)).strip())
|
# Every FrontmatterError message is a COMPLETE clause, never a detail a
|
||||||
|
# caller wraps in one. Callers used to prefix a hard-coded "frontmatter
|
||||||
|
# is not valid YAML (...)", which is true only of this branch: the two
|
||||||
|
# type failures below come from frontmatter that parsed fine, and
|
||||||
|
# telling their author the YAML is invalid sends them hunting for a
|
||||||
|
# syntax error that is not there — on a blocking gate with no baseline.
|
||||||
|
raise FrontmatterError('frontmatter is not valid YAML (%s)'
|
||||||
|
% re.sub(r'\s+', ' ', str(exc)).strip())
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise FrontmatterError('frontmatter is not a YAML mapping')
|
raise FrontmatterError('frontmatter is not a YAML mapping')
|
||||||
value = data.get('description')
|
value = data.get('description')
|
||||||
if value is None:
|
if value is None:
|
||||||
return ''
|
return ''
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
value = str(value)
|
# NOT str()-coerced. `description: true` became the 4-character "True"
|
||||||
|
# and sailed through the 400-character gate; a list or mapping was
|
||||||
|
# measured as its Python repr. Neither is a description a host can
|
||||||
|
# preload, so this is a parse failure, reported as one.
|
||||||
|
raise FrontmatterError(
|
||||||
|
'description is a %s, not a string' % type(value).__name__)
|
||||||
return re.sub(r'\s+', ' ', value).strip()
|
return re.sub(r'\s+', ' ', value).strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -717,6 +772,18 @@ def mask_fenced(text):
|
|||||||
if (marker and marker[0] == fence[0] and len(marker) >= len(fence)
|
if (marker and marker[0] == fence[0] and len(marker) >= len(fence)
|
||||||
and not stripped.strip()[len(marker):].strip()):
|
and not stripped.strip()[len(marker):].strip()):
|
||||||
fence = None
|
fence = None
|
||||||
|
# An UNCLOSED fence has no cost-free answer, only a choice of which way to
|
||||||
|
# be wrong. Masking to end-of-body blanks the rest of the body, silently
|
||||||
|
# disabling the ERROR-tier references/ check and the gotcha counts.
|
||||||
|
# Returning the raw text instead exposes the unclosed example's own
|
||||||
|
# content, so a fenced example naming a nonexistent references/ file
|
||||||
|
# becomes a hard ERROR it would not have been had the fence been closed —
|
||||||
|
# confirmed, not hypothetical. The loud-false-positive direction is the one
|
||||||
|
# chosen: this script's rule is that a file it cannot measure must never
|
||||||
|
# report green, and masking-onward is exactly that failure. Both outcomes
|
||||||
|
# need an already-malformed file, and the false positive costs one fence.
|
||||||
|
if fence is not None:
|
||||||
|
return text
|
||||||
return ''.join(out)
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
@@ -802,8 +869,11 @@ name = name_m.group(1).strip('"\'') if name_m else ""
|
|||||||
try:
|
try:
|
||||||
desc = description_value(fm)
|
desc = description_value(fm)
|
||||||
except FrontmatterError as exc:
|
except FrontmatterError as exc:
|
||||||
fail(f"frontmatter is not valid YAML ({exc}). Nothing downstream can be "
|
# `exc` carries the whole clause — invalid YAML, a non-mapping block, or a
|
||||||
f"measured, so this is a hard failure, not a skip")
|
# description of the wrong type. Do not prefix a diagnosis here; the last
|
||||||
|
# one named a syntax error for two failures that have none.
|
||||||
|
fail(f"{exc}. Nothing downstream can be measured, so this is a hard "
|
||||||
|
f"failure, not a skip")
|
||||||
print("One or more checks failed.")
|
print("One or more checks failed.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|||||||
@@ -222,17 +222,21 @@ def read_text(path):
|
|||||||
# which is what a monorepo means,
|
# which is what a monorepo means,
|
||||||
# 2. the target's own apm package,
|
# 2. the target's own apm package,
|
||||||
# 3. the packages that package DECLARES in apm.yml dependencies.apm.
|
# 3. the packages that package DECLARES in apm.yml dependencies.apm.
|
||||||
# Deployed .claude/ and .agents/ trees are deliberately NOT consulted in that
|
# Deployed .claude/ and .agents/ trees are deliberately NOT consulted when the
|
||||||
# case. They are `apm install` output, gitignored, and present only on a machine
|
# root came from the plugins/ probe. They are `apm install` output, gitignored,
|
||||||
# that has run it: four cross-plugin targets in this repo (gitea-branches ->
|
# and present only on a machine that has run it: four cross-plugin targets in
|
||||||
# git-branches, gitea-branches -> git-history, gitea-issues -> git-branches,
|
# this repo (gitea-branches -> git-branches, gitea-branches -> git-history,
|
||||||
# gitea-workflow -> git-workflow) resolved through .claude/skills/ alone, so the
|
# gitea-issues -> git-branches, gitea-workflow -> git-workflow) resolved through
|
||||||
# same commit measured 2 dangling targets on a developer machine and 6 on a
|
# .claude/skills/ alone, so the same commit measured 2 dangling targets on a
|
||||||
# fresh clone. A gate shipping hot with no baseline cannot give two answers.
|
# developer machine and 6 on a fresh clone. A gate shipping hot with no baseline
|
||||||
|
# cannot give two answers.
|
||||||
#
|
#
|
||||||
# Deployed trees are used only when NO authoring root exists — the consumer
|
# Deployed trees ARE used when no plugin monorepo was found — whether the walk
|
||||||
# case, where the file being checked lives in or beside a deployed tree and
|
# landed on a bare .git ancestor or on nothing at all. That is the consumer
|
||||||
# there is no monorepo to read.
|
# case: the file being checked lives in or beside a deployed tree, inside an
|
||||||
|
# ordinary git repo, with no monorepo to read. The two cases are told apart by
|
||||||
|
# which probe matched, never by how many names a root contributed; see
|
||||||
|
# known_targets().
|
||||||
|
|
||||||
|
|
||||||
def _is_fs_root(path):
|
def _is_fs_root(path):
|
||||||
@@ -241,11 +245,17 @@ def _is_fs_root(path):
|
|||||||
|
|
||||||
def _collect_package(pkg_dir, names):
|
def _collect_package(pkg_dir, names):
|
||||||
"""Add every skill/agent name a package directory exposes, any layout."""
|
"""Add every skill/agent name a package directory exposes, any layout."""
|
||||||
|
# glob.escape() the DIRECTORY only. A checkout path containing `[`, `]`,
|
||||||
|
# `*` or `?` — a worktree named `feature[2]`, say — otherwise turns the
|
||||||
|
# whole pattern into a character class that matches nothing, and the
|
||||||
|
# resolver degrades to the "DID NOT RUN" INFO with rc=0 across every file
|
||||||
|
# in the tree. The wildcards in `sub` are the intended ones and stay raw.
|
||||||
|
safe_dir = glob.escape(pkg_dir)
|
||||||
for sub in ('.apm/skills/*/', 'skills/*/'):
|
for sub in ('.apm/skills/*/', 'skills/*/'):
|
||||||
for path in glob.glob(os.path.join(pkg_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
names.add(os.path.basename(path.rstrip('/')).lower())
|
names.add(os.path.basename(path.rstrip('/')).lower())
|
||||||
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
||||||
for path in glob.glob(os.path.join(pkg_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
base = os.path.basename(path)
|
base = os.path.basename(path)
|
||||||
if base.endswith('.agent.md'):
|
if base.endswith('.agent.md'):
|
||||||
base = base[:-len('.agent.md')]
|
base = base[:-len('.agent.md')]
|
||||||
@@ -277,28 +287,35 @@ def _apm_package_root(start_dir):
|
|||||||
def _authoring_root(start_dir):
|
def _authoring_root(start_dir):
|
||||||
"""Nearest ancestor that is a plugin monorepo, else the nearest .git tree.
|
"""Nearest ancestor that is a plugin monorepo, else the nearest .git tree.
|
||||||
|
|
||||||
|
Returns (root, matched_plugins_probe). The flag reports WHICH probe
|
||||||
|
matched: True for the plugins/*/.apm/{skills,agents} glob, False for the
|
||||||
|
.git fallback and for no match at all. known_targets() needs that
|
||||||
|
distinction — only a real plugins/ root makes the deployed trees
|
||||||
|
redundant, and a name-count delta cannot tell the two apart.
|
||||||
|
|
||||||
Two passes, not one interleaved walk: a nested .git (a submodule, a
|
Two passes, not one interleaved walk: a nested .git (a submodule, a
|
||||||
worktree of a sub-package) must not win over a real plugins/ root further
|
worktree of a sub-package) must not win over a real plugins/ root further
|
||||||
up. Both passes stop before the filesystem root for the same reason
|
up. Both passes stop before the filesystem root for the same reason
|
||||||
_apm_package_root does.
|
_apm_package_root does.
|
||||||
"""
|
"""
|
||||||
for probe in (
|
probes = (
|
||||||
lambda d: bool(glob.glob(os.path.join(d, 'plugins', '*', '.apm', 'skills'))
|
lambda d: bool(glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'skills'))
|
||||||
or glob.glob(os.path.join(d, 'plugins', '*', '.apm', 'agents'))),
|
or glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'agents'))),
|
||||||
lambda d: os.path.exists(os.path.join(d, '.git'))):
|
lambda d: os.path.exists(os.path.join(d, '.git')))
|
||||||
|
for index, probe in enumerate(probes):
|
||||||
current = os.path.abspath(start_dir)
|
current = os.path.abspath(start_dir)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if _is_fs_root(current):
|
if _is_fs_root(current):
|
||||||
break
|
break
|
||||||
if probe(current):
|
if probe(current):
|
||||||
return current
|
return current, index == 0
|
||||||
current = os.path.dirname(current)
|
current = os.path.dirname(current)
|
||||||
return None
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
def _collect_authoring_root(root, names):
|
def _collect_authoring_root(root, names):
|
||||||
"""Every plugin in the monorepo contributes its names."""
|
"""Every plugin in the monorepo contributes its names."""
|
||||||
for pkg in glob.glob(os.path.join(root, 'plugins', '*')):
|
for pkg in glob.glob(os.path.join(glob.escape(root), 'plugins', '*')):
|
||||||
if os.path.isdir(pkg):
|
if os.path.isdir(pkg):
|
||||||
_collect_package(pkg, names)
|
_collect_package(pkg, names)
|
||||||
|
|
||||||
@@ -362,7 +379,7 @@ def _declared_dependency_dirs(pkg_dir):
|
|||||||
def _deployed_roots(start_dir):
|
def _deployed_roots(start_dir):
|
||||||
""".claude/ and .agents/ trees above start_dir — what a host really sees.
|
""".claude/ and .agents/ trees above start_dir — what a host really sees.
|
||||||
|
|
||||||
Consulted ONLY when no authoring root exists; see the section header. The
|
Consulted ONLY when no plugin monorepo root was found; see the header. The
|
||||||
filesystem root is skipped for the same reason _apm_package_root skips it:
|
filesystem root is skipped for the same reason _apm_package_root skips it:
|
||||||
a stray /.claude/skills/ must not join every path's universe.
|
a stray /.claude/skills/ must not join every path's universe.
|
||||||
"""
|
"""
|
||||||
@@ -401,10 +418,22 @@ def known_targets(start_dir):
|
|||||||
for dep_dir in _declared_dependency_dirs(package):
|
for dep_dir in _declared_dependency_dirs(package):
|
||||||
_collect_package(dep_dir, names)
|
_collect_package(dep_dir, names)
|
||||||
|
|
||||||
root = _authoring_root(start)
|
# A .git ancestor is an authoring root only if it actually holds plugins.
|
||||||
|
# _authoring_root() falls back to the nearest .git, so it is truthy in ANY
|
||||||
|
# git repo; without the distinction that fallback wins in every consumer
|
||||||
|
# checkout, _collect_authoring_root() contributes nothing, and the deployed
|
||||||
|
# branch below is dead code in the exact case it exists for. So condition
|
||||||
|
# on WHICH probe matched, which _authoring_root() reports directly. A
|
||||||
|
# name-count delta looks equivalent and is not: _collect_authoring_root()
|
||||||
|
# re-collects the checked file's own plugin, whose names the blocks above
|
||||||
|
# already added, so a one-plugin monorepo shows a delta of zero and would
|
||||||
|
# wrongly reach for the deployed trees — including the user's global
|
||||||
|
# ~/.claude/skills, making the verdict depend on what happens to be
|
||||||
|
# installed (ADR-0020 lines 118-127).
|
||||||
|
root, root_has_plugins = _authoring_root(start)
|
||||||
if root:
|
if root:
|
||||||
_collect_authoring_root(root, names)
|
_collect_authoring_root(root, names)
|
||||||
else:
|
if not root_has_plugins:
|
||||||
for base in _deployed_roots(start):
|
for base in _deployed_roots(start):
|
||||||
_collect_package(base, names)
|
_collect_package(base, names)
|
||||||
return names
|
return names
|
||||||
@@ -510,11 +539,17 @@ MARKED_TARGET = r"(?:`/?(%s)`|(?<![\w./*-])/(%s)\b)" % (NAME_ANY, NAME_ANY)
|
|||||||
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
||||||
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
||||||
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
||||||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET)
|
# re.I on ALL of them, uniformly. The patterns are built from the same
|
||||||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET)
|
# lowercase NAME_* fragments, so half of them carrying the flag and half not
|
||||||
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET)
|
# meant `Skill-Audit` at the start of a boundary sentence was extracted by
|
||||||
|
# ROUTE_ANY but invisible to BACKTICK — contradicting normalize_target()'s own
|
||||||
|
# docstring, which exists precisely because extraction is case-insensitive and
|
||||||
|
# the universe is not.
|
||||||
|
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||||||
|
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||||||
|
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
||||||
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
||||||
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH)
|
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I)
|
||||||
# A boundary clause takes two shapes and BOTH count: the prose markers, and
|
# A boundary clause takes two shapes and BOTH count: the prose markers, and
|
||||||
# ADR-0020's compressed arrow form `Not <thing> -> <name>`.
|
# ADR-0020's compressed arrow form `Not <thing> -> <name>`.
|
||||||
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
|
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
|
||||||
@@ -681,8 +716,16 @@ def unresolved_targets(description, known):
|
|||||||
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
|
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
|
||||||
# be measured must never report green, so every caller of these two ERRORs on a
|
# be measured must never report green, so every caller of these two ERRORs on a
|
||||||
# miss instead of moving on.
|
# miss instead of moving on.
|
||||||
|
#
|
||||||
|
# The CLOSING marker is anchored at column 0 — deliberately NOT `[ \t]*---`.
|
||||||
|
# YAML block-scalar content must be indented deeper than its key, so an
|
||||||
|
# indented `---` inside a folded description is CONTENT; letting it close the
|
||||||
|
# frontmatter truncated the description mid-value and silently reclassified the
|
||||||
|
# rest as body, which is a vacuous green in both directions at once. Leading
|
||||||
|
# whitespace is still tolerated on the OPENING marker, where no such content
|
||||||
|
# can exist.
|
||||||
FRONTMATTER_RE = re.compile(
|
FRONTMATTER_RE = re.compile(
|
||||||
r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n[ \t]*---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
def strip_bom(text):
|
def strip_bom(text):
|
||||||
@@ -714,14 +757,26 @@ def description_value(fm_text):
|
|||||||
try:
|
try:
|
||||||
data = yaml.safe_load(fm_text)
|
data = yaml.safe_load(fm_text)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise FrontmatterError(re.sub(r'\s+', ' ', str(exc)).strip())
|
# Every FrontmatterError message is a COMPLETE clause, never a detail a
|
||||||
|
# caller wraps in one. Callers used to prefix a hard-coded "frontmatter
|
||||||
|
# is not valid YAML (...)", which is true only of this branch: the two
|
||||||
|
# type failures below come from frontmatter that parsed fine, and
|
||||||
|
# telling their author the YAML is invalid sends them hunting for a
|
||||||
|
# syntax error that is not there — on a blocking gate with no baseline.
|
||||||
|
raise FrontmatterError('frontmatter is not valid YAML (%s)'
|
||||||
|
% re.sub(r'\s+', ' ', str(exc)).strip())
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise FrontmatterError('frontmatter is not a YAML mapping')
|
raise FrontmatterError('frontmatter is not a YAML mapping')
|
||||||
value = data.get('description')
|
value = data.get('description')
|
||||||
if value is None:
|
if value is None:
|
||||||
return ''
|
return ''
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
value = str(value)
|
# NOT str()-coerced. `description: true` became the 4-character "True"
|
||||||
|
# and sailed through the 400-character gate; a list or mapping was
|
||||||
|
# measured as its Python repr. Neither is a description a host can
|
||||||
|
# preload, so this is a parse failure, reported as one.
|
||||||
|
raise FrontmatterError(
|
||||||
|
'description is a %s, not a string' % type(value).__name__)
|
||||||
return re.sub(r'\s+', ' ', value).strip()
|
return re.sub(r'\s+', ' ', value).strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -791,6 +846,18 @@ def mask_fenced(text):
|
|||||||
if (marker and marker[0] == fence[0] and len(marker) >= len(fence)
|
if (marker and marker[0] == fence[0] and len(marker) >= len(fence)
|
||||||
and not stripped.strip()[len(marker):].strip()):
|
and not stripped.strip()[len(marker):].strip()):
|
||||||
fence = None
|
fence = None
|
||||||
|
# An UNCLOSED fence has no cost-free answer, only a choice of which way to
|
||||||
|
# be wrong. Masking to end-of-body blanks the rest of the body, silently
|
||||||
|
# disabling the ERROR-tier references/ check and the gotcha counts.
|
||||||
|
# Returning the raw text instead exposes the unclosed example's own
|
||||||
|
# content, so a fenced example naming a nonexistent references/ file
|
||||||
|
# becomes a hard ERROR it would not have been had the fence been closed —
|
||||||
|
# confirmed, not hypothetical. The loud-false-positive direction is the one
|
||||||
|
# chosen: this script's rule is that a file it cannot measure must never
|
||||||
|
# report green, and masking-onward is exactly that failure. Both outcomes
|
||||||
|
# need an already-malformed file, and the false positive costs one fence.
|
||||||
|
if fence is not None:
|
||||||
|
return text
|
||||||
return ''.join(out)
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
@@ -869,12 +936,15 @@ def get_frontmatter_keys(fm):
|
|||||||
return keys
|
return keys
|
||||||
|
|
||||||
def agent_description(fm, local_fname):
|
def agent_description(fm, local_fname):
|
||||||
"""The folded description VALUE, or None if the frontmatter is not YAML."""
|
"""The folded description VALUE, or None if it could not be read."""
|
||||||
try:
|
try:
|
||||||
return description_value(fm)
|
return description_value(fm)
|
||||||
except FrontmatterError as exc:
|
except FrontmatterError as exc:
|
||||||
fail(f"frontmatter is not valid YAML ({exc}) — the ADR-0020 description and "
|
# `exc` carries the whole clause — invalid YAML, a non-mapping block, or
|
||||||
f"boundary-target gates could not run — {local_fname}")
|
# a description of the wrong type. Do not prefix a diagnosis here; the
|
||||||
|
# last one named a syntax error for two failures that have none.
|
||||||
|
fail(f"{exc} — the ADR-0020 description and boundary-target gates could "
|
||||||
|
f"not run — {local_fname}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def check_description_budget(value, local_fname):
|
def check_description_budget(value, local_fname):
|
||||||
@@ -944,11 +1014,31 @@ def check_boundary(value, fpath, local_fname):
|
|||||||
f"{local_fname}")
|
f"{local_fname}")
|
||||||
|
|
||||||
def extract_tools_list(fm):
|
def extract_tools_list(fm):
|
||||||
"""Extract tool names from the tools frontmatter field (space or comma separated)."""
|
"""Tool names from the `tools` field — inline scalar OR YAML block sequence.
|
||||||
val = extract_field(fm, 'tools')
|
|
||||||
if not val:
|
Read off the PARSED mapping, never off extract_field(). That function's
|
||||||
|
capture is newline-bounded on purpose (`[^\\S\\r\\n]*(.+)`), so a `tools:`
|
||||||
|
written as a block sequence — the shape Copilot agent files use — captured
|
||||||
|
nothing at all and the subagent-unavailable-tool check silently stopped
|
||||||
|
firing on exactly the files it was written for. Both spellings are legal
|
||||||
|
YAML, so both are read here.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(fm)
|
||||||
|
except Exception:
|
||||||
|
# Not this function's failure to report: the frontmatter's validity is
|
||||||
|
# decided (and failed) by agent_description() on the same text.
|
||||||
return set()
|
return set()
|
||||||
return set(re.split(r'[\s,]+', val.strip()))
|
if not isinstance(data, dict):
|
||||||
|
return set()
|
||||||
|
val = data.get('tools')
|
||||||
|
if isinstance(val, list):
|
||||||
|
items = [str(item).strip() for item in val]
|
||||||
|
elif isinstance(val, str):
|
||||||
|
items = re.split(r'[\s,]+', val.strip())
|
||||||
|
else:
|
||||||
|
return set()
|
||||||
|
return {item for item in items if item}
|
||||||
|
|
||||||
def is_copilot_cloud_ide(fpath):
|
def is_copilot_cloud_ide(fpath):
|
||||||
"""True if the file is a cloud/IDE Copilot agent (name is optional for these)."""
|
"""True if the file is a cloud/IDE Copilot agent (name is optional for these)."""
|
||||||
@@ -1063,6 +1153,15 @@ def check_apm_agent_file(fpath, allowlist, stem):
|
|||||||
fail(f"file is {exc}. Nothing could be measured, so this is a hard "
|
fail(f"file is {exc}. Nothing could be measured, so this is a hard "
|
||||||
f"failure, not a skip — {local_fname}")
|
f"failure, not a skip — {local_fname}")
|
||||||
return
|
return
|
||||||
|
except OSError as exc:
|
||||||
|
# A path that cannot be opened gets a FAIL line naming it, not a bare
|
||||||
|
# FileNotFoundError traceback. scripts/check-apm-agents-valid.sh takes
|
||||||
|
# this path for an agent file deleted from the worktree but still
|
||||||
|
# tracked in the index — a real, expected state, and the caller needs to
|
||||||
|
# be told which file, not handed an interpreter stack.
|
||||||
|
fail(f"could not be read ({exc.strerror or exc}): {fpath}. Nothing could "
|
||||||
|
f"be measured, so this is a hard failure, not a skip — {local_fname}")
|
||||||
|
return
|
||||||
|
|
||||||
fm, body = parse_frontmatter(content)
|
fm, body = parse_frontmatter(content)
|
||||||
if fm is None:
|
if fm is None:
|
||||||
@@ -1168,6 +1267,13 @@ def check_file(fpath, file_provider):
|
|||||||
fail(f"file is {exc}. Nothing could be measured, so this is a hard "
|
fail(f"file is {exc}. Nothing could be measured, so this is a hard "
|
||||||
f"failure, not a skip — {local_fname}")
|
f"failure, not a skip — {local_fname}")
|
||||||
return
|
return
|
||||||
|
except OSError as exc:
|
||||||
|
# Same reason as check_apm_agent_file's: a diagnostic naming the path
|
||||||
|
# beats a FileNotFoundError traceback. The counterpart is pre-checked at
|
||||||
|
# the bottom of this script, but agent_file itself never was.
|
||||||
|
fail(f"could not be read ({exc.strerror or exc}): {fpath}. Nothing could "
|
||||||
|
f"be measured, so this is a hard failure, not a skip — {local_fname}")
|
||||||
|
return
|
||||||
|
|
||||||
fm, body = parse_frontmatter(content)
|
fm, body = parse_frontmatter(content)
|
||||||
if fm is None:
|
if fm is None:
|
||||||
|
|||||||
@@ -148,17 +148,21 @@ def read_text(path):
|
|||||||
# which is what a monorepo means,
|
# which is what a monorepo means,
|
||||||
# 2. the target's own apm package,
|
# 2. the target's own apm package,
|
||||||
# 3. the packages that package DECLARES in apm.yml dependencies.apm.
|
# 3. the packages that package DECLARES in apm.yml dependencies.apm.
|
||||||
# Deployed .claude/ and .agents/ trees are deliberately NOT consulted in that
|
# Deployed .claude/ and .agents/ trees are deliberately NOT consulted when the
|
||||||
# case. They are `apm install` output, gitignored, and present only on a machine
|
# root came from the plugins/ probe. They are `apm install` output, gitignored,
|
||||||
# that has run it: four cross-plugin targets in this repo (gitea-branches ->
|
# and present only on a machine that has run it: four cross-plugin targets in
|
||||||
# git-branches, gitea-branches -> git-history, gitea-issues -> git-branches,
|
# this repo (gitea-branches -> git-branches, gitea-branches -> git-history,
|
||||||
# gitea-workflow -> git-workflow) resolved through .claude/skills/ alone, so the
|
# gitea-issues -> git-branches, gitea-workflow -> git-workflow) resolved through
|
||||||
# same commit measured 2 dangling targets on a developer machine and 6 on a
|
# .claude/skills/ alone, so the same commit measured 2 dangling targets on a
|
||||||
# fresh clone. A gate shipping hot with no baseline cannot give two answers.
|
# developer machine and 6 on a fresh clone. A gate shipping hot with no baseline
|
||||||
|
# cannot give two answers.
|
||||||
#
|
#
|
||||||
# Deployed trees are used only when NO authoring root exists — the consumer
|
# Deployed trees ARE used when no plugin monorepo was found — whether the walk
|
||||||
# case, where the file being checked lives in or beside a deployed tree and
|
# landed on a bare .git ancestor or on nothing at all. That is the consumer
|
||||||
# there is no monorepo to read.
|
# case: the file being checked lives in or beside a deployed tree, inside an
|
||||||
|
# ordinary git repo, with no monorepo to read. The two cases are told apart by
|
||||||
|
# which probe matched, never by how many names a root contributed; see
|
||||||
|
# known_targets().
|
||||||
|
|
||||||
|
|
||||||
def _is_fs_root(path):
|
def _is_fs_root(path):
|
||||||
@@ -167,11 +171,17 @@ def _is_fs_root(path):
|
|||||||
|
|
||||||
def _collect_package(pkg_dir, names):
|
def _collect_package(pkg_dir, names):
|
||||||
"""Add every skill/agent name a package directory exposes, any layout."""
|
"""Add every skill/agent name a package directory exposes, any layout."""
|
||||||
|
# glob.escape() the DIRECTORY only. A checkout path containing `[`, `]`,
|
||||||
|
# `*` or `?` — a worktree named `feature[2]`, say — otherwise turns the
|
||||||
|
# whole pattern into a character class that matches nothing, and the
|
||||||
|
# resolver degrades to the "DID NOT RUN" INFO with rc=0 across every file
|
||||||
|
# in the tree. The wildcards in `sub` are the intended ones and stay raw.
|
||||||
|
safe_dir = glob.escape(pkg_dir)
|
||||||
for sub in ('.apm/skills/*/', 'skills/*/'):
|
for sub in ('.apm/skills/*/', 'skills/*/'):
|
||||||
for path in glob.glob(os.path.join(pkg_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
names.add(os.path.basename(path.rstrip('/')).lower())
|
names.add(os.path.basename(path.rstrip('/')).lower())
|
||||||
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
||||||
for path in glob.glob(os.path.join(pkg_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||
base = os.path.basename(path)
|
base = os.path.basename(path)
|
||||||
if base.endswith('.agent.md'):
|
if base.endswith('.agent.md'):
|
||||||
base = base[:-len('.agent.md')]
|
base = base[:-len('.agent.md')]
|
||||||
@@ -203,28 +213,35 @@ def _apm_package_root(start_dir):
|
|||||||
def _authoring_root(start_dir):
|
def _authoring_root(start_dir):
|
||||||
"""Nearest ancestor that is a plugin monorepo, else the nearest .git tree.
|
"""Nearest ancestor that is a plugin monorepo, else the nearest .git tree.
|
||||||
|
|
||||||
|
Returns (root, matched_plugins_probe). The flag reports WHICH probe
|
||||||
|
matched: True for the plugins/*/.apm/{skills,agents} glob, False for the
|
||||||
|
.git fallback and for no match at all. known_targets() needs that
|
||||||
|
distinction — only a real plugins/ root makes the deployed trees
|
||||||
|
redundant, and a name-count delta cannot tell the two apart.
|
||||||
|
|
||||||
Two passes, not one interleaved walk: a nested .git (a submodule, a
|
Two passes, not one interleaved walk: a nested .git (a submodule, a
|
||||||
worktree of a sub-package) must not win over a real plugins/ root further
|
worktree of a sub-package) must not win over a real plugins/ root further
|
||||||
up. Both passes stop before the filesystem root for the same reason
|
up. Both passes stop before the filesystem root for the same reason
|
||||||
_apm_package_root does.
|
_apm_package_root does.
|
||||||
"""
|
"""
|
||||||
for probe in (
|
probes = (
|
||||||
lambda d: bool(glob.glob(os.path.join(d, 'plugins', '*', '.apm', 'skills'))
|
lambda d: bool(glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'skills'))
|
||||||
or glob.glob(os.path.join(d, 'plugins', '*', '.apm', 'agents'))),
|
or glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'agents'))),
|
||||||
lambda d: os.path.exists(os.path.join(d, '.git'))):
|
lambda d: os.path.exists(os.path.join(d, '.git')))
|
||||||
|
for index, probe in enumerate(probes):
|
||||||
current = os.path.abspath(start_dir)
|
current = os.path.abspath(start_dir)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if _is_fs_root(current):
|
if _is_fs_root(current):
|
||||||
break
|
break
|
||||||
if probe(current):
|
if probe(current):
|
||||||
return current
|
return current, index == 0
|
||||||
current = os.path.dirname(current)
|
current = os.path.dirname(current)
|
||||||
return None
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
def _collect_authoring_root(root, names):
|
def _collect_authoring_root(root, names):
|
||||||
"""Every plugin in the monorepo contributes its names."""
|
"""Every plugin in the monorepo contributes its names."""
|
||||||
for pkg in glob.glob(os.path.join(root, 'plugins', '*')):
|
for pkg in glob.glob(os.path.join(glob.escape(root), 'plugins', '*')):
|
||||||
if os.path.isdir(pkg):
|
if os.path.isdir(pkg):
|
||||||
_collect_package(pkg, names)
|
_collect_package(pkg, names)
|
||||||
|
|
||||||
@@ -288,7 +305,7 @@ def _declared_dependency_dirs(pkg_dir):
|
|||||||
def _deployed_roots(start_dir):
|
def _deployed_roots(start_dir):
|
||||||
""".claude/ and .agents/ trees above start_dir — what a host really sees.
|
""".claude/ and .agents/ trees above start_dir — what a host really sees.
|
||||||
|
|
||||||
Consulted ONLY when no authoring root exists; see the section header. The
|
Consulted ONLY when no plugin monorepo root was found; see the header. The
|
||||||
filesystem root is skipped for the same reason _apm_package_root skips it:
|
filesystem root is skipped for the same reason _apm_package_root skips it:
|
||||||
a stray /.claude/skills/ must not join every path's universe.
|
a stray /.claude/skills/ must not join every path's universe.
|
||||||
"""
|
"""
|
||||||
@@ -327,10 +344,22 @@ def known_targets(start_dir):
|
|||||||
for dep_dir in _declared_dependency_dirs(package):
|
for dep_dir in _declared_dependency_dirs(package):
|
||||||
_collect_package(dep_dir, names)
|
_collect_package(dep_dir, names)
|
||||||
|
|
||||||
root = _authoring_root(start)
|
# A .git ancestor is an authoring root only if it actually holds plugins.
|
||||||
|
# _authoring_root() falls back to the nearest .git, so it is truthy in ANY
|
||||||
|
# git repo; without the distinction that fallback wins in every consumer
|
||||||
|
# checkout, _collect_authoring_root() contributes nothing, and the deployed
|
||||||
|
# branch below is dead code in the exact case it exists for. So condition
|
||||||
|
# on WHICH probe matched, which _authoring_root() reports directly. A
|
||||||
|
# name-count delta looks equivalent and is not: _collect_authoring_root()
|
||||||
|
# re-collects the checked file's own plugin, whose names the blocks above
|
||||||
|
# already added, so a one-plugin monorepo shows a delta of zero and would
|
||||||
|
# wrongly reach for the deployed trees — including the user's global
|
||||||
|
# ~/.claude/skills, making the verdict depend on what happens to be
|
||||||
|
# installed (ADR-0020 lines 118-127).
|
||||||
|
root, root_has_plugins = _authoring_root(start)
|
||||||
if root:
|
if root:
|
||||||
_collect_authoring_root(root, names)
|
_collect_authoring_root(root, names)
|
||||||
else:
|
if not root_has_plugins:
|
||||||
for base in _deployed_roots(start):
|
for base in _deployed_roots(start):
|
||||||
_collect_package(base, names)
|
_collect_package(base, names)
|
||||||
return names
|
return names
|
||||||
@@ -436,11 +465,17 @@ MARKED_TARGET = r"(?:`/?(%s)`|(?<![\w./*-])/(%s)\b)" % (NAME_ANY, NAME_ANY)
|
|||||||
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
||||||
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
||||||
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
||||||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET)
|
# re.I on ALL of them, uniformly. The patterns are built from the same
|
||||||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET)
|
# lowercase NAME_* fragments, so half of them carrying the flag and half not
|
||||||
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET)
|
# meant `Skill-Audit` at the start of a boundary sentence was extracted by
|
||||||
|
# ROUTE_ANY but invisible to BACKTICK — contradicting normalize_target()'s own
|
||||||
|
# docstring, which exists precisely because extraction is case-insensitive and
|
||||||
|
# the universe is not.
|
||||||
|
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||||||
|
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||||||
|
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
||||||
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
||||||
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH)
|
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I)
|
||||||
# A boundary clause takes two shapes and BOTH count: the prose markers, and
|
# A boundary clause takes two shapes and BOTH count: the prose markers, and
|
||||||
# ADR-0020's compressed arrow form `Not <thing> -> <name>`.
|
# ADR-0020's compressed arrow form `Not <thing> -> <name>`.
|
||||||
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
|
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
|
||||||
@@ -607,8 +642,16 @@ def unresolved_targets(description, known):
|
|||||||
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
|
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
|
||||||
# be measured must never report green, so every caller of these two ERRORs on a
|
# be measured must never report green, so every caller of these two ERRORs on a
|
||||||
# miss instead of moving on.
|
# miss instead of moving on.
|
||||||
|
#
|
||||||
|
# The CLOSING marker is anchored at column 0 — deliberately NOT `[ \t]*---`.
|
||||||
|
# YAML block-scalar content must be indented deeper than its key, so an
|
||||||
|
# indented `---` inside a folded description is CONTENT; letting it close the
|
||||||
|
# frontmatter truncated the description mid-value and silently reclassified the
|
||||||
|
# rest as body, which is a vacuous green in both directions at once. Leading
|
||||||
|
# whitespace is still tolerated on the OPENING marker, where no such content
|
||||||
|
# can exist.
|
||||||
FRONTMATTER_RE = re.compile(
|
FRONTMATTER_RE = re.compile(
|
||||||
r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n[ \t]*---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
def strip_bom(text):
|
def strip_bom(text):
|
||||||
@@ -640,14 +683,26 @@ def description_value(fm_text):
|
|||||||
try:
|
try:
|
||||||
data = yaml.safe_load(fm_text)
|
data = yaml.safe_load(fm_text)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise FrontmatterError(re.sub(r'\s+', ' ', str(exc)).strip())
|
# Every FrontmatterError message is a COMPLETE clause, never a detail a
|
||||||
|
# caller wraps in one. Callers used to prefix a hard-coded "frontmatter
|
||||||
|
# is not valid YAML (...)", which is true only of this branch: the two
|
||||||
|
# type failures below come from frontmatter that parsed fine, and
|
||||||
|
# telling their author the YAML is invalid sends them hunting for a
|
||||||
|
# syntax error that is not there — on a blocking gate with no baseline.
|
||||||
|
raise FrontmatterError('frontmatter is not valid YAML (%s)'
|
||||||
|
% re.sub(r'\s+', ' ', str(exc)).strip())
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise FrontmatterError('frontmatter is not a YAML mapping')
|
raise FrontmatterError('frontmatter is not a YAML mapping')
|
||||||
value = data.get('description')
|
value = data.get('description')
|
||||||
if value is None:
|
if value is None:
|
||||||
return ''
|
return ''
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
value = str(value)
|
# NOT str()-coerced. `description: true` became the 4-character "True"
|
||||||
|
# and sailed through the 400-character gate; a list or mapping was
|
||||||
|
# measured as its Python repr. Neither is a description a host can
|
||||||
|
# preload, so this is a parse failure, reported as one.
|
||||||
|
raise FrontmatterError(
|
||||||
|
'description is a %s, not a string' % type(value).__name__)
|
||||||
return re.sub(r'\s+', ' ', value).strip()
|
return re.sub(r'\s+', ' ', value).strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -717,6 +772,18 @@ def mask_fenced(text):
|
|||||||
if (marker and marker[0] == fence[0] and len(marker) >= len(fence)
|
if (marker and marker[0] == fence[0] and len(marker) >= len(fence)
|
||||||
and not stripped.strip()[len(marker):].strip()):
|
and not stripped.strip()[len(marker):].strip()):
|
||||||
fence = None
|
fence = None
|
||||||
|
# An UNCLOSED fence has no cost-free answer, only a choice of which way to
|
||||||
|
# be wrong. Masking to end-of-body blanks the rest of the body, silently
|
||||||
|
# disabling the ERROR-tier references/ check and the gotcha counts.
|
||||||
|
# Returning the raw text instead exposes the unclosed example's own
|
||||||
|
# content, so a fenced example naming a nonexistent references/ file
|
||||||
|
# becomes a hard ERROR it would not have been had the fence been closed —
|
||||||
|
# confirmed, not hypothetical. The loud-false-positive direction is the one
|
||||||
|
# chosen: this script's rule is that a file it cannot measure must never
|
||||||
|
# report green, and masking-onward is exactly that failure. Both outcomes
|
||||||
|
# need an already-malformed file, and the false positive costs one fence.
|
||||||
|
if fence is not None:
|
||||||
|
return text
|
||||||
return ''.join(out)
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
@@ -802,8 +869,11 @@ name = name_m.group(1).strip('"\'') if name_m else ""
|
|||||||
try:
|
try:
|
||||||
desc = description_value(fm)
|
desc = description_value(fm)
|
||||||
except FrontmatterError as exc:
|
except FrontmatterError as exc:
|
||||||
fail(f"frontmatter is not valid YAML ({exc}). Nothing downstream can be "
|
# `exc` carries the whole clause — invalid YAML, a non-mapping block, or a
|
||||||
f"measured, so this is a hard failure, not a skip")
|
# description of the wrong type. Do not prefix a diagnosis here; the last
|
||||||
|
# one named a syntax error for two failures that have none.
|
||||||
|
fail(f"{exc}. Nothing downstream can be measured, so this is a hard "
|
||||||
|
f"failure, not a skip")
|
||||||
print("One or more checks failed.")
|
print("One or more checks failed.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,11 @@ set -euo pipefail
|
|||||||||||||||
# SKILL.md could pass its own audit and still be blocked by the commit hook.
|
# SKILL.md could pass its own audit and still be blocked by the commit hook.
|
||||||||||||||
# The ADR-0020 ceilings are inclusive the same way.
|
# The ADR-0020 ceilings are inclusive the same way.
|
||||||||||||||
#
|
#
|
||||||||||||||
# Token counts aren't computed exactly here — word count (`wc -w`) is used as
|
# Token counts aren't computed exactly here — a whitespace word count is used
|
||||||||||||||
# a proxy. Measured over this repo's 39 in-scope SKILL.md files, characters per
|
# as a proxy (Python's str.split(), the same primitive
|
||||||||||||||
|
# skill-audit/scripts/validate.sh applies to these two constants; `wc -w`
|
||||||||||||||
|
# disagrees with it on Unicode separators, which is why the awk pass that used
|
||||||||||||||
|
# to live in the loop below is gone). Measured over this repo's 39 in-scope SKILL.md files, characters per
|
||||||||||||||
# word runs min 5.97 / median 6.79 / mean 6.77 / max 7.22. At the standard
|
# word runs min 5.97 / median 6.79 / mean 6.77 / max 7.22. At the standard
|
||||||||||||||
# ~4-characters-per-token English approximation that is 1.49 / 1.70 / 1.69 /
|
# ~4-characters-per-token English approximation that is 1.49 / 1.70 / 1.69 /
|
||||||||||||||
# 1.81 tokens per word.
|
# 1.81 tokens per word.
|
||||||||||||||
@@ -105,23 +108,19 @@ for f in "$@"; do
|
|||||||||||||||
continue
|
continue
|
||||||||||||||
fi
|
fi
|
||||||||||||||
|
|
||||||||||||||
# Single awk pass computes both line count and word count, avoiding a
|
# The MAX_LINES / MAX_WORDS ceilings are NOT measured here. They used to be,
|
||||||||||||||
# second read of the file. NR counts the final line even without a
|
# in a single awk pass, and that pass was wrong twice over:
|
||||||||||||||
# trailing newline, matching Python's splitlines() semantics (used by
|
# * `read -r lines words <<< "$(awk ...)"` discarded awk's exit status, so a
|
||||||||||||||
# skill-audit/scripts/validate.sh for its own line count) — `wc -l`
|
# file awk could not read yielded empty variables, bash arithmetic read
|
||||||||||||||
|
|
|||||||||||||||
# undercounts by 1 in that case. Word count uses awk's default
|
# them as 0, and both ceilings passed in total silence — the one outcome
|
||||||||||||||
# whitespace-splitting NF, matching `wc -w` semantics.
|
# this script forbids itself.
|
||||||||||||||
read -r lines words <<< "$(awk '{w += NF} END{print NR, w+0}' "$f")"
|
# * awk's NR/NF do not agree with the Python splitlines()/split() that
|
||||||||||||||
|
# skill-audit/scripts/validate.sh uses for the SAME two constants.
|
||||||||||||||
if (( lines > MAX_LINES )); then
|
# splitlines() also breaks on \x0b \x0c \x1c \x1d \x1e \x85 U+2028 U+2029
|
||||||||||||||
echo "ERROR: $f has $lines lines, exceeding the $MAX_LINES-line ceiling (agentskills.io skill-authoring.md)" >&2
|
# and split() on every Unicode space, so a body padded with U+2028 read as
|
||||||||||||||
FAIL=1
|
# 6 lines here and 606 lines there — hook green, audit FAIL.
|
||||||||||||||
fi
|
# One implementation now owns both: the Python block below already reads every
|
||||||||||||||
|
# file (with a real diagnostic on failure), so it counts there.
|
||||||||||||||
if (( words > MAX_WORDS )); then
|
|
||||||||||||||
echo "ERROR: $f has $words words (proxy for tokens), exceeding the $MAX_WORDS-word ceiling (~5,000 tokens, agentskills.io skill-authoring.md)" >&2
|
|
||||||||||||||
FAIL=1
|
|
||||||||||||||
fi
|
|
||||||||||||||
done
|
done
|
||||||||||||||
|
|
||||||||||||||
if ! command -v python3 > /dev/null 2>&1; then
|
if ! command -v python3 > /dev/null 2>&1; then
|
||||||||||||||
@@ -140,7 +139,7 @@ fi
|
|||||||||||||||
|
|
||||||||||||||
if ! python3 -u - \
|
if ! python3 -u - \
|
||||||||||||||
"$DESC_SUGGEST_CHARS" "$DESC_MAX_CHARS" \
|
"$DESC_SUGGEST_CHARS" "$DESC_MAX_CHARS" \
|
||||||||||||||
"$BODY_SUGGEST_WORDS" "$BODY_MAX_WORDS" "$MAX_WORDS" "$@" <<'PYTHON'
|
"$BODY_SUGGEST_WORDS" "$BODY_MAX_WORDS" "$MAX_WORDS" "$MAX_LINES" "$@" <<'PYTHON'
|
||||||||||||||
import glob
|
import glob
|
||||||||||||||
import os
|
import os
|
||||||||||||||
import re
|
import re
|
||||||||||||||
@@ -153,7 +152,8 @@ DESC_MAX_CHARS = int(sys.argv[2])
|
|||||||||||||||
BODY_SUGGEST_WORDS = int(sys.argv[3])
|
BODY_SUGGEST_WORDS = int(sys.argv[3])
|
||||||||||||||
BODY_MAX_WORDS = int(sys.argv[4])
|
BODY_MAX_WORDS = int(sys.argv[4])
|
||||||||||||||
MAX_WORDS = int(sys.argv[5])
|
MAX_WORDS = int(sys.argv[5])
|
||||||||||||||
files = sys.argv[6:]
|
MAX_LINES = int(sys.argv[6])
|
||||||||||||||
|
files = sys.argv[7:]
|
||||||||||||||
|
|
||||||||||||||
failed = False
|
failed = False
|
||||||||||||||
|
|
||||||||||||||
@@ -232,17 +232,21 @@ def read_text(path):
|
|||||||||||||||
# which is what a monorepo means,
|
# which is what a monorepo means,
|
||||||||||||||
# 2. the target's own apm package,
|
# 2. the target's own apm package,
|
||||||||||||||
# 3. the packages that package DECLARES in apm.yml dependencies.apm.
|
# 3. the packages that package DECLARES in apm.yml dependencies.apm.
|
||||||||||||||
# Deployed .claude/ and .agents/ trees are deliberately NOT consulted in that
|
# Deployed .claude/ and .agents/ trees are deliberately NOT consulted when the
|
||||||||||||||
# case. They are `apm install` output, gitignored, and present only on a machine
|
# root came from the plugins/ probe. They are `apm install` output, gitignored,
|
||||||||||||||
# that has run it: four cross-plugin targets in this repo (gitea-branches ->
|
# and present only on a machine that has run it: four cross-plugin targets in
|
||||||||||||||
# git-branches, gitea-branches -> git-history, gitea-issues -> git-branches,
|
# this repo (gitea-branches -> git-branches, gitea-branches -> git-history,
|
||||||||||||||
# gitea-workflow -> git-workflow) resolved through .claude/skills/ alone, so the
|
# gitea-issues -> git-branches, gitea-workflow -> git-workflow) resolved through
|
||||||||||||||
# same commit measured 2 dangling targets on a developer machine and 6 on a
|
# .claude/skills/ alone, so the same commit measured 2 dangling targets on a
|
||||||||||||||
# fresh clone. A gate shipping hot with no baseline cannot give two answers.
|
# developer machine and 6 on a fresh clone. A gate shipping hot with no baseline
|
||||||||||||||
|
# cannot give two answers.
|
||||||||||||||
#
|
#
|
||||||||||||||
# Deployed trees are used only when NO authoring root exists — the consumer
|
# Deployed trees ARE used when no plugin monorepo was found — whether the walk
|
||||||||||||||
# case, where the file being checked lives in or beside a deployed tree and
|
# landed on a bare .git ancestor or on nothing at all. That is the consumer
|
||||||||||||||
# there is no monorepo to read.
|
# case: the file being checked lives in or beside a deployed tree, inside an
|
||||||||||||||
|
# ordinary git repo, with no monorepo to read. The two cases are told apart by
|
||||||||||||||
|
# which probe matched, never by how many names a root contributed; see
|
||||||||||||||
|
# known_targets().
|
||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
def _is_fs_root(path):
|
def _is_fs_root(path):
|
||||||||||||||
@@ -251,11 +255,17 @@ def _is_fs_root(path):
|
|||||||||||||||
|
|
||||||||||||||
def _collect_package(pkg_dir, names):
|
def _collect_package(pkg_dir, names):
|
||||||||||||||
"""Add every skill/agent name a package directory exposes, any layout."""
|
"""Add every skill/agent name a package directory exposes, any layout."""
|
||||||||||||||
|
# glob.escape() the DIRECTORY only. A checkout path containing `[`, `]`,
|
||||||||||||||
|
# `*` or `?` — a worktree named `feature[2]`, say — otherwise turns the
|
||||||||||||||
|
# whole pattern into a character class that matches nothing, and the
|
||||||||||||||
|
# resolver degrades to the "DID NOT RUN" INFO with rc=0 across every file
|
||||||||||||||
|
# in the tree. The wildcards in `sub` are the intended ones and stay raw.
|
||||||||||||||
|
safe_dir = glob.escape(pkg_dir)
|
||||||||||||||
for sub in ('.apm/skills/*/', 'skills/*/'):
|
for sub in ('.apm/skills/*/', 'skills/*/'):
|
||||||||||||||
for path in glob.glob(os.path.join(pkg_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||||||||||
names.add(os.path.basename(path.rstrip('/')).lower())
|
names.add(os.path.basename(path.rstrip('/')).lower())
|
||||||||||||||
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
for sub in ('.apm/agents/*.md', 'agents/*.md'):
|
||||||||||||||
for path in glob.glob(os.path.join(pkg_dir, sub)):
|
for path in glob.glob(os.path.join(safe_dir, sub)):
|
||||||||||||||
base = os.path.basename(path)
|
base = os.path.basename(path)
|
||||||||||||||
if base.endswith('.agent.md'):
|
if base.endswith('.agent.md'):
|
||||||||||||||
base = base[:-len('.agent.md')]
|
base = base[:-len('.agent.md')]
|
||||||||||||||
@@ -287,28 +297,35 @@ def _apm_package_root(start_dir):
|
|||||||||||||||
def _authoring_root(start_dir):
|
def _authoring_root(start_dir):
|
||||||||||||||
"""Nearest ancestor that is a plugin monorepo, else the nearest .git tree.
|
"""Nearest ancestor that is a plugin monorepo, else the nearest .git tree.
|
||||||||||||||
|
|
||||||||||||||
|
Returns (root, matched_plugins_probe). The flag reports WHICH probe
|
||||||||||||||
|
matched: True for the plugins/*/.apm/{skills,agents} glob, False for the
|
||||||||||||||
|
.git fallback and for no match at all. known_targets() needs that
|
||||||||||||||
|
distinction — only a real plugins/ root makes the deployed trees
|
||||||||||||||
|
redundant, and a name-count delta cannot tell the two apart.
|
||||||||||||||
|
|
||||||||||||||
Two passes, not one interleaved walk: a nested .git (a submodule, a
|
Two passes, not one interleaved walk: a nested .git (a submodule, a
|
||||||||||||||
worktree of a sub-package) must not win over a real plugins/ root further
|
worktree of a sub-package) must not win over a real plugins/ root further
|
||||||||||||||
up. Both passes stop before the filesystem root for the same reason
|
up. Both passes stop before the filesystem root for the same reason
|
||||||||||||||
_apm_package_root does.
|
_apm_package_root does.
|
||||||||||||||
"""
|
"""
|
||||||||||||||
for probe in (
|
probes = (
|
||||||||||||||
lambda d: bool(glob.glob(os.path.join(d, 'plugins', '*', '.apm', 'skills'))
|
lambda d: bool(glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'skills'))
|
||||||||||||||
or glob.glob(os.path.join(d, 'plugins', '*', '.apm', 'agents'))),
|
or glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'agents'))),
|
||||||||||||||
lambda d: os.path.exists(os.path.join(d, '.git'))):
|
lambda d: os.path.exists(os.path.join(d, '.git')))
|
||||||||||||||
|
for index, probe in enumerate(probes):
|
||||||||||||||
current = os.path.abspath(start_dir)
|
current = os.path.abspath(start_dir)
|
||||||||||||||
for _ in range(12):
|
for _ in range(12):
|
||||||||||||||
if _is_fs_root(current):
|
if _is_fs_root(current):
|
||||||||||||||
break
|
break
|
||||||||||||||
if probe(current):
|
if probe(current):
|
||||||||||||||
return current
|
return current, index == 0
|
||||||||||||||
current = os.path.dirname(current)
|
current = os.path.dirname(current)
|
||||||||||||||
return None
|
return None, False
|
||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
def _collect_authoring_root(root, names):
|
def _collect_authoring_root(root, names):
|
||||||||||||||
"""Every plugin in the monorepo contributes its names."""
|
"""Every plugin in the monorepo contributes its names."""
|
||||||||||||||
for pkg in glob.glob(os.path.join(root, 'plugins', '*')):
|
for pkg in glob.glob(os.path.join(glob.escape(root), 'plugins', '*')):
|
||||||||||||||
if os.path.isdir(pkg):
|
if os.path.isdir(pkg):
|
||||||||||||||
_collect_package(pkg, names)
|
_collect_package(pkg, names)
|
||||||||||||||
|
|
||||||||||||||
@@ -372,7 +389,7 @@ def _declared_dependency_dirs(pkg_dir):
|
|||||||||||||||
def _deployed_roots(start_dir):
|
def _deployed_roots(start_dir):
|
||||||||||||||
""".claude/ and .agents/ trees above start_dir — what a host really sees.
|
""".claude/ and .agents/ trees above start_dir — what a host really sees.
|
||||||||||||||
|
|
||||||||||||||
Consulted ONLY when no authoring root exists; see the section header. The
|
Consulted ONLY when no plugin monorepo root was found; see the header. The
|
||||||||||||||
filesystem root is skipped for the same reason _apm_package_root skips it:
|
filesystem root is skipped for the same reason _apm_package_root skips it:
|
||||||||||||||
a stray /.claude/skills/ must not join every path's universe.
|
a stray /.claude/skills/ must not join every path's universe.
|
||||||||||||||
"""
|
"""
|
||||||||||||||
@@ -411,10 +428,22 @@ def known_targets(start_dir):
|
|||||||||||||||
for dep_dir in _declared_dependency_dirs(package):
|
for dep_dir in _declared_dependency_dirs(package):
|
||||||||||||||
_collect_package(dep_dir, names)
|
_collect_package(dep_dir, names)
|
||||||||||||||
|
|
||||||||||||||
root = _authoring_root(start)
|
# A .git ancestor is an authoring root only if it actually holds plugins.
|
||||||||||||||
|
# _authoring_root() falls back to the nearest .git, so it is truthy in ANY
|
||||||||||||||
|
# git repo; without the distinction that fallback wins in every consumer
|
||||||||||||||
|
# checkout, _collect_authoring_root() contributes nothing, and the deployed
|
||||||||||||||
|
# branch below is dead code in the exact case it exists for. So condition
|
||||||||||||||
|
# on WHICH probe matched, which _authoring_root() reports directly. A
|
||||||||||||||
|
# name-count delta looks equivalent and is not: _collect_authoring_root()
|
||||||||||||||
|
# re-collects the checked file's own plugin, whose names the blocks above
|
||||||||||||||
|
# already added, so a one-plugin monorepo shows a delta of zero and would
|
||||||||||||||
|
# wrongly reach for the deployed trees — including the user's global
|
||||||||||||||
|
# ~/.claude/skills, making the verdict depend on what happens to be
|
||||||||||||||
|
# installed (ADR-0020 lines 118-127).
|
||||||||||||||
|
root, root_has_plugins = _authoring_root(start)
|
||||||||||||||
if root:
|
if root:
|
||||||||||||||
_collect_authoring_root(root, names)
|
_collect_authoring_root(root, names)
|
||||||||||||||
else:
|
if not root_has_plugins:
|
||||||||||||||
for base in _deployed_roots(start):
|
for base in _deployed_roots(start):
|
||||||||||||||
_collect_package(base, names)
|
_collect_package(base, names)
|
||||||||||||||
return names
|
return names
|
||||||||||||||
@@ -520,11 +549,17 @@ MARKED_TARGET = r"(?:`/?(%s)`|(?<![\w./*-])/(%s)\b)" % (NAME_ANY, NAME_ANY)
|
|||||||||||||||
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
||||||||||||||
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
||||||||||||||
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
||||||||||||||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET)
|
# re.I on ALL of them, uniformly. The patterns are built from the same
|
||||||||||||||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET)
|
# lowercase NAME_* fragments, so half of them carrying the flag and half not
|
||||||||||||||
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET)
|
# meant `Skill-Audit` at the start of a boundary sentence was extracted by
|
||||||||||||||
|
# ROUTE_ANY but invisible to BACKTICK — contradicting normalize_target()'s own
|
||||||||||||||
|
# docstring, which exists precisely because extraction is case-insensitive and
|
||||||||||||||
|
# the universe is not.
|
||||||||||||||
|
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||||||||||||||
|
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||||||||||||||
|
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
|
||||||||||||||
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I)
|
||||||||||||||
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH)
|
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I)
|
||||||||||||||
# A boundary clause takes two shapes and BOTH count: the prose markers, and
|
# A boundary clause takes two shapes and BOTH count: the prose markers, and
|
||||||||||||||
# ADR-0020's compressed arrow form `Not <thing> -> <name>`.
|
# ADR-0020's compressed arrow form `Not <thing> -> <name>`.
|
||||||||||||||
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
|
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
|
||||||||||||||
@@ -691,8 +726,16 @@ def unresolved_targets(description, known):
|
|||||||||||||||
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
|
# description with a 1,000-word body exited 0 behind a BOM). A file that cannot
|
||||||||||||||
# be measured must never report green, so every caller of these two ERRORs on a
|
# be measured must never report green, so every caller of these two ERRORs on a
|
||||||||||||||
# miss instead of moving on.
|
# miss instead of moving on.
|
||||||||||||||
|
#
|
||||||||||||||
|
# The CLOSING marker is anchored at column 0 — deliberately NOT `[ \t]*---`.
|
||||||||||||||
|
# YAML block-scalar content must be indented deeper than its key, so an
|
||||||||||||||
|
# indented `---` inside a folded description is CONTENT; letting it close the
|
||||||||||||||
|
# frontmatter truncated the description mid-value and silently reclassified the
|
||||||||||||||
|
Claude
commented
Vacuous green, and shared verbatim by all three validators. A non-string YAML value is
A mis-indented folded scalar collapsing into a block sequence is one of the two likeliest YAML slips in precisely the field this ADR exists for — and the boundary/routing extraction then runs over the repr. The inconsistency is sharp: valueless, **Vacuous green**, and shared verbatim by all three validators. A non-string YAML value is `str()`-coerced and then measured as a Python repr. All rc=0:
| frontmatter | measured as |
|---|---|
| `description:` + `- alpha` / `- beta` | `"['alpha', 'beta']"` (17 chars) |
| `description: true` | `"True"` (4 chars) |
| `description:` + `a: 1` | `"{'a': 1}"` (8 chars) |
A mis-indented folded scalar collapsing into a block sequence is one of the two likeliest YAML slips in precisely the field this ADR exists for — and the boundary/routing extraction then runs over the repr.
The inconsistency is sharp: valueless, `null`, `''`, `""` and an empty `>` are all hard FAILs, with `tests/test-adr0020-frontmatter.sh:253-266` pinning all five. A list, mapping or bool is not. Should raise `FrontmatterError`.
|
|||||||||||||||
|
# rest as body, which is a vacuous green in both directions at once. Leading
|
||||||||||||||
|
# whitespace is still tolerated on the OPENING marker, where no such content
|
||||||||||||||
|
# can exist.
|
||||||||||||||
FRONTMATTER_RE = re.compile(
|
FRONTMATTER_RE = re.compile(
|
||||||||||||||
r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n[ \t]*---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
def strip_bom(text):
|
def strip_bom(text):
|
||||||||||||||
@@ -724,14 +767,26 @@ def description_value(fm_text):
|
|||||||||||||||
try:
|
try:
|
||||||||||||||
data = yaml.safe_load(fm_text)
|
data = yaml.safe_load(fm_text)
|
||||||||||||||
except Exception as exc:
|
except Exception as exc:
|
||||||||||||||
raise FrontmatterError(re.sub(r'\s+', ' ', str(exc)).strip())
|
# Every FrontmatterError message is a COMPLETE clause, never a detail a
|
||||||||||||||
|
# caller wraps in one. Callers used to prefix a hard-coded "frontmatter
|
||||||||||||||
|
# is not valid YAML (...)", which is true only of this branch: the two
|
||||||||||||||
|
# type failures below come from frontmatter that parsed fine, and
|
||||||||||||||
|
# telling their author the YAML is invalid sends them hunting for a
|
||||||||||||||
|
# syntax error that is not there — on a blocking gate with no baseline.
|
||||||||||||||
|
raise FrontmatterError('frontmatter is not valid YAML (%s)'
|
||||||||||||||
|
% re.sub(r'\s+', ' ', str(exc)).strip())
|
||||||||||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||||||||||
raise FrontmatterError('frontmatter is not a YAML mapping')
|
raise FrontmatterError('frontmatter is not a YAML mapping')
|
||||||||||||||
value = data.get('description')
|
value = data.get('description')
|
||||||||||||||
if value is None:
|
if value is None:
|
||||||||||||||
return ''
|
return ''
|
||||||||||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||||||||||
value = str(value)
|
# NOT str()-coerced. `description: true` became the 4-character "True"
|
||||||||||||||
|
# and sailed through the 400-character gate; a list or mapping was
|
||||||||||||||
|
# measured as its Python repr. Neither is a description a host can
|
||||||||||||||
|
# preload, so this is a parse failure, reported as one.
|
||||||||||||||
|
raise FrontmatterError(
|
||||||||||||||
|
'description is a %s, not a string' % type(value).__name__)
|
||||||||||||||
return re.sub(r'\s+', ' ', value).strip()
|
return re.sub(r'\s+', ' ', value).strip()
|
||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
@@ -801,6 +856,18 @@ def mask_fenced(text):
|
|||||||||||||||
if (marker and marker[0] == fence[0] and len(marker) >= len(fence)
|
if (marker and marker[0] == fence[0] and len(marker) >= len(fence)
|
||||||||||||||
and not stripped.strip()[len(marker):].strip()):
|
and not stripped.strip()[len(marker):].strip()):
|
||||||||||||||
fence = None
|
fence = None
|
||||||||||||||
|
# An UNCLOSED fence has no cost-free answer, only a choice of which way to
|
||||||||||||||
|
# be wrong. Masking to end-of-body blanks the rest of the body, silently
|
||||||||||||||
|
# disabling the ERROR-tier references/ check and the gotcha counts.
|
||||||||||||||
|
# Returning the raw text instead exposes the unclosed example's own
|
||||||||||||||
|
# content, so a fenced example naming a nonexistent references/ file
|
||||||||||||||
|
# becomes a hard ERROR it would not have been had the fence been closed —
|
||||||||||||||
|
# confirmed, not hypothetical. The loud-false-positive direction is the one
|
||||||||||||||
|
# chosen: this script's rule is that a file it cannot measure must never
|
||||||||||||||
|
# report green, and masking-onward is exactly that failure. Both outcomes
|
||||||||||||||
|
# need an already-malformed file, and the false positive costs one fence.
|
||||||||||||||
|
if fence is not None:
|
||||||||||||||
|
return text
|
||||||||||||||
return ''.join(out)
|
return ''.join(out)
|
||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
@@ -868,12 +935,28 @@ for path in files:
|
|||||||||||||||
"silence." % (path, why))
|
"silence." % (path, why))
|
||||||||||||||
continue
|
continue
|
||||||||||||||
try:
|
try:
|
||||||||||||||
content = strip_bom(read_text(path))
|
raw = read_text(path)
|
||||||||||||||
except EncodingError as exc:
|
except EncodingError as exc:
|
||||||||||||||
error("%s: %s. None of the ADR-0020 gates could run on this file."
|
error("%s: %s. Neither the spec line/word ceilings nor any of the "
|
||||||||||||||
% (path, exc))
|
"ADR-0020 gates could run on this file." % (path, exc))
|
||||||||||||||
continue
|
continue
|
||||||||||||||
|
|
||||||||||||||
|
# SPEC CONFORMANCE (family 1). Whole file, frontmatter included, counted
|
||||||||||||||
|
# with the SAME primitives skill-audit/scripts/validate.sh uses for these
|
||||||||||||||
|
# two constants — see the note in the bash loop above for what the previous
|
||||||||||||||
|
# awk pass got wrong.
|
||||||||||||||
|
lines = len(raw.splitlines())
|
||||||||||||||
|
words = len(raw.split())
|
||||||||||||||
|
if lines > MAX_LINES:
|
||||||||||||||
|
error("%s has %d lines, exceeding the %d-line ceiling "
|
||||||||||||||
|
"(agentskills.io skill-authoring.md)" % (path, lines, MAX_LINES))
|
||||||||||||||
|
if words > MAX_WORDS:
|
||||||||||||||
|
error("%s has %d words (proxy for tokens), exceeding the %d-word ceiling "
|
||||||||||||||
|
"(~5,000 tokens, agentskills.io skill-authoring.md)"
|
||||||||||||||
|
% (path, words, MAX_WORDS))
|
||||||||||||||
|
|
||||||||||||||
|
content = strip_bom(raw)
|
||||||||||||||
|
|
||||||||||||||
fm_match = FRONTMATTER_RE.match(content)
|
fm_match = FRONTMATTER_RE.match(content)
|
||||||||||||||
if not fm_match:
|
if not fm_match:
|
||||||||||||||
error("%s: no parseable YAML frontmatter block. Expected a `---` line, "
|
error("%s: no parseable YAML frontmatter block. Expected a `---` line, "
|
||||||||||||||
@@ -887,8 +970,11 @@ for path in files:
|
|||||||||||||||
try:
|
try:
|
||||||||||||||
desc = description_value(fm_match.group(1))
|
desc = description_value(fm_match.group(1))
|
||||||||||||||
except FrontmatterError as exc:
|
except FrontmatterError as exc:
|
||||||||||||||
error("%s: frontmatter is not valid YAML (%s). None of the ADR-0020 "
|
# `exc` carries the whole clause — invalid YAML, a non-mapping block, or
|
||||||||||||||
"gates could run on this file." % (path, exc))
|
# a description of the wrong type. Do not prefix a diagnosis here; the
|
||||||||||||||
|
# last one named a syntax error for two failures that have none.
|
||||||||||||||
|
error("%s: %s. None of the ADR-0020 gates could run on this file."
|
||||||||||||||
|
% (path, exc))
|
||||||||||||||
continue
|
continue
|
||||||||||||||
|
|
||||||||||||||
body = content[fm_match.end():]
|
body = content[fm_match.end():]
|
||||||||||||||
|
|||||||||||||||
Reference in New Issue
Block a user
Two problems on this line.
1 — the exit status is discarded. On an awk read failure
lines/wordscome back empty, bash arithmetic treats both as 0, and the 500-line and 2,770-word ceilings both record a silent pass — in a script whose stated rule (:92,:855) is that a measurement not taken must never be quiet.2 — this disagrees with
skill-audit/scripts/validate.sh:901,909, which measures the same two ceilings with Pythonsplitlines()/split(). Python splits on\x0b \x0c \x1c \x85and every Unicode space; awk splits on neither. Confirmed: a body padded with U+2028 → hook reports 6 lines, rc=0, whilevalidate.shreportsFAIL … 606 lines, rc=1. Padded with U+00A0 → awk 814 words (silent pass) vs Python 3013 (FAIL … exceeds 2770).That is the "fix one gate, get blocked by the other" bug, on the two axes nothing tests —
tests/test-adr0020-differential.sh:290deliberately excludesMAX_LINES/MAX_WORDSfrom the cross-script comparison. The header comment at:111-113asserts the equivalence that does not hold (wc -wmatches awk only underLC_ALL=C).Moving both counts into the Python block that already reads the file fixes this and the
PermissionErrorabort at:208-217together.