Files
holocron/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-provenance-skill.sh
Defame1297 2c4b6d2615 fix(kyberforge): harden Research doc and Basis parsing in the validator
Review of PR 139 found list-rejection and confinement holes that let the
exact malformed entries the grammar forbids pass check 7.

- Reject comma, space-separated and backticked path lists, so
  `a/sources.md (x), b/topic.md` no longer exits 0 unchecked.
- FAIL absolute paths and any path whose realpath leaves the repo, for
  both `Research doc:` and `Basis:`.
- Anchor `(removed in <sha>)` to the end of the value with a 7-40 hex
  sha. The sha is format-checked only, not resolved with git cat-file.
- Read `* ` bullets and `- **X**` bullets correctly under a `**Basis:**`
  header, and strip backticks from Basis paths.
- Stop the semicolon rule firing on annotation prose, and stop `none`
  matching `none/foo.md`.
- Update the stale field messages to the new grammar and report an empty
  field as empty, not missing.
- Skip a removed Basis silently when there is no repo root.

Adds 40 tests. Each guarded line was mutated in place and every mutant
is caught.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGHFJextYtVQseaHPDDhxB
2026-09-21 19:40:28 +00:00

1251 lines
62 KiB
Bash
Executable File

#!/usr/bin/env bash
# lib-provenance-skill.sh — SOURCED, never executed.
#
# skill-audit's provenance suite: its validate-provenance.sh, minus the shared
# Contributing-files parser (lib-contributing-files.sh holds the one copy) and
# minus the --help dispatch that validate-provenance.sh now owns. The bash
# argument handling, the preconditions and every exit code are lifted verbatim.
#
# The two provenance modes have DIFFERENT exit contracts and they are NOT
# unified. Skill mode exits 0 with output whenever the only findings are INFO
# — a check that could not run, announced rather than skipped silently — so a
# caller must read exit 0 plus output as INFO-only findings, not as noise.
# Agent mode (lib-provenance-agent.sh) prints nothing at all on a clean run and
# additionally exits 0 SILENTLY when the scope walk-up finds no plugin package.
# Skill mode has no such verdict: it has already hard-failed (exit 2) on a
# directory that is not a skill before the interpreter starts.
#
# Skill mode also owns the --base-ref=<ref> flag (check 9) and the
# VALIDATE_PROVENANCE_BASE_REF environment variable. Agent mode has no check 9
# and takes no flags, so a --base-ref passed to an agent target is still an
# extra argument and is still rejected, exactly as before the merge.
#
# Consumed by: validate-provenance.sh, skill mode.
# shellcheck shell=bash
# shellcheck disable=SC2034
kyberforge_prov_skill_usage() {
cat <<EOF
Usage: validate-provenance.sh <skill-dir> [--base-ref=<ref>]
Validate that a skill's sources provenance chain is complete and internally consistent.
Arguments:
skill-dir Path to the skill directory to validate.
--base-ref=REF Git ref to diff references/sources.md against for check 9.
Defaults to \`git merge-base HEAD origin/main\`. Override this
when origin/main is not the right comparison point (a fork,
a long-lived branch, a mirror with a different remote name).
The VALIDATE_PROVENANCE_BASE_REF environment variable is an
equivalent, lower-precedence way to set it — the flag wins
if both are given, including when the flag is given empty
(\`--base-ref=\`), which selects the default resolution.
Exit codes:
0 All checks passed (or nothing to validate)
1 One or more checks failed
2 Usage error, or the argument is not a skill directory
An exit code of 2 is NOT a finding. SKILL.md tells the auditor to surface a
non-zero exit as findings, so a usage error leaving exit 1 with nothing on
stdout was indistinguishable from a clean-but-failing run. Environment and
argument problems exit 2; only real findings exit 1.
Checks performed:
0 source_keys present but references/sources.md absent
1 FILL IN: placeholders in sources.md
2 source_keys in SKILL.md → slug exists in sources.md
3 source_keys in references/*.md → slug exists in sources.md (INFO if no
source_keys; an explicit 'source_keys: []' declares the file house-authored
and passes silently)
4 Contributing files listed in sources.md exist on disk. An explicit
'(none)' skips silently; a Contributing files block this parser cannot
read is reported as an INFO saying checks 4 and 5 did not run, never
skipped silently.
5 Contributing files back-reference the parent slug in their source_keys
6 Research doc field present and not a placeholder, and exactly ONE path — the Research registry, a plugin's
research sources.md whose H2 headings are the source slugs. A brace
expansion, a comma-separated list, a semicolon-separated pair and a
repeated '- **Research doc:**' line are each a FAIL. An entry with no
registry writes 'Research doc: none' (a trailing annotation after an em
dash is fine) and names what it was drawn from in '- **Basis:**', one
repo path per bullet; a missing Basis, or a Basis path that does not
exist, is a FAIL. A Basis bullet annotated '(removed in <sha>)' skips
the existence check.
7 Slug in sources.md present in the Research registry (FAIL). A section
annotation ('§ ...', '→ ...', '(...)') is stripped before the path is
resolved. A path that does not resolve, or no repo root above the skill
directory, is reported as an INFO saying check 7 did not run, never
skipped silently. A Research doc that resolves to a file NOT named
sources.md (a topic document) is a FAIL.
8 (retired — #121) The reverse check, "every extracted slug in the research
doc appears in this skill's sources.md", could not be satisfied when one
registry serves many skills. The number is left vacant so check 9 keeps
the name the rest of the repo cites.
9 Description or Contributing files text changed since --base-ref (INFO
only — a bash script cannot verify the claim is still TRUE, only that it
changed; the auditor reads the named files to check that). Wrapped values
are joined before comparison, so a re-wrap alone is not a change and a
rewrite of any line of one is. A slug absent at the base ref is a
creation, not a change, and is not flagged; a field that WAS there and is
now gone is announced as a removal. When the base ref cannot be resolved,
or references/sources.md is not tracked under this path at that ref, this
is announced as ONE INFO for the whole check, never a silent skip.
Check 7 applies to a Research doc that names a Research registry — a file
whose basename is sources.md, whose H2 headings ARE source slugs. A topic
document is a FAIL, not a value the check skips, and every other reason it
does not run is announced as an INFO.
EOF
}
kyberforge_prov_skill_run() {
local arg
# --base-ref=<ref> is the only recognised flag, for check 9's base-ref
# override. It is pulled out before the positional-count checks below so it
# never counts against them — a caller passing it alongside skill-dir sees
# the same argument-count behaviour as one who does not pass it at all, and a
# genuinely extra positional argument is still rejected.
#
# BASE_REF_OVERRIDE is deliberately left UNSET here rather than initialised to
# the empty string, and deliberately NOT declared `local`: `local X` with no
# value still leaves X unset, but declaring it at all would scope it away from
# a future caller that wants to inspect it. `--base-ref=` (given, but empty)
# and "no flag at all" are different instructions — the first says "use the
# default resolution, ignoring the environment", the second says "fall back to
# the environment" — and an empty-string initialiser collapsed them:
# `${BASE_REF_OVERRIDE:-$ENV}` treats an empty flag value as absent, so the
# environment variable won and the usage text's "the flag wins if both are
# given" was false for exactly that spelling.
declare -a POSITIONAL_ARGS=()
for arg in "$@"; do
case "$arg" in
--base-ref=*)
BASE_REF_OVERRIDE="${arg#--base-ref=}"
;;
*)
POSITIONAL_ARGS+=("$arg")
;;
esac
done
# Usage and environment problems exit 2, findings exit 1. See the usage text
# above for why the two must not share a code. This is a deliberate divergence
# from validate.sh, which has no 2 tier: validate.sh always prints PASS lines,
# so a usage error there is visibly not a findings report. This script prints
# NOTHING on a clean run, so exit 1 plus empty stdout was the only signal a
# caller got either way.
if [[ ${#POSITIONAL_ARGS[@]} -lt 1 ]]; then
echo "Error: skill-dir is required." >&2
echo "" >&2
kyberforge_prov_skill_usage >&2
exit 2
fi
# Extra positional arguments were silently dropped, so a typo'd flag or a second
# path looked like it had been honoured.
if [[ ${#POSITIONAL_ARGS[@]} -gt 1 ]]; then
echo "Error: expected exactly one argument, got ${#POSITIONAL_ARGS[@]}: ${POSITIONAL_ARGS[*]}" >&2
echo "" >&2
kyberforge_prov_skill_usage >&2
exit 2
fi
local SKILL_DIR_ARG="${POSITIONAL_ARGS[0]}"
# The flag wins over the environment variable whenever the flag was GIVEN —
# `+x` tests for presence, not for a non-empty value, which is the distinction
# `:-` could not make. An empty result either way tells the Python body to fall
# back to `git merge-base HEAD origin/main`.
local BASE_REF
if [[ -n "${BASE_REF_OVERRIDE+x}" ]]; then
BASE_REF="$BASE_REF_OVERRIDE"
else
BASE_REF="${VALIDATE_PROVENANCE_BASE_REF:-}"
fi
# python3 is a HARD dependency. Without this preflight a missing interpreter
# produced 'line NN: python3: command not found' and exit 127 — an exit code no
# caller maps to anything, from a message that names this script's line number
# rather than the missing dependency.
if ! command -v python3 > /dev/null 2>&1; then
echo "Error: python3 is required but was not found on PATH." >&2
echo " Why: skipping the provenance checks entirely would be a vacuous pass." >&2
echo " Fix: install python3 (pre-commit itself is a Python application, so it is almost certainly already present)." >&2
exit 2
fi
# A path that is not a directory, or a directory that is not a skill, used to
# reach the Python body, find no sources.md and no source_keys, take the
# "nothing to validate" early exit and report exit 0 with no output — which
# references/skill-validation-scripts.md explicitly told the auditor to read as a
# pass. A typo'd target was therefore indistinguishable from a clean skill.
# vale-wrap.sh hard-errors on a nonexistent path for exactly this reason.
if [[ ! -d "$SKILL_DIR_ARG" ]]; then
echo "Error: not a directory: $SKILL_DIR_ARG" >&2
echo " Why: a nonexistent target would otherwise report a silent pass." >&2
echo " Fix: pass the path of the skill directory to validate." >&2
exit 2
fi
if [[ ! -f "$SKILL_DIR_ARG/SKILL.md" ]]; then
echo "Error: not a skill directory (no SKILL.md): $SKILL_DIR_ARG" >&2
echo " Why: a directory with no SKILL.md has no provenance chain to validate, and reporting that as a pass hides the wrong-target mistake." >&2
echo " Fix: pass the skill directory itself, not its parent or its references/ subdirectory." >&2
exit 2
fi
# The Python program, reassembled in the order the parser block sat in before
# the merge: preamble, shared parser, body.
local prog="$KYBERFORGE_PROV_SKILL_PREAMBLE_PY
$KYBERFORGE_CONTRIBUTING_FILES_PY
$KYBERFORGE_PROV_SKILL_BODY_PY"
local rc=0
python3 -u - "$SKILL_DIR_ARG" "$BASE_REF" <<< "$prog" || rc=$?
# The findings code travels in KYBERFORGE_PROV_RC and this function returns 0,
# so the caller can invoke it UNTESTED. Testing a function's status (`f || RC=$?`)
# disables errexit for its entire body, which would leave every command above
# unguarded -- and no subshell or `set -e` inside can re-arm it once the call
# sits in a condition context. Error paths above use `exit`, which is unaffected
# either way; this keeps errexit armed for anything added later.
KYBERFORGE_PROV_RC="$rc"
return 0
}
IFS='' read -r -d '' KYBERFORGE_PROV_SKILL_PREAMBLE_PY <<'KYBERFORGE_PROV_SKILL_PREAMBLE' || true
import sys
import os
import re
import subprocess
# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams
# default to ASCII, and every finding this script prints contains an em dash.
# Pinning only the reads moved the crash from the read to the write — a
# UnicodeEncodeError inside print_findings(), which loses the whole report
# after all the checks have already run.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding='utf-8')
except AttributeError: # pragma: no cover — Python < 3.7
pass
skill_dir = os.path.abspath(sys.argv[1])
sources_md_path = os.path.join(skill_dir, "references", "sources.md")
refs_dir = os.path.join(skill_dir, "references")
# Empty string (the shell side passes "" when neither --base-ref nor
# VALIDATE_PROVENANCE_BASE_REF was given) means: resolve the default via
# `git merge-base HEAD origin/main` at check-9 time, below.
base_ref_override = sys.argv[2] if len(sys.argv) > 2 else ""
# --- Helpers ---
# The trailing character class used to be CONSUMING — `[^`\n]` — so a
# `FILL IN:` at end of line matched nothing and escaped checks 1 and 6
# entirely. `- **Description:** FILL IN:` is the most likely spelling of a
# half-written entry, and it was the one spelling the placeholder gate could
# not see. The exclusion it was really expressing is "not inside backticks",
# which a lookahead states without eating a character.
PLACEHOLDER_RE = re.compile(r'(?<!`)FILL IN:(?!`)')
# --- Input ----------------------------------------------------------------
# Ported from validate.sh, where the same two problems were already fixed.
#
# read_text() pins UTF-8 explicitly instead of inheriting
# locale.getpreferredencoding(), which is ASCII under LC_ALL=C — an ordinary em
# dash in a references file then aborted the run with a bare UnicodeDecodeError
# traceback, or, at the one call site that wrapped its read in `except
# Exception: return False`, reported the unreadable file as having no
# source_keys and therefore as clean. A file that genuinely is not UTF-8 still
# fails; it just says which file and why.
#
# strip_bom() runs on every read because a leading BOM defeats
# parse_frontmatter()'s `^---` anchor, which silently disabled check 2 on a
# BOM-prefixed SKILL.md: no frontmatter parsed means no source_keys parsed
# means nothing to validate.
class EncodingError(Exception):
pass
def strip_bom(text):
return text[1:] if text.startswith(u'\ufeff') else text
def read_text(path):
"""File contents as text, UTF-8 and BOM-free, with a diagnostic instead of a traceback."""
try:
with open(path, encoding='utf-8') as fh:
return strip_bom(fh.read())
except UnicodeDecodeError as exc:
raise EncodingError(
"not valid UTF-8 (%s at byte %d) — re-save the file as UTF-8; "
"this gate does not guess at other encodings"
% (exc.reason, exc.start))
def parse_frontmatter(content):
"""Return (frontmatter_str, body_str) or (None, content) if no frontmatter."""
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not m:
return None, content
return m.group(1), content[m.end():]
def parse_source_keys(fm):
"""Extract list of source_keys from frontmatter string (handles metadata.source_keys and top-level)."""
if fm is None:
return []
keys = []
# Match either:
# metadata:\n source_keys:\n - slug
# or:
# source_keys:\n - slug
in_source_keys = False
in_metadata = False
for line in fm.splitlines():
if re.match(r'^metadata:', line):
in_metadata = True
continue
if in_metadata and re.match(r'^ source_keys:', line):
in_source_keys = True
continue
if not in_metadata and re.match(r'^source_keys:', line):
in_source_keys = True
continue
if in_source_keys:
m = re.match(r'^[ \t]+-\s+(\S+)', line)
if m:
keys.append(m.group(1).strip())
elif line and not line[0].isspace():
in_source_keys = False
in_metadata = False
return keys
# An explicit `source_keys: []` — top-level or under metadata: — is a
# DECLARATION that the file is house-authored and has no external source.
# parse_source_keys() returns [] both for that and for a file with no
# source_keys key at all, so the two are indistinguishable downstream and
# check 3 emitted the same INFO for each (#111). That left no honest way to
# record "this file has no external source": the only ways to silence the INFO
# were to invent a slug or borrow an unrelated one, both false provenance
# claims that then have to be maintained in sources.md as well. A bare
# `source_keys:` with nothing after it is NOT accepted here — that reads as a
# truncated or half-written entry, not a decision.
#
# The indent is pinned to the two positions parse_source_keys() actually reads
# — column 0, or two spaces under `metadata:`. A permissive `^\s*` matched a
# `source_keys: []` nested at ANY depth under an unrelated key, which
# parse_source_keys() never reads, so a stray nested key silenced the check-3
# INFO for a file that had declared nothing.
EMPTY_SOURCE_KEYS_RE = re.compile(r'^(?: )?source_keys:\s*\[\s*\]\s*$')
def declares_empty_source_keys(fm):
"""True when frontmatter carries an explicit, empty `source_keys: []`."""
if fm is None:
return False
return any(EMPTY_SOURCE_KEYS_RE.match(line) for line in fm.splitlines())
def parse_h2_slugs(content):
"""Return list of H2 heading values from a markdown file."""
return re.findall(r'^## (.+)$', content, re.MULTILINE)
KYBERFORGE_PROV_SKILL_PREAMBLE
KYBERFORGE_PROV_SKILL_PREAMBLE_PY="${KYBERFORGE_PROV_SKILL_PREAMBLE_PY%$'\n'}"
IFS='' read -r -d '' KYBERFORGE_PROV_SKILL_BODY_PY <<'KYBERFORGE_PROV_SKILL_BODY' || true
def _entry_block(content, slug):
"""The text under a '## slug' heading, or None when there is no such entry."""
pattern = re.compile(
r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)',
re.MULTILINE | re.DOTALL
)
m = pattern.search(content)
return m.group(1) if m else None
def parse_field_values(content, slug, label):
"""Every value of a '**label:**' field under a slug H2, in document order.
The SPELLING of a field must not decide whether it is read. Three
spellings are in the corpus and all three are accepted here:
- **Label:** value (the documented form)
**Label:** value (no leading hyphen — gitea-releases writes Status so)
**Label:** (a header, then '- value' bullets)
- value
A field parsed by a regex that knew only the first form returned "nothing
found" for the other two, and every caller read that as "nothing declared"
(#121, second comment; the same failure shape as #111 and #118). A header's
bullets stop at the first line that is neither blank nor a bullet, and a
'- **Other:**' bullet is the NEXT field, not a value of this one ('* '
bullets count too, and a bold bullet with no colon is a value).
"""
block = _entry_block(content, slug)
if block is None:
return []
values = []
lines = block.splitlines()
label_re = re.compile(r'^(?:[-*] )?\*\*' + re.escape(label) + r':\*\*[ \t]*(.*)$')
# A bullet that opens with a bold '**Other:**' label is the NEXT field. A
# bold bullet WITHOUT the colon ('- **docs/x.md**') is just a value.
next_field_re = re.compile(r'^[-*] \*\*[^*]*:\*\*')
i = 0
while i < len(lines):
m = label_re.match(lines[i])
i += 1
if not m:
continue
inline = m.group(1).strip()
if inline:
values.append(inline)
continue
found = False
while i < len(lines):
line = lines[i].strip()
if not line:
i += 1
continue
if not (line.startswith('- ') or line.startswith('* ')) or next_field_re.match(line):
break
values.append(line[2:].strip())
found = True
i += 1
if not found:
# The field is DECLARED but carries nothing: report an empty value,
# not an absent field, so callers say 'empty' rather than 'missing'.
values.append('')
return values
def parse_research_docs(content, slug):
"""Every Research doc value under a given slug H2, in document order.
Research doc takes exactly ONE path, so the caller FAILs on a second value
rather than using the first and announcing the rest — an author who added a
doc rather than replacing one otherwise got check 7 run against the
old path and a verdict that looked complete.
"""
return parse_field_values(content, slug, 'Research doc')
def parse_basis(content, slug):
"""Every Basis value under a slug H2 — the repo paths an entry with no
Research registry was actually drawn from, one per bullet."""
return parse_field_values(content, slug, 'Basis')
# A Research doc value is a path, and very often a path PLUS an annotation
# naming the section the slug came from:
#
# plugins/git/docs/research/docs/git/gitflow.md (whole-document reference)
# plugins/git/docs/research/docs/git/remotes.md → `## Pushing (`git push`)`
# .../pre-commit/hooks-reference.md § "pre-commit-hooks (official collection)"
#
# os.path.isfile() is false for every one of those strings, and check 7
# used to skip SILENTLY whenever the path did not resolve. The effect was that
# both checks were dead on eight of the nine git skills — git-history, the one
# skill writing a bare path, was the only place they ran, which is why it was
# the only skill ever reporting a check-7 INFO. Strip the annotation before
# resolving, and report when the result still does not resolve: a check that
# quietly does not run is worse than one that fails.
RESEARCH_DOC_ANNOTATION_RE = re.compile(r'[§→(]')
def strip_research_doc_annotation(value):
"""Path part of a Research doc value, with any section annotation removed
and surrounding backticks unwrapped ('`a/b.md`' resolves as 'a/b.md')."""
head = RESEARCH_DOC_ANNOTATION_RE.split(value, maxsplit=1)[0].strip()
return head.strip('`').strip()
def research_doc_is_none(value):
"""True when a Research doc value declares that no research doc backs the slug.
Both '(none)' and the bare 'none — org convention, ...' spelling are in
use; recognising only the parenthesised one would report the other as an
unresolvable path. Checked BEFORE the annotation strip, because '(none)'
is itself a parenthesis and would strip to the empty string.
"""
# 'none/foo.md' and 'none-of-these.md' are PATHS: after 'none' only the end,
# whitespace or an em/en dash may follow (or the parenthesised '(none)').
return re.match(r'(?:\(none\)|none(?=$|\s|[\u2014\u2013]))', value.strip(), re.IGNORECASE) is not None
# A Research doc or Basis value names ONE path. The three list spellings seen
# in the corpus — a brace expansion, a comma-separated list and a
# semicolon-separated pair — are humans writing "several documents" into a
# single-path field. Nothing expands a brace in a markdown field, and the
# annotation strip above discards everything after the first '(' or section
# marker, so a second path parked after one was NEVER resolved and no check
# said so. Detected on the raw value, with commas and semicolons INSIDE the
# annotation left alone: those are prose ('cross-cutting; no dedicated
# section'), and only a second path-shaped token after a ';' is a list.
SECOND_PATH_AFTER_SEMICOLON_RE = re.compile(r'[;,]\s*[\w.\-]+/[\w./\-]*\.[A-Za-z]+')
# Only the LAST character class matters for the removal annotation: it must end
# the value, so '(removed in <sha>) but still here' is not the annotation.
BASIS_REMOVED_RE = re.compile(r'\(removed in [0-9a-f]{7,40}\)\s*$')
PAREN_GROUP_RE = re.compile(r'\([^()]*\)')
def names_more_than_one_path(value):
"""True when a Research doc / Basis value is a list rather than one path.
Three places to look, none of which is prose:
- the leading path token: whitespace inside it ('a.md b.md'), or any of
, ; { } or a stray backtick, is a list;
- the text after it, once balanced '(...)' annotations are removed (a
comma or semicolon INSIDE parentheses is prose): a bare , ; { } there
is a second path parked after the first ('a.md (x), b.md');
- after a section marker (§, →) prose may hold commas, so only a
second path-SHAPED token after ',' or ';' counts.
"""
head = strip_research_doc_annotation(value)
if re.search(r'[\s,;{}`]', head):
return True
rest = value[len(RESEARCH_DOC_ANNOTATION_RE.split(value, maxsplit=1)[0]):]
while True:
stripped = PAREN_GROUP_RE.sub('', rest)
if stripped == rest:
break
rest = stripped
if rest.lstrip().startswith(('§', '→')):
return SECOND_PATH_AFTER_SEMICOLON_RE.search(rest) is not None
return re.search(r'[,;{}]', rest) is not None
def path_escapes_repo(repo_root, rel_path):
"""True when rel_path is absolute or resolves (symlinks followed) outside
repo_root. Research doc and Basis are repo-relative, so anything else is
either a mistake or a way to make the checker read a file elsewhere."""
if os.path.isabs(rel_path):
return True
root = os.path.realpath(repo_root)
real = os.path.realpath(os.path.join(root, rel_path))
return not (real == root or real.startswith(root + os.sep))
def find_repo_root(start_dir):
"""Walk up from start_dir until we find a directory containing .git."""
current = start_dir
while True:
if os.path.exists(os.path.join(current, ".git")):
return current
parent = os.path.dirname(current)
if parent == current:
return None
current = parent
# --- Check 9 helpers ---------------------------------------------------
# Check 9 needs a raw field VALUE (as text, to diff against an earlier
# version), not the parsed structure parse_contributing_files() and
# parse_field_values() return. The ONE normalization applied is whitespace
# collapsing, which is what makes a re-wrap or a re-indent invisible; nothing
# else is normalized away.
#
# In particular a REORDERED Contributing files list DOES fire this check, and
# that is deliberate — the header here used to claim the opposite, which the
# code never did. Order-insensitivity cannot be had for one field without
# distorting the other: the two fields share this parser, and the only way to
# ignore order is to split the value into items and sort them, which for a
# prose Description means splitting on commas and would then hide a genuine
# rewrite that merely permuted its clauses. Check 9 is always an INFO whose
# whole job is to point a human at a place to read; a reordered list costs
# that human one glance to dismiss, whereas a hidden rewrite is the exact
# failure #118 exists to catch. False positive over false negative, on this
# check, on purpose.
def run_git(args, cwd):
"""Run `git <args>` in cwd. Returns (returncode, stdout, stderr) — never
raises, so a missing git binary or an unexpected OSError is just another
non-zero result the caller folds into "could not run", not a crash."""
try:
result = subprocess.run(
["git"] + args, cwd=cwd, capture_output=True, text=True,
encoding="utf-8", errors="replace"
)
return result.returncode, result.stdout, result.stderr
except OSError as exc:
return 1, "", str(exc)
def ref_exists(ref, cwd):
"""True when ref resolves to a commit in the repo at cwd."""
rc, _out, _err = run_git(["rev-parse", "--verify", "--quiet", ref + "^{commit}"], cwd)
return rc == 0
def find_slug_block(content, slug):
"""The raw text of a '## <slug>' entry's body, or None if no such H2."""
pattern = re.compile(
r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)',
re.MULTILINE | re.DOTALL
)
m = pattern.search(content)
return m.group(1) if m else None
# A field value ENDS at the next field, the next heading, or a blank line.
# Every other non-blank line is a continuation of the value the author wrapped
# across physical lines.
#
# This boundary is what the old `(.+)$` regex did not have. `.` does not cross
# a newline, so only the FIRST physical line of a wrapped value was ever
# compared — and a rewrite confined to a continuation line produced no finding
# at all. That is verbatim the hedge-to-confident-claim regression #118 exists
# to catch, invisible to the check written to catch it. The bullet branch had
# the same defect one level down: a wrapped bullet's continuation does not
# start with '- ', so the loop broke there and silently dropped every
# remaining bullet.
#
# A continuation line that itself opens with bold text ('**note** — ...') is
# read as a boundary and truncates the value. That is a known, narrow
# false-negative, accepted because the alternative — no boundary at all —
# is what produced the wide one above.
FIELD_BOUNDARY_RE = re.compile(r'^(?:- )?\*\*|^#{1,6} ')
def _is_field_boundary(stripped_line):
"""True when a stripped line starts a new field, bullet-less heading or H2."""
return bool(FIELD_BOUNDARY_RE.match(stripped_line))
def parse_field_raw(content, slug, field_name):
"""Raw text of a '**<field_name>:**' field under a slug H2, wrapping joined.
Mirrors the two authored shapes parse_contributing_files() and
parse_field_values() already handle (inline value on the same line, or a
bare heading followed by '- ' bullets), but returns text rather than a
parsed structure, because check 9 diffs wording, not semantics.
Continuation lines are joined into the value they belong to before the
caller normalizes and compares, so a value wrapped across two lines and
the same value on one line are the same text — and a change made on any
line of a wrapped value is visible, not just one made on the first.
Returns None when the H2 itself is absent (the slug did not exist at
this content's revision) or the field is absent — both read as "no
earlier claim to compare against" to the caller, which is deliberate:
a field appearing for the first time is a creation, not a change.
"""
block = find_slug_block(content, slug)
if block is None:
return None
lines = block.splitlines()
inline_re = re.compile(r'^\- \*\*' + re.escape(field_name) + r':\*\*[ \t]*(.*)$')
heading_re = re.compile(r'^\*\*' + re.escape(field_name) + r':\*\*[ \t]*$')
for idx, line in enumerate(lines):
im = inline_re.match(line)
if im:
parts = [im.group(1).strip()]
for cont in lines[idx + 1:]:
stripped = cont.strip()
if not stripped or stripped.startswith("- ") or _is_field_boundary(stripped):
break
parts.append(stripped)
joined = " ".join(p for p in parts if p).strip()
return joined or None
if heading_re.match(line):
entries = []
for cont in lines[idx + 1:]:
stripped = cont.strip()
if not stripped:
if entries:
break
continue
if _is_field_boundary(stripped):
break
if stripped.startswith("- "):
entries.append(stripped[2:].strip())
elif entries:
# A wrapped bullet: fold it back into the bullet it
# continues rather than ending the list here.
entries[-1] = (entries[-1] + " " + stripped).strip()
else:
break
return ", ".join(e for e in entries if e) or None
return None
def normalize_field_text(value):
"""Collapse whitespace so reformatting alone never registers as a change.
True only because parse_field_raw() joins wrapped continuation lines
first: collapsing whitespace inside a value that had already been
truncated at its first newline normalized nothing a re-wrap could change.
"""
return re.sub(r'\s+', ' ', value).strip()
findings = []
has_fail = False
# A finding identical in every field is the same finding, and the same file is
# now reached by more than one check — the walk that looks for source_keys and
# check 3 both read every references/*.md, so an unreadable one would otherwise
# be reported twice with the same words. Distinct findings about the same file
# still both appear.
def _record(entry):
if entry not in findings:
findings.append(entry)
def emit_fail(desc, fpath, why, fix):
global has_fail
has_fail = True
_record(("FAIL", desc, fpath, why, fix, None))
def emit_info(desc, fpath, note):
_record(("INFO", desc, fpath, None, None, note))
def print_findings():
for entry in findings:
kind = entry[0]
desc = entry[1]
fpath = entry[2]
why = entry[3]
fix = entry[4]
note = entry[5]
if kind == "FAIL":
print(f"FAIL {desc} — {fpath}")
print(f" Why: {why}")
print(f" Fix: {fix}")
print()
else:
print(f"INFO {desc} — {fpath}")
print(f" Note: {note}")
print()
# --- Scan for any file with source_keys ---
def emit_unreadable(rel, exc):
"""Report a file this script cannot decode. Never a silent skip."""
emit_fail(
f"File is {exc}",
rel,
f"'{rel}' cannot be decoded, so its frontmatter — and any source_keys in it — "
f"cannot be read. This used to be swallowed by a bare 'except Exception: return False', "
f"which reported the unreadable file as having no source_keys and therefore as clean.",
f"Re-save '{rel}' as UTF-8."
)
def file_has_source_keys(fpath, rel):
try:
content = read_text(fpath)
except EncodingError as exc:
emit_unreadable(rel, exc)
return False
fm, _ = parse_frontmatter(content)
if fm is None:
return False
return bool(parse_source_keys(fm))
def find_files_with_source_keys():
"""Return list of (relative_path, abs_path) for all skill files with source_keys."""
results = []
for root, dirs, files in os.walk(skill_dir):
# Skip hidden dirs
dirs[:] = [d for d in dirs if not d.startswith('.')]
for fname in sorted(files):
if fname.endswith('.md'):
abs_path = os.path.join(root, fname)
rel = os.path.relpath(abs_path, skill_dir)
if file_has_source_keys(abs_path, rel):
results.append((rel, abs_path))
return results
sources_md_exists = os.path.isfile(sources_md_path)
files_with_source_keys = find_files_with_source_keys()
# Early exit: nothing to validate. The scan itself can raise a finding — an
# unreadable file — so print before leaving; the clean case still prints
# nothing and exits 0.
if not sources_md_exists and not files_with_source_keys:
print_findings()
sys.exit(1 if has_fail else 0)
# Load sources.md if it exists
sources_content = None
if sources_md_exists:
try:
sources_content = read_text(sources_md_path)
except EncodingError as exc:
emit_unreadable("references/sources.md", exc)
print_findings()
sys.exit(1)
sources_slugs = set(parse_h2_slugs(sources_content))
else:
sources_slugs = set()
# --- Check 0: source_keys without sources.md ---
if not sources_md_exists:
for rel, abs_path in files_with_source_keys:
emit_fail(
f"source_keys declared but references/sources.md is absent",
rel,
"source_keys references research provenance that has no sources index to validate against.",
"Create references/sources.md with an H2 entry for each slug referenced by source_keys."
)
print_findings()
sys.exit(1)
# --- Check 1: FILL IN: placeholders in sources.md ---
for line in sources_content.splitlines():
if PLACEHOLDER_RE.search(line):
emit_fail(
"Unfilled FILL IN: placeholder",
"references/sources.md",
"sources.md contains an unfilled placeholder, meaning provenance is incomplete.",
"Replace all 'FILL IN:' values in references/sources.md with real content."
)
break
# --- Check 2: source_keys in SKILL.md → slug exists in sources.md ---
skill_md_path = os.path.join(skill_dir, "SKILL.md")
if os.path.isfile(skill_md_path):
try:
skill_content = read_text(skill_md_path)
except EncodingError as exc:
emit_unreadable("SKILL.md", exc)
skill_content = ""
skill_fm, _ = parse_frontmatter(skill_content)
skill_source_keys = parse_source_keys(skill_fm)
for slug in skill_source_keys:
if slug not in sources_slugs:
emit_fail(
f"source_keys slug '{slug}' not found in sources.md",
"SKILL.md",
f"SKILL.md declares '{slug}' as a source but there is no '## {slug}' heading in references/sources.md.",
f"Add '## {slug}' entry to references/sources.md or remove '{slug}' from SKILL.md source_keys."
)
# --- Check 3: source_keys in references/*.md → slug exists in sources.md (INFO if no source_keys) ---
# os.walk, not os.listdir: find_files_with_source_keys() above already walks
# references/ recursively, so a source_keys-bearing file in
# references/<subdir>/ was collected there — and then never validated here,
# because the flat listdir could not see it. The two halves of the same check
# disagreed about which files exist.
if os.path.isdir(refs_dir):
ref_paths = []
for root, dirs, files in os.walk(refs_dir):
dirs[:] = sorted(d for d in dirs if not d.startswith('.'))
for fname in sorted(files):
if not fname.endswith('.md'):
continue
fpath = os.path.join(root, fname)
if os.path.relpath(fpath, refs_dir) == "sources.md":
continue
ref_paths.append(fpath)
for fpath in ref_paths:
rel = os.path.relpath(fpath, skill_dir)
try:
ref_content = read_text(fpath)
except EncodingError as exc:
emit_unreadable(rel, exc)
continue
ref_fm, _ = parse_frontmatter(ref_content)
ref_keys = parse_source_keys(ref_fm)
if not ref_keys:
# An explicit `source_keys: []` is a deliberate declaration that
# the file is house-authored, and passes silently. The INFO is for
# files that never said either way.
if declares_empty_source_keys(ref_fm):
continue
emit_info(
f"No source_keys frontmatter",
rel,
"This references file has no source_keys — provenance cannot be verified. "
"Add source_keys frontmatter listing the slugs from references/sources.md that informed this file, "
"or declare an explicit 'source_keys: []' if the file is house-authored and has no external source."
)
else:
for slug in ref_keys:
if slug not in sources_slugs:
emit_fail(
f"source_keys slug '{slug}' not found in sources.md",
rel,
f"'{rel}' declares '{slug}' as a source but there is no '## {slug}' heading in references/sources.md.",
f"Add '## {slug}' entry to references/sources.md or remove '{slug}' from {rel} source_keys."
)
# --- Checks 4, 5, 6, 7, 8: Per-slug checks in sources.md ---
repo_root = find_repo_root(skill_dir)
# Every per-slug parser below — parse_contributing_files, parse_research_docs,
# parse_basis — locates its block with pattern.search(), so a slug written
# twice resolves to the FIRST block every time. Iterating the raw heading list
# therefore checked the first block's fields twice and the second block's
# never: a duplicated slug is half-validated, and looked fully validated. The
# duplicate is announced and the repeat visit dropped.
all_slugs = parse_h2_slugs(sources_content)
unique_slugs = []
for _slug in all_slugs:
if _slug in unique_slugs:
continue
unique_slugs.append(_slug)
_count = all_slugs.count(_slug)
if _count > 1:
emit_info(
f"Duplicate '## {_slug}' entry in sources.md — only the first block is checked",
f"references/sources.md (## {_slug})",
f"'## {_slug}' appears {_count} times. Every field parser here takes the first match, so the "
f"second and later blocks' Contributing files, Research doc and Status are never validated — "
f"checks 4, 5, 6 and 7 did not run for them. "
f"Merge the blocks into one entry, or give each a distinct slug and reference it from source_keys."
)
for slug in unique_slugs:
# Checks 4 and 5: Contributing files exist, and back-reference the slug.
# `[]` and None are NOT the same answer here. `[]` is the author writing
# "(none)" — there is nothing to check and the skip is correct. None is a
# Contributing-files block this parser cannot read, and skipping THAT
# silently disables both checks on the one entry least likely to be right,
# which is the failure mode parse_contributing_files' own docstring warns
# about. Say so out loud instead, the same way an unresolvable Research doc
# value does.
cf_files = parse_contributing_files(sources_content, slug)
if cf_files is None:
emit_info(
f"Contributing-file checks skipped for '{slug}' — the Contributing files block could not be parsed",
f"references/sources.md (## {slug})",
f"The '## {slug}' entry has no Contributing files list this parser can read — a missing field, a bare heading, '*' bullets, a numbered list, or prose all read as unparsable rather than as an empty declaration. "
f"Checks 4 and 5 did not run for this slug, so nothing verified that its contributing files exist or name it back. "
f"Write the value as '- **Contributing files:** <comma-separated paths>', or as a '**Contributing files:**' heading followed by '- ' bullets — "
f"or record '(none)' if this source contributed no files."
)
elif cf_files:
for cf_rel in cf_files:
cf_abs = os.path.join(skill_dir, cf_rel)
if not os.path.isfile(cf_abs):
emit_fail(
f"Contributing file '{cf_rel}' does not exist",
f"references/sources.md (## {slug})",
f"sources.md claims '{cf_rel}' was contributed to by slug '{slug}' but the file does not exist.",
f"Create '{cf_rel}' relative to the skill directory, or correct the path in sources.md."
)
else:
# Check 5: Bidirectional — file should list slug in its source_keys
# Skip sources.md itself
if cf_rel == "references/sources.md":
continue
try:
cf_content = read_text(cf_abs)
except EncodingError as exc:
emit_unreadable(cf_rel, exc)
continue
cf_fm, _ = parse_frontmatter(cf_content)
cf_keys = parse_source_keys(cf_fm)
if slug not in cf_keys:
emit_fail(
f"Contributing file '{cf_rel}' does not list '{slug}' in its source_keys",
f"references/sources.md (## {slug})",
f"sources.md says '{cf_rel}' was informed by '{slug}', but '{cf_rel}' does not declare '{slug}' in its source_keys frontmatter.",
f"Add '{slug}' to the source_keys frontmatter of '{cf_rel}'."
)
# Check 6: Research doc field required
rd_values = parse_research_docs(sources_content, slug)
if len(rd_values) > 1:
emit_fail(
f"Multiple '- **Research doc:**' lines for '{slug}' — Research doc takes exactly one path",
f"references/sources.md (## {slug})",
f"The '## {slug}' entry has {len(rd_values)} Research doc lines. Research doc names one Research registry, "
f"so a second line is a list, and a list is not a grammar this field has.",
f"Keep one Research doc line, pointing at the plugin's research sources.md. If the entry has no registry, "
f"write '- **Research doc:** none' and name what it was drawn from in '- **Basis:**', one repo path per bullet."
)
rd_value = rd_values[0] if rd_values else None
if rd_value is None:
emit_fail(
f"Research doc field missing",
f"references/sources.md (## {slug})",
f"The '## {slug}' entry in sources.md has no '- **Research doc:**' line.",
f"Add '- **Research doc:** <path to the plugin's research sources.md>' to the '## {slug}' entry in references/sources.md, "
f"or '- **Research doc:** none' plus a '- **Basis:** <repo path>' line if no registry backs it."
)
elif rd_value == "" or PLACEHOLDER_RE.search(rd_value):
emit_fail(
f"Research doc field is empty or placeholder",
f"references/sources.md (## {slug})",
f"The '## {slug}' entry has an unfilled Research doc value.",
f"Set '- **Research doc:**' to the plugin's research sources.md (a path relative to the repo root), or to 'none' "
f"with a '- **Basis:** <repo path>' line if no registry backs this entry."
)
elif research_doc_is_none(rd_value):
# An entry with no Research registry must still say what it WAS drawn
# from. Basis names repo paths, one per bullet, and each is checked to
# exist — the honest way to record an org convention, an ADR or a
# house-verified reproduction, none of which has a registry entry.
basis_values = parse_basis(sources_content, slug)
if not basis_values:
emit_fail(
f"Basis missing for '{slug}' — Research doc is 'none'",
f"references/sources.md (## {slug})",
f"The '## {slug}' entry declares no Research registry ('{rd_value}') and no '- **Basis:**' line, "
f"so nothing records what the entry was drawn from.",
f"Add '- **Basis:** <repo path>' to the '## {slug}' entry, one line per path, naming the ADR, "
f"convention file or reproduction the entry rests on."
)
for basis in basis_values:
basis_path = strip_research_doc_annotation(basis)
if PLACEHOLDER_RE.search(basis) or not basis_path:
emit_fail(
f"Basis is empty or placeholder for '{slug}'",
f"references/sources.md (## {slug})",
f"The '## {slug}' entry has an unfilled Basis value '{basis}'.",
f"Set '- **Basis:**' to one repo path."
)
elif names_more_than_one_path(basis):
emit_fail(
f"Basis value names more than one path for '{slug}'",
f"references/sources.md (## {slug})",
f"The Basis value '{basis}' is a brace expansion or a comma- or semicolon-separated list.",
f"Write one '- **Basis:** <repo path>' line per path."
)
elif BASIS_REMOVED_RE.search(basis):
# A path the entry HISTORICALLY rested on, annotated
# '(removed in <sha>)' at the end of the value, is a declaration
# that it is gone on purpose. The sha is not resolved
# (git cat-file was judged over-engineering, ADR-0028 Q7), and
# with no repo root there is nothing to check either way, so
# this skips silently in both cases.
continue
elif not repo_root:
emit_info(
f"Basis check skipped for '{slug}' — no repo root above the skill directory",
f"references/sources.md (## {slug})",
f"'{basis}' is a path relative to the repo root, but no ancestor of the skill directory contains a .git entry, "
f"so it cannot be resolved. Run this script against a skill inside a checkout."
)
elif path_escapes_repo(repo_root, basis_path):
emit_fail(
f"Basis path '{basis_path}' is outside the repository for '{slug}'",
f"references/sources.md (## {slug})",
f"'{basis_path}' is absolute or resolves outside the repo root. Basis names repo paths.",
f"Use a path relative to the repo root that stays inside it."
)
elif not os.path.exists(os.path.join(repo_root, basis_path)):
emit_fail(
f"Basis path '{basis_path}' does not exist",
f"references/sources.md (## {slug})",
f"'{basis}' resolves to '{basis_path}' relative to the repo root and nothing is there.",
f"Correct the path, or remove the Basis line if the entry no longer rests on it."
)
elif names_more_than_one_path(rd_value):
emit_fail(
f"Research doc names more than one path for '{slug}'",
f"references/sources.md (## {slug})",
f"The Research doc value '{rd_value}' is a brace expansion or a comma- or semicolon-separated list. "
f"Research doc names exactly one Research registry.",
f"Point Research doc at the plugin's research sources.md. If the entry has no registry, write "
f"'- **Research doc:** none' and name what it was drawn from in '- **Basis:**', one repo path per bullet."
)
else:
# Check 7: Upstream forward — slug should appear in research doc.
# Every path out of here that does NOT run the check says so out loud.
rd_path = strip_research_doc_annotation(rd_value)
if not repo_root:
emit_info(
f"Upstream checks skipped for '{slug}' — no repo root above the skill directory",
f"references/sources.md (## {slug})",
f"'{rd_value}' is a path relative to the repo root, but no ancestor of the skill directory contains a .git entry, "
f"so it cannot be resolved. Check 7 did not run for this slug. "
f"Run this script against a skill inside a checkout."
)
elif not rd_path:
emit_info(
f"Upstream checks skipped for '{slug}' — Research doc value names no path",
f"references/sources.md (## {slug})",
f"The Research doc value '{rd_value}' is entirely annotation — stripping the section marker leaves no path. "
f"Check 7 did not run for this slug. "
f"Give the value a file path relative to the repo root, or record 'none' plus a '- **Basis:**' if no registry backs this entry."
)
elif path_escapes_repo(repo_root, rd_path):
emit_fail(
f"Research doc '{rd_path}' for '{slug}' is outside the repository",
f"references/sources.md (## {slug})",
f"'{rd_path}' is absolute or resolves outside the repo root. Research doc names a file in this repo.",
f"Point Research doc at the plugin's research sources.md, as a path relative to the repo root."
)
else:
rd_abs = os.path.join(repo_root, rd_path)
if not os.path.isfile(rd_abs):
emit_info(
f"Upstream checks skipped for '{slug}' — research doc '{rd_path}' does not exist",
f"references/sources.md (## {slug})",
f"'{rd_value}' resolves to '{rd_path}' relative to the repo root and no file is there. "
f"Check 7 did not run for this slug, so nothing verified that the research doc still backs it. "
f"Point the value at the one existing Research registry (the plugin's research sources.md), "
f"or record 'none' plus a '- **Basis:**' if no registry backs this entry."
)
elif os.path.basename(rd_path) != "sources.md":
# Check 7 matches slugs against the H2 headings of a
# Research registry — a sources.md whose H2s ARE source slugs.
# A topic document (remotes.md, gitflow.md) has section headings
# for H2s, so no slug can ever match one. Research doc names the
# registry (#121), so a topic document there is the wrong file,
# not a value these checks cannot verify. A pointer to the topic
# document that digested the source belongs in the free-text
# annotation after the path, where it is not checked.
emit_fail(
f"Research doc '{rd_path}' for '{slug}' is a topic document, not a Research registry",
f"references/sources.md (## {slug})",
f"'{os.path.basename(rd_path)}' is not a sources.md, so its H2s are section headings and no slug can match one. "
f"Research doc names the plugin's Research registry — the sources.md whose H2s are source slugs.",
f"Repoint '{slug}' at the sibling sources.md in '{os.path.dirname(rd_path)}/', and keep the topic document in the "
f"annotation, e.g. '<registry path> (digested in {os.path.basename(rd_path)})'."
)
else:
try:
rd_content = read_text(rd_abs)
except EncodingError as exc:
emit_info(
f"Upstream checks skipped for '{slug}' — research doc '{rd_path}' is {exc}",
f"references/sources.md (## {slug})",
f"'{rd_path}' could not be decoded, so check 7 did not run for this slug. "
f"Re-save the research doc as UTF-8."
)
continue
rd_slugs = set(parse_h2_slugs(rd_content))
if slug not in rd_slugs:
emit_fail(
f"Slug '{slug}' not found as H2 in research doc '{rd_path}'",
f"references/sources.md (## {slug})",
f"The Research registry '{rd_path}' does not have a '## {slug}' heading, so the entry's provenance "
f"link resolves to nothing.",
f"Rename the slug to match a '## ' heading in '{rd_path}', or repoint Research doc at the registry that has it."
)
# --- Check 9: Description / Contributing files changed since --base-ref ---
# A structural fact — the field's TEXT differs from an earlier revision — is
# all git can tell us. Whether the (possibly stronger) new wording is still
# TRUE is a semantic question no parser here can answer; that is what sent
# the earlier literal-text approaches (flagging a named-but-missing filename)
# to 3/3 false positives against the real corpus without even catching the
# bug that motivated this check. So check 9 does the one thing git reliably
# can: detect the change, and hand the auditor the slug and field to go read,
# never a verdict on the claim itself. Always INFO, never FAIL.
if repo_root is None:
emit_info(
"Check 9 skipped — no repo root above the skill directory",
"references/sources.md",
"No ancestor of the skill directory contains a .git entry, so there is no git history to diff "
"references/sources.md against. Check 9 did not run for any slug in this skill. "
"Run this script against a skill inside a checkout to get this check."
)
else:
resolved_base_ref = base_ref_override.strip()
resolve_error = None
if not resolved_base_ref:
rc, mb_out, mb_err = run_git(["merge-base", "HEAD", "origin/main"], repo_root)
if rc == 0 and mb_out.strip():
resolved_base_ref = mb_out.strip()
else:
resolve_error = (
"`git merge-base HEAD origin/main` could not resolve a base ref"
+ (f" ({mb_err.strip()})" if mb_err.strip() else "")
+ " — there may be no origin/main remote, HEAD may be detached, or the clone may be shallow."
)
elif not ref_exists(resolved_base_ref, repo_root):
resolve_error = f"--base-ref value '{resolved_base_ref}' does not resolve to a commit in this repository."
if resolve_error:
emit_info(
"Check 9 skipped — no base ref could be resolved",
"references/sources.md",
resolve_error + " Check 9 did not run for any slug in this skill. "
"Pass --base-ref=<ref>, or set the VALIDATE_PROVENANCE_BASE_REF environment variable, "
"to compare against something other than origin/main."
)
else:
sources_md_relpath = os.path.relpath(sources_md_path, repo_root)
rc, old_sources_content, show_err = run_git(
["show", f"{resolved_base_ref}:{sources_md_relpath}"], repo_root
)
if rc != 0:
# The base ref resolved fine but `git show <ref>:<path>` did not.
# That single return code covers two situations this check cannot
# tell apart, and only one of them is harmless:
#
# the file genuinely did not exist at the base ref — the whole
# sources.md is new, every entry in it is a creation, and there
# is nothing check 9 could have flagged;
#
# the path is not TRACKED under that name at the base ref — a
# renamed skill directory, or a copy of the skill living
# somewhere untracked or gitignored (an installed .claude/skills
# tree is the everyday case).
#
# Treating both as "creation, nothing to flag" made the second one
# a silent, whole-skill skip: the same directory audited at its
# authoring path reported changed claims and at its deployed path
# reported nothing, with no way to tell that from a clean run.
# That is the exact fail-open this script's own header forbids —
# "never a silent skip" — so announce it once for the whole check
# and hand over git's own stderr, which is the only diagnostic
# that separates the two cases.
detail = show_err.strip().splitlines()
detail = detail[0] if detail else "git gave no reason"
emit_info(
f"Check 9 skipped — '{sources_md_relpath}' is not tracked at {resolved_base_ref}",
"references/sources.md",
f"`git show {resolved_base_ref}:{sources_md_relpath}` failed ({detail}). "
f"Either the file did not exist at that ref — in which case every entry is a "
f"creation and there was nothing to flag — or this path is not tracked under "
f"that name there: a renamed skill directory, or an untracked or gitignored copy "
f"of the skill such as a deployed .claude/skills/ tree. "
f"Check 9 did not run for any slug in this skill. "
f"Re-run against the tracked authoring path, or pass --base-ref=<ref> naming a "
f"commit where this path exists."
)
old_sources_content = None
if old_sources_content is not None:
for slug in unique_slugs:
changed_fields = []
removed_fields = []
for field_name in ("Description", "Contributing files"):
old_value = parse_field_raw(old_sources_content, slug, field_name)
new_value = parse_field_raw(sources_content, slug, field_name)
if old_value is None and new_value is None:
continue
if old_value is None:
# No earlier claim to compare against — a brand-new
# entry, or a field that did not exist yet at the
# base ref. That is a creation, not a change, and is
# never flagged.
continue
if new_value is None:
# The field existed at the base ref and is gone now.
# This was folded into the creation skip above, which
# justified only the other half: deleting a whole
# '- **Description:**' line left NO finding anywhere —
# no other check in this script requires the field, so
# a claim could be removed as invisibly as it could be
# strengthened. Announce it; the auditor decides
# whether the removal was intended.
removed_fields.append(field_name)
continue
if normalize_field_text(old_value) != normalize_field_text(new_value):
changed_fields.append(field_name)
if removed_fields:
removed_list = " and ".join(removed_fields)
emit_info(
f"'{removed_list}' removed for '{slug}' since {resolved_base_ref}",
f"references/sources.md (## {slug})",
f"The '## {slug}' entry had {removed_list} at {resolved_base_ref} and has "
f"none now. Nothing else in this script requires the field, so the removal "
f"is otherwise invisible. Confirm it was deliberate — a provenance claim "
f"withdrawn is as much a change to the chain as one rewritten — and "
f"restore the field if it was lost to an edit."
)
if changed_fields:
field_list = " and ".join(changed_fields)
emit_info(
f"'{field_list}' changed for '{slug}' since {resolved_base_ref}",
f"references/sources.md (## {slug})",
f"The '## {slug}' entry's {field_list} text differs from the version at "
f"{resolved_base_ref}. This script can confirm the entry is internally "
f"consistent, but it cannot verify whether the claim itself is still true — a "
f"retrofit once turned an honest hedge into an unsupported confident claim and "
f"every structural check here passed it silently. Re-read the upstream research "
f"doc named in this entry's Research doc field and the Contributing files it "
f"lists, and confirm by hand that the wording still holds."
)
print_findings()
sys.exit(1 if has_fail else 0)
KYBERFORGE_PROV_SKILL_BODY
KYBERFORGE_PROV_SKILL_BODY_PY="${KYBERFORGE_PROV_SKILL_BODY_PY%$'\n'}"