"""Create a hash-backed, non-legal triage report for unresolved Flutter licenses.""" from __future__ import annotations import argparse import json import re from datetime import UTC, datetime from pathlib import Path try: from scripts.generate_component_inventory import ( digest, flutter_license_file, package_roots, parse_lockfile, ) except ModuleNotFoundError: # direct execution as `python scripts/...py` from generate_component_inventory import ( digest, flutter_license_file, package_roots, parse_lockfile, ) def candidate_license(text: str) -> tuple[str | None, str]: """Suggest an SPDX identifier only when the license's identifying text is clear. Suggestions are for human triage. They do not establish provenance, package applicability, exceptions, or permission to redistribute. """ normalized = re.sub(r"(?m)^\s*(?://|#|\*)\s?", "", text).lower() normalized = re.sub(r"\s+", " ", normalized) matches: list[tuple[str, str]] = [] if "mozilla public license version 2.0" in normalized: matches.append(("MPL-2.0", "license text contains the Mozilla Public License 2.0 title")) if "apache license" in normalized and "version 2.0" in normalized: matches.append(("Apache-2.0", "license text contains the Apache License 2.0 title")) if ( "permission is hereby granted, free of charge" in normalized and "the software is provided" in normalized and "in no event shall" in normalized ): matches.append(("MIT", "license text contains the standard MIT grant and warranty disclaimer")) if ( "redistribution and use in source and binary forms" in normalized and "neither the name" in normalized and "disclaimer" in normalized ): matches.append(("BSD-3-Clause", "license text contains the three-clause BSD endorsement restriction")) if ( "redistribution and use in source and binary forms" in normalized and "neither the name" not in normalized and "disclaimer" in normalized and "provided that the following conditions are met" in normalized ): matches.append(("BSD-2-Clause", "license text contains a two-clause BSD-style grant and disclaimer")) if len(matches) == 1: return matches[0] if len(matches) > 1: return None, "multiple license signatures occur in this file; manual review required" return None, "no conservative license-text signature matched" def build_report(root: Path) -> dict[str, object]: app_root = root / "flutter_app" packages = parse_lockfile(app_root / "pubspec.lock") roots = package_roots(app_root / ".dart_tool" / "package_config.json") entries: list[dict[str, object]] = [] missing: list[dict[str, str]] = [] for package in packages: name = package["name"] package_root = roots.get(name) license_file = flutter_license_file(package_root) if package_root else None if license_file is None: missing.append({"name": name, "version": package["version"]}) continue text = license_file.read_text(encoding="utf-8", errors="replace") candidate, basis = candidate_license(text) entries.append( { "name": name, "version": package["version"], "scope": package.get("dependency", "transitive"), "license_file": license_file.name, "license_file_sha256": digest(license_file), "candidate_spdx": candidate, "candidate_basis": basis, "status": "candidate only; human review required", } ) counts: dict[str, int] = {} for entry in entries: candidate = entry["candidate_spdx"] or "unclassified" counts[str(candidate)] = counts.get(str(candidate), 0) + 1 return { "format": "flutter-license-triage-v1", "generated_at": datetime.now(UTC).isoformat(), "source": "flutter_app/pubspec.lock and resolved package LICENSE files", "notice": ( "Text-signature suggestions only. Not legal advice, provenance verification, " "or approval to redistribute. Review each package, source, notices, and terms." ), "summary": { "locked_packages": len(packages), "packages_with_license_file": len(entries), "packages_without_license_file": len(missing), "candidate_counts": dict(sorted(counts.items())), }, "packages": sorted(entries, key=lambda item: str(item["name"]).lower()), "missing_license_file": sorted(missing, key=lambda item: item["name"].lower()), } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--project-root", type=Path, default=Path(__file__).resolve().parents[1], ) parser.add_argument("--output", type=Path, default=None) args = parser.parse_args() root = args.project_root.resolve() output = args.output or root / "sbom" / "flutter-license-triage.json" report = build_report(root) output.parent.mkdir(parents=True, exist_ok=True) output.write_text( json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) print(json.dumps(report["summary"], ensure_ascii=False, sort_keys=True)) print(f"Wrote {output}") return 0 if __name__ == "__main__": raise SystemExit(main())