feat: carry new upstream names and hashes in json

--detect-new-files and --watch-hashes were printed only by the text report and refused with --json, so check_freshness --profiles never saw a name or hash the cited code gained. Both now live on the report, and check_freshness passes them and marks such a profile stale.
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 09:51:14 +02:00
1 parent fd9a9b14f8
commit 6877512af7
5 files changed
+181 -34

No files matched your search

+18 -3
View File
@@ -1253,6 +1253,8 @@ def check_profiles(run: bool, offline: bool) -> list[Finding]:
"--all",
"--json",
"--check-version",
"--detect-new-files",
"--watch-hashes",
]
if offline:
cmd.append("--offline")
@@ -1273,7 +1275,9 @@ def summarize_profile_sync(report: object) -> list[Finding]:
The JSON carries the ref verdicts and the pin against the tip. A profile
whose refs need review is stale, one the forge could not serve is unknown,
and one whose upstream moved while every ref still anchors is reported as
such without being counted as stale.
such without being counted as stale. A cited file that gained a file name
since the pin, or a hash the profile does not declare, is stale too: the
upstream may load a file the profile does not document.
"""
if not isinstance(report, list):
return [Finding("profiles", "profile_sync", ERROR, detail="report is not a list")]
@@ -1303,7 +1307,18 @@ def summarize_profile_sync(report: object) -> list[Finding]:
continue
review = row.get("needs_review") or 0
pin, head = str(row.get("pin") or ""), str(row.get("head") or "")
if review:
added = sorted({
str(item.get("name"))
for item in row.get("new_files") or ()
if isinstance(item, dict) and item.get("since_pin")
})
hashes = len(row.get("new_hashes") or ())
reasons = [f"{review} refs to review"] if review else []
if added:
reasons.append(f"new file names since the pin: {', '.join(added)}")
if hashes:
reasons.append(f"{hashes} undeclared hashes added since the pin")
if reasons:
findings.append(
Finding(
"profiles",
@@ -1311,7 +1326,7 @@ def summarize_profile_sync(report: object) -> list[Finding]:
STALE,
local=pin[:8],
remote=head[:8],
detail=f"{review} refs to review",
detail=", ".join(reasons),
)
)
elif pin and head and pin != head:
+76 -25
View File
@@ -966,6 +966,14 @@ class ProfileReport:
# release, kept on the report so every output carries it.
version: VersionReport | None = None
version_warning: str | None = None
# --detect-new-files and --watch-hashes, kept on the report so --json
# carries them to check_freshness. None when not asked. A new file is
# {path, line, name, since_pin}: since_pin when the pinned revision of
# the same file does not carry the name. A new hash is {path, kind, value}.
new_files: list[dict] | None = None
new_files_elided: dict[str, int] = field(default_factory=dict)
new_hashes: list[dict] | None = None
detection_skipped: str | None = None
# Skips that say the profile cannot be checked as written: a repository
# that no longer answers, no pin to read, a death the forge contradicts.
@@ -3756,37 +3764,84 @@ def _cited_paths(report: ProfileReport) -> list[str]:
)
def _print_detection(
def _attach_detection(
args: argparse.Namespace, profile: dict, report: ProfileReport
) -> None:
"""Record undeclared names and hashes of the cited files on the report.
Kept on the report rather than printed, so --json carries them as the
text report does. A forge that refuses is named on the report: the ref
report already muted it, and one refusal must not end a pass over every
profile. A quota signal still stops everything.
"""
if not report.head or not (args.detect_new_files or args.watch_hashes):
return
repo = select_repo(profile)
if repo is None:
return
try:
_detect(args, profile, report, repo)
except upstream.RateLimitError:
raise
except upstream.UpstreamError as exc:
report.detection_skipped = str(exc)
def _detect(
args: argparse.Namespace, profile: dict, report: ProfileReport,
repo: upstream.Repo,
) -> None:
names, hashes = declared_names(profile), declared_hashes(profile)
new_files: list[dict] = []
new_hashes: list[dict] = []
for path in _cited_paths(report):
head_lines = upstream.fetch_file(
repo, report.head, path, args.cache_dir, args.offline
)
if head_lines is None:
continue
pin_lines = (
upstream.fetch_file(repo, report.pin, path, args.cache_dir, args.offline)
or []
)
if args.detect_new_files:
found, elided = detect_new_files(head_lines, names)
for number, name in found:
print(f" new file: {path}:{number} {name}")
if elided:
print(f" new file: {elided} further matches not shown")
if args.watch_hashes:
pin_lines = (
upstream.fetch_file(
repo, report.pin, path, args.cache_dir, args.offline
)
or []
at_pin = {
name.casefold()
for _, name in detect_new_files(pin_lines, names, cap=sys.maxsize)[0]
}
new_files.extend(
{"path": path, "line": number, "name": name,
"since_pin": name.casefold() not in at_pin}
for number, name in found
)
if elided:
report.new_files_elided[path] = elided
if args.watch_hashes:
added = [
line
for line in difflib.unified_diff(pin_lines, head_lines, n=0)
if line.startswith("+") and not line.startswith("+++")
]
for kind, value in watch_hashes(added, hashes):
print(f" new {kind}: {path} {value}")
new_hashes.extend(
{"path": path, "kind": kind, "value": value}
for kind, value in watch_hashes(added, hashes)
)
if args.detect_new_files:
report.new_files = new_files
if args.watch_hashes:
report.new_hashes = new_hashes
def _print_detection(report: ProfileReport) -> None:
if report.detection_skipped:
print(f" extras skipped: {report.detection_skipped}")
for item in report.new_files or ():
print(f" new file: {item['path']}:{item['line']} {item['name']}")
for path, elided in report.new_files_elided.items():
print(f" new file: {path}: {elided} further matches not shown")
for item in report.new_hashes or ():
print(f" new {item['kind']}: {item['path']} {item['value']}")
def _attach_version(
@@ -3820,13 +3875,8 @@ def _print_extras(
instead of ending a pass over every profile. A quota signal still
stops everything.
"""
wants = (
args.detect_new_files
or args.watch_hashes
or args.full_diff
or args.tree_diff
)
if not wants or not report.head:
_print_detection(report)
if not (args.full_diff or args.tree_diff) or not report.head:
return
repo = select_repo(profile)
if repo is None:
@@ -3843,8 +3893,6 @@ def _print_extras_from(
args: argparse.Namespace, profile: dict, report: ProfileReport,
repo: upstream.Repo,
) -> None:
if args.detect_new_files or args.watch_hashes:
_print_detection(args, profile, report, repo)
if args.full_diff:
for path in _cited_paths(report):
if args.ref and args.ref not in path:
@@ -3991,13 +4039,15 @@ def _refuse_flag_combinations(args: argparse.Namespace) -> None:
]
# The version check lives on the report, so the outputs built from the
# report (text, --json, --markdown) carry it. A URL list and a ranking
# do not.
# do not. Detection lives on the report too, and --json carries it for
# check_freshness; the markdown table has no column for it.
lists_only = args.fetch_plan or args.triage
off_json = lists_only or args.markdown
extras = [
flag for flag, on in (
("--check-version", args.check_version and lists_only),
("--detect-new-files", args.detect_new_files),
("--watch-hashes", args.watch_hashes),
("--detect-new-files", args.detect_new_files and off_json),
("--watch-hashes", args.watch_hashes and off_json),
("--full-diff", args.full_diff),
("--tree-diff", args.tree_diff),
) if on
@@ -4094,6 +4144,7 @@ def main() -> None:
profile["version_source"].get("no_genuine_copy") or "") or None
if args.check_version:
_attach_version(args, profile, report)
_attach_detection(args, profile, report)
reports.append(report)
if writes:
try: