Simpl-Resultat/scripts/review-levels.py
le king fu 24da72b7f4
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
docs(review): apply the review of #460, version the calibration script
REVIEW.md, from the suggestions of the first review of #460:
- stacked-PR pitfall: fs_commands.rs compiles two CSV files of
  src/__fixtures__/samples/ through include_str!, outside the paths of
  check-rust;
- high level: .claude/settings.json and .mcp.json, a pull_request_target
  trigger, an install step that changes the toolchain or the PATH (the
  check-workflow list is now given as examples of the principle), a lock
  origin whose host or scheme changes even with a new version, and a diff
  that removes or bypasses a consent (price consent lives on the front);
- db.ts and dataExportService.ts stay normal (Max's decision of
  2026-10-18), and the data zone now names the lock, the profile switch
  and the restore;
- release zone: cargo-xwin and the Rust toolchain, installed unpinned at
  each tag, also run with the signing key;
- windows-sys guard: EXPECTED joins CRATES and PIN;
- identifier: changing it orphans the databases and the app_data_dir
  files (license key, activation token, token fallback); the keychain
  service is a constant and does not follow.

scripts/review-levels.py is the calibration script of the PR body, now in
the repo: pages of 50 files read until an empty page (the instance caps
pages at 50), the new rules above, comment lines ignored by the workflow
checks, and --check, which compares its patterns with the Haut table
without network access. Fresh run: 3/28/8 on the 39 reference PRs and
6/33/12 on the 51, unchanged.

Refs #456

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:55:37 -04:00

393 lines
17 KiB
Python
Executable file

