Files
inliver233 7176b5e1c9 Restructure to the skills/<name>/ layout the skills CLI resolves
The skill lived at the repository root, which the CLI reads as a single-skill repository and which makes a second skill impossible to add. SKILL.md, references/ and scripts/ move under skills/apk-reverse/ with git mv, so history follows and no in-skill reference needs changing (they are relative to SKILL.md).

check_repo.py now discovers every skills/*/SKILL.md, validates frontmatter by parsing it instead of pattern-matching it, and requires the README to name skills through explicit skills/<name>/... paths.

check_refs.py is new: it audits file.md section cross-references against the real headings in the target file. It found six dangling ones, all fixed here.

SKILL.md: the description is now quoted. Unquoted, it contained a colon-space, which is invalid YAML, so the CLI skipped the skill and reported No skills found. Adds a Coverage section stating what is out of scope and what to do when nothing matches. toolchain.md: IDA is not GUI-only (idalib-mcp is a headless MCP server), plus a section on MCP servers as external dependencies and the deobfuscate-before-LLM boundary. README.md: install via npx skills add, the new layout, coverage and maintenance tools.

Verified: check_repo.py and check_refs.py both clean, build_scripts.py audit 0 findings, npx skills add . --list reports apk-reverse, and an install into a scratch directory lands SKILL.md plus 28 references and 38 scripts.
2026-09-20 03:37:46 +08:00

251 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""Audit cross-references of the form `file.md` §section, and `file.md` P12.
A reference like `references/toolchain.md` §"not on PATH" is a promise that the
target file has a section by that name. When a section is renamed or removed the
promise silently breaks: the reader arrives at a file that does not contain the
thing they were sent for, and the most likely outcome is that they conclude the
material is missing rather than that the link is stale. Prose links do not fail
loudly the way a missing file does, which is why this script exists separately
from `check_repo.py` (that one only proves the *file* resolves).
A section reference is reported as:
* OK -- normalised, it matches a heading (or bold numbered rule) in the target
* WARN -- its words appear in the target body, but no heading carries them;
usually a stale or paraphrased link, worth a human look
* FAIL -- nothing in the target file corresponds; the link is dangling
Layout is discovered the same way as in `check_repo.py`, so a second skill under
skills/ is audited with no change here.
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
SKILLS_DIR = os.path.join(ROOT, 'skills')
# A section reference. The optional leading file may be separated from the
# section mark by the closing backtick and a line break, since references are
# often wrapped mid-sentence.
REF_RE = re.compile(
r'(?:(?P<file>[A-Za-z0-9_\-]+\.md)`?[\s`]{0,40})?'
r'§\s*(?P<sec>[^\n|`();,.\u2014\u3002]{2,90})')
# A failure-catalogue reference, e.g. `pitfalls.md` P31, or a bare (P18).
PITFALL_RE = re.compile(
r'(?:(?P<file>[A-Za-z0-9_\-]+\.md)`?[\s`]{0,40})?'
r'\(?\bP(?P<num>\d{1,2})\b')
HEADING_RE = re.compile(r'^#{1,6}\s+(?P<title>.+?)\s*$')
BOLD_RE = re.compile(r'^\*\*(?P<title>.+?)\*\*\s*$')
def norm(text):
"""Fold a heading or a reference into comparable tokens.
Hyphens and slashes become spaces so that `finding-the-call-site` matches
"Finding the call site", and quotes/emphasis are dropped so that
`§"not on PATH"` matches a heading containing the same words quoted.
"""
text = text.lower()
text = text.replace('\u2014', ' ').replace('\u2013', ' ')
text = re.sub(r'[`*_"\'()\[\]]', '', text)
text = re.sub(r'[^a-z0-9]+', ' ', text)
return text.strip()
def discover_skills():
"""Return [(name, skill_dir)] for each skill root, shallow shadowing deep."""
found = []
if not os.path.isdir(SKILLS_DIR):
return found
for dirpath, dirnames, filenames in os.walk(SKILLS_DIR):
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
rel = os.path.relpath(dirpath, SKILLS_DIR)
depth = 0 if rel == '.' else rel.count(os.sep) + 1
if depth > 3:
dirnames[:] = []
continue
if 'SKILL.md' in filenames:
found.append((rel.replace(os.sep, '/'), dirpath))
dirnames[:] = []
return sorted(found)
def discover_docs():
"""Return [(path, skill_name_or_None, text)] for every markdown document."""
docs = []
readme = os.path.join(ROOT, 'README.md')
if os.path.exists(readme):
docs.append((readme, None, open(readme, encoding='utf-8').read()))
for skill, skill_dir in discover_skills():
for dirpath, dirnames, filenames in os.walk(skill_dir):
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
for fname in sorted(filenames):
if not fname.endswith('.md'):
continue
path = os.path.join(dirpath, fname)
docs.append((path, skill, open(path, encoding='utf-8').read()))
return docs
def anchors(text):
"""Headings, and bold stand-alone lines (`**2a. ...**`), as match targets."""
out = []
for line in text.splitlines():
line = line.strip()
m = HEADING_RE.match(line) or BOLD_RE.match(line)
if m:
out.append(m.group('title'))
return out
def matches(sec, anchor_list):
want = norm(sec)
if not want:
return True
want_tokens = want.split()
for raw in anchor_list:
have_tokens = norm(raw).split()
if not have_tokens:
continue
# A numbered heading (`## 3. Reading AOT code`) carries the number as a
# token, but a reference may name the section with or without it, so
# both readings are candidates.
candidates = [have_tokens]
if have_tokens[0].isdigit() and len(have_tokens) > 1:
candidates.append(have_tokens[1:])
for have in candidates:
text = ' '.join(have)
if text == want or text.startswith(want + ' ') \
or want.startswith(text + ' '):
return True
if all(tok in have for tok in want_tokens):
return True
# A reference frequently runs on into the sentence that follows it
# (`§Gates are cleared here or not at all`), leaving prose attached
# to the heading name. Sharing the leading word is the strongest
# signal available in that shape.
if have[0] == want_tokens[0]:
return True
return False
def first_token_match(sec, anchor_list):
"""True when some heading starts with the same word as the reference.
A reference is often a heading plus the rest of the sentence
(`§Gates are cleared here or not at all`), so an exact match is too strict
to be the only accepted shape. This is deliberately weaker than matches()
and is reported as a warning, not as a pass.
"""
want = norm(sec).split()
if not want:
return False
for raw in anchor_list:
have = norm(raw).split()
if have and have[0] == want[0]:
return True
return False
def in_body(sec, text):
"""Weaker fallback: every token of the section name occurs in the body."""
want_tokens = norm(sec).split()
if not want_tokens:
return True
body = norm(text)
return all(tok in body for tok in want_tokens)
def main():
docs = discover_docs()
by_name = {}
for path, skill, text in docs:
by_name.setdefault(os.path.basename(path), []).append((path, skill, text))
ok = warn = 0
problems = []
warnings = []
for path, skill, text in docs:
where = os.path.relpath(path, ROOT).replace(os.sep, '/')
for m in REF_RE.finditer(text):
raw_sec = m.group('sec').strip().strip('.,;:!?\'")*').lower()
# `§7 gives the exact framing` -- keep only the number.
num = re.match(r'(\d{1,2}[a-z]?)\b', raw_sec)
sec = num.group(1) if num else raw_sec
if not sec:
continue
# `§8C` names sub-item C of section 8, so the bare section number is
# an acceptable fallback target.
variants = [sec]
sub = re.fullmatch(r'(\d{1,2})[a-z]', sec)
if sub:
variants.append(sub.group(1))
target_name = m.group('file')
if target_name is None:
target_text, target_list = text, [path]
target_label = where
else:
candidates = [c for c in by_name.get(target_name, [])
if skill is None or c[1] == skill] \
or by_name.get(target_name, [])
if not candidates:
problems.append('%s: §%s -> %s (no such file)'
% (where, sec, target_name))
continue
target_text = candidates[0][2]
target_label = os.path.relpath(candidates[0][0], ROOT).replace(os.sep, '/')
if any(matches(v, anchors(target_text)) for v in variants):
ok += 1
elif len(norm(sec).split()) >= 2 and first_token_match(
sec, anchors(target_text)):
warn += 1
warnings.append('%s: §%s -> %s (heading starts with this word '
'but the wording differs)'
% (where, sec, target_label))
elif in_body(sec, target_text):
warn += 1
warnings.append('%s: §%s -> %s (words present, no matching heading)'
% (where, sec, target_label))
else:
problems.append('%s: §%s -> %s (section not found)'
% (where, sec, target_label))
# Failure-catalogue references: `pitfalls.md` P31, or a bare (P18).
pit_anchors = set()
for name, entries in by_name.items():
for path, _skill, text in entries:
for raw in anchors(text):
m = re.match(r'P(\d{1,2})[.\s]', raw)
if m:
pit_anchors.add(m.group(1))
for path, _skill, text in docs:
where = os.path.relpath(path, ROOT).replace(os.sep, '/')
for m in PITFALL_RE.finditer(text):
if m.group('num') not in pit_anchors:
problems.append('%s: P%s (no such pitfall)' % (where, m.group('num')))
else:
ok += 1
print('== markdown documents ==')
for path, _skill, _text in docs:
print(' %s' % os.path.relpath(path, ROOT).replace(os.sep, '/'))
print('\n== section references: %d reachable ==' % ok)
for w in sorted(set(warnings)):
print(' WARN %s' % w)
for p in sorted(set(problems)):
print(' FAIL %s' % p)
print('\n== result: %d dangling, %d warning(s) =='
% (len(set(problems)), len(set(warnings))))
return 1 if problems else 0
if __name__ == '__main__':
sys.exit(main())