mirror of
https://github.com/newliver666/apk-reverse.git
synced 2026-09-28 21:56:59 +02:00
a1ada96cc3
PR #3 proposed adding mypy and flake8 to CI, with configurations for both. The idea is right and the configurations are kept. Three things about the PR itself needed correcting, and all three were found by running the gate rather than reading the diff: 1. It was written against an older tree. Its own edits reference code that no longer exists there (`unknown, positions, warnings = ...`, a `struct` import at line 857 that is used 600 lines later), so merging it as-is would have removed a live import. The fixes were re-derived against the current tree instead, and the PR is superseded. 2. It added a `paths:` filter to the workflow, watching only `*.py` plus the two lint configs. That would stop `check_refs.py` and `check_routing.py` -- documentation gates -- from running on a documentation-only commit, which is exactly the change they exist to catch. The workflow keeps the PR's `actions/*@v7` bump and gains no filter; the reason is written next to the trigger. 3. `pip install --upgrade pip pytest` was dropped to just the packages, because upgrading pip in CI is not a goal and can fail for reasons unrelated to this repository. What the gate found on the current tree, and what was done about it -- 70 flake8 findings and 3 mypy errors, none of which were cosmetic-only: F401 (23) unused imports, removed. These were safe to delete because the import statement itself cannot have a side effect that matters here. F841 (12) unused bindings, handled three ways rather than one. Dead code was deleted (`mt_mcp_probe.print_waiting_help`'s `host`, `repack.py`'s `signer_jar`); a computed value that was then forgotten was **wired up** (`rasc_build.py` now names the linker target in its build-failure hint instead of discarding it); and a binding that is intentional kept its place with `# noqa: F841` and the reason -- `java2c_probe` reads dex header fields one by one against the format spec, and deleting three of them to please a linter would have made the parser stop lining up with the format it implements. E741 (14) the ambiguous loop name `l`, renamed with an AST pass that resolves each binding to its own scope. A regex rename would have been wrong: several scopes bind their own `l`. That pass had a defect of its own -- it treated a comprehension target as living in the comprehension scope while leaving references to it in the enclosing one -- which produced seven `undefined name 'l'`. Found by running flake8, fixed, and the counting bug is documented in `check_routing.py`, whose own `tables()` had the same class of error in the other direction. style continuation indents, blank lines between top-level definitions, and lines over length. The line limit is 160, not 120, and the number is a **measurement**: this repository's secret-scanner rule table holds raw regex source of 148 and 156 characters, which cannot be split without changing the pattern. mypy (3) two missing annotations on module-level containers, and one optional-dependency rebind (`dexutil = None` after `except ImportError`) that is the repository's own idiom and now says so with a `type: ignore[assignment]` and the reason. No code was reordered to satisfy the checker. Verified on a venv containing only `mypy` and `flake8` -- the poorest environment the configuration must work in, and the one CI uses: **flake8 reports 0, mypy reports "Success: no issues found in 55 source files"**. Behaviour is unchanged: 625 passed / 163 skipped / 25 xfailed, the same five gates green (check_repo 0 problem, check_refs 0 dangling, check_routing coherent, check_budget 0 failure, check_commands 0 drift), and every key script still prints help. .flake8 new, with the line-length and complexity numbers and their reasons mypy.ini new; optional imports declared, nothing excluded from the analysis ci.yml `actions/*@v7`; new `lint` job on one interpreter; no path filter 30 files the findings above
253 lines
9.8 KiB
Python
253 lines
9.8 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_list` is kept as part of the pair this branch assigns so both arms
|
|
# of the branch below produce the same two names; only `target_text` is read.
|
|
target_text, target_list = text, [path] # noqa: F841
|
|
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())
|