merge: reconcile Wave 1.1 with post-PR40 lab

Merge canonical lab 9557b8d5909eb4a885c3bf49e19a65dd904f8c1d exactly once.
Retain invocation journal ownership and lineage, provider terminal ordering,
teacher handoff, framed DONE handling, and canonical authority/Ajax routing.

Combine dynamic dispatch receipts with lab policy forwarding. Adapt native
shell/patch evidence, explicit TUI verifiers, and artifact recovery presentation.
Refresh generated configuration source links and strengthen adapter regressions.

Validation: focused 2118 passed; Wave 1.1 script 2291 passed; broad runtime
5649 passed; full pytest 11581 passed, 53 skipped, 2 xfailed, 6 subtests passed.
Compileall 1689 Python files; syntax 279 JS and 82 MJS files; diff and
conflict-marker checks passed.
This commit is contained in:
Alexandre Teixeira
2026-10-01 09:09:55 +01:00
304 changed files with 41744 additions and 24384 deletions
+6 -1
View File
@@ -97,7 +97,8 @@ def playwright_available(node="node", cwd=ROOT):
def capture(origin, inventory=None, *, swap_rule=None, variants=None,
node="node", cwd=ROOT, timeout=CAPTURE_TIMEOUT_SECONDS):
measurement_delay_ms=0, node="node", cwd=ROOT,
timeout=CAPTURE_TIMEOUT_SECONDS):
"""Drive the browser capture and return ``{"snapshot": ..., "missing": ...}``.
``swap_rule`` swaps the first two top-level declarations of one selector
@@ -107,6 +108,9 @@ def capture(origin, inventory=None, *, swap_rule=None, variants=None,
``variants`` restricts the run to the named variants, for a faster
focused capture.
``measurement_delay_ms`` perturbs the capture timing for the determinism
self-test; elapsed wall time must not change an idle-state snapshot.
"""
inventory = inventory or load_inventory()
selected = inventory["variants"]
@@ -122,6 +126,7 @@ def capture(origin, inventory=None, *, swap_rule=None, variants=None,
"variants": selected,
"pages": inventory["pages"],
"swapRule": swap_rule,
"measurementDelayMs": measurement_delay_ms,
}
result = subprocess.run(
[node, str(CAPTURE_SCRIPT)],
+71
View File
@@ -0,0 +1,71 @@
"""Non-mutating Ajax first-call smoke test; records proposals, never executes tools."""
import argparse
import json
import time
from pathlib import Path
import httpx
import jsonschema
from src.clean_agent_preview import compact_schemas
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
CASES = [
('todo', 'Make a todo: drop keys, drop off Bjorn, buy a present.', 'manage_notes'),
('note', 'Save a note titled Door code with body: Ask the concierge.', 'manage_notes'),
('notes_lookup', 'Find my note about the dentist.', 'manage_notes'),
('calendar_today', 'Add a calendar meeting today at 2pm.', 'manage_calendar'),
('calendar_ambiguous', 'Add calendar meeting 2pm.', 'manage_calendar'),
('calendar_list', 'What is on my calendar tomorrow?', 'manage_calendar'),
('task_daily', 'Every day at 7:30am summarize my unread emails in a chat.', 'manage_tasks'),
('task_list', 'Show my paused tasks.', 'manage_tasks'),
('document', 'Create a Python document that prints hello world.', 'create_document'),
('search', 'Search the web for the latest Blender release.', 'web_search'),
]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--endpoint', required=True)
parser.add_argument('--output', required=True)
args = parser.parse_args()
rows = []
core = {'bash', 'python', 'read_file', 'web_fetch', 'web_search'}
with httpx.Client(timeout=90) as client:
for name, prompt, expected in CASES:
tools = compact_schemas([s for s in FUNCTION_TOOL_SCHEMAS
if s['function']['name'] in core | {expected}], model='Ajax')
start = time.monotonic()
response = client.post(args.endpoint.rstrip('/') + '/chat/completions', json={
'model': 'Ajax', 'temperature': 0, 'max_tokens': 768,
'chat_template_kwargs': {'enable_thinking': False}, 'tools': tools,
'messages': [
{'role': 'system', 'content': 'You are an assistant using Odysseus tools. '
'Current local date/time: 2026-09-30 09:00, UTC+02:00. '
'Current UTC date/time: 2026-09-30 07:00. No document is open. '
'Use tools to fulfill requests, and ask in plain text when required information is missing.'},
{'role': 'user', 'content': prompt},
],
})
response.raise_for_status()
message = response.json()['choices'][0]['message']
calls = message.get('tool_calls') or []
errors = []
for call in calls:
try:
fn = call['function']
schema = next(s['function']['parameters'] for s in tools if s['function']['name'] == fn['name'])
jsonschema.validate(json.loads(fn['arguments']), schema)
except (ValueError, StopIteration, jsonschema.ValidationError) as exc:
errors.append(str(exc)[:250])
row = {'case': name, 'prompt': prompt, 'expected_tool': expected,
'seconds': round(time.monotonic() - start, 3), 'message': message,
'schema_errors': errors}
rows.append(row)
print(json.dumps(row, ensure_ascii=False), flush=True)
Path(args.output).write_text(json.dumps(rows, indent=2, ensure_ascii=False) + '\n')
if __name__ == '__main__':
main()
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
"""odysseus-mail — Unix-style command-line wrapper around the email
backend that powers the web UI.
Calls the same helpers `routes/email_helpers.py` exports, so a request
Calls the same helpers `routes/email/email_helpers.py` exports, so a request
issued from the shell hits IMAP/SMTP through the same connection pool
and the same parsing pipeline as the HTTP routes. State is shared via
`data/app.db` and `data/.app_key` (passwords decrypt automatically).
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""odysseus-smoke — boot this worktree and drive every advertised feature area once.
The decomposition work has two safety nets and neither one covers the
product: the checkpoint benchmark measures the agent runtime, and the
computed-style snapshot pins the CSS. Nothing checked that Notes,
Calendar, Documents, Email, Memory, Cookbook or Settings still worked
after a route package moved or a 17,000-line module was split. This is
that check, and it is deliberately shallow: one scenario per area,
asserting a user-visible outcome rather than an HTTP 200.
It owns no instance logic. `odysseus dev` already isolates the ports,
the data dir and ChromaDB per worktree, so this boots through it, hands
the details to pytest in the environment, and stops what it started.
odysseus smoke # boot, run every area, stop again
odysseus smoke --keep-up # leave the instance running afterwards
odysseus smoke --no-boot # drive whatever is already up here
odysseus smoke --restart # stop a running instance and boot fresh
odysseus smoke --areas # print the coverage table without running
odysseus smoke -- -k notes # everything after -- goes to pytest
The report is a per-area table, printed by the suite itself, listing the
areas it does not cover next to the ones it does. An area with no
scenario shows up as NOT RUN rather than going missing.
"""
from __future__ import annotations
import importlib.machinery
import importlib.util
import os
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
from cli import quiet_logs, fail, common_parser, run # noqa: E402
quiet_logs()
SCRIPTS_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPTS_DIR.parent
# The launcher this tool delegates every instance decision to.
DEV_SCRIPT = "odysseus-dev"
# What pytest is pointed at, relative to the checkout root.
SMOKE_SUITE = "tests/smoke"
# Email is the one area with no reachable real backend, and the repo
# already has a deterministic path for it. Turning it on is the reason
# this tool owns the boot rather than leaving it to the caller: the flag
# is read inside the app's process, so it has to be in the environment
# the app is started with.
EMAIL_FIXTURE_ENV = "ODYSSEUS_EMAIL_FIXTURE"
def load_dev():
"""Import `scripts/odysseus-dev` as a module.
Same loader the CLI tests use. Delegating by import rather than by
parsing `odysseus dev env` output means the port derivation and the
credential handling have exactly one implementation.
"""
path = SCRIPTS_DIR / DEV_SCRIPT
if not path.exists():
fail(f"{path} is missing; this tool boots through it.", code=2)
loader = importlib.machinery.SourceFileLoader("odysseus_dev_cli", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
def suite_environment(dev, root, ports, account):
"""The environment the smoke suite reads its target instance from.
Deliberately the same values `odysseus dev env` prints, plus the dev
admin account, so a manual `pytest tests/smoke` under
`eval $(odysseus dev env)` behaves the way this tool does.
"""
data_dir = dev.dev_dir(root) / "data"
env = dict(os.environ)
env.update({
"APP_PORT": str(ports["app"]),
"CHROMADB_PORT": str(ports["chroma"]),
"ODYSSEUS_TEST_STATIC_PORT": str(ports["test_static"]),
"ODYSSEUS_DATA_DIR": str(data_dir),
"DATABASE_URL": f"sqlite:///{data_dir / 'app.db'}",
"ODYSSEUS_ADMIN_USER": account["username"],
"ODYSSEUS_ADMIN_PASSWORD": account["password"],
})
return env
def boot(dev, root, args):
"""Start the instance, or adopt one already running in this worktree.
Returns (started_by_us, note). A reused instance is never restarted
without being asked: it may be someone's debugging session, and the
one thing it can cost us is the email fixture flag, which the suite
reports as a skip rather than a pass.
"""
already = dev.running_app(dev.read_state(root))
if already and args.restart:
subprocess.run([sys.executable, str(SCRIPTS_DIR / DEV_SCRIPT), "down"],
cwd=str(root), check=False)
already = None
if already:
return False, (
f"reusing the instance already up on port {already['port']} "
f"(pid {already['pid']}). If it was not booted with "
f"{EMAIL_FIXTURE_ENV}=1 the Email area will report a skip; "
f"re-run with --restart for a clean boot."
)
if args.no_boot:
fail(
"nothing is running in this worktree and --no-boot was passed.\n"
" boot it with `odysseus dev up`, or drop --no-boot.",
)
command = [sys.executable, str(SCRIPTS_DIR / DEV_SCRIPT), "up"]
if args.venv:
command += ["--venv", args.venv]
env = dict(os.environ)
env[EMAIL_FIXTURE_ENV] = "1"
result = subprocess.run(command, cwd=str(root), env=env, check=False)
if result.returncode != 0:
fail(f"`odysseus dev up` exited {result.returncode}; not running the suite.")
return True, ""
def venv_python(dev, root, args):
"""The interpreter to run pytest with: the one the app runs under."""
recorded = (dev.read_state(root) or {}).get("venv")
for candidate in (Path(args.venv).expanduser() if args.venv else None,
Path(recorded) if recorded else None,
Path(root) / "venv"):
if candidate and (candidate / "bin" / "python").exists():
return candidate / "bin" / "python"
fail(
f"no interpreter found for the suite (looked at {Path(root) / 'venv'}).\n"
f" build one with ./start-macos.sh, or pass --venv."
)
def cmd_run(args):
dev = load_dev()
root = dev.find_repo_root(Path.cwd())
if root is None:
fail(f"not inside an Odysseus checkout (looked upwards from {Path.cwd()})", code=2)
if args.areas:
sys.path.insert(0, str(root))
from tests.smoke import areas
sys.stdout.write(areas.render_table({}, header="Odysseus release smoke - coverage") + "\n")
return 0
ports = dev.derive_ports(root)
account = dev.credentials(root)
started_by_us, note = boot(dev, root, args)
if note:
sys.stdout.write(f" {note}\n")
python = venv_python(dev, root, args)
env = suite_environment(dev, root, ports, account)
command = [str(python), "-m", "pytest", SMOKE_SUITE, "-q"] + list(args.pytest_args)
sys.stdout.write(f"\n running {SMOKE_SUITE} against http://127.0.0.1:{ports['app']}\n\n")
# Flush before handing the terminal to pytest, or our own lines land
# after its output and the report reads out of order.
sys.stdout.flush()
result = subprocess.run(command, cwd=str(root), env=env, check=False)
if started_by_us and not args.keep_up:
subprocess.run([sys.executable, str(SCRIPTS_DIR / DEV_SCRIPT), "down"],
cwd=str(root), check=False)
elif started_by_us:
sys.stdout.write(
f"\n left running: http://127.0.0.1:{ports['app']} "
f"({account['username']} / {account['password']})\n"
f" stop it with `odysseus dev down`\n"
)
# `cli.run` discards a returned value but lets SystemExit through, and
# a smoke run's exit code is the whole point of having one command.
if result.returncode != 0:
raise SystemExit(result.returncode)
return 0
def build_parser():
parser = common_parser("odysseus-smoke",
"Boot this worktree and run the release smoke suite.")
parser.add_argument("--keep-up", action="store_true",
help="leave the instance running after the suite finishes")
parser.add_argument("--no-boot", action="store_true",
help="require an instance already up in this worktree")
parser.add_argument("--restart", action="store_true",
help="stop a running instance and boot a fresh one")
parser.add_argument("--venv", help="use this venv instead of ./venv")
parser.add_argument("--areas", action="store_true",
help="print the coverage table and exit without booting")
parser.add_argument("pytest_args", nargs="*", metavar="-- PYTEST ARGS",
help="arguments forwarded to pytest after a literal --")
parser.set_defaults(func=cmd_run)
return parser
if __name__ == "__main__":
sys.exit(run(build_parser()))
+586
View File
@@ -0,0 +1,586 @@
#!/usr/bin/env python3
"""Read-only audit of what one git ref carries that another does not.
Two long-lived lines that are not merged into each other drift silently. A fix
landed on one of them leaves no mark on the other, and nothing in git tells you
so: the two histories share only a distant merge base, so `git log A..B` lists
thousands of commits whose content is in fact already present on both sides
under different SHAs.
This script answers the question that actually matters at release time -- which
commits on the source ref left *no trace at all* in the target ref -- by
sampling distinctive added lines from each commit and searching the target tree
for them. It also reports the file-level presence diff, which catches the case
the line sampling cannot: a fix whose production change was reproduced on the
target but whose test file was never brought over.
It is read-only. It runs `git log`, `git show`, `git diff`, `git grep`,
`git ls-tree` and `git merge-base`, writes nothing to the repository, touches no
remote, and does not import the Odysseus application package.
Usage:
scripts/ref_parity_audit.py --source public/dev --target lab --since 2026-08-10
Read `docs/ref-parity-audit.md` before acting on the output: the line sampling
is a heuristic and the report labels which of its verdicts are exact.
"""
import argparse
import fnmatch
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, Sequence
REPO_ROOT = Path(__file__).resolve().parents[1]
# Paths whose contents are never worth probing: vendored third-party code,
# committed build output, lockfiles and binaries. A distinctive line does not
# exist in a minified bundle, and a lockfile churns on every dependency bump.
DEFAULT_EXCLUDES = (
"static/lib/*",
"static/js/editor/build/*",
"*.min.js",
"*.min.css",
"*.map",
"package-lock.json",
"*.lock",
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.ico",
"*.webp",
"*.svg",
"*.pdf",
"*.woff",
"*.woff2",
"*.ttf",
"*.otf",
"*.mp3",
"*.mp4",
"*.wav",
"*.zip",
"*.gz",
)
# A probe has to be long enough and carry enough named things to be unlikely to
# appear by coincidence. `return hosts` is in a hundred files; a line naming two
# identifiers over 24 characters is usually unique to the change that added it.
MIN_PROBE_LENGTH = 24
MIN_PROBE_IDENTIFIERS = 2
IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{2,}")
FIELD_SEP = "\x1f"
VERDICT_ABSENT = "absent"
VERDICT_PARTIAL = "partial"
VERDICT_PRESENT = "present"
VERDICT_NO_PROBE = "no-probe"
class GitError(RuntimeError):
"""A git invocation failed in a way the audit cannot work around."""
@dataclass
class Commit:
sha: str
author: str
date: str
subject: str
parent_count: int
@dataclass
class CommitVerdict:
commit: Commit
probes: tuple[str, ...]
found: tuple[str, ...]
paths: tuple[str, ...]
@property
def verdict(self) -> str:
if not self.probes:
return VERDICT_NO_PROBE
if not self.found:
return VERDICT_ABSENT
if len(self.found) < len(self.probes):
return VERDICT_PARTIAL
return VERDICT_PRESENT
@dataclass
class Report:
source: str
source_sha: str
target: str
target_sha: str
merge_base: str
since: str | None
until: str | None
traversal: str
probe_limit: int
verdicts: list[CommitVerdict] = field(default_factory=list)
source_only_files: tuple[str, ...] = ()
target_only_files: tuple[str, ...] = ()
def by_verdict(self, verdict: str) -> list[CommitVerdict]:
return [v for v in self.verdicts if v.verdict == verdict]
# --------------------------------------------------------------------------- #
# git plumbing
# --------------------------------------------------------------------------- #
def run_git(args: Sequence[str], repo: Path) -> str:
"""Run a read-only git command and return stdout, raising on failure."""
proc = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
)
if proc.returncode != 0:
raise GitError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
return proc.stdout
def resolve_ref(ref: str, repo: Path) -> str:
return run_git(["rev-parse", "--short=8", ref], repo).strip()
def merge_base(source: str, target: str, repo: Path) -> str:
try:
return run_git(["merge-base", source, target], repo).strip()[:8]
except GitError:
# Unrelated histories have no merge base. That is a finding, not a crash.
return ""
def list_commits(
source: str,
target: str,
repo: Path,
since: str | None = None,
until: str | None = None,
traversal: str = "linear",
) -> list[Commit]:
"""List commits reachable from `source` but not from `target`.
`linear` drops merge commits and reports the individual authored commits,
which is what finds a fix that arrived on a side branch. `first-parent`
reports one entry per merge into the source branch, which reads as one row
per merged pull request.
"""
args = [
"log",
"--date=short",
f"--format=%H{FIELD_SEP}%an{FIELD_SEP}%cd{FIELD_SEP}%p{FIELD_SEP}%s",
]
args.append("--no-merges" if traversal == "linear" else "--first-parent")
if since:
args.append(f"--since={since}")
if until:
args.append(f"--until={until}")
args.append(f"{target}..{source}")
commits = []
for line in run_git(args, repo).splitlines():
if not line.strip():
continue
sha, author, date, parents, subject = line.split(FIELD_SEP, 4)
commits.append(
Commit(
sha=sha,
author=author,
date=date,
subject=subject,
parent_count=len(parents.split()) if parents.strip() else 0,
)
)
return commits
def commit_diff(commit: Commit, repo: Path) -> str:
"""Return the commit's patch with no context lines.
A merge is diffed against its first parent so the whole merged content is
visible; `git show` would otherwise print only the conflicting hunks.
"""
if commit.parent_count > 1:
return run_git(
["diff", "--no-color", "--no-renames", "-U0", f"{commit.sha}^1", commit.sha],
repo,
)
return run_git(
["show", "--no-color", "--no-renames", "-U0", "--format=", commit.sha], repo
)
def probe_present(probe: str, ref: str, repo: Path) -> bool:
"""Is this exact text anywhere in the ref's tree?
The whole tree is searched on purpose. The question is whether the change
left a trace at all, not whether it landed in the same file -- a ported fix
routinely moves, and the exclusion list only governs where probes come
from.
"""
proc = subprocess.run(
["git", "-C", str(repo), "grep", "--fixed-strings", "--quiet", "-e", probe, ref],
capture_output=True,
text=True,
)
if proc.returncode not in (0, 1):
raise GitError(f"git grep failed for {ref}: {proc.stderr.strip()}")
return proc.returncode == 0
def list_tree(ref: str, repo: Path) -> list[str]:
raw = run_git(["ls-tree", "-r", "-z", "--name-only", ref], repo)
return [path for path in raw.split("\0") if path]
# --------------------------------------------------------------------------- #
# probe selection (pure)
# --------------------------------------------------------------------------- #
def is_excluded(path: str, patterns: Iterable[str]) -> bool:
name = path.rsplit("/", 1)[-1]
return any(
fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(name, pattern)
for pattern in patterns
)
def added_lines(patch: str, excludes: Iterable[str]) -> list[tuple[str, str]]:
"""Extract `(path, added line)` pairs from a unified diff."""
results = []
path = None
skip = False
for line in patch.splitlines():
if line.startswith("+++ "):
target = line[4:].strip()
path = None if target == "/dev/null" else target[2:] if target.startswith("b/") else target
skip = path is None or is_excluded(path, excludes)
elif line.startswith("--- ") or line.startswith("diff --git "):
continue
elif line.startswith("+") and path and not skip:
results.append((path, line[1:]))
return results
def probe_score(text: str) -> int:
"""Rank a candidate probe: distinct named things first, then length."""
identifiers = set(IDENTIFIER_RE.findall(text))
return len(identifiers) * 1000 + min(len(text), 400)
def is_probe_candidate(text: str) -> bool:
stripped = text.strip()
if len(stripped) < MIN_PROBE_LENGTH:
return False
if "\0" in stripped:
return False
return len(set(IDENTIFIER_RE.findall(stripped))) >= MIN_PROBE_IDENTIFIERS
def pick_probes(lines: Sequence[tuple[str, str]], limit: int) -> list[str]:
"""Pick up to `limit` distinctive stripped lines, highest-scoring first.
Leading and trailing whitespace is dropped so a re-indented port still
counts as present. Ties break on first appearance, keeping the output
stable across runs.
"""
seen: dict[str, int] = {}
for index, (_path, text) in enumerate(lines):
stripped = text.strip()
if not is_probe_candidate(stripped) or stripped in seen:
continue
seen[stripped] = index
ranked = sorted(seen, key=lambda text: (-probe_score(text), seen[text]))
return ranked[:limit]
# --------------------------------------------------------------------------- #
# audit
# --------------------------------------------------------------------------- #
def audit(
source: str,
target: str,
repo: Path,
since: str | None = None,
until: str | None = None,
traversal: str = "linear",
probe_limit: int = 4,
excludes: Sequence[str] = DEFAULT_EXCLUDES,
progress: bool = False,
) -> Report:
report = Report(
source=source,
source_sha=resolve_ref(source, repo),
target=target,
target_sha=resolve_ref(target, repo),
merge_base=merge_base(source, target, repo),
since=since,
until=until,
traversal=traversal,
probe_limit=probe_limit,
)
commits = list_commits(source, target, repo, since, until, traversal)
for index, commit in enumerate(commits, start=1):
if progress:
print(
f"\r[{index}/{len(commits)}] {commit.sha[:8]}",
end="",
file=sys.stderr,
flush=True,
)
lines = added_lines(commit_diff(commit, repo), excludes)
probes = pick_probes(lines, probe_limit)
found = tuple(p for p in probes if probe_present(p, target, repo))
report.verdicts.append(
CommitVerdict(
commit=commit,
probes=tuple(probes),
found=found,
paths=tuple(dict.fromkeys(path for path, _ in lines)),
)
)
if progress:
print("", file=sys.stderr)
source_files = {p for p in list_tree(source, repo) if not is_excluded(p, excludes)}
target_files = {p for p in list_tree(target, repo) if not is_excluded(p, excludes)}
report.source_only_files = tuple(sorted(source_files - target_files))
report.target_only_files = tuple(sorted(target_files - source_files))
return report
# --------------------------------------------------------------------------- #
# rendering
# --------------------------------------------------------------------------- #
def _commit_table(verdicts: Sequence[CommitVerdict]) -> list[str]:
rows = [
"| Commit | Committed | Author | Probes found | Subject |",
"|---|---|---|---|---|",
]
for item in verdicts:
rows.append(
f"| `{item.commit.sha[:8]}` | {item.commit.date} | {item.commit.author} "
f"| {len(item.found)}/{len(item.probes)} | {item.commit.subject} |"
)
return rows
def _file_list(paths: Sequence[str], top: int) -> list[str]:
lines = [f"- `{path}`" for path in paths[:top]]
if len(paths) > top:
lines.append(f"- … and {len(paths) - top} more")
return lines
def render_markdown(report: Report, top: int = 50) -> str:
absent = report.by_verdict(VERDICT_ABSENT)
partial = report.by_verdict(VERDICT_PARTIAL)
present = report.by_verdict(VERDICT_PRESENT)
no_probe = report.by_verdict(VERDICT_NO_PROBE)
window = []
if report.since:
window.append(f"since {report.since}")
if report.until:
window.append(f"until {report.until}")
out = [
"# Ref parity audit",
"",
f"Source `{report.source}` @ `{report.source_sha}` → "
f"target `{report.target}` @ `{report.target_sha}`.",
f"Merge base `{report.merge_base or 'none (unrelated histories)'}`.",
f"{len(report.verdicts)} commits on the source and not the target "
f"({report.traversal} traversal"
+ (", " + ", ".join(window) if window else "")
+ f"), up to {report.probe_limit} probes each.",
"",
f"No trace in the target: **{len(absent)}**. "
f"Partly present: **{len(partial)}**. "
f"Fully present: **{len(present)}**. "
f"Unprobeable: **{len(no_probe)}**.",
"",
"## Commits with no trace in the target",
"",
]
out += _commit_table(absent) if absent else ["None."]
out += [
"",
"## Commits only partly present",
"",
"A partial verdict is inconclusive, not a finding: a line can move or be "
"rewritten by a refactor on the target and still be the same change. Read the "
"diff before porting anything from this table.",
"",
]
out += _commit_table(partial) if partial else ["None."]
out += ["", "## Commits with no usable probe", ""]
if no_probe:
out += [
"Deletion-only commits, and commits touching nothing but excluded paths. "
"The audit has no verdict on these.",
"",
] + _commit_table(no_probe)
else:
out.append("None.")
out += [
"",
f"## Files on the source and not the target ({len(report.source_only_files)})",
"",
"Exact, not sampled. A file here whose commit is reported fully present is "
"usually a fix that was reproduced without its test.",
"",
]
out += _file_list(report.source_only_files, top) if report.source_only_files else ["None."]
out += [
"",
f"## Files on the target and not the source ({len(report.target_only_files)})",
"",
]
out += _file_list(report.target_only_files, top) if report.target_only_files else ["None."]
out.append("")
return "\n".join(out)
def render_json(report: Report) -> str:
return json.dumps(
{
"source": {"ref": report.source, "sha": report.source_sha},
"target": {"ref": report.target, "sha": report.target_sha},
"merge_base": report.merge_base,
"since": report.since,
"until": report.until,
"traversal": report.traversal,
"probe_limit": report.probe_limit,
"totals": {
verdict: len(report.by_verdict(verdict))
for verdict in (
VERDICT_ABSENT,
VERDICT_PARTIAL,
VERDICT_PRESENT,
VERDICT_NO_PROBE,
)
},
"commits": [
{
"sha": item.commit.sha,
"date": item.commit.date,
"author": item.commit.author,
"subject": item.commit.subject,
"verdict": item.verdict,
"probes": list(item.probes),
"probes_found": list(item.found),
"paths": list(item.paths),
}
for item in report.verdicts
],
"source_only_files": list(report.source_only_files),
"target_only_files": list(report.target_only_files),
},
indent=2,
sort_keys=True,
)
# --------------------------------------------------------------------------- #
# cli
# --------------------------------------------------------------------------- #
def positive_int(value: str) -> int:
parsed = int(value)
if parsed < 1:
raise argparse.ArgumentTypeError("must be 1 or greater")
return parsed
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Read-only audit of which commits on one ref left no trace in another."
)
parser.add_argument("--source", required=True, help="Ref whose commits are audited")
parser.add_argument("--target", required=True, help="Ref searched for traces of them")
parser.add_argument("--repo", default=str(REPO_ROOT), help="Repository to run in")
parser.add_argument("--since", help="Only commits committed on or after this date")
parser.add_argument("--until", help="Only commits committed on or before this date")
parser.add_argument(
"--traversal",
choices=["linear", "first-parent"],
default="linear",
help="linear: individual commits, no merges. first-parent: one row per merge",
)
parser.add_argument(
"--probes", type=positive_int, default=4, help="Probe lines sampled per commit"
)
parser.add_argument(
"--exclude",
action="append",
default=[],
metavar="GLOB",
help="Extra path glob whose lines are not used as probes (repeatable)",
)
parser.add_argument(
"--no-default-excludes",
action="store_true",
help="Drop the built-in vendored/lockfile/binary exclusions",
)
parser.add_argument("--format", choices=["markdown", "json"], default="markdown")
parser.add_argument("--top", type=positive_int, default=50, help="Rows per file list")
parser.add_argument("--output", help="Write the report here instead of stdout")
parser.add_argument("--quiet", action="store_true", help="No progress output")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
excludes = list(args.exclude)
if not args.no_default_excludes:
excludes = list(DEFAULT_EXCLUDES) + excludes
try:
report = audit(
source=args.source,
target=args.target,
repo=Path(args.repo),
since=args.since,
until=args.until,
traversal=args.traversal,
probe_limit=args.probes,
excludes=excludes,
progress=not args.quiet and sys.stderr.isatty(),
)
except GitError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
text = render_json(report) if args.format == "json" else render_markdown(report, args.top)
if args.output:
Path(args.output).write_text(text + "\n", encoding="utf-8")
else:
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())