diff --git a/CHANGELOG.md b/CHANGELOG.md index 193c949..bd79b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ contract or a policy always come with a version bump of that contract. Offline Repository Analysis, target E4, issue #72. +- Release-readiness repairs: reject ambiguous/malformed current CI inventory + before it can remove carried failures; preserve immutable revision time; + require exact bounded attempt identities and retain UNKNOWN history. Activity + shares current-record admission and exposes rejected verification inputs. +- Replay native source-selection predicates instead of accepting a recognized + omission reason alone. Operational verification explicitly distinguishes + proved source/tree bytes from unproven omitted sizes and history ancestry; + canonical v1 artifact bytes and existing lineages remain unchanged. +- Reconcile the October 21 release state/sequence and add a tested direct + first-report path; the v0.6.0 installation remains conditional on publication. + - New stdlib-only `code` companion: immutable local Git snapshot, Python 3.12 AST structure, six scoped findings, declared-module import cycles and bounded recent non-merge change hotspots. No source execution or forge dependency. diff --git a/README.md b/README.md index 31ee0a3..980dc80 100644 --- a/README.md +++ b/README.md @@ -211,15 +211,27 @@ For this unreleased checkout: pip install -e . ``` -After the 0.5.0 release tag is published: +After the 0.6.0 release tag is published: ```bash pip install git+https://github.com/drevendev/devostasis@v0.6.0 ``` -Observe one repository (a GitHub token is read from `DEVOSTASIS_GITHUB_TOKEN`, +Produce your first seven-Vital report without a configuration file: + +```bash +devostasis run --repo drevendev/Devostasis --store ./history-local +devostasis verify --bundle history-local/projects/github.com/drevendev/devostasis/latest +``` + +Open `history-local/projects/github.com/drevendev/devostasis/latest/report.md`. +The first successful run is a `BASELINE`; later runs compare with the previous +verified bundle. Unavailable API evidence remains explicit, and planning/debt +without configured registers or mappings remains undeclared/uninstrumented. + +A GitHub token is read from `DEVOSTASIS_GITHUB_TOKEN`, `GITHUB_TOKEN`, `GH_TOKEN` or `gh auth token`; public repositories work -without one, with a low rate limit): +without one, with a low rate limit. For separate observation and evaluation: ```bash devostasis observe --repo owner/name --out observations.json @@ -332,6 +344,9 @@ gauges, accepted band ordering and executable conformance vectors. The pending 0.2.0 adoption adds the accepted Vital repairs and durable Integrity history; 0.3.0 adds the separate Evidence to Action companion and 0.4.0 adds reproducible consumer handoff and accepted receipt/compatibility contracts. +0.5.0 adds bound consumer operations and portable private history; 0.6.0 adds +native offline Python analysis and verified source packets. The cumulative +release is still under review, including inherited history-admission repairs. Its GitLab work collector does not yet implement the core GitLab Vital adapter or the accepted Instruments carrier. diff --git a/ROADMAP.md b/ROADMAP.md index eff266e..dba863e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,71 @@ Ordered by what unblocks what, not by what is interesting. Every item names who or what blocks it, so a reader can tell the difference between work not started and work that cannot start. -## Release state and next steps — 2026-10-08 +## Release state and next steps — 2026-10-21 + +The project is at **release stabilization**, with Phase A closed and Phase B +five of seven items implemented. B1 still has 62 named cases without executable +proof, and B7 still needs sustained outside-consumer evidence. The latest +published version is **v0.1.9**; 0.2.0 through 0.6.0 remain unreleased. + +The audit began with a clean working tree. All local and fetched remote heads +are ancestors of upstream `release/0.6.0` at `22a2894`, including the original +maintenance commits and the Dependabot correction merged through #79. Nine +earlier PRs remain open: #36/#37/#50/#53/#57/#59/#64/#69/#75. There is no +unrepresented development branch to recover. Inclusion in the release branch +does not publish its code on `master` or create a release tag. + +Upstream CI for the 0.6 source head `09d497f` passed on Python 3.12/3.13/3.14 +in [run 39030444073](https://github.com/drevendev/Devostasis/actions/runs/39030444073). +The Dependabot merge head `22a2894` also passed +[CI](https://github.com/drevendev/Devostasis/actions/runs/39169319950). +The old `action_required` run is historical evidence, not the current CI state. +These successful checks do not resolve the October 14/16/18 admission reviews +on #57/#75 or approve a later repaired head. + +### Delivery order + +1. **Finish the admission repairs before release.** Current malformed or + duplicate revision records must not erase carried failures or inflate the + sample. Admit timestamps before reconciliation, preserve immutable revision + time, check exact attempt identities without allocation by their declared + maximum, and retain UNKNOWN attempt evidence. Exercise actual four-bundle + evaluator/store chains and current/historical replay. Source omissions must + satisfy path/mode/scope and deterministic budget predicates; omitted blob + sizes remain acquisition claims, explicitly unproven by a tree proof. +2. **Propagate and review every final release head.** Preserve the prepared + sequence: corrected #57 into `release/0.2.0`, then #53, #59, #64, #69 and #75 + into `master`, with their respective tags under CONTRIBUTING.md. Carry the + history repair into descendants, include #79's Dependabot correction in + 0.6, and verify each changed head independently. Reconcile #36/#37/#50 after + their delivered commits land. A green matrix is evidence, not acceptance. +3. **Make the existing product straightforward to try.** The README now starts + with a direct single-repository report and its actual path (#78). Retain + source-install instructions until publication is confirmed. #63 still needs + actual scheduled Dependabot execution; the configuration correction alone + does not complete that operational acceptance. +4. **Complete assurance before increasing reach.** Work the outstanding + timestamp/lineage/store audit families (#35), the remaining exact B1 cases + (#20/#23) and independently proved source-history ancestry/counts (#73). + Keep partial coverage, historical compatibility and evidence limits visible. + +### Proposed next major code increment + +After releasing 0.6, the highest-impact code-only increment is **independently +verifiable history and executable assurance**: bounded commit/parent/tree +proofs for #73, hostile rehashed-history controls, and the missing executable +conformance boundaries for B1. This strengthens the proof objects on which +both work routing and native hotspots depend. It is a proposal, not acceptance +of a new research contract, a new release number, or completion of B1. + +The larger product milestone remains a real outside consumer completing work +from verified scopes with retained results and calendar history. Deployment +and scheduling remain owner-deferred in #71; they are not silently reinstated +by this audit. Once B7 and existing compatibility/review gates are met, extend +reach with the accepted core GitLab adapter and Instrument carrier. The +existing GitLab work collector does not implement that core adapter. + +## Historical 0.6 implementation snapshot — 2026-10-08 **0.6.0 Offline Repository Analysis (E4) is prepared on `zendreven/release-0.6.0`, carrying the pending 0.2–0.5 ancestry.** diff --git a/docs/code-analysis.md b/docs/code-analysis.md index ba700b8..ca29021 100644 --- a/docs/code-analysis.md +++ b/docs/code-analysis.md @@ -48,6 +48,12 @@ The local identity derives from the recorded root commit IDs, not a forge URL; shallow or unrelated roots and changed policy make comparisons INCOMPARABLE. History counts and root traversal are acquisition observations; their complete ancestry is not independently proved by the source tree proof (issue #73). +Likewise, a tree binds an omitted blob's identity, not its separately recorded +size. Verification checks the deterministic selection predicates and marks +omitted blob sizes `UNPROVEN` in its operational `proof_scope` receipt. Byte-cap +reasons that depend on those sizes remain acquisition claims; `VERIFIED` means +the admitted proofs and recorded bundle replay, not proof of those omissions. +The receipt is outside the immutable artifact and does not rewrite v1 bundles. Compare two verified bundles under the same policy: diff --git a/docs/spec/code-analysis.md b/docs/spec/code-analysis.md index 433a6b0..a10d93d 100644 --- a/docs/spec/code-analysis.md +++ b/docs/spec/code-analysis.md @@ -45,6 +45,18 @@ UNSUPPORTED/LANGUAGE_UNSUPPORTED. UNAVAILABLE reasons are UNSAFE_PATH, SOURCE_LINK_OR_SUBMODULE, FILE_BYTE_CAP, FILE_COUNT_CAP and TOTAL_BYTE_CAP. `sources` maps exactly admitted paths to canonical base64 of unmodified bytes. +Offline admission applies the collector's same ordered predicates: safe path, +policy scope, regular blob mode, supported language, per-file bytes, preceding +admitted-file count and preceding verified bytes plus current declared size. +A recognized reason alone is insufficient to omit an otherwise eligible file. +Path/mode and policy/count predicates are independently checked. A tree does +not authenticate a blob's size when its bytes are omitted; byte-based omission +predicates can only be replayed against that acquisition claim. They do not +prove that the omission was necessary. `code verify` reports `proof_scope` +outside the canonical members: source tree and admitted blobs VERIFIED, +omitted blob sizes UNPROVEN (or NOT_APPLICABLE), history ancestry UNPROVEN. +No unbounded blob read or new compatibility/migration rule is implied. + `proof` contains the raw base64 commit object and an object-ID-to-base64 map of every reachable tree. Verification computes framed Git object hashes, checks the commit/tree link, expands all trees, checks the entire inventory and diff --git a/docs/spec/integrity-ci.md b/docs/spec/integrity-ci.md index 7b854ad..3873595 100644 --- a/docs/spec/integrity-ci.md +++ b/docs/spec/integrity-ci.md @@ -157,6 +157,25 @@ When the current series is partial or unusable, carried records are kept, so history crosses a bundle whose verification evidence could not be read. A current verdict is never carried (HIST-16). +Current inventory admission validates every row before treating absence as +branch departure. Duplicate current ids are diagnosed as +`REVISION_RECORD_DUPLICATE:`; malformed rows and contradictory +coverage prevent a completeness claim. Ambiguous current records cannot +replace carried facts. Current timestamps are parsed before reconciliation; +a different instant for a known revision yields +`IMMUTABLE_COMMIT_TIME_MISMATCH` and preserves its prior time and attempts. +Equivalent timezone representations name the same instant. + +Attempt completeness requires exactly the observed identities 1..latest, +with work bounded by the observed cardinality. A missing or extra identity +cannot establish completeness. Unknown state vocabulary is malformed input; +the legitimate `UNKNOWN` outcome is retained as incomplete history and never +proves absence of failure. Successive states of one attempt, including +`VERIFY_UNRESOLVED` then `VERIFY_PASS`, remain valid observations. An observed +failure remains absorbing even when the rest of history is incomplete. +These are admission repairs within the existing history lineage and accepted +rules, not new thresholds, external-check semantics or a migration. + **Lineage.** A carried union of `devostasis.ci-history.v2` is read as it is. The revision records a 0.1.x bundle keeps in `ci.revision_verdicts_14d` (`devostasis.ci-history.v1`) are replayed from the attempts they kept, never diff --git a/src/devostasis/activity.py b/src/devostasis/activity.py index 88a4650..50aa822 100644 --- a/src/devostasis/activity.py +++ b/src/devostasis/activity.py @@ -14,6 +14,7 @@ from .normalize import CI_REVISIONS, INV_COMMITS, INV_CRS, INV_ISSUES, INV_RELEASES from .observations import ObservationSet from .policy import ACTIVITY +from .revision_history import _series_records BASIS_PREVIOUS = "PREVIOUS_BUNDLE" BASIS_WINDOW = "OBSERVATION_WINDOW_28D" @@ -130,7 +131,11 @@ def build_activity( verification_items: list[dict[str, Any]] = [] verified = failed = unresolved = 0 if ci is not None and ci.has_value: - for rev in ci.value: + admitted, _, problems = _series_records(ci) + coverage_notes.extend(f"VERIFICATION_INPUT_INADMISSIBLE:{problem}" for problem in problems) + if admitted is None: + coverage_notes.append(f"VERIFICATION:{ci.status}:{ci.freshness}") + for rev in admitted or []: if not rev.get("parents") or not _in_interval(rev.get("committed_at"), start, end): continue verified += 1 diff --git a/src/devostasis/codeanalysis/bundle.py b/src/devostasis/codeanalysis/bundle.py index ca84c37..a291775 100644 --- a/src/devostasis/codeanalysis/bundle.py +++ b/src/devostasis/codeanalysis/bundle.py @@ -17,7 +17,7 @@ from ..workscope.operations import no_links from . import CONTRACT, ENGINE from .engine import RULES, evaluate, policy -from .git import object_digest +from .git import object_digest, source_selection MEMBERS = ("inputs.json", "policy.json", "analysis.json", "findings.sarif", "report.md") MAX_BUNDLE_BYTES = 128 * 1024 * 1024 @@ -92,6 +92,8 @@ def admit(raw, config): require(entry["status"] in ("ADMITTED", "UNAVAILABLE", "UNSUPPORTED", "OUT_OF_SCOPE"), "inventory availability unknown") if entry["kind"] == "blob": number(entry["size"], 0, 2**63 - 1) else: require(entry["size"] is None, "submodule must not claim blob size") + selection = source_selection(safe, entry["mode"], entry["kind"], entry["size"], config, len(admitted), source_total) + require((entry["status"], entry["reason"]) == selection, "unjustified source selection") included = safe is not None and (not config["include"] or any(safe == p or safe.startswith(p + "/") for p in config["include"])) excluded = safe is not None and any(safe == p or safe.startswith(p + "/") for p in config["exclude"]) if entry["status"] == "ADMITTED": @@ -210,6 +212,14 @@ def verify(root): return verify_content(read(root)) +def proof_scope(decoded): + """Operational verification limits, outside the immutable v1 artifact bytes.""" + omitted = any(e["kind"] == "blob" and e["status"] != "ADMITTED" for e in decoded["inputs.json"]["entries"]) + return {"source_tree": "VERIFIED", "admitted_blobs": "VERIFIED", + "omitted_blob_sizes": "UNPROVEN" if omitted else "NOT_APPLICABLE", + "history_ancestry": "UNPROVEN"} + + def publish(root, content): verify_content(content); root = no_links(root) root.parent.mkdir(parents=True, exist_ok=True); no_links(root.parent) diff --git a/src/devostasis/codeanalysis/cli.py b/src/devostasis/codeanalysis/cli.py index c053de9..29f0ae8 100644 --- a/src/devostasis/codeanalysis/cli.py +++ b/src/devostasis/codeanalysis/cli.py @@ -5,7 +5,7 @@ from ..canonical import canonical_bytes, loads from ..workscope.model import require -from .bundle import MAX_BUNDLE_BYTES, build, publish, verify +from .bundle import MAX_BUNDLE_BYTES, build, proof_scope, publish, verify from .consumer import compare, packet, verify_packet, work_evidence from .engine import default_policy from .git import collect @@ -42,7 +42,8 @@ def cmd(args): if args.operation == "compare": emit(compare(verify(args.before), verify(args.after)), args.output); return 0 decoded = verify(args.bundle) - if args.operation == "verify": emit({"status": "VERIFIED", "bundle_id": decoded["manifest.json"]["bundle_id"]}) + if args.operation == "verify": emit({"status": "VERIFIED", "bundle_id": decoded["manifest.json"]["bundle_id"], + "proof_scope": proof_scope(decoded)}) elif args.operation == "show": emit(decoded["analysis.json"], args.output) elif args.operation == "sarif": emit(decoded["findings.sarif"], args.output) elif args.operation == "packet": emit(packet(decoded, args.path, args.max_files, args.max_bytes), args.output) diff --git a/src/devostasis/codeanalysis/git.py b/src/devostasis/codeanalysis/git.py index 7dc1576..1dcb409 100644 --- a/src/devostasis/codeanalysis/git.py +++ b/src/devostasis/codeanalysis/git.py @@ -32,6 +32,27 @@ def blob_digest(data, object_format): return object_digest(data, object_format) +def source_selection(name, mode, kind, size, config, count, total): + """Deterministic acquisition priority; omitted blob sizes remain caller observations.""" + if name is None: + return "UNAVAILABLE", "UNSAFE_PATH" + included = not config["include"] or any(name == p or name.startswith(p + "/") for p in config["include"]) + excluded = any(name == p or name.startswith(p + "/") for p in config["exclude"]) + if not included or excluded: + return "OUT_OF_SCOPE", "POLICY_EXCLUSION" + if mode not in ("100644", "100755") or kind != "blob": + return "UNAVAILABLE", "SOURCE_LINK_OR_SUBMODULE" + if not name.endswith(".py"): + return "UNSUPPORTED", "LANGUAGE_UNSUPPORTED" + if size > config["budget"]["max_file_bytes"]: + return "UNAVAILABLE", "FILE_BYTE_CAP" + if count >= config["budget"]["max_files"]: + return "UNAVAILABLE", "FILE_COUNT_CAP" + if total + size > config["budget"]["max_total_bytes"]: + return "UNAVAILABLE", "TOTAL_BYTE_CAP" + return "ADMITTED", None + + class Git: def __init__(self, directory, seconds=120): self.directory = Path(directory).resolve() @@ -133,23 +154,10 @@ def collect(directory, ref, config, at=None): entry["path"] = path(filename.decode("utf-8")) except (UnicodeError, ValueError): entries.append(entry); continue - name = entry["path"] - included = not config["include"] or any(name == p or name.startswith(p + "/") for p in config["include"]) - excluded = any(name == p or name.startswith(p + "/") for p in config["exclude"]) - if not included or excluded: - entry.update(status="OUT_OF_SCOPE", reason="POLICY_EXCLUSION") - elif mode not in (b"100644", b"100755") or kind != b"blob": - entry.update(reason="SOURCE_LINK_OR_SUBMODULE") - elif not name.endswith(".py"): - entry.update(status="UNSUPPORTED", reason="LANGUAGE_UNSUPPORTED") - elif entry["size"] > config["budget"]["max_file_bytes"]: - entry.update(reason="FILE_BYTE_CAP") - elif len(selected) >= config["budget"]["max_files"]: - entry.update(reason="FILE_COUNT_CAP") - elif total + entry["size"] > config["budget"]["max_total_bytes"]: - entry.update(reason="TOTAL_BYTE_CAP") - else: - entry.update(status="ADMITTED", reason=None); selected.append(entry); total += entry["size"] + status, reason = source_selection(entry["path"], entry["mode"], entry["kind"], entry["size"], config, len(selected), total) + entry.update(status=status, reason=reason) + if status == "ADMITTED": + selected.append(entry); total += entry["size"] entries.append(entry) sources = {} if selected: diff --git a/src/devostasis/revision_history.py b/src/devostasis/revision_history.py index 8c24663..e7c8719 100644 --- a/src/devostasis/revision_history.py +++ b/src/devostasis/revision_history.py @@ -67,6 +67,7 @@ CONTRIBUTION = {FAILURE_OBSERVED: "VERIFY_FAIL", PASS_ONLY_OBSERVED: "VERIFY_PASS", NO_DECISIVE_OBSERVED: None, UNKNOWN_HISTORY: None} ACTIONS_KIND = "github_actions_workflow_run" +ATTEMPT_STATES = {"VERIFY_PASS", "VERIFY_FAIL", "VERIFY_UNRESOLVED", "NON_VERIFY_TERMINAL", "NOT_EXECUTED", "UNKNOWN"} # Where the carried history came from. SOURCE_CARRIED = "CARRIED" @@ -102,6 +103,8 @@ def _attempts(raw: Any, where: str) -> list[dict[str, Any]]: for attempt in raw: if not isinstance(attempt, dict) or not isinstance(attempt.get("state"), str): raise HistoryShapeError(f"{where}: an attempt carries no state") + if attempt["state"] not in ATTEMPT_STATES: + raise HistoryShapeError(f"{where}: unknown attempt state {attempt['state']!r}") number = attempt.get("attempt") if number is not None and (isinstance(number, bool) or not isinstance(number, int) or number < 1): raise HistoryShapeError(f"{where}: attempt number {number!r} is not a positive integer") @@ -183,10 +186,15 @@ def merge_parents(*groups: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], def parent_complete(parent: dict[str, Any]) -> bool: """Every attempt of an Actions run, 1..latest, is in the union. A check suite never is.""" latest = parent.get("latest_attempt") - if parent.get("kind") != ACTIONS_KIND or not isinstance(latest, int) or latest < 1: + if parent.get("kind") != ACTIONS_KIND or type(latest) is not int or latest < 1: return False - numbers = {a["attempt"] for a in parent["attempts"] if isinstance(a.get("attempt"), int)} - return set(range(1, latest + 1)) <= numbers + attempts = parent["attempts"] + if any(type(a.get("attempt")) is not int or not 1 <= a["attempt"] <= latest + or a.get("state") not in ATTEMPT_STATES - {"UNKNOWN"} for a in attempts): + return False + # Bound work by observed evidence, never by an untrusted declared maximum. + # Several successive states of one attempt still represent one identity. + return len({a["attempt"] for a in attempts}) == latest def union_state(parents: list[dict[str, Any]], unresolved: list[str]) -> tuple[str, bool]: @@ -299,11 +307,40 @@ def output(self) -> dict[str, Any]: return {"lineage": LINEAGE, "source": dict(self.source), "records": [encode(record) for record in ordered]} -def _series_records(series: Observation | None) -> tuple[list[dict[str, Any]] | None, bool]: +def _series_records(series: Observation | None) -> tuple[list[dict[str, Any]] | None, bool, list[str]]: """The current revision records and whether they are the whole 14-day inventory.""" if series is None or series.status not in (AVAILABLE, PARTIAL) or series.freshness != FRESH or not isinstance(series.value, list): - return None, False - return [r for r in series.value if isinstance(r, dict) and isinstance(r.get("revision"), str)], series.status == AVAILABLE + return None, False, [] + records, problems, seen, duplicates = [], [], set(), set() + for position, record in enumerate(series.value): + ref = record.get("revision") if isinstance(record, dict) else None + if isinstance(ref, str) and ref: + if ref in seen: + duplicates.add(ref) + seen.add(ref) + try: + if not isinstance(record, dict) or not isinstance(ref, str) or not ref: + raise HistoryShapeError("no immutable revision") + if not isinstance(record.get("committed_at"), str): + raise HistoryShapeError("no committed_at") + timeutil.parse_ts(record["committed_at"]) + if not isinstance(record.get("parents"), list): + raise HistoryShapeError("parents is not a list") + for parent in record["parents"]: + parent_from_current(parent, f"revision {ref}") + if (record.get("history_state") not in CONTRIBUTION + or record.get("historical_contribution") != CONTRIBUTION[record["history_state"]] + or not isinstance(record.get("current_verdict"), (str, type(None)))): + raise HistoryShapeError("inconsistent history contribution or current verdict") + except (HistoryShapeError, ValueError, TypeError) as exc: + problems.append(f"REVISION_RECORD_INCONSISTENT:{ref if isinstance(ref, str) else position}:{exc}") + continue + records.append(record) + problems.extend(f"REVISION_RECORD_DUPLICATE:{ref}" for ref in sorted(duplicates)) + if series.status == AVAILABLE and isinstance(series.coverage, dict) and any(series.coverage.get(key) is False + for key in ("complete", "runs_complete", "attempts_complete", "suites_complete")): + problems.append("REVISION_SERIES_COVERAGE_INCOMPLETE") + return [r for r in records if r["revision"] not in duplicates], series.status == AVAILABLE and not problems, problems def reconcile(obs: ObservationSet) -> Reconciled: @@ -332,7 +369,8 @@ def reconcile(obs: ObservationSet) -> Reconciled: source = {"status": SOURCE_MALFORMED} problems.append(f"REVISION_HISTORY_CARRY_MALFORMED:{exc}") - current, inventory_complete = _series_records(obs.get(REVISIONS)) + current, inventory_complete, current_problems = _series_records(obs.get(REVISIONS)) + problems.extend(current_problems) current_by_rev = {r["revision"]: r for r in current or []} records: dict[str, dict[str, Any]] = {} @@ -345,15 +383,17 @@ def reconcile(obs: ObservationSet) -> Reconciled: for revision, record in current_by_rev.items(): committed_at = record.get("committed_at") - if not isinstance(committed_at, str): - problems.append(f"REVISION_RECORD_INCONSISTENT:{revision}") - continue + prior = records.get(revision) + if prior: + if timeutil.parse_ts(committed_at) != timeutil.parse_ts(prior["committed_at"]): + problems.append(f"IMMUTABLE_COMMIT_TIME_MISMATCH:{revision}") + continue + committed_at = prior["committed_at"] try: visible = [parent_from_current(p, f"revision {revision}") for p in record.get("parents") or []] except HistoryShapeError as exc: problems.append(f"REVISION_RECORD_INCONSISTENT:{revision}:{exc}") continue - prior = records.get(revision) prior_parents = prior["parents"] if prior else [] unresolved = list(prior.get("unresolved") or []) if prior else [] if unresolved and visible and all(parent_complete(p) for p in visible): diff --git a/src/devostasis/vitals/integrity.py b/src/devostasis/vitals/integrity.py index 1430dc2..75c8c7b 100644 --- a/src/devostasis/vitals/integrity.py +++ b/src/devostasis/vitals/integrity.py @@ -194,6 +194,25 @@ def _bearing(history: Reconciled, revisions: list[dict[str, Any]]) -> list[dict[ return [r for r in records if r["parents"] or r.get("unresolved")] +def _unknown_current(revisions: list[dict[str, Any]], diagnostics: list[str], history: Reconciled) -> dict[str, Any]: + """Retain the accepted current-verdict diagnostic beside an independent admission/history gap.""" + active = [r for r in revisions if _record_consistent(r) and r.get("parents") + and isinstance(r.get("committed_at"), str)] + if not active: + return {} + latest = _newest(active) + current = latest.get("current_verdict") or VERDICT_UNKNOWN + if current not in VERDICTS: + diagnostics.append(f"CURRENT_VERDICT_UNRECOGNIZED:{current}") + current = VERDICT_UNKNOWN + if current != VERDICT_UNKNOWN: + return {} + diagnostics.append(f"CURRENT_VERDICT_UNKNOWN:{latest['revision']}") + return {"unknown_verdict_rule": UNKNOWN_RULE, "current_revision": { + "revision": latest["revision"], "committed_at": latest["committed_at"], + "current_verdict": current, "history_state": history.records.get(latest["revision"], {}).get("history_state", latest["history_state"])}} + + def evaluate(obs: ObservationSet) -> VitalResult: diagnostics: list[str] = [] reconciled = reconcile(obs) @@ -230,7 +249,7 @@ def evaluate(obs: ObservationSet) -> VitalResult: # shape its lineage declares, is a coverage defect, not a pass. diagnostics.extend(f"REVISION_RECORD_INCONSISTENT:{_record_label(r)}" for r in inconsistent) diagnostics.extend(reconciled.problems) - return _unknown(obs, history, diagnostics) + return _unknown(obs, {**history, **_unknown_current(revisions, diagnostics, reconciled)}, diagnostics) active = [r for r in revisions if r.get("parents")] provenance: dict[str, int] = {} for r in active: @@ -276,8 +295,16 @@ def evaluate(obs: ObservationSet) -> VitalResult: # R52 and PV-HIST-002: history nobody can prove is neither a pass nor a # zero, and T9 puts it above every conservative branch. diagnostics.extend(f"REVISION_HISTORY_UNKNOWN:{r['revision']}" for r in unknown_history) + current_gap = _unknown_current(revisions, diagnostics, reconciled) + if current_gap: + current_gap["failure_ratio_14d"] = ratio(f, n) if n else None + strength = sample_strength(n) + if strength is not None: + current_gap["sample_strength"] = strength + if strength == SAMPLE_SPARSE: + diagnostics.append(SPARSE_DIAGNOSTIC) return _result( - obs, None, EVAL_UNKNOWN, None, None, {**counts, **history}, diagnostics, + obs, None, EVAL_UNKNOWN, None, None, {**counts, **history, **current_gap}, diagnostics, f"The verification history of {len(unknown_history)} recent revisions cannot be proven complete; {f} of {n} decisive revisions failed, and no band is claimed around the unknown ones.", ) diff --git a/tests/test_ci_history_admission.py b/tests/test_ci_history_admission.py new file mode 100644 index 0000000..6f8e8fb --- /dev/null +++ b/tests/test_ci_history_admission.py @@ -0,0 +1,129 @@ +"""Release-review regressions at real observation, evaluator and bundle boundaries.""" + +from copy import deepcopy +import json + +import pytest + +from devostasis.bundle import verify_dir +from devostasis.config import single_project +from devostasis.history import FilesystemHistoryStore +from devostasis.observations import ObservationSet +from devostasis.revision_history import ( + CARRIED, LINEAGE, REPLAYABLE_LINEAGE, parent_from_current, union_state, +) +from devostasis.runner import build_from_observations +from devostasis.vitals import integrity +from helpers import add +from test_revision_history import ( + LOCATOR, _build, _integrity, _obs, _passing, _record, _run, _states, +) + + +@pytest.mark.parametrize("conflicting,reverse", [(False, False), (True, False), (True, True)]) +def test_duplicate_current_revisions_never_improve_sample_or_overwrite_failure(tmp_path, conflicting, reverse): + rows = _passing([1, 2, 3]) + duplicate = deepcopy(rows[0]) if not conflicting else _record("p1", 1, [_run(801, [(1, "VERIFY_FAIL")])]) + rows.append(duplicate) + if reverse: + rows.reverse() + obs = ObservationSet.from_dict(_obs("2026-09-08T12:00:00Z", rows).to_dict()) + original = deepcopy(obs.to_dict()) + store = FilesystemHistoryStore(tmp_path) + project = single_project(LOCATOR, config_version="1") + bundle = build_from_observations(project, obs, store) + result = _integrity(bundle) + assert (result["band"], result["evaluation_status"]) == (None, "UNKNOWN") + assert "REVISION_RECORD_DUPLICATE:p1" in result["diagnostics"] + assert obs.to_dict() == original + assert verify_dir(store.commit(bundle)) == [] + saved = ObservationSet.from_dict(json.loads(bundle.members["observations.json"])) + assert build_from_observations(project, saved, FilesystemHistoryStore(tmp_path / "replay")).members == bundle.members + + +@pytest.mark.parametrize("bad", [None, [], {}, {"revision": "r"}, {"revision": "", "committed_at": "2026-09-04T10:00:00Z"}, + {"revision": 7, "committed_at": "2026-09-04T10:00:00Z"}, {"revision": "r", "committed_at": None}, + {"revision": "r", "committed_at": "invalid"}]) +def test_malformed_current_inventory_cannot_prove_carried_failure_left_branch(tmp_path, bad): + store = FilesystemHistoryStore(tmp_path) + _build(store, "2026-09-06T12:00:00Z", [_record("r", 4, [_run(41, [(1, "VERIFY_FAIL")])])]) + bundle, path = _build(store, "2026-09-07T12:00:00Z", _passing([1, 2, 3, 5]) + [bad]) + assert _integrity(bundle)["evaluation_status"] == "UNKNOWN" + assert _states(bundle)["r"] == "FAILURE_OBSERVED" + assert verify_dir(path) == [] + + +@pytest.mark.parametrize("bad_time", ["invalid", "2026-08-01T10:00:00Z", "2026-10-01T10:00:00Z"]) +def test_four_bundles_preserve_failure_after_rejected_immutable_timestamp(tmp_path, bad_time): + store = FilesystemHistoryStore(tmp_path) + passing = _passing([1, 2, 3, 5]) + failed = _record("r", 4, [_run(41, [(1, "VERIFY_FAIL")])]) + _build(store, "2026-09-06T12:00:00Z", passing + [failed]) + corrupted = _record("r", 4, [_run(41, [(2, "VERIFY_PASS")], latest=2)]) + corrupted["committed_at"] = bad_time + second, second_path = _build(store, "2026-09-07T12:00:00Z", passing + [corrupted]) + assert _states(second)["r"] == "FAILURE_OBSERVED" + assert _integrity(second)["evaluation_status"] == "UNKNOWN" + assert verify_dir(second_path) == [] + for day in (8, 9): + bundle, path = _build(store, f"2026-09-{day:02d}T12:00:00Z", passing + [_record("r", 4, [_run(41, [(2, "VERIFY_PASS")], latest=2)])]) + result = _integrity(bundle) + record = next(r for r in result["derived"]["revision_history"]["records"] if r["revision"] == "r") + assert record["committed_at"] == failed["committed_at"] + assert record["history_state"] == "FAILURE_OBSERVED" + assert result["derived"]["failed_count_14d"] == 1 and result["band"] == "FLAKY" + assert verify_dir(path) == [] + + +def test_equivalent_timestamp_and_proven_departure_remain_admissible(tmp_path): + store = FilesystemHistoryStore(tmp_path) + failed = _record("r", 4, [_run(41, [(1, "VERIFY_FAIL")])]) + _build(store, "2026-09-06T12:00:00Z", [failed]) + equivalent = deepcopy(failed); equivalent["committed_at"] = "2026-09-04T12:00:00+02:00" + bundle, _ = _build(store, "2026-09-07T12:00:00Z", [equivalent]) + assert _states(bundle)["r"] == "FAILURE_OBSERVED" + assert not any("IMMUTABLE_COMMIT_TIME_MISMATCH" in d for d in _integrity(bundle)["diagnostics"]) + partial, _ = _build(store, "2026-09-08T12:00:00Z", [], series_status="PARTIAL") + assert _states(partial)["r"] == "FAILURE_OBSERVED" + departed, _ = _build(store, "2026-09-09T12:00:00Z", []) + assert "r" not in _states(departed) + + +@pytest.mark.parametrize("attempts,latest", [([(1, "VERIFY_PASS"), (3, "VERIFY_PASS")], 3), + ([(1, "VERIFY_PASS"), (2, "VERIFY_PASS"), (3, "VERIFY_PASS")], 2), + ([(1, "VERIFY_PASS")], 10**12), ([(1, "UNKNOWN"), (2, "VERIFY_PASS")], 2)]) +def test_incomplete_or_unknown_attempts_never_prove_pass_only(attempts, latest): + parent = parent_from_current(_run(41, attempts, latest=latest), "test") + assert union_state([parent], []) == ("UNKNOWN_HISTORY", False) + + +@pytest.mark.parametrize("lineage", [LINEAGE, REPLAYABLE_LINEAGE]) +def test_unknown_attempts_stay_unknown_in_carried_lineages(lineage): + from devostasis.revision_history import encode, union_record + raw = _record("r", 4, [_run(41, [(1, "UNKNOWN"), (2, "VERIFY_PASS")])]) + records = [raw] if lineage == REPLAYABLE_LINEAGE else [encode(union_record("r", raw["committed_at"], [parent_from_current(raw["parents"][0], "test")], []))] + obs = _obs("2026-09-08T12:00:00Z", [_record("r", 4, [])]) + add(obs, CARRIED, {"lineage": lineage, "records": records}, "record") + assert integrity.evaluate(obs).band is None + assert integrity.evaluate(obs).derived["revision_history"]["records"][0]["history_state"] == "UNKNOWN_HISTORY" + + +def test_unknown_vocabulary_is_rejected_but_legitimate_transitions_and_failures_survive(): + from devostasis.revision_history import HistoryShapeError, merge_parents + with pytest.raises(HistoryShapeError): + parent_from_current(_run(41, [(1, "NOT_A_CI_STATE")]), "test") + pending = parent_from_current(_run(41, [(1, "VERIFY_UNRESOLVED")]), "test") + passed = parent_from_current(_run(41, [(1, "VERIFY_PASS")]), "test") + for first, second in ((pending, passed), (passed, pending)): + merged, conflicts = merge_parents([first], [second]) + assert union_state(merged, conflicts) == ("PASS_ONLY_OBSERVED", True) + failed = parent_from_current(_run(41, [(1, "VERIFY_FAIL"), (3, "UNKNOWN")], latest=3), "test") + assert union_state([failed], []) == ("FAILURE_OBSERVED", False) + + +def test_current_revision_metadata_matches_its_reconciled_unknown_history(): + from helpers import parent, revision + raw = revision("new", "2026-09-06T10:00:00Z", [parent("p-new", "UNKNOWN")], "UNKNOWN", "NO_DECISIVE_OBSERVED") + result = integrity.evaluate(_obs("2026-09-08T12:00:00Z", _passing([1, 2, 3]) + [raw])) + assert result.derived["current_revision"]["history_state"] == "UNKNOWN_HISTORY" + assert result.derived["unknown_history_count_14d"] == 1 diff --git a/tests/test_source_admission.py b/tests/test_source_admission.py new file mode 100644 index 0000000..21d8e9e --- /dev/null +++ b/tests/test_source_admission.py @@ -0,0 +1,61 @@ +"""Independent source-selection predicates and explicit omitted-size proof limits.""" + +from copy import deepcopy +import json + +import pytest + +from devostasis.cli import main +from devostasis.codeanalysis.bundle import build, proof_scope, publish, verify_content +from devostasis.codeanalysis.engine import default_policy +from devostasis.codeanalysis.git import collect +from test_code_analysis import AT, repository + + +@pytest.mark.parametrize("reason", ["UNSAFE_PATH", "SOURCE_LINK_OR_SUBMODULE", "FILE_BYTE_CAP", "FILE_COUNT_CAP", "TOTAL_BYTE_CAP"]) +def test_rehashed_source_omission_requires_its_actual_predicate(tmp_path, reason): + config = default_policy() + raw = collect(repository(tmp_path, {"safe.py": "pass\n"}), "HEAD", config, AT) + raw["entries"][0].update(status="UNAVAILABLE", reason=reason) + raw["sources"].clear() + with pytest.raises(ValueError, match="unjustified source selection"): + build(raw, config) + + +def test_valid_caps_and_policy_exclusions_still_replay(tmp_path): + repo = repository(tmp_path, {"a.py": "pass\n", "b.py": "pass\n", "vendor/c.py": "pass\n", "README.md": "fixture"}) + for budget, value in (("max_files", 1), ("max_file_bytes", 1), ("max_total_bytes", 6)): + config = default_policy(); config["budget"][budget] = value + if budget == "max_total_bytes": config["budget"]["max_file_bytes"] = value + raw = collect(repo, "HEAD", config, AT) + _, members = build(raw, config) + decoded = verify_content(members) + assert decoded["analysis.json"]["coverage"] == "PARTIAL" + assert proof_scope(decoded)["omitted_blob_sizes"] == "UNPROVEN" + assert any(e["status"] == "OUT_OF_SCOPE" for e in raw["entries"]) + + +def test_omitted_size_claim_does_not_become_independent_blob_proof(tmp_path, capsys): + config = default_policy() + raw = collect(repository(tmp_path, {"safe.py": "pass\n"}), "HEAD", config, AT) + # The tree authenticates the blob id, not this replacement size metadata. + changed = deepcopy(raw) + changed["entries"][0].update(status="UNAVAILABLE", reason="FILE_BYTE_CAP", size=2_000_000) + changed["sources"].clear() + _, members = build(changed, config) + decoded = verify_content(members) + assert decoded["analysis.json"]["coverage"] == "PARTIAL" + assert proof_scope(decoded)["omitted_blob_sizes"] == "UNPROVEN" + root = publish(tmp_path / "bundle", members) + assert main(["code", "verify", "--bundle", str(root)]) == 0 + receipt = json.loads(capsys.readouterr().out) + assert receipt["proof_scope"]["omitted_blob_sizes"] == "UNPROVEN" + assert receipt["proof_scope"]["history_ancestry"] == "UNPROVEN" + + +def test_admitted_size_is_independently_bound_to_original_bytes(tmp_path): + config = default_policy() + raw = collect(repository(tmp_path, {"safe.py": "pass\n"}), "HEAD", config, AT) + raw["entries"][0]["size"] += 1 + with pytest.raises(ValueError, match="source blob binding mismatch"): + build(raw, config)