diff --git a/.kiro/specs/pre-push-safety-net/tasks.md b/.kiro/specs/pre-push-safety-net/tasks.md index f8657bd..04c3c58 100644 --- a/.kiro/specs/pre-push-safety-net/tasks.md +++ b/.kiro/specs/pre-push-safety-net/tasks.md @@ -636,10 +636,11 @@ Preserve all completed task evidence below. - Evidence: the three repositories' terminal output names engine-missing, Go not covered and the new branch. - _Done 2026-10-02:_ new `push/explain.py` turns every raw coverage reason into one plain `Skipped:` line per distinct kind (hex paths decoded, sha256 tokens dropped and counted, unknown reasons still printed); the existing one-line `Notice:` keeps its next action and gains an engine-missing variant. `language_not_covered:::` is recorded for a changed language with no engine frontend and no quick rules; `ousast scan` prints `notice: language not covered: go (N files)` and its report says so. `reports.py` renders the eight previously dropped degradations and prints any reason it has no sentence for. The hook passes `OUSAST_COMPARISON_BASE`. The new-branch line names the base it lacks; what was checked instead lands with the quick tier (17.3). Tests: `tests/test_push_explain.py` (6), two in `tests/test_reports.py`; module-audit manifest regenerated for the new module. Gates: 1995 passed, 42 skipped; ruff, format, mypy clean. - _Requirements: 9.2, 5.2, 8.2_ -- [ ] 17.3 Run the quick rules as the fast first tier +- [x] 17.3 Run the quick rules as the fast first tier - Inside the deadline and before the engine, the shipped quick rules run over the head side of the changed files and keep the findings on changed lines or in changed functions. They print as advisory pattern matches, labeled apart from admitted alerts, recorded in the artifact's `quick_tier`, and never block. - Files: `src/openultrasast/push/quick.py` (new), `src/openultrasast/push/runner.py`, `src/openultrasast/push/report.py`. Tests: `tests/test_push_quick.py` (new). - Evidence: serve-static's planted `eval` in the new `debug.js` is shown; whether records' planted `%`-formatted SQL is shown is recorded as is, with no rule edits for it. + - _Done 2026-10-02:_ new `push/quick.py` runs the shipped quick rules (no shadow rules, no model) over the changed head files inside `_prepare` under the shared deadline, before the engine, keeping matches on a changed line or inside a changed function (entry-point ranges); it records `files`/`bytes_read` as proof of input. The terminal shows them under "Quick rules: N pattern match(es) on changed code ... Advisory: not verified by the engine."; the artifact keeps `quick_tier`; admission and push disposition are untouched. A new branch runs the quick tier over its last commit's changes and says so. `_analyze` gained `engine="off"`. On the audit's serve-static planted commit the `eval` in the new `debug.js` is now shown (js-eval, js-xss-response at debug.js:2, ~1.1 s on the host); the planted `%`-formatted SQL in records is NOT matched by any quick rule (coverage gap, no rule edited). Tests: `tests/test_push_quick.py` (3). Gates: 1998 passed, 42 skipped; ruff, format, mypy clean. - _Requirements: 9.3_ - [ ] 17.4 Classify a file new in head as new, not unknown - `ChangeContext` records the paths Git reports as added. An operation in an added file, when no add/delete pairing is unresolved and its head evidence is complete, is `new` with reason `file_added_in_head`, and admission accepts that reason as change evidence. An engine finding with established novelty whose only vetoes are capability vetoes renders as an advisory engine finding, not an alert. Every other unknown comparison keeps its veto. diff --git a/benchmarks/measurements/2026-09-08-module-audit.json b/benchmarks/measurements/2026-09-08-module-audit.json index a10dc62..0eb8b32 100644 --- a/benchmarks/measurements/2026-09-08-module-audit.json +++ b/benchmarks/measurements/2026-09-08-module-audit.json @@ -1,7 +1,7 @@ { - "modules": 152, + "modules": 153, "counts": { - "load_bearing": 147, + "load_bearing": 148, "standalone_capability": 5 }, "orphaned": [], @@ -229,6 +229,7 @@ "model.report", "pairs", "plane.tasks.alerts", + "push.quick", "push.runner", "rank", "regress.candidate", @@ -561,6 +562,7 @@ "pairs", "plane.tasks.alerts", "preprocess", + "push.quick", "push.runner" ] }, @@ -604,6 +606,7 @@ "push.cache", "push.contracts", "push.policy", + "push.quick", "push.report", "push.reuse", "push.runner", @@ -651,6 +654,7 @@ "push.cache", "push.contracts", "push.policy", + "push.quick", "push.report", "push.reuse", "push.runner", @@ -1097,6 +1101,7 @@ "improve.evolve", "improve.validator", "plane.tasks.loop", + "push.quick", "scoring.project_score", "semantic.overlay" ] @@ -1136,6 +1141,7 @@ "plane.tasks.alerts", "plane.tasks.features", "plane.tasks.repo_facts", + "push.quick", "push.runner", "rank", "regress.candidate", @@ -1258,6 +1264,14 @@ "push.vetoes" ] }, + { + "module": "push.quick", + "classification": "load_bearing", + "reason": "reachable from gate, map_gate, pair_gate, cli", + "importers": [ + "push.runner" + ] + }, { "module": "push.report", "classification": "load_bearing", @@ -1328,7 +1342,8 @@ "improve.evolve", "map_gate", "pairs", - "plane.tasks.alerts" + "plane.tasks.alerts", + "push.quick" ] }, { diff --git a/src/openultrasast/push/explain.py b/src/openultrasast/push/explain.py index 1881da6..39a39c2 100644 --- a/src/openultrasast/push/explain.py +++ b/src/openultrasast/push/explain.py @@ -57,6 +57,7 @@ GLOSSARY: dict[str, str] = { "query_too_expensive": "some engine questions were too expensive to answer", "regions_truncated": "only the highest-ranked regions were examined (--max-regions)", "files_unparsed": "some files could not be parsed by the engine", + "engine_off": "engine checks were switched off for this run (--engine off)", "cross_partition_semantics_unresolved": "flows between languages in this repository are not followed", "resolution": "the pushed ref could not be resolved ({detail})", "push_input_failed": "the push input could not be read ({detail})", diff --git a/src/openultrasast/push/quick.py b/src/openultrasast/push/quick.py new file mode 100644 index 0000000..92b3801 --- /dev/null +++ b/src/openultrasast/push/quick.py @@ -0,0 +1,130 @@ +"""The quick-rule tier of pre-push: the fast first look (task 17.3, Requirement 9.3). + +The shipped pattern rules run over the head side of the files a push changes, before the engine and +inside the same deadline, and keep the matches that fall on a changed line or inside a changed +function. They are pattern matches: shown as advisory, labeled apart from admitted alerts, never +admitted and never blocking. Requirement 3.2 still governs every engine candidate. +""" + +from __future__ import annotations + +import time +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from openultrasast.model.contracts import ChangeContext + +Basis = Literal["comparison", "last_commit"] + + +@dataclass(frozen=True) +class QuickTierResult: + status: Literal["completed", "not_run", "failed"] + basis: Basis + files: tuple[str, ...] = () + bytes_read: int = 0 + findings: tuple[Mapping[str, object], ...] = () + reason: str | None = None + seconds: float = 0.0 + basis_commit: str | None = None + + def to_payload(self) -> dict[str, object]: + return { + "label": "quick rules: pattern matches on changed lines; advisory, not admitted, never blocking", + "status": self.status, + "basis": self.basis, + "basis_commit": self.basis_commit, + "files": list(self.files), + "bytes_read": self.bytes_read, + "findings": [dict(item) for item in self.findings], + "reason": self.reason, + "seconds": self.seconds, + } + + +def head_spans(context: ChangeContext) -> dict[str, list[tuple[int, int]]]: + """Changed head lines per decoded path, for every changed file that still exists in head.""" + deleted = set(context.deleted_paths) + spans: dict[str, list[tuple[int, int]]] = {} + for path in context.changed_paths: + if path not in deleted: + spans.setdefault(context.decode_path(path), []) + for span in context.spans: + if span.side == "head": + spans.setdefault(context.decode_path(span.path), []).append((span.start_line, span.end_line)) + return spans + + +def run_quick_tier( + root: Path, spans: Mapping[str, Sequence[tuple[int, int]]], *, basis: Basis = "comparison", basis_commit: str | None = None +) -> QuickTierResult: + """Pattern rules over the changed head files of a materialized snapshot at `root`. + + Pure and bounded by its caller: it reads only the named files, runs no project code and calls no + model. `files`/`bytes_read` prove what it read, so a zero is never an unread input. + """ + from openultrasast.findings import PATTERN_RULES, quick_scan_findings + from openultrasast.mapping import analyze_entry_points, attach_reachability_hints + from openultrasast.policy import load_policy + from openultrasast.preprocess import build_file_target, detect_language + from openultrasast.rank import rank_targets + + started = time.monotonic() + resolved = root.resolve() + paths = [] + read = 0 + for name in sorted(spans): + path = resolved / name + if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(resolved): + continue + if detect_language(path) == "unknown": + continue + paths.append(path) + read += path.stat().st_size + targets = [build_file_target(resolved, path) for path in paths] + targets = attach_reachability_hints(targets, analyze_entry_points(resolved, targets)) + rules = {rule.rule_id: rule for rule in PATTERN_RULES} + kept: list[Mapping[str, object]] = [] + for finding in quick_scan_findings(resolved, targets, rank_targets(targets), PATTERN_RULES, load_policy()): + if finding.status == "shadow" or finding.line is None: + continue + changed = spans.get(finding.path, ()) + where = _changed_scope(finding.line, changed, finding.reachability_evidence) + if where is None: + continue + rule_id = finding.finding_id.split(":", 1)[0] + rule = rules.get(rule_id) + kept.append( + { + "rule": rule_id, + "title": finding.title, + "cwe": rule.cwe if rule is not None else None, + "severity": finding.severity, + "path": finding.path, + "line": finding.line, + "function": finding.function_name, + "scope": where, + } + ) + return QuickTierResult( + "completed", + basis, + tuple(target.path for target in targets), + read, + tuple(kept), + seconds=time.monotonic() - started, + basis_commit=basis_commit, + ) + + +def _changed_scope(line: int, changed: Sequence[tuple[int, int]], hints: Sequence[Mapping[str, object]]) -> str | None: + if any(start <= line <= end for start, end in changed): + return "changed_line" + for hint in hints: + start, end = hint.get("line"), hint.get("end_line") + inside = isinstance(start, int) and isinstance(end, int) and start <= line <= end + if inside and any(a <= end and start <= b for a, b in changed): # type: ignore[operator] + return "changed_function" + return None diff --git a/src/openultrasast/push/report.py b/src/openultrasast/push/report.py index 0f26477..b7bee46 100644 --- a/src/openultrasast/push/report.py +++ b/src/openultrasast/push/report.py @@ -45,6 +45,8 @@ class PushReport: # Labeled M1a recorded-veto evaluation. Present only under the explicit replay flag; # it is retained beside the decision and never feeds admission or enforcement. experimental: Mapping[str, object] = field(default_factory=dict) + # The quick-rule tier (task 17.3): one payload per comparison it ran for; advisory only. + quick: tuple[Mapping[str, object], ...] = () def __post_init__(self) -> None: object.__setattr__(self, "model_assistance", MappingProxyType(dict(self.model_assistance))) @@ -134,6 +136,7 @@ def _write_artifact(report: PushReport, target: Path, temporary: Path, connectio "timing_scope": "Caller stage measurements; publication and rendering duration is returned in ReportDelivery.", "resolution": report.resolution.to_payload() if report.resolution else None, "experimental": dict(report.experimental), + "quick_tier": [dict(item) for item in report.quick], } json.dump(payload, stream, ensure_ascii=True, allow_nan=False, indent=2) stream.write("\n") @@ -216,6 +219,37 @@ def _experimental_line(experimental: Mapping[str, object]) -> str: ) +# Quick-rule matches shown per comparison; the artifact keeps every one. +_QUICK_LINES = 5 + + +def _quick_lines(payload: Mapping[str, object]) -> list[str]: + """The quick tier in the terminal: advisory pattern matches, never presented as alerts.""" + files = payload.get("files") + count = len(files) if isinstance(files, list) else 0 + read = f"{count} file(s), {payload.get('bytes_read', 0)} bytes read" + lines: list[str] = [] + if payload.get("basis") == "last_commit": + commit = str(payload.get("basis_commit") or "")[:7] + lines.append(f"New branch: instead, quick rules checked the files its last commit changed (compared with {commit}).") + if payload.get("status") != "completed": + return [*lines, f"Quick rules did not run ({_short(str(payload.get('reason') or 'unknown'))})."] + findings = payload.get("findings") + found = [item for item in findings if isinstance(item, Mapping)] if isinstance(findings, list) else [] + if not found: + return [*lines, f"Quick rules: no pattern match on changed code ({read})."] + lines.append(f"Quick rules: {len(found)} pattern match(es) on changed code ({read}). Advisory: not verified by the engine.") + for item in found[:_QUICK_LINES]: + cwe = f"{item.get('cwe')}, " if item.get("cwe") else "" + lines.append( + f"- {_short(str(item.get('rule')))} ({cwe}{_short(str(item.get('severity')))}) at " + f"{_short(str(item.get('path')), 4096)}:{item.get('line')}: {_short(str(item.get('title')))}" + ) + if len(found) > _QUICK_LINES: + lines.append(f"{len(found) - _QUICK_LINES} more quick-rule matches retained in the detailed result.") + return lines + + def render_report(report: PushReport, *, artifact: Path | None, error: str | None = None) -> str: lines: list[str] = [] defects = report.admission.defects @@ -251,6 +285,8 @@ def render_report(report: PushReport, *, artifact: Path | None, error: str | Non ) if len(defects) > 3: lines.append(f"{len(defects) - 3} more actionable defects retained in the detailed result.") + for payload in report.quick: + lines.extend(_quick_lines(payload)) if not defects: coverage = report.result.coverage_status if coverage == "complete_within_scope": diff --git a/src/openultrasast/push/runner.py b/src/openultrasast/push/runner.py index 4c632fc..de65b12 100644 --- a/src/openultrasast/push/runner.py +++ b/src/openultrasast/push/runner.py @@ -42,6 +42,7 @@ from openultrasast.push.policy import ( dependency_gaps, semantics_digest, ) +from openultrasast.push.quick import Basis, QuickTierResult, head_spans, run_quick_tier from openultrasast.push.report import PushReport, ReportDelivery, ScanRecord, _completed_scan, deliver_report, render_report from openultrasast.push.reuse import ReusingBackend, declaration_identity, discovery from openultrasast.push.snapshot import SnapshotAdapter @@ -267,6 +268,8 @@ def _analyze( resolved: PushComparison | None = None, record_vetoes: bool = False, declarations: Path | None = None, + engine: Literal["inline", "off"] = "inline", + quick_basis: Basis = "comparison", ) -> PushReport: """Run a single explicit comparison; exit policy never stands for coverage. @@ -295,6 +298,7 @@ def _analyze( comparison = resolved context = None experimental: dict[str, object] = {"label": LABEL, "flag": FLAG, "status": "not_evaluated"} if record_vetoes else {} + quick: QuickTierResult | None = None timings: dict[str, float] = { "deadline_seconds": config.deadline_seconds, "cancellation_allowance_seconds": config.cancellation_allowance_seconds, @@ -375,68 +379,82 @@ def _analyze( reasons.extend(context.unresolved_boundaries) reasons.extend(_uncovered_languages(tip.manifest, context)) stage_started = _stage(timings, stage, stage_started) - stage = "head" - head_scan = scan_repository( - tip.root, - head_regions, - backend=head_backend, - budget=scan_budget, - execution_budget=budget, - ranking_mode="evidence", - unit="repository", - population_complete=all(m.complete for m in manifests), - change_context=context, - ) - records.append(ScanRecord(comparison, "head", head_scan)) + stage = "quick" + # The fast first tier: pattern rules over the changed head lines, before the engine + # and inside the same deadline (Requirement 9.3). Advisory, never admitted. + try: + spans = head_spans(context) + basis_commit = comparison.base_oid if quick_basis == "last_commit" else None + quick = _prepare(lambda: run_quick_tier(tip.root, spans, basis=quick_basis, basis_commit=basis_commit), budget) + except Exception as error: # noqa: BLE001 -- a failed quick tier is a stated gap, never the end of the check + quick = QuickTierResult("failed", quick_basis, reason=type(error).__name__) + reasons.append("quick_rules_failed:" + type(error).__name__) stage_started = _stage(timings, stage, stage_started) - stage = "base" - delta = compare_targeted_base( - old.root, - head=head_scan, - head_regions=head_regions, - base_regions=base_regions, - context=context, - backend=base_backend, - execution_budget=budget, - scan_budget=scan_budget, - head_semantics=provenance["semantics"], - base_semantics=provenance["semantics"], - cache=cache, - cache_semantics=cache_semantics, - ) - if delta.base_scan is not None: - records.append(ScanRecord(comparison, "base", delta.base_scan)) - reasons.extend(delta.coverage_reasons) - stage_started = _stage(timings, stage, stage_started) - stage = "admission" - candidates = tuple( - AdmissionCandidate( - c, - CapabilityKey( - c.head_operation.question.language if c.head_operation else "unknown", - "unspecified", - "unspecified", - c.family, - c.head_operation.mechanism if c.head_operation else "taint", - "unreviewed", - delta.semantics, - ), - comparison, - dependency_gaps(c, context=context, head=head_scan, base=delta.base_scan), + if engine == "off": + reasons.append("engine_off") + if engine == "inline": + stage = "head" + head_scan = scan_repository( + tip.root, + head_regions, + backend=head_backend, + budget=scan_budget, + execution_budget=budget, + ranking_mode="evidence", + unit="repository", + population_complete=all(m.complete for m in manifests), + change_context=context, ) - for c in delta.candidates - ) - admission = _prepare(lambda: _admit(candidates, provenance), budget) - stage_started = _stage(timings, stage, stage_started) - if record_vetoes: - stage, stage_started = "recorded_vetoes", time.monotonic() - assert context is not None - recorded = _prepare( - lambda: _record(head_scan, delta, context, comparison, tuple(reasons), admission, provenance, declarations), - budget, - ) - experimental = {**recorded, "status": "evaluated"} + records.append(ScanRecord(comparison, "head", head_scan)) stage_started = _stage(timings, stage, stage_started) + stage = "base" + delta = compare_targeted_base( + old.root, + head=head_scan, + head_regions=head_regions, + base_regions=base_regions, + context=context, + backend=base_backend, + execution_budget=budget, + scan_budget=scan_budget, + head_semantics=provenance["semantics"], + base_semantics=provenance["semantics"], + cache=cache, + cache_semantics=cache_semantics, + ) + if delta.base_scan is not None: + records.append(ScanRecord(comparison, "base", delta.base_scan)) + reasons.extend(delta.coverage_reasons) + stage_started = _stage(timings, stage, stage_started) + stage = "admission" + candidates = tuple( + AdmissionCandidate( + c, + CapabilityKey( + c.head_operation.question.language if c.head_operation else "unknown", + "unspecified", + "unspecified", + c.family, + c.head_operation.mechanism if c.head_operation else "taint", + "unreviewed", + delta.semantics, + ), + comparison, + dependency_gaps(c, context=context, head=head_scan, base=delta.base_scan), + ) + for c in delta.candidates + ) + admission = _prepare(lambda: _admit(candidates, provenance), budget) + stage_started = _stage(timings, stage, stage_started) + if record_vetoes: + stage, stage_started = "recorded_vetoes", time.monotonic() + assert context is not None + recorded = _prepare( + lambda: _record(head_scan, delta, context, comparison, tuple(reasons), admission, provenance, declarations), + budget, + ) + experimental = {**recorded, "status": "evaluated"} + stage_started = _stage(timings, stage, stage_started) stage = "cleanup" timings[stage + "_seconds"] = time.monotonic() - stage_started except Exception as error: @@ -478,6 +496,30 @@ def _analyze( snapshots=tuple(manifests), change_context=context, experimental=experimental, + quick=(quick.to_payload(),) if quick is not None else (), + ) + + +def _last_commit_quick( + repository: Path, comparison: PushComparison, *, artifact: Path, config: PushConfig, budget: ExecutionBudget +) -> PushReport | None: + """Quick rules over the files the pushed head's last commit changed; None for a root commit.""" + try: + parent = SnapshotAdapter(repository, execution_budget=budget).resolve_replay(comparison.head_oid + "^", comparison.head_oid) + except Exception: # noqa: BLE001 -- a root commit has no parent to compare with + return None + resolved = PushComparison(comparison.head_oid, parent.base_oid, "new_branch_last_commit", comparison.refs) + assert resolved.base_oid is not None + return _analyze( + repository, + base=resolved.base_oid, + head=resolved.head_oid, + artifact=artifact, + config=config, + execution_budget=budget, + resolved=resolved, + engine="off", + quick_basis="last_commit", ) @@ -549,6 +591,7 @@ def push( budget = execution_budget or ExecutionBudget(started + config.deadline_seconds, config.cancellation_allowance_seconds) reports: list[PushReport] = [] analyses: list[ComparisonAnalysis] = [] + fallbacks: list[PushReport] = [] reasons: list[str] = [] resolution = None try: @@ -562,6 +605,12 @@ def push( if comparison.base_oid is None or time.monotonic() >= budget.deadline_monotonic: analyses.append(ComparisonAnalysis(comparison, (), (), "unavailable")) reasons.append("base_comparison_unavailable" if comparison.base_oid is None else "deadline_exhausted") + if comparison.base_oid is None: + # A new branch has no remote base. Say what was checked instead: the quick rules + # over the files its last commit changed (Requirement 9.2, 9.3). + fallback = _last_commit_quick(repository, comparison, artifact=artifact, config=config, budget=budget) + if fallback is not None: + fallbacks.append(fallback) continue report = _analyze( repository, @@ -624,6 +673,7 @@ def push( resolution=resolution, snapshots=tuple(s for r in reports for s in r.snapshots), change_contexts=tuple(r.change_context for r in reports if r.change_context is not None), + quick=tuple(q for r in (*reports, *fallbacks) for q in r.quick), ) from openultrasast.push.assistance import assist diff --git a/tests/test_push_quick.py b/tests/test_push_quick.py new file mode 100644 index 0000000..1a5b155 --- /dev/null +++ b/tests/test_push_quick.py @@ -0,0 +1,54 @@ +"""The quick-rule tier of pre-push (task 17.3, Requirement 9.3): fast, advisory, never an alert.""" + +from __future__ import annotations + +import json + +from test_push_runner import history + +from openultrasast.cpg.backend import NullBackend +from openultrasast.push.quick import run_quick_tier +from openultrasast.push.runner import push, replay + +HANDLER = "module.exports = function debug (req, res) {\n res.end(String(eval(req.query.code)))\n}\n" + + +def test_only_matches_on_changed_lines_are_kept_and_input_is_proven(tmp_path): + (tmp_path / "debug.js").write_text(HANDLER) + (tmp_path / "old.js").write_text("eval(x)\n") + hit = run_quick_tier(tmp_path, {"debug.js": [(2, 2)], "old.js": [(5, 5)]}) + assert hit.status == "completed" and hit.files == ("debug.js", "old.js") + assert hit.bytes_read == len(HANDLER) + len("eval(x)\n") + assert {(f["rule"], f["path"], f["line"], f["scope"]) for f in hit.findings} >= {("js-eval", "debug.js", 2, "changed_line")} + assert all(f["path"] == "debug.js" for f in hit.findings) + miss = run_quick_tier(tmp_path, {"debug.js": [(3, 3)]}) + assert miss.findings == () and miss.files == ("debug.js",) + + +def test_replay_shows_quick_matches_as_advisory_and_never_admits_or_blocks(tmp_path): + root, _, base, git = history(tmp_path) + (root / "debug.js").write_text(HANDLER) + git("add", "debug.js") + git("commit", "-m", "debug") + head = git("rev-parse", "HEAD") + artifact = tmp_path / "r.json" + delivery = replay(root, base=base, head=head, artifact=artifact, backend=NullBackend()) + assert delivery.exit_code == 0 and delivery.result.actionable_defect_ids == () + assert "Quick rules: " in delivery.text and "Advisory: not verified by the engine." in delivery.text + assert "- js-eval (CWE-95" in delivery.text and "debug.js:2" in delivery.text + data = json.loads(artifact.read_text()) + (tier,) = data["quick_tier"] + assert tier["status"] == "completed" and tier["basis"] == "comparison" and tier["files"] == ["debug.js"] + assert tier["bytes_read"] == len(HANDLER) and "advisory" in tier["label"] + assert data["admission"]["defects"] == [] + + +def test_new_branch_says_there_is_no_base_and_what_was_checked_instead(tmp_path): + root, _, head, git = history(tmp_path) + updates = f"refs/heads/feature {head} refs/heads/feature {'0' * 40}\n" + delivery = push(root, updates=updates, remote_name="origin", remote_url="/r", artifact=tmp_path / "p.json", backend=NullBackend()) + assert delivery.exit_code == 0 + assert "Skipped: new branch: the remote has no base to compare against" in delivery.text + assert "New branch: instead, quick rules checked the files its last commit changed" in delivery.text + (tier,) = json.loads((tmp_path / "p.json").read_text())["quick_tier"] + assert tier["basis"] == "last_commit" and tier["files"] == ["api.js"]