#!/usr/bin/env python3
"""Replay the review levels of REVIEW.md on merged pull requests (#456).
Usage:
python3 scripts/review-levels.py [FIRST LAST] (default: 344 452)
python3 scripts/review-levels.py --check (no network)
The first form reads the Forgejo API (token in $FORGEJO_TOKEN, else
~/.forgejo-token; read-only calls) and prints each merged PR of the range
with its trial level (pilot document, section 4.3) and its REVIEW.md level,
then the distributions for the 39 reference PRs and for the whole range.
--check compares the patterns of this script with the first column of the
Haut table of REVIEW.md and exits 1 if they differ: a PR that changes the
Niveaux section updates this script in the same diff.
The content conditions of the Haut table are approximated from the diff:
lib.rs (only generate_handler![] entries are exempt), Tauri configuration
and manifests (only the app's version is exempt), lock files (a package
added or removed, an origin changed), check workflows (a change that can
turn a failing check green, read against the PR's merge base), network
calls (reqwest in Rust, fetch to an absolute URL in TS), consent files.
"""
import itertools
import json
import os
import re
import sys
import urllib.request
API = "https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat"
REVIEW_MD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "REVIEW.md")
# The 39 reference PRs of the pilot document, frozen by number: the listing
# that produced them (closed PRs, limit=40, sort=recentupdate) is not stable.
REF39 = [344, 375, 377, 378, 379, 380, 381, 382, 383, 386, 387, 388, 394, 402, 403,
407, 412, 413, 414, 415, 416, 417, 418, 420, 428, 433, 434, 435, 436, 438,
439, 440, 441, 442, 443, 444, 445, 448, 452]
TRIAL_HIGH = [
"src-tauri/src/commands/{auth_commands,token_store,license_commands,entitlements,account_cache,backup_commands,fs_commands,export_import_commands}.rs",
"src-tauri/src/lib.rs", "src-tauri/src/database/**", "src-tauri/tauri.conf.json",
"src-tauri/capabilities/**", ".forgejo/workflows/**", ".cargo/**", "src-tauri/Cargo.{toml,lock}",
"package.json", "package-lock.json",
"src/services/{authService,licenseService,db,feedbackService,dataExportService}.ts",
"src/services/entitlements*", "scripts/build-latest-json*",
]
TRIAL_LOW = ["**/*.md", "docs/**", "reports/**", "tasks/**", "public/CHANGELOG*", "LICENSE"]
# REVIEW.md, table Haut: rows without a condition.
HIGH_ALWAYS = [
"src-tauri/src/database/**",
"src-tauri/src/commands/profile_commands.rs",
"src-tauri/src/commands/{license_commands,entitlements,auth_commands,token_store,account_cache}.rs",
"src/services/{licenseService,authService}.ts",
".claude/skills/issue-license/**",
"src-tauri/src/commands/export_import_commands.rs",
"src-tauri/src/commands/feedback_commands.rs", "src/services/feedbackService.ts",
"src-tauri/capabilities/**",
".forgejo/workflows/release.yml", "scripts/build-latest-json*", "scripts/check-latest-json.jq",
".claude/skills/release/**",
"src-tauri/build.rs", "{vite,vitest,postcss}.config.*", ".postcssrc*", ".npmrc", "**/rust-toolchain*",
"Cargo.{toml,lock}",
"**/.cargo/**",
".claude/settings.json", ".mcp.json",
]
CONSENT_FILES = {"src/components/balance/PriceFetchControl.tsx",
"src/components/settings/PriceFetchConsentToggle.tsx"}
TAURI_CONFIG = ["src-tauri/{tauri*.conf.json*,Tauri*.toml}"]
CHECK_WORKFLOWS = {".forgejo/workflows/check-rust.yml", ".forgejo/workflows/check-frontend.yml"}
SHIPPED = ["src/**", "src-tauri/src/**", "index.html", "public/**"]
LOW = ["**/*.md", "docs/**", "LICENSE", "tasks/**", "reports/**"]
NEVER_LOW = ["CLAUDE.md", "REVIEW.md", "docs/adr/**", "docs/architecture.md", "docs/qa-update-cycle.md", ".claude/**"]
def token():
return os.environ.get("FORGEJO_TOKEN") or open(os.path.expanduser("~/.forgejo-token")).read().strip()
def get(path, raw=False):
req = urllib.request.Request(API + path, headers={"Authorization": "token " + token()})
data = urllib.request.urlopen(req).read()
return data if raw else json.loads(data)
def expand_braces(pat):
m = re.search(r"\{([^{}]*)\}", pat)
if not m:
return [pat]
return list(itertools.chain.from_iterable(
expand_braces(pat[:m.start()] + alt + pat[m.end():]) for alt in m.group(1).split(",")))
def glob_re(pat):
# '**/' spans zero or more directories, '*' and '?' never cross '/'
i, out = 0, ""
while i < len(pat):
if pat.startswith("**/", i):
out += "(?:.*/)?"; i += 3; continue
if pat.startswith("**", i):
out += ".*"; i += 2; continue
c = pat[i]
out += "[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c)
i += 1
return re.compile("^" + out + "$")
def match(pats, path):
# '{a,b}' alternates, wildcards allowed inside
return any(glob_re(p).match(path) for pat in pats for p in expand_braces(pat))
def file_blocks(diff):
out = {}
for block in re.split(r"(?m)^diff --git ", diff)[1:]:
out[block.split("\n", 1)[0].split(" b/")[-1]] = block
return out
def meaningful(text):
t = text.strip()
return t != "" and not t.startswith(("#", "//"))
def changed(block):
return [l for l in block.split("\n")
if l[:1] in "+-" and not l.startswith(("+++", "---")) and meaningful(l[1:])]
def lock_adds_or_removes(name, block):
cargo = name.endswith("Cargo.lock")
lines = block.split("\n")
if cargo and any(re.match(r'^[+-]name = ', l) for l in lines):
return True
if not cargo and any(re.match(r'^[+-]\s+"node_modules/[^"]+": \{', l) for l in lines):
return True
source = r'^([+-])source = "([^"]+)"' if cargo else r'^([+-])\s+"resolved": "([^"]+)"'
digest = r'^\+(checksum) = ' if cargo else r'^\+\s+"(integrity)": '
version = r'^\+version = ' if cargo else r'^\+\s+"version": '
for hunk in re.split(r"(?m)^@@", block)[1:]:
hl = hunk.split("\n")
# an origin whose host or scheme changes, with or without a new version
before, after = set(), set()
for l in hl:
m = re.match(source, l)
if m:
(before if m.group(1) == "-" else after).add(origin_of(m.group(2)))
if after - before:
return True
# a checksum or integrity that changes without a new version
if any(re.match(digest, l) for l in hl) and not any(re.match(version, l) for l in hl):
return True
return False
def origin_of(url):
m = re.match(r"^([a-z+]+)://([^/]+)", url)
return m.group(1) + "://" + m.group(2) if m else url
STEP = re.compile(r"^\s*- (name|uses):")
VERDICT = re.compile(r"npm ci|npm run build|npm test|cargo check|cargo test|cargo audit|cargo-audit|Verify", re.I)
def regions(base):
lines = base.split("\n")
region, top, job_key = {}, None, None
for i, l in enumerate(lines, 1):
m = re.match(r"^([A-Za-z_][\w-]*):", l)
if m:
top = m.group(1)
m = re.match(r"^ ([A-Za-z_][\w-]*):", l)
if m:
job_key = m.group(1)
elif re.match(r"^\S", l) or re.match(r"^ \S", l):
job_key = None
if top in ("on", "true"):
region[i] = "on"
elif top == "permissions" or job_key in ("env", "container", "permissions"):
region[i] = "jobcfg"
else:
region[i] = "other"
starts = [i for i, l in enumerate(lines, 1) if STEP.match(l)]
for k, s in enumerate(starts):
e = starts[k + 1] - 1 if k + 1 < len(starts) else len(lines)
if VERDICT.search("\n".join(lines[s - 1:e])):
for i in range(s, e + 1):
if region[i] == "other":
region[i] = "verdict"
return region, lines
def weakening(block, base):
"""Ways a check-workflow diff could turn a failing check green (empty = none)."""
region, lines = regions(base)
reasons, old, plus = set(), 0, []
def flush(pos):
texts = [p[1:] for p in plus]
active = [t for t in texts if meaningful(t)] # comments never count
if any(re.search(r"continue-on-error|\|\|\s*true", t) for t in active):
reasons.add("continue-on-error or || true added")
if any(re.search(r"GITHUB_ENV|GITHUB_PATH|npm pkg set|git checkout|git restore", t) for t in active):
reasons.add("writes the job environment or the repository")
if any(re.search(r"^\s*(- )?uses:", t) and not re.search(r"Swatinem/rust-cache|actions/cache", t) for t in active):
reasons.add("action added (not a cache)")
if any(re.search(r"rustup|toolchain|\bPATH\b|/bin/", t) for t in active):
reasons.add("install step changes the toolchain or the PATH")
if any(re.match(r"^(\s{4})?(if|defaults):", t) for t in active):
reasons.add("job-level if/defaults changed")
first = next((t for t in texts if meaningful(t)), None)
if first is None or STEP.match(first):
return
prev, nxt = region.get(pos - 1, "other"), region.get(pos, "other")
if "on" in (prev, nxt):
if any(re.search(r"branches|paths-ignore", t) for t in active):
reasons.add("trigger narrowed")
j = pos - 1
while j > 0 and not re.match(r"^\s*[\w-]+:", lines[j - 1]):
j -= 1
if j > 0 and "paths-ignore" in lines[j - 1] and any(re.match(r"^\s*- ", t) for t in active):
reasons.add("paths-ignore entry added")
elif prev == nxt == "verdict":
reasons.add("verdict step modified")
elif "jobcfg" in (prev, nxt):
reasons.add("job env/container/permissions modified")
for line in block.split("\n"):
m = re.match(r"^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@", line)
if m:
flush(old); plus = []
old = int(m.group(1)); continue
if line.startswith("+") and not line.startswith("+++"):
plus.append(line); continue
flush(old); plus = []
if line.startswith("-") and not line.startswith("---"):
if meaningful(line[1:]):
if re.match(r"^(\s{4})?(if|defaults):", line[1:]):
reasons.add("job-level if/defaults changed")
if re.search(r"rustup|toolchain|\bPATH\b|/bin/", line[1:]):
reasons.add("install step changes the toolchain or the PATH")
r = region.get(old, "other")
if r == "on":
reasons.add("trigger removed or changed")
elif r == "verdict":
reasons.add("verdict step modified or removed")
elif r == "jobcfg":
reasons.add("job env/container/permissions modified")
old += 1
elif line.startswith(" "):
old += 1
flush(old)
return sorted(reasons)
def adds_network_call(path, block):
if not match(SHIPPED, path) or re.search(r"test", path):
return False
for l in block.split("\n"):
if l.startswith("+") and not l.startswith("+++"):
if path.endswith(".rs") and "reqwest" in l:
return True
if re.search(r"\.(ts|tsx)$", path) and re.search(r"fetch\(\s*[`'\"]https?://", l):
return True
return False
def trial_level(paths):
if any(match(TRIAL_HIGH, p) for p in paths):
return "haut"
return "bas" if all(match(TRIAL_LOW, p) for p in paths) else "normal"
def review_level(number, files, diff, merge_base):
blocks = file_blocks(diff)
triggers = []
for f in files:
b = blocks.get(f["filename"], "")
for path in filter(None, {f["filename"], f.get("previous_filename")}):
if match(HIGH_ALWAYS, path):
triggers.append(path)
elif path == "src-tauri/src/lib.rs":
if [l for l in changed(b) if not re.match(r"^[+-]\s*commands::\w+,?\s*$", l)]:
triggers.append(path)
elif path == "src-tauri/src/commands/balance_commands.rs":
hunks = re.split(r"(?m)^(?=@@)", b)[1:]
if any(re.search(r"fetch_price|activation", h.split("\n")[0]) or
any(re.search(r"fetch_price|activation", l) for l in changed(h)) for h in hunks):
triggers.append(path)
elif match(TAURI_CONFIG, path) or path in ("src-tauri/Cargo.toml", "package.json"):
if [l for l in changed(b) if not re.match(r'^[+-]\s*"?version"?\s*[:=]', l)]:
triggers.append(path)
elif path in ("src-tauri/Cargo.lock", "package-lock.json"):
if lock_adds_or_removes(path, b):
triggers.append(path)
elif path == ".forgejo/workflows/audit.yml" and changed(b):
triggers.append(path)
elif path in CHECK_WORKFLOWS:
wf = path.split("/")[-1]
base = get(f"/raw/.forgejo/workflows/{wf}?ref={merge_base}", raw=True).decode()
if weakening(b, base):
triggers.append(path)
elif path.startswith(".forgejo/workflows/") and f["status"] == "added":
triggers.append(path + " (new workflow)")
elif path == "REVIEW.md":
triggers.append(path + " (check the « Niveaux » section by hand)")
if adds_network_call(path, b):
triggers.append(path + " (network call)")
added = [l for l in b.split("\n") if l.startswith("+") and not l.startswith("+++")]
if path.startswith(".forgejo/workflows/") and any(re.search(r"\bsecrets\b|pull_request_target", l) for l in added):
triggers.append(path + " (secrets or pull_request_target added)")
if path in CONSENT_FILES and any(re.search(r"consent", l, re.I) for l in changed(b)):
triggers.append(path + " (consent)")
parts = path.split("/")
top_level = len(parts) == 1 or (parts[0] == "src-tauri" and len(parts) == 2)
new_hidden = f["status"] == "added" and any(
x.startswith(".") for x in parts[:-1]) and parts[0] not in (".forgejo", ".claude", ".cargo", ".github")
# Forgejo reports an edited file as "changed"
if (f["status"] not in ("changed", "modified") and top_level and not path.endswith(".md")) or new_hidden:
triggers.append(path + " (" + f["status"] + " at top level)")
paths = [f["filename"] for f in files]
if triggers:
return "haut", sorted(set(triggers))
if all(match(LOW, p) and not match(NEVER_LOW, p) for p in paths):
return "bas", []
return "normal", []
CONDITIONAL = {
"src-tauri/src/lib.rs", "src-tauri/src/commands/balance_commands.rs",
"src-tauri/Cargo.toml", "package.json", "src-tauri/Cargo.lock", "package-lock.json",
".forgejo/workflows/**", ".forgejo/workflows/audit.yml",
".forgejo/workflows/{check-rust,check-frontend}.yml", "REVIEW.md",
}
def check():
"""Compare this script's patterns with the first column of the Haut table."""
text = open(REVIEW_MD, encoding="utf-8").read()
table = text.split("### Haut", 1)[1].split("\n\n", 2)[1]
documented = set()
for row in table.split("\n"):
if not row.startswith("| ") or row.startswith("| Motif") or row.startswith("| fichier à la racine"):
continue
documented.update(re.findall(r"`([^`]+)`", row.split(" | ")[0]))
coded = set(HIGH_ALWAYS) | set(TAURI_CONFIG) | set(SHIPPED) | CONDITIONAL
for label, extra in (("REVIEW.md only", documented - coded), ("script only", coded - documented)):
for pattern in sorted(extra):
print(f"{label}: {pattern}")
if documented != coded:
return 1
print(f"ok: {len(coded)} patterns, identical to the Haut table of REVIEW.md")
return 0
def main():
if sys.argv[1:] == ["--check"]:
sys.exit(check())
first, last = (int(sys.argv[1]), int(sys.argv[2])) if len(sys.argv) == 3 else (344, 452)
merged, page = [], 1
while True:
batch = get(f"/pulls?state=closed&limit=50&page={page}")
if not batch:
break
merged += [p for p in batch if p.get("merged") and first <= p["number"] <= last]
page += 1
merged.sort(key=lambda p: p["number"])
rows = []
for p in merged:
n = p["number"]
files, fpage = [], 1
while True:
# the instance caps a page at 50 items: read until an empty page
fb = get(f"/pulls/{n}/files?limit=50&page={fpage}")
if not fb:
break
files += fb
fpage += 1
diff = get(f"/pulls/{n}.diff", raw=True).decode("utf-8", "replace")
merge_base = get(f"/pulls/{n}")["merge_base"]
trial = trial_level([f["filename"] for f in files])
level, triggers = review_level(n, files, diff, merge_base)
rows.append((n, trial, level, triggers))
print(f"#{n}\t{'ref' if n in REF39 else ' '}\t{trial}\t{level}\t{'; '.join(triggers)}")
for label, keep in (("39 reference PRs", lambda n: n in REF39), (f"{len(rows)} PRs #{first}-#{last}", lambda n: True)):
sel = [r for r in rows if keep(r[0])]
for col, name in ((1, "trial"), (2, "REVIEW.md")):
counts = {lvl: sum(1 for r in sel if r[col] == lvl) for lvl in ("bas", "normal", "haut")}
share = 100 * counts["haut"] / len(sel)
print(f"{label}, {name}: {counts} -> haut {share:.1f} %")
if __name__ == "__main__":
main()