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
1251 lines
62 KiB
Bash
Executable File
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'}"
|