mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(pre-push-safety-net): retain bounded change and rename context
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Brief: pre-push-safety-net
|
# Brief: pre-push-safety-net
|
||||||
|
|
||||||
Status: requirements, design and tasks approved on 2026-09-12; group 1 and task 2.1 verified complete; group 2 implementation underway. Language: English.
|
Status: requirements, design and tasks approved on 2026-09-12; groups 1 and 2 verified complete (7/27 subtasks); groups 3–8 pending. Language: English.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
@@ -11,8 +11,8 @@ maintainer explicitly rejects noisy advisory output as well as noisy blocking ou
|
|||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
The scan driver and Joern arbiters exist, with demonstrated PHP/Python cases. Immutable push-tip
|
The scan driver and Joern arbiters exist, with demonstrated PHP/Python cases. Immutable push-tip
|
||||||
resolution is implemented and verified. Isolated source materialization and change context are
|
resolution, isolated source materialization and lexical change context are implemented and verified.
|
||||||
under implementation; persistent graph caching and an end-to-end deadline remain pending.
|
Ranker integration, persistent graph caching and an end-to-end deadline remain pending.
|
||||||
The driver builds a whole-root graph before cutting the region budget. The latest PMPro ranking
|
The driver builds a whole-root graph before cutting the region budget. The latest PMPro ranking
|
||||||
experiment alone spends about 130 seconds building and 296 seconds extracting evidence. A manually
|
experiment alone spends about 130 seconds building and 296 seconds extracting evidence. A manually
|
||||||
selected two-file slice finding a CVE does not demonstrate automatic incremental analysis.
|
selected two-file slice finding a CVE does not demonstrate automatic incremental analysis.
|
||||||
|
|||||||
@@ -192,6 +192,12 @@ automatic fetch inside the hook. Peel annotated tags to commits; other objects a
|
|||||||
Deletion updates require no target scan. Deduplicate identical target graphs, but retain separate
|
Deletion updates require no target scan. Deduplicate identical target graphs, but retain separate
|
||||||
comparisons when the same target has different bases. Multiple refs share one deadline.
|
comparisons when the same target has different bases. Multiple refs share one deadline.
|
||||||
|
|
||||||
|
The snapshot implementation uses `SnapshotAdapter.resolve_updates`, the owned `materialize`
|
||||||
|
context manager, and `compare`. Preparation has explicit file, source-byte, tree/diff-output and
|
||||||
|
line-mapping limits. Materialization verifies blob identity and records omitted symlinks, gitlinks
|
||||||
|
and LFS pointers. Its completeness flag describes source preparation, not security coverage.
|
||||||
|
The shipped implementation uses Linux directory-relative cleanup that does not follow links.
|
||||||
|
|
||||||
Materialize tracked blobs directly into isolated scratch storage; do not checkout over the user's
|
Materialize tracked blobs directly into isolated scratch storage; do not checkout over the user's
|
||||||
tree or run filters/build scripts. Use NUL-delimited tree/diff records and argument arrays. Include
|
tree or run filters/build scripts. Use NUL-delimited tree/diff records and argument arrays. Include
|
||||||
deletions as impact seeds and retain rename attribution. Symlinks are not followed outside the
|
deletions as impact seeds and retain rename attribution. Symlinks are not followed outside the
|
||||||
@@ -206,6 +212,17 @@ an alternative inclusion policy. The existing ranker produces `ScopeDecision`: s
|
|||||||
IDs, their priority/evidence, deferred question IDs with reasons, and unresolved context boundaries.
|
IDs, their priority/evidence, deferred question IDs with reasons, and unresolved context boundaries.
|
||||||
Identifiers include unit/language/path/function/family to avoid collisions across partitions.
|
Identifiers include unit/language/path/function/family to avoid collisions across partitions.
|
||||||
|
|
||||||
|
Adapter-produced contexts declare `path_encoding="filesystem-bytes-hex"` for structural paths
|
||||||
|
(changed/deleted/declaration paths, spans, renames and line correspondences). Use
|
||||||
|
`ChangeContext.decode_path` when matching canonical question paths; semantic relationship
|
||||||
|
`QuestionIdentity` paths retain their normal encoding. Existing text-context construction remains
|
||||||
|
compatible. This preserves unusual and non-UTF-8 filesystem names without ambiguous display escaping.
|
||||||
|
The caller supplies known declaration/configuration paths as raw bytes; an unavailable declaration
|
||||||
|
inventory is explicitly unresolved. Unique identical lines outside edit hunks provide lexical
|
||||||
|
correspondence even after a declaration rename or guard removal. Repeated lines, unmatched moves
|
||||||
|
and ambiguous rename attribution remain unresolved; these anchors do not prove semantic function
|
||||||
|
identity, guard effects or defect novelty. Those decisions remain with tasks 3.3 and 4.1.
|
||||||
|
|
||||||
Third-party vendor exclusions apply before both preprocessing targets and graph construction.
|
Third-party vendor exclusions apply before both preprocessing targets and graph construction.
|
||||||
Tests remain available but unshipped, consistent with the approved layout decision. The ranker
|
Tests remain available but unshipped, consistent with the approved layout decision. The ranker
|
||||||
cannot reintroduce vendor sources. Known external summaries are versioned facts; without a relevant
|
cannot reintroduce vendor sources. Known external summaries are versioned facts; without a relevant
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Group 2 implementation verification
|
||||||
|
|
||||||
|
Date: 2026-09-12. Scope: tasks 2.1–2.3. All three tasks received independent
|
||||||
|
APPROVED reviews after task-local verification. The specification remains in
|
||||||
|
implementation: 7/27 executable subtasks are complete, with groups 3–8 pending.
|
||||||
|
|
||||||
|
## Verification result
|
||||||
|
|
||||||
|
- STATUS: VERIFIED
|
||||||
|
- CLAIM_TYPE: TASK
|
||||||
|
- CLAIM: Group 2 resolves immutable pushed revisions, materializes isolated tracked
|
||||||
|
source, and supplies bounded lexical change evidence to the shared contract.
|
||||||
|
- EVIDENCE: Fresh final `.venv/bin/pytest -q`: 1,069 passed, 9 skipped (exit 0).
|
||||||
|
Focused snapshot/contracts/audit suite: 148 passed. Global Ruff check and format
|
||||||
|
check passed (218 files formatted); touched-module mypy and compileall passed.
|
||||||
|
Detection, split-sink map and local pair regression gates each passed.
|
||||||
|
- GAPS: This is not feature-level GO. Ranker integration, graph/vendor partitions,
|
||||||
|
semantic change attribution, persistent caching and the operational hook remain
|
||||||
|
scheduled work. No hook detection, precision or latency claim follows.
|
||||||
|
|
||||||
|
## Task evidence
|
||||||
|
|
||||||
|
| Task | Verified result |
|
||||||
|
| --- | --- |
|
||||||
|
| 2.1 | Exact supplied base/head comparisons, configured unique merge bases, tag peeling, special-update dispositions and all ref associations. SHA-1/SHA-256 tests include replacement/graft suppression and missing promisor objects without fetching. Review caught Unicode-aware protocol splitting; six regression cases now retain Git-valid Unicode refs. Full suite at completion: 997 passed, 9 skipped. Commit `05da202`. |
|
||||||
|
| 2.2 | Direct bounded blob reads with object hashes, reversible binary paths, explicit symlink/gitlink/LFS limits, and owned scratch cleanup. Dirty/non-HEAD success and failure paths preserve live files, index, refs and hooks. Independent hanging-child cancellation removed scratch in 0.202 s. Full suite: 1,037 passed, 9 skipped. Commit `97fd633`. |
|
||||||
|
| 2.3 | NUL-safe comparison metadata, both-side changed spans, declared configuration paths, renamed-path attribution and unique unchanged-line anchors. PHP/JavaScript declaration-renaming and removed-guard fixtures preserve the unchanged operation's lexical anchor. Repeated lines, unmatched/ambiguous moves, binary and unavailable inputs retain explicit uncertainty. Independent review additionally checked 40 real-Git edits with 4,880 base bytes read. |
|
||||||
|
|
||||||
|
The initial disabled implementations failed the relevant tests before implementation;
|
||||||
|
expanded tests passed after flag removal. Final normalization also fixed formatting
|
||||||
|
in the earlier push-foundation files without changing their behavior.
|
||||||
|
|
||||||
|
## Real repository and packaged checks
|
||||||
|
|
||||||
|
`benchmarks/measurements/2026-09-12-snapshot-materialization.json` records a lab
|
||||||
|
materialization of commit `05da202`: 1,157 tracked files and 8,737,198 source bytes.
|
||||||
|
Every materialized file was reopened and checked for length and SHA256; the pinned
|
||||||
|
2,547-byte `pyproject.toml` also matched its Git blob directly. Scratch removal and
|
||||||
|
unchanged live status/index were verified. Preparation took 5.783 seconds under an
|
||||||
|
explicit 120-second lab budget. This is preparation cost, not complete hook latency.
|
||||||
|
The artifact's source hashes match committed task 2.2 implementation `97fd633`.
|
||||||
|
|
||||||
|
After final source formatting, `docker compose build ousast` exited 0. Image:
|
||||||
|
`sha256:2a0dd7943ca9b9c84adc821990d38bff13a7824c7d5a9f94d07c38cb04ce333f`.
|
||||||
|
Seven packaged source-file hashes matched the workspace. The non-root,
|
||||||
|
network-disabled `ops/smoke_snapshot.py` run verified 103 JavaScript bytes and 46
|
||||||
|
PHP bytes, exact pushed comparisons, renamed/removed spans and declared configuration
|
||||||
|
context in a dirty non-HEAD checkout. Scratch was removed and live state preserved.
|
||||||
|
The packaged CLI help also exited 0. Retained evidence:
|
||||||
|
`benchmarks/measurements/2026-09-12-packaged-snapshot-smoke.json`.
|
||||||
|
Reproduction instructions are in `ops/README.md`.
|
||||||
|
|
||||||
|
## Remaining limits and next group
|
||||||
|
|
||||||
|
Full-tree `.venv/bin/mypy src/openultrasast` still exits 1 with the same three
|
||||||
|
pre-existing errors in `model/scan.py` at 299, 302 and 310, checking 108 source files.
|
||||||
|
No additional type errors were reported. The nine skipped tests remain outside
|
||||||
|
demonstrated coverage.
|
||||||
|
|
||||||
|
Snapshot completeness describes materialized source only. The snapshot contains the
|
||||||
|
tracked population; task 3.4 applies vendor exclusion and supported language partitions
|
||||||
|
before graph construction and target selection. Cleanup currently targets the shipped
|
||||||
|
Linux environment. The resolver's transaction-wide deadline integration remains pending.
|
||||||
|
|
||||||
|
Change-context structural paths use `filesystem-bytes-hex`; consumers must decode them
|
||||||
|
with `ChangeContext.decode_path` before matching canonical question paths. Declaration
|
||||||
|
paths come from the caller's project discovery; an unavailable inventory is recorded.
|
||||||
|
Lexical correspondence does not prove semantic function identity, a guard's effect,
|
||||||
|
or defect novelty. Tasks 3.3 and 4.1 own those decisions.
|
||||||
|
|
||||||
|
Next: group 3 integrates the shared execution budget, exact existing-ranker scope,
|
||||||
|
affected first-party relationships and vendor/language boundaries. The first complete
|
||||||
|
PMPro/Node experiment remains task 5.2, before persistent caching in group 6.
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
- User direction: a scalable pre-push safety net as AI accelerates development; WordPress is one case.
|
- User direction: a scalable pre-push safety net as AI accelerates development; WordPress is one case.
|
||||||
- Clarifications: advisory must be actionable, not SAST noise; the ranker is the scope mechanism;
|
- Clarifications: advisory must be actionable, not SAST noise; the ranker is the scope mechanism;
|
||||||
third-party vendor code must remain outside targets and graphs.
|
third-party vendor code must remain outside targets and graphs.
|
||||||
- Requirements, design and tasks are now approved, and group 1 implementation is verified complete; see
|
- Requirements, design and tasks are now approved, and groups 1 and 2 are verified complete; see
|
||||||
`tasks.md` for verified completion. The discovery log below preserves the earlier drafting state
|
`tasks.md` for verified completion. The discovery log below preserves the earlier drafting state
|
||||||
and measurements. No hook latency claim is made.
|
and measurements. No hook latency claim is made.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"feature_name": "pre-push-safety-net",
|
"feature_name": "pre-push-safety-net",
|
||||||
"created_at": "2026-09-12T15:35:00Z",
|
"created_at": "2026-09-12T15:35:00Z",
|
||||||
"updated_at": "2026-09-12T18:32:19.033933Z",
|
"updated_at": "2026-09-12T19:09:25.547910Z",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"phase": "implementation",
|
"phase": "implementation",
|
||||||
"approvals": {
|
"approvals": {
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ready_for_implementation": true,
|
"ready_for_implementation": true,
|
||||||
"implementation_approval_note": "Groups 1 and 2 explicitly authorized by the maintainer. Group 1 verified complete; this run implements group 2 (2.1\u20132.3). Remaining task plan retains approval, with execution sequenced by the spec.",
|
"implementation_approval_note": "Groups 1 and 2 explicitly authorized by the maintainer and verified complete after independent review. Remaining task plan retains approval, with execution sequenced by the spec. Implementation of the complete hook is still pending.",
|
||||||
"depends_on": [
|
"depends_on": [
|
||||||
"contributor-scan"
|
"contributor-scan"
|
||||||
],
|
],
|
||||||
@@ -61,11 +61,17 @@
|
|||||||
"1.1",
|
"1.1",
|
||||||
"1.2",
|
"1.2",
|
||||||
"1.3",
|
"1.3",
|
||||||
"1.4"
|
"1.4",
|
||||||
|
"2.1",
|
||||||
|
"2.2",
|
||||||
|
"2.3"
|
||||||
],
|
],
|
||||||
"remaining_tasks": 23,
|
"remaining_tasks": 20,
|
||||||
"verification_record": "implementation-group-1.md",
|
"verification_record": "implementation-group-2.md",
|
||||||
"next_group": 2,
|
"verification_records": [
|
||||||
"active_group": 2
|
"implementation-group-1.md",
|
||||||
|
"implementation-group-2.md"
|
||||||
|
],
|
||||||
|
"next_group": 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Implementation Plan: pre-push-safety-net
|
# Implementation Plan: pre-push-safety-net
|
||||||
|
|
||||||
Status: task plan approved by the maintainer's "approve." on 2026-09-12. Group 1 (tasks 1.1–1.4)
|
Status: task plan approved by the maintainer's "approve." on 2026-09-12. Groups 1 and 2 (tasks 1.1–2.3)
|
||||||
is complete after independent review and fresh verification. Remaining groups are pending;
|
are complete after independent review and fresh verification. Groups 3–8 remain pending;
|
||||||
the hook and capability admission are not yet implemented. See `implementation-group-1.md`.
|
the hook and capability admission are not yet implemented. See `implementation-group-1.md` and `implementation-group-2.md`.
|
||||||
|
|
||||||
## Execution contract
|
## Execution contract
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ the hook and capability admission are not yet implemented. See `implementation-g
|
|||||||
- _Requirements: 4.1, 4.3, 5.3, 5.4, 5.5, 7.1, 7.3_
|
- _Requirements: 4.1, 4.3, 5.3, 5.4, 5.5, 7.1, 7.3_
|
||||||
- _Verified 2026-09-12:_ independent review APPROVED; 965 tests passed, 9 skipped; touched-source Ruff/mypy passed. Parent rebuilt the local image and verified non-root, network-disabled CLI startup, all contract imports, an 83-byte configuration round trip and invalid-budget rejection. Comparison-owned completion preserves different bases for a shared head. Contracts are structural; runtime snapshot/cache/runner/admission implementations remain in later tasks.
|
- _Verified 2026-09-12:_ independent review APPROVED; 965 tests passed, 9 skipped; touched-source Ruff/mypy passed. Parent rebuilt the local image and verified non-root, network-disabled CLI startup, all contract imports, an 83-byte configuration round trip and invalid-budget rejection. Comparison-owned completion preserves different bases for a shared head. Contracts are structural; runtime snapshot/cache/runner/admission implementations remain in later tasks.
|
||||||
|
|
||||||
- [ ] 2. Resolve immutable pushed content
|
- [x] 2. Resolve immutable pushed content
|
||||||
- [x] 2.1 Resolve every supplied ref update and comparison
|
- [x] 2.1 Resolve every supplied ref update and comparison
|
||||||
- Parse Git's complete update input using object-format-aware validation; resolve existing tips, new-branch configured bases, force-pushes, merges, peeled tags, deleted refs and unsupported objects.
|
- Parse Git's complete update input using object-format-aware validation; resolve existing tips, new-branch configured bases, force-pushes, merges, peeled tags, deleted refs and unsupported objects.
|
||||||
- Deduplicate equal targets without merging different base comparisons; retain every ref association. Do not silently substitute HEAD or fetch missing objects.
|
- Deduplicate equal targets without merging different base comparisons; retain every ref association. Do not silently substitute HEAD or fetch missing objects.
|
||||||
@@ -70,12 +70,13 @@ the hook and capability admission are not yet implemented. See `implementation-g
|
|||||||
- _Boundary: Snapshot Adapter_
|
- _Boundary: Snapshot Adapter_
|
||||||
- _Requirements: 1.1, 1.2, 1.3, 2.3, 7.1, 7.3_
|
- _Requirements: 1.1, 1.2, 1.3, 2.3, 7.1, 7.3_
|
||||||
- _Verified 2026-09-12:_ independent review APPROVED; 1,037 tests passed, 9 skipped; focused 116 passed; touched Ruff/mypy passed. Real SHA-1/SHA-256 fixtures verify binary-safe paths, exact blobs, no filters, explicit symlink/gitlink/LFS limits, cancellation and unchanged live state. Independent hanging-child cleanup took 0.202 s. Parent reopened and checked all 1,157 materialized repository files (8,737,198 bytes), with scratch removed and live state preserved; evidence `benchmarks/measurements/2026-09-12-snapshot-materialization.json` records a 5.783 s preparation-only lab run, not end-to-end hook latency.
|
- _Verified 2026-09-12:_ independent review APPROVED; 1,037 tests passed, 9 skipped; focused 116 passed; touched Ruff/mypy passed. Real SHA-1/SHA-256 fixtures verify binary-safe paths, exact blobs, no filters, explicit symlink/gitlink/LFS limits, cancellation and unchanged live state. Independent hanging-child cleanup took 0.202 s. Parent reopened and checked all 1,157 materialized repository files (8,737,198 bytes), with scratch removed and live state preserved; evidence `benchmarks/measurements/2026-09-12-snapshot-materialization.json` records a 5.783 s preparation-only lab run, not end-to-end hook latency.
|
||||||
- [ ] 2.3 Attach changed spans and deletion/rename context
|
- [x] 2.3 Attach changed spans and deletion/rename context
|
||||||
- Compare recorded base/head objects using NUL-safe path handling; retain removed spans, renamed identities and declaration/config changes.
|
- Compare recorded base/head objects using NUL-safe path handling; retain removed spans, renamed identities and declaration/config changes.
|
||||||
- Supply change context to the shared ranker contract rather than selecting an independent changed-files-only scope.
|
- Supply change context to the shared ranker contract rather than selecting an independent changed-files-only scope.
|
||||||
- Done when renamed functions retain correspondence and a removed guard remains an impact seed for an unchanged operation, with missing correspondence explicitly unresolved.
|
- Done when renamed functions retain correspondence and a removed guard remains an impact seed for an unchanged operation, with missing correspondence explicitly unresolved.
|
||||||
- _Boundary: Snapshot Adapter_
|
- _Boundary: Snapshot Adapter_
|
||||||
- _Requirements: 1.4, 2.1, 2.4, 3.4_
|
- _Requirements: 1.4, 2.1, 2.4, 3.4_
|
||||||
|
- _Verified 2026-09-12:_ independent review APPROVED; fresh final suite 1,069 passed, 9 skipped; focused 148 passed. Global Ruff/format, touched mypy and regression gates passed. PHP/JavaScript fixtures retain removed spans and unchanged-operation anchors across declaration renames; uncertain moves, repeated lines, binary and unavailable inputs remain unresolved. Forty independent real-Git comparisons checked exact spans/anchors. Network-disabled image smoke preserved committed PHP/JavaScript bytes and dirty non-HEAD state, with cleanup verified; see `implementation-group-2.md`. Lexical anchors do not establish semantic impact or novelty.
|
||||||
|
|
||||||
- [ ] 3. Integrate bounded execution and ranker scope with the existing engine
|
- [ ] 3. Integrate bounded execution and ranker scope with the existing engine
|
||||||
- [ ] 3.1 (P) Enforce one remaining-time budget in the backend
|
- [ ] 3.1 (P) Enforce one remaining-time budget in the backend
|
||||||
|
|||||||
@@ -46,13 +46,14 @@ engine are out of scope.
|
|||||||
- [ ] pre-push-safety-net — Immutable pushed tips, existing-ranker integration, compatible artifact
|
- [ ] pre-push-safety-net — Immutable pushed tips, existing-ranker integration, compatible artifact
|
||||||
reuse, total deadline, change attribution, actionability admission and compact hook behavior.
|
reuse, total deadline, change attribution, actionability admission and compact hook behavior.
|
||||||
Dependencies: contributor-scan. Requirements/design approved 2026-09-12; tasks generated
|
Dependencies: contributor-scan. Requirements/design approved 2026-09-12; tasks generated
|
||||||
(8 groups, 27 executable subtasks), independently reviewed and approved; group 1 is complete (4/27 tasks).
|
(8 groups, 27 executable subtasks), independently reviewed and approved; groups 1 and 2 are complete (7/27 tasks).
|
||||||
|
|
||||||
### Next concrete step
|
### Next concrete step
|
||||||
|
|
||||||
Group 1 has passed independent review: reproducible PHP/JavaScript runtime, pinned PMPro/Node inputs,
|
Groups 1 and 2 have passed independent review: reproducible runtime, pinned inputs, honest scoring,
|
||||||
honest scoring and shared contracts. Continue with group 2's immutable push snapshots under the
|
shared contracts, immutable push snapshots and lexical change context. Continue with group 3: one
|
||||||
approved task plan; see `pre-push-safety-net/implementation-group-1.md` for evidence and limits.
|
execution budget, exact ranker scope, affected relationships and vendor/language partitions. See
|
||||||
|
`pre-push-safety-net/implementation-group-2.md` for evidence and limits.
|
||||||
The first complete experiment is 5.2, before persistent caching and hook
|
The first complete experiment is 5.2, before persistent caching and hook
|
||||||
installation work. Keep PMPro as the primary core-optimization example, then freeze the core and
|
installation work. Keep PMPro as the primary core-optimization example, then freeze the core and
|
||||||
prove a changed security flow and its fixed twin on a JavaScript/Express Node API workload through
|
prove a changed security flow and its fixed twin on a JavaScript/Express Node API workload through
|
||||||
|
|||||||
@@ -79,5 +79,6 @@ updates as a shortcut to an unmeasured latency promise.
|
|||||||
The product direction above is user-requested. The amended requirements, design and initial
|
The product direction above is user-requested. The amended requirements, design and initial
|
||||||
evaluation profile in `pre-push-safety-net` were approved for task generation on 2026-09-12
|
evaluation profile in `pre-push-safety-net` were approved for task generation on 2026-09-12
|
||||||
("ok, next kiro phase"). The maintainer then approved the task plan and starting group 1
|
("ok, next kiro phase"). The maintainer then approved the task plan and starting group 1
|
||||||
("approve."). Implementation is authorized under that plan; task completion still requires
|
("approve.") and then group 2 ("ok, next kiro phase, approved"). Groups 1 and 2 are now verified
|
||||||
|
complete. Implementation is authorized under that plan; task completion still requires
|
||||||
independent review and fresh evidence. Hook performance and capability admission remain unproven.
|
independent review and fresh evidence. Hook performance and capability admission remain unproven.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"standalone_capability": 9
|
"standalone_capability": 9
|
||||||
},
|
},
|
||||||
"modules": 107,
|
"modules": 107,
|
||||||
"note": "Refreshed 2026-09-12 after pre-push-safety-net task 2.1: local immutable ref resolution is spec-owned; runner integration remains pending.",
|
"note": "Refreshed 2026-09-12 after pre-push-safety-net group 2: immutable comparison, materialization and lexical change context are spec-owned; ranker/runner integration remains pending.",
|
||||||
"orphaned": [],
|
"orphaned": [],
|
||||||
"rows": [
|
"rows": [
|
||||||
{
|
{
|
||||||
@@ -351,7 +351,8 @@
|
|||||||
"model.specs",
|
"model.specs",
|
||||||
"model.taint",
|
"model.taint",
|
||||||
"preprocess",
|
"preprocess",
|
||||||
"push.contracts"
|
"push.contracts",
|
||||||
|
"push.snapshot"
|
||||||
],
|
],
|
||||||
"module": "model",
|
"module": "model",
|
||||||
"reason": "reachable from gate, map_gate, pair_gate or the CLI"
|
"reason": "reachable from gate, map_gate, pair_gate or the CLI"
|
||||||
@@ -389,7 +390,8 @@
|
|||||||
{
|
{
|
||||||
"classification": "load_bearing",
|
"classification": "load_bearing",
|
||||||
"importers": [
|
"importers": [
|
||||||
"push.contracts"
|
"push.contracts",
|
||||||
|
"push.snapshot"
|
||||||
],
|
],
|
||||||
"module": "model.contracts",
|
"module": "model.contracts",
|
||||||
"reason": "pre-push-safety-net Req 4.1/7.3: generic deadline, change and exact scope contracts; integration pending"
|
"reason": "pre-push-safety-net Req 4.1/7.3: generic deadline, change and exact scope contracts; integration pending"
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{
|
||||||
|
"purpose": "packaged snapshot and change-context smoke, not vulnerability detection or hook latency",
|
||||||
|
"uid": 1000,
|
||||||
|
"git_version": "git version 2.43.0",
|
||||||
|
"source_bytes": {
|
||||||
|
"javascript": 103,
|
||||||
|
"php": 46
|
||||||
|
},
|
||||||
|
"elapsed_seconds": 0.066,
|
||||||
|
"snapshot_complete": true,
|
||||||
|
"live_state_preserved": true,
|
||||||
|
"scratch_removed": true,
|
||||||
|
"change_context": {
|
||||||
|
"base_revision": "571abb8f43ec7b3fa8de233be65114f57cb0e77b",
|
||||||
|
"head_revision": "9dfa656cc7e5b8f07737ab155d0fb23d90d90e07",
|
||||||
|
"changed_paths": [
|
||||||
|
"6f6c642d6170692e6a73",
|
||||||
|
"6170692e6a73",
|
||||||
|
"7061636b6167652e6a736f6e"
|
||||||
|
],
|
||||||
|
"deleted_paths": [],
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"path": "6f6c642d6170692e6a73",
|
||||||
|
"start_line": 1,
|
||||||
|
"end_line": 2,
|
||||||
|
"side": "base"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "6170692e6a73",
|
||||||
|
"start_line": 1,
|
||||||
|
"end_line": 1,
|
||||||
|
"side": "head"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "7061636b6167652e6a736f6e",
|
||||||
|
"start_line": 1,
|
||||||
|
"end_line": 1,
|
||||||
|
"side": "base"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "7061636b6167652e6a736f6e",
|
||||||
|
"start_line": 1,
|
||||||
|
"end_line": 1,
|
||||||
|
"side": "head"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"renames": [
|
||||||
|
{
|
||||||
|
"base_path": "6f6c642d6170692e6a73",
|
||||||
|
"head_path": "6170692e6a73"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"declaration_paths": [
|
||||||
|
"7061636b6167652e6a736f6e"
|
||||||
|
],
|
||||||
|
"relationships": [],
|
||||||
|
"unresolved_boundaries": [
|
||||||
|
"7061636b6167652e6a736f6e:line_correspondence_unavailable"
|
||||||
|
],
|
||||||
|
"path_encoding": "filesystem-bytes-hex",
|
||||||
|
"line_correspondences": [
|
||||||
|
{
|
||||||
|
"base_path": "6f6c642d6170692e6a73",
|
||||||
|
"head_path": "6170692e6a73",
|
||||||
|
"base_start_line": 3,
|
||||||
|
"base_end_line": 3,
|
||||||
|
"head_start_line": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"base_path": "6f6c642d6170692e6a73",
|
||||||
|
"head_path": "6170692e6a73",
|
||||||
|
"base_start_line": 4,
|
||||||
|
"base_end_line": 4,
|
||||||
|
"head_start_line": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"base_path": "6f6c642d6170692e6a73",
|
||||||
|
"head_path": "6170692e6a73",
|
||||||
|
"base_start_line": 5,
|
||||||
|
"base_end_line": 5,
|
||||||
|
"head_start_line": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"base_path": "6f6c642d6170692e6a73",
|
||||||
|
"head_path": "6170692e6a73",
|
||||||
|
"base_start_line": 6,
|
||||||
|
"base_end_line": 6,
|
||||||
|
"head_start_line": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"image_id": "sha256:2a0dd7943ca9b9c84adc821990d38bff13a7824c7d5a9f94d07c38cb04ce333f",
|
||||||
|
"implementation_sha256": {
|
||||||
|
"src/openultrasast/push/snapshot.py": "c6891f6de9221f63e534372c3009190d227172ca0b006bfb68bb6cffd7bf38f3",
|
||||||
|
"src/openultrasast/model/contracts.py": "89ea70185f9dabd6a264d1cd79b3e0c73161f5af4f29fd6f391d27f357439bbf",
|
||||||
|
"src/openultrasast/push/contracts.py": "a0bbef3137cd1e43c46b45209e5997ca89d46f60460402333c7ef37b8f0aec99",
|
||||||
|
"src/openultrasast/config.py": "1c2dd83f5245222c4be3efff6952a2f26984f2743193aa17f536e2a87af75872",
|
||||||
|
"src/openultrasast/contracts.py": "646f944f44a203315944ab8939aaec2609c3a045469c6dab303441d675373fe1",
|
||||||
|
"src/openultrasast/cpg/artifact.py": "8d6fd7af3db55b22e5b150ecd322984291db4c847c45da0ee11d12be06181257",
|
||||||
|
"src/openultrasast/push_scoring.py": "085ca153732b8b900f162408ce834fccacccf1187788ba2b6f436dd65c68f1b7"
|
||||||
|
},
|
||||||
|
"smoke_script_sha256": "e2402ceb5105d323388b1e2bc3dc2ceb01e73b75acdadbfede5656af9b070e7c",
|
||||||
|
"packaged_source_matches_workspace": true,
|
||||||
|
"cli_help_exit_code": 0
|
||||||
|
}
|
||||||
@@ -127,3 +127,17 @@ PHP, ABAP and Ghidra binaries. We currently model **python, javascript, java, c*
|
|||||||
**PHP works but needs a PHP interpreter**: `php2cpg` drives PHP-Parser and shells out to `php`, so on a host
|
**PHP works but needs a PHP interpreter**: `php2cpg` drives PHP-Parser and shells out to `php`, so on a host
|
||||||
without it a PHP tree fails to build with an opaque "Process exited with code 1". Set `php_frontend: True` in
|
without it a PHP tree fails to build with an opaque "Process exited with code 1". Set `php_frontend: True` in
|
||||||
the inventory to install `php-cli` alongside the JVM.
|
the inventory to install `php-cli` alongside the JVM.
|
||||||
|
|
||||||
|
## Snapshot preparation smoke
|
||||||
|
|
||||||
|
After building the image, exercise immutable Git comparisons and isolated source
|
||||||
|
materialization without network access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -i --network none --entrypoint python openultrasast:dev - < ops/smoke_snapshot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke reads committed PHP/JavaScript bytes from a dirty, non-HEAD checkout,
|
||||||
|
checks rename/deletion and declared configuration context, and verifies scratch
|
||||||
|
cleanup and unchanged live state. Its JSON reports snapshot preparation only;
|
||||||
|
it does not run the detector, install a hook, or establish hook latency.
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Check packaged Git snapshot preparation; no graph, detector or hook-latency claim."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from openultrasast.model.contracts import ExecutionBudget
|
||||||
|
from openultrasast.push.snapshot import SnapshotAdapter
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
assert os.getuid() != 0, "exercise the shipped non-root runtime"
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ousast-push-smoke-") as temporary:
|
||||||
|
repo = Path(temporary) / "repository"
|
||||||
|
repo.mkdir()
|
||||||
|
|
||||||
|
def git(*args: str) -> bytes:
|
||||||
|
return subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, timeout=10).stdout
|
||||||
|
|
||||||
|
def commit(message: str) -> str:
|
||||||
|
git("add", ".")
|
||||||
|
git("commit", "-m", message)
|
||||||
|
return git("rev-parse", "HEAD").decode().strip()
|
||||||
|
|
||||||
|
git("init")
|
||||||
|
git("config", "user.name", "Snapshot smoke")
|
||||||
|
git("config", "user.email", "[email protected]")
|
||||||
|
git("config", "core.hooksPath", os.devnull)
|
||||||
|
before = (
|
||||||
|
b"function before(req, res) {\n if (!authorized(req)) return;\n"
|
||||||
|
b" const input = req.body;\n const value = input.value;\n res.send(value);\n}\n"
|
||||||
|
)
|
||||||
|
after = before.replace(b"function before", b"function after").replace(b" if (!authorized(req)) return;\n", b"")
|
||||||
|
php = b"<?php\nfunction handler($req) { return $req; }\n"
|
||||||
|
(repo / "old-api.js").write_bytes(before)
|
||||||
|
(repo / "handler.php").write_bytes(php)
|
||||||
|
(repo / "package.json").write_bytes(b'{"type":"commonjs"}\n')
|
||||||
|
base = commit("base")
|
||||||
|
(repo / "old-api.js").unlink()
|
||||||
|
(repo / "api.js").write_bytes(after)
|
||||||
|
(repo / "package.json").write_bytes(b'{"type":"module"}\n')
|
||||||
|
head = commit("pushed change")
|
||||||
|
(repo / "api.js").write_bytes(b"different checked-out commit\n")
|
||||||
|
checked = commit("checked-out revision")
|
||||||
|
(repo / "api.js").write_bytes(b"uncommitted content\n")
|
||||||
|
status = git("status", "--porcelain=v1", "-z")
|
||||||
|
index = (repo / ".git/index").read_bytes()
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
budget = ExecutionBudget(started + 120, 2)
|
||||||
|
adapter = SnapshotAdapter(repo)
|
||||||
|
resolution = adapter.resolve_updates(f"refs/heads/topic {head} refs/heads/topic {base}\n")
|
||||||
|
(comparison,) = resolution.comparisons
|
||||||
|
assert comparison.head_oid == head and comparison.base_oid == base
|
||||||
|
with adapter.materialize(head, budget=budget) as snapshot:
|
||||||
|
scratch = snapshot.root
|
||||||
|
assert snapshot.manifest.complete
|
||||||
|
assert (scratch / "api.js").read_bytes() == after
|
||||||
|
assert (scratch / "handler.php").read_bytes() == php
|
||||||
|
assert snapshot.manifest.bytes_read == len(after) + len(php) + len(b'{"type":"module"}\n')
|
||||||
|
context = adapter.compare(comparison, declaration_paths=(b"package.json",), budget=budget)
|
||||||
|
assert context.base_revision == base and context.head_revision == head
|
||||||
|
assert any(
|
||||||
|
context.decode_path(item.base_path) == "old-api.js" and context.decode_path(item.head_path) == "api.js"
|
||||||
|
for item in context.renames
|
||||||
|
)
|
||||||
|
assert any(context.decode_path(item.path) == "old-api.js" and item.side == "base" for item in context.spans)
|
||||||
|
assert "package.json" in tuple(map(context.decode_path, context.declaration_paths))
|
||||||
|
assert not scratch.exists()
|
||||||
|
assert git("status", "--porcelain=v1", "-z") == status
|
||||||
|
assert (repo / ".git/index").read_bytes() == index
|
||||||
|
assert git("rev-parse", "HEAD").decode().strip() == checked
|
||||||
|
assert (repo / "api.js").read_bytes() == b"uncommitted content\n"
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"purpose": "packaged snapshot and change-context smoke, not vulnerability detection or hook latency",
|
||||||
|
"uid": os.getuid(),
|
||||||
|
"git_version": git("--version").decode().strip(),
|
||||||
|
"source_bytes": {"javascript": len(after), "php": len(php)},
|
||||||
|
"elapsed_seconds": round(time.monotonic() - started, 3),
|
||||||
|
"snapshot_complete": snapshot.manifest.complete,
|
||||||
|
"live_state_preserved": True,
|
||||||
|
"scratch_removed": True,
|
||||||
|
"change_context": context.to_payload(),
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -178,7 +178,7 @@ class PushConfig(Contract):
|
|||||||
comparison_base: str | None = None
|
comparison_base: str | None = None
|
||||||
deadline_seconds: float = 30.0
|
deadline_seconds: float = 30.0
|
||||||
cancellation_allowance_seconds: float = 2.0
|
cancellation_allowance_seconds: float = 2.0
|
||||||
cache_max_bytes: int = 2 * 1024 ** 3
|
cache_max_bytes: int = 2 * 1024**3
|
||||||
mode: Literal["advisory", "blocking"] = "advisory"
|
mode: Literal["advisory", "blocking"] = "advisory"
|
||||||
incomplete_coverage_policy: Literal["allow", "block"] = "allow"
|
incomplete_coverage_policy: Literal["allow", "block"] = "allow"
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
These schemas validate structure, not source readability, Git object existence or alert
|
These schemas validate structure, not source readability, Git object existence or alert
|
||||||
eligibility. Live resources deliberately have no manifest decoder.
|
eligibility. Live resources deliberately have no manifest decoder.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Structural validity is necessary but insufficient for reuse. Task 6.1 will validate
|
Structural validity is necessary but insufficient for reuse. Task 6.1 will validate
|
||||||
actual source bytes, graph digest/census and engine compatibility before issuing leases.
|
actual source bytes, graph digest/census and engine compatibility before issuing leases.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
These are integration contracts; constructing them neither selects nor executes work.
|
These are integration contracts; constructing them neither selects nor executes work.
|
||||||
Revision identities are opaque to the engine. The snapshot adapter resolves Git objects.
|
Revision identities are opaque to the engine. The snapshot adapter resolves Git objects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
@@ -71,6 +73,27 @@ class AffectedRelationship(Contract):
|
|||||||
evidence: tuple[str, ...]
|
evidence: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LineCorrespondence(Contract):
|
||||||
|
"""Unique identical source lines outside edit hunks; never semantic identity proof.
|
||||||
|
|
||||||
|
A renamed declaration can retain its unchanged operation's lexical anchor.
|
||||||
|
Frontend evidence must still establish function/guard relationships and novelty.
|
||||||
|
Paths use the owning ChangeContext's explicit encoding.
|
||||||
|
"""
|
||||||
|
|
||||||
|
base_path: str
|
||||||
|
head_path: str
|
||||||
|
base_start_line: int
|
||||||
|
base_end_line: int
|
||||||
|
head_start_line: int
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
super().__post_init__()
|
||||||
|
if self.base_start_line < 1 or self.base_end_line < self.base_start_line or self.head_start_line < 1:
|
||||||
|
raise ValueError("line correspondence requires positive ordered lines")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ChangeContext(Contract):
|
class ChangeContext(Contract):
|
||||||
base_revision: str | None
|
base_revision: str | None
|
||||||
@@ -82,6 +105,39 @@ class ChangeContext(Contract):
|
|||||||
declaration_paths: tuple[str, ...]
|
declaration_paths: tuple[str, ...]
|
||||||
relationships: tuple[AffectedRelationship, ...]
|
relationships: tuple[AffectedRelationship, ...]
|
||||||
unresolved_boundaries: tuple[str, ...]
|
unresolved_boundaries: tuple[str, ...]
|
||||||
|
# Filesystem paths can be whitespace-only or non-UTF8. Adapter-produced contexts use
|
||||||
|
# lowercase raw-byte hex for change paths, spans, renames and line anchors.
|
||||||
|
# Relationship QuestionIdentity paths retain canonical question encoding; they
|
||||||
|
# are not encoded or decoded by this field.
|
||||||
|
path_encoding: Literal["text", "filesystem-bytes-hex"] = "text"
|
||||||
|
line_correspondences: tuple[LineCorrespondence, ...] = ()
|
||||||
|
|
||||||
|
def decode_path(self, path: str) -> str:
|
||||||
|
"""Return the filesystem spelling for matching ordinary question paths.
|
||||||
|
|
||||||
|
Raw undecodable filename bytes survive through filesystem surrogate escapes;
|
||||||
|
serialization should retain the authoritative encoded contract path instead.
|
||||||
|
"""
|
||||||
|
return os.fsdecode(bytes.fromhex(path)) if self.path_encoding == "filesystem-bytes-hex" else path
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
super().__post_init__()
|
||||||
|
if self.path_encoding == "filesystem-bytes-hex":
|
||||||
|
paths = [
|
||||||
|
*self.changed_paths,
|
||||||
|
*self.deleted_paths,
|
||||||
|
*self.declaration_paths,
|
||||||
|
*(s.path for s in self.spans),
|
||||||
|
*(p for r in self.renames for p in (r.base_path, r.head_path)),
|
||||||
|
*(p for m in self.line_correspondences for p in (m.base_path, m.head_path)),
|
||||||
|
]
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
raw = bytes.fromhex(path)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("invalid raw filesystem path hex") from error
|
||||||
|
if not raw or b"\0" in raw or raw.hex() != path:
|
||||||
|
raise ValueError("invalid raw filesystem path hex")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Git resolution, admission policy and execution are separate later implementations.
|
Git resolution, admission policy and execution are separate later implementations.
|
||||||
No default result status can turn an unexecuted contract into a complete negative.
|
No default result status can turn an unexecuted contract into a complete negative.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -92,8 +93,17 @@ class UpdateResolution(Contract):
|
|||||||
"""One record per supplied line, including repeated ref associations."""
|
"""One record per supplied line, including repeated ref associations."""
|
||||||
|
|
||||||
update: PushUpdate
|
update: PushUpdate
|
||||||
disposition: Literal["ready", "deleted", "missing_target", "unsupported_target", "missing_base", "unsupported_base",
|
disposition: Literal[
|
||||||
"no_merge_base", "ambiguous_base", "resolution_error"]
|
"ready",
|
||||||
|
"deleted",
|
||||||
|
"missing_target",
|
||||||
|
"unsupported_target",
|
||||||
|
"missing_base",
|
||||||
|
"unsupported_base",
|
||||||
|
"no_merge_base",
|
||||||
|
"ambiguous_base",
|
||||||
|
"resolution_error",
|
||||||
|
]
|
||||||
head_oid: str | None
|
head_oid: str | None
|
||||||
base_oid: str | None
|
base_oid: str | None
|
||||||
base_reason: str
|
base_reason: str
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Resolve supplied push tips using local Git objects, never the live checkout.
|
"""Resolve supplied push tips using local Git objects, never the live checkout.
|
||||||
|
|
||||||
No fetch, replacement objects, checkout, filters or project commands are used.
|
No fetch, replacement objects, checkout, filters or project commands are used.
|
||||||
Snapshots contain exact tracked blobs; changed spans are a subsequent adapter task.
|
Snapshots and change evidence come from exact tracked objects, not working-tree files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -14,12 +15,19 @@ import stat
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from collections import Counter
|
||||||
from collections.abc import Callable, Iterator
|
from collections.abc import Callable, Iterator
|
||||||
from contextlib import contextmanager, suppress
|
from contextlib import contextmanager, suppress
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from openultrasast.model.contracts import ExecutionBudget
|
from openultrasast.model.contracts import (
|
||||||
|
ChangeContext,
|
||||||
|
ChangedSpan,
|
||||||
|
ExecutionBudget,
|
||||||
|
LineCorrespondence,
|
||||||
|
PathRename,
|
||||||
|
)
|
||||||
from openultrasast.push.contracts import (
|
from openultrasast.push.contracts import (
|
||||||
PushComparison,
|
PushComparison,
|
||||||
PushResolution,
|
PushResolution,
|
||||||
@@ -41,9 +49,16 @@ class SnapshotAdapter:
|
|||||||
self.comparison_base = comparison_base
|
self.comparison_base = comparison_base
|
||||||
# Hook-inherited Git variables may redirect objects or worktrees elsewhere.
|
# Hook-inherited Git variables may redirect objects or worktrees elsewhere.
|
||||||
self._env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
|
self._env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
|
||||||
self._env.update(GIT_NO_REPLACE_OBJECTS="1", GIT_NO_LAZY_FETCH="1", GIT_TERMINAL_PROMPT="0",
|
self._env.update(
|
||||||
GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, GIT_ALLOW_PROTOCOL="", GIT_OPTIONAL_LOCKS="0",
|
GIT_NO_REPLACE_OBJECTS="1",
|
||||||
GIT_GRAFT_FILE=os.devnull)
|
GIT_NO_LAZY_FETCH="1",
|
||||||
|
GIT_TERMINAL_PROMPT="0",
|
||||||
|
GIT_CONFIG_NOSYSTEM="1",
|
||||||
|
GIT_CONFIG_GLOBAL=os.devnull,
|
||||||
|
GIT_ALLOW_PROTOCOL="",
|
||||||
|
GIT_OPTIONAL_LOCKS="0",
|
||||||
|
GIT_GRAFT_FILE=os.devnull,
|
||||||
|
)
|
||||||
self.object_format = self._required("rev-parse", "--show-object-format=storage")
|
self.object_format = self._required("rev-parse", "--show-object-format=storage")
|
||||||
# Ask the installed Git for its object ID width rather than assuming SHA-1.
|
# Ask the installed Git for its object ID width rather than assuming SHA-1.
|
||||||
self._oid_width = len(self._required("hash-object", "--stdin", input=""))
|
self._oid_width = len(self._required("hash-object", "--stdin", input=""))
|
||||||
@@ -57,17 +72,21 @@ class SnapshotAdapter:
|
|||||||
|
|
||||||
def _repository_path(self, *args: str) -> Path:
|
def _repository_path(self, *args: str) -> Path:
|
||||||
# Binary framing also matters for the repository's own whitespace/non-UTF8 name.
|
# Binary framing also matters for the repository's own whitespace/non-UTF8 name.
|
||||||
raw = self._bounded_git(("rev-parse", *args), 65536,
|
raw = self._bounded_git(("rev-parse", *args), 65536, ExecutionBudget(time.monotonic() + 10.0, 2.0), None, [])
|
||||||
ExecutionBudget(time.monotonic() + 10.0, 2.0), None, [])
|
|
||||||
return Path(os.fsdecode(raw.removesuffix(b"\n"))).resolve()
|
return Path(os.fsdecode(raw.removesuffix(b"\n"))).resolve()
|
||||||
|
|
||||||
def _git(self, *args: str, input: str | None = None) -> subprocess.CompletedProcess[str]:
|
def _git(self, *args: str, input: str | None = None) -> subprocess.CompletedProcess[str]:
|
||||||
try:
|
try:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["git", "-c", "protocol.allow=never", "-c", "core.hooksPath=" + os.devnull,
|
["git", "-c", "protocol.allow=never", "-c", "core.hooksPath=" + os.devnull, "-C", str(self.repository), *args],
|
||||||
"-C", str(self.repository), *args],
|
input=input,
|
||||||
input=input, capture_output=True, text=True, encoding="utf-8", errors="surrogateescape",
|
capture_output=True,
|
||||||
env=self._env, timeout=10, check=False,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="surrogateescape",
|
||||||
|
env=self._env,
|
||||||
|
timeout=10,
|
||||||
|
check=False,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired) as error:
|
except (OSError, subprocess.TimeoutExpired) as error:
|
||||||
raise SnapshotInputError(f"local Git resolution failed: {type(error).__name__}") from error
|
raise SnapshotInputError(f"local Git resolution failed: {type(error).__name__}") from error
|
||||||
@@ -91,8 +110,7 @@ class SnapshotAdapter:
|
|||||||
if len(parts) != 4 or any(not part for part in parts) or any(ord(char) < 32 for char in record):
|
if len(parts) != 4 or any(not part for part in parts) or any(ord(char) < 32 for char in record):
|
||||||
raise SnapshotInputError(f"invalid pre-push update at line {number}")
|
raise SnapshotInputError(f"invalid pre-push update at line {number}")
|
||||||
local_ref, local_oid, remote_ref, remote_oid = parts
|
local_ref, local_oid, remote_ref, remote_oid = parts
|
||||||
if any(re.fullmatch(r"[0-9a-f]{" + str(self._oid_width) + r"}", oid) is None
|
if any(re.fullmatch(r"[0-9a-f]{" + str(self._oid_width) + r"}", oid) is None for oid in (local_oid, remote_oid)):
|
||||||
for oid in (local_oid, remote_oid)):
|
|
||||||
raise SnapshotInputError(f"invalid {self.object_format} object ID at line {number}")
|
raise SnapshotInputError(f"invalid {self.object_format} object ID at line {number}")
|
||||||
if self._git("check-ref-format", remote_ref).returncode or not remote_ref.startswith("refs/"):
|
if self._git("check-ref-format", remote_ref).returncode or not remote_ref.startswith("refs/"):
|
||||||
raise SnapshotInputError(f"invalid destination ref at line {number}")
|
raise SnapshotInputError(f"invalid destination ref at line {number}")
|
||||||
@@ -127,8 +145,9 @@ class SnapshotAdapter:
|
|||||||
return UpdateResolution(update, "deleted", None, None, "ref_deleted_no_analysis")
|
return UpdateResolution(update, "deleted", None, None, "ref_deleted_no_analysis")
|
||||||
head, kind = self._commit(update.local_oid)
|
head, kind = self._commit(update.local_oid)
|
||||||
if head is None:
|
if head is None:
|
||||||
return UpdateResolution(update, "missing_target" if kind == "missing" else "unsupported_target",
|
return UpdateResolution(
|
||||||
None, None, "target_not_a_local_commit")
|
update, "missing_target" if kind == "missing" else "unsupported_target", None, None, "target_not_a_local_commit"
|
||||||
|
)
|
||||||
if update.remote_oid != self._zero:
|
if update.remote_oid != self._zero:
|
||||||
base, kind = self._commit(update.remote_oid)
|
base, kind = self._commit(update.remote_oid)
|
||||||
disposition = "ready" if base else ("missing_base" if kind == "missing" else "unsupported_base")
|
disposition = "ready" if base else ("missing_base" if kind == "missing" else "unsupported_base")
|
||||||
@@ -141,8 +160,9 @@ class SnapshotAdapter:
|
|||||||
return UpdateResolution(update, "missing_base", head, None, "configured_base_unavailable")
|
return UpdateResolution(update, "missing_base", head, None, "configured_base_unavailable")
|
||||||
anchor, kind = self._commit(configured.stdout.strip())
|
anchor, kind = self._commit(configured.stdout.strip())
|
||||||
if anchor is None:
|
if anchor is None:
|
||||||
return UpdateResolution(update, "missing_base" if kind == "missing" else "unsupported_base",
|
return UpdateResolution(
|
||||||
head, None, "configured_base_not_a_local_commit")
|
update, "missing_base" if kind == "missing" else "unsupported_base", head, None, "configured_base_not_a_local_commit"
|
||||||
|
)
|
||||||
reason += ":" + anchor
|
reason += ":" + anchor
|
||||||
merged = self._git("merge-base", "--all", anchor, head)
|
merged = self._git("merge-base", "--all", anchor, head)
|
||||||
if merged.returncode not in (0, 1):
|
if merged.returncode not in (0, 1):
|
||||||
@@ -179,10 +199,225 @@ class SnapshotAdapter:
|
|||||||
comparisons.append(PushComparison(*key, refs=(update.remote_ref,)))
|
comparisons.append(PushComparison(*key, refs=(update.remote_ref,)))
|
||||||
return PushResolution(self.object_format, tuple(records), tuple(comparisons), tuple(targets))
|
return PushResolution(self.object_format, tuple(records), tuple(comparisons), tuple(targets))
|
||||||
|
|
||||||
|
def compare(
|
||||||
|
self,
|
||||||
|
comparison: PushComparison,
|
||||||
|
*,
|
||||||
|
declaration_paths: tuple[bytes, ...] | None = None,
|
||||||
|
budget: ExecutionBudget | None = None,
|
||||||
|
limits: SnapshotLimits | None = None,
|
||||||
|
cancelled: Callable[[], bool] | None = None,
|
||||||
|
) -> ChangeContext:
|
||||||
|
"""Supply bounded lexical change evidence, never select analysis scope.
|
||||||
|
|
||||||
|
Change paths, spans, renames and line anchors use raw path hex. Canonical
|
||||||
|
question paths in relationships retain their own encoding. Declarations are
|
||||||
|
supplied by project discovery (including configuration/manifests); None
|
||||||
|
records unknown coverage. Unchanged-line anchors are not function identity
|
||||||
|
or security proof. Semantic relationships belong to frontend/ranker work.
|
||||||
|
"""
|
||||||
|
budget = budget or ExecutionBudget(time.monotonic() + 30.0, 2.0)
|
||||||
|
limits = limits or SnapshotLimits()
|
||||||
|
if declaration_paths is not None and (
|
||||||
|
type(declaration_paths) is not tuple or any(type(p) is not bytes or not p or b"\0" in p for p in declaration_paths)
|
||||||
|
):
|
||||||
|
raise ValueError("declaration paths must be a tuple of raw nonempty Git paths")
|
||||||
|
boundaries = ["declaration_paths_unavailable"] if declaration_paths is None else []
|
||||||
|
changed: list[str] = []
|
||||||
|
deleted: list[str] = []
|
||||||
|
spans: list[ChangedSpan] = []
|
||||||
|
renames: list[PathRename] = []
|
||||||
|
mappings: list[LineCorrespondence] = []
|
||||||
|
cleanup: list[float] = []
|
||||||
|
total_bytes = 0
|
||||||
|
remaining_diff = limits.max_diff_bytes
|
||||||
|
remaining_lines = limits.max_mapping_lines
|
||||||
|
try:
|
||||||
|
if comparison.base_oid is None:
|
||||||
|
raise SnapshotInputError("comparison_base_unavailable")
|
||||||
|
for oid in (comparison.base_oid, comparison.head_oid):
|
||||||
|
if re.fullmatch(r"[0-9a-f]{" + str(self._oid_width) + r"}", oid) is None:
|
||||||
|
raise SnapshotInputError("comparison_requires_immutable_commit_oid")
|
||||||
|
if self._bounded_git(("cat-file", "-t", oid), 64, budget, cancelled, cleanup) != b"commit\n":
|
||||||
|
raise SnapshotInputError("comparison_requires_commit_objects")
|
||||||
|
raw = self._bounded_git(
|
||||||
|
(
|
||||||
|
"diff-tree",
|
||||||
|
"-r",
|
||||||
|
"--no-commit-id",
|
||||||
|
"--raw",
|
||||||
|
"-z",
|
||||||
|
"--no-abbrev",
|
||||||
|
"--no-ext-diff",
|
||||||
|
"--no-textconv",
|
||||||
|
"--no-relative",
|
||||||
|
"--ignore-submodules=none",
|
||||||
|
"--find-renames=50%",
|
||||||
|
"-l1000",
|
||||||
|
comparison.base_oid,
|
||||||
|
comparison.head_oid,
|
||||||
|
"--",
|
||||||
|
),
|
||||||
|
min(limits.max_tree_bytes, remaining_diff),
|
||||||
|
budget,
|
||||||
|
cancelled,
|
||||||
|
cleanup,
|
||||||
|
)
|
||||||
|
remaining_diff -= len(raw)
|
||||||
|
records = _diff_records(raw, limits.max_files)
|
||||||
|
statuses = {record[4] for record in records}
|
||||||
|
if {"A", "D"} <= statuses:
|
||||||
|
# Rename thresholds/limits can leave a moved declaration as A+D.
|
||||||
|
# Absence of a Git pairing is not proof of unrelated/new behavior.
|
||||||
|
boundaries.append("unmatched_added_deleted_path_correspondence")
|
||||||
|
old_blob_counts = Counter(record[2] for record in records if record[4] != "A")
|
||||||
|
new_blob_counts = Counter(record[3] for record in records if record[4] != "D")
|
||||||
|
for mode_base, mode_head, oid_base, oid_head, status, path_base, path_head in records:
|
||||||
|
_check_budget(budget, cancelled)
|
||||||
|
paths = tuple(p for p in (path_base, path_head) if p is not None)
|
||||||
|
for path in paths:
|
||||||
|
if path.hex() not in changed:
|
||||||
|
changed.append(path.hex())
|
||||||
|
if status == "D":
|
||||||
|
assert path_base is not None
|
||||||
|
deleted.append(path_base.hex())
|
||||||
|
if status == "R":
|
||||||
|
assert path_base is not None and path_head is not None
|
||||||
|
renames.append(PathRename(path_base.hex(), path_head.hex()))
|
||||||
|
label = (path_head or path_base or b"?").hex()
|
||||||
|
ambiguous_rename = status == "R" and (old_blob_counts[oid_base] > 1 or new_blob_counts[oid_head] > 1)
|
||||||
|
if ambiguous_rename:
|
||||||
|
boundaries.append(label + ":ambiguous_path_rename_correspondence")
|
||||||
|
try:
|
||||||
|
if any(len(p) > limits.max_path_bytes or len(p.split(b"/")) > limits.max_path_depth for p in paths):
|
||||||
|
raise SnapshotInputError("unsupported_path")
|
||||||
|
contents: list[bytes] = []
|
||||||
|
for mode, oid in ((mode_base, oid_base), (mode_head, oid_head)):
|
||||||
|
if mode == "000000":
|
||||||
|
contents.append(b"")
|
||||||
|
continue
|
||||||
|
if mode not in ("100644", "100755"):
|
||||||
|
raise SnapshotInputError("unsupported_diff_entry")
|
||||||
|
size = int(self._bounded_git(("cat-file", "-s", oid), 64, budget, cancelled, cleanup))
|
||||||
|
if size < 0 or size > min(limits.max_blob_bytes, limits.max_total_bytes - total_bytes):
|
||||||
|
raise SnapshotInputError("source_byte_limit")
|
||||||
|
data = self._bounded_git(("cat-file", "blob", oid), size, budget, cancelled, cleanup)
|
||||||
|
if (
|
||||||
|
len(data) != size
|
||||||
|
or hashlib.new(self.object_format, b"blob " + str(size).encode() + b"\0" + data).hexdigest() != oid
|
||||||
|
):
|
||||||
|
raise SnapshotInputError("blob_identity_mismatch")
|
||||||
|
total_bytes += size
|
||||||
|
if data.startswith(b"version https://git-lfs.github.com/spec/v1\n"):
|
||||||
|
raise SnapshotInputError("lfs_content_unavailable")
|
||||||
|
if b"\0" in data:
|
||||||
|
raise SnapshotInputError("binary_line_correspondence_unavailable")
|
||||||
|
contents.append(data)
|
||||||
|
before, after = contents
|
||||||
|
# LF is the only source-line delimiter; Unicode and CR remain data.
|
||||||
|
line_count = sum(data.count(b"\n") + int(bool(data) and not data.endswith(b"\n")) for data in (before, after))
|
||||||
|
if line_count > remaining_lines:
|
||||||
|
raise SnapshotInputError("line_mapping_limit")
|
||||||
|
remaining_lines -= line_count
|
||||||
|
old_lines, new_lines = _source_lines(before), _source_lines(after)
|
||||||
|
if path_base is None:
|
||||||
|
if new_lines:
|
||||||
|
spans.append(ChangedSpan(label, 1, len(new_lines), "head"))
|
||||||
|
continue
|
||||||
|
if path_head is None:
|
||||||
|
if old_lines:
|
||||||
|
spans.append(ChangedSpan(label, 1, len(old_lines), "base"))
|
||||||
|
continue
|
||||||
|
patch = self._bounded_git(
|
||||||
|
(
|
||||||
|
"diff",
|
||||||
|
"--no-ext-diff",
|
||||||
|
"--no-textconv",
|
||||||
|
"--no-color",
|
||||||
|
"--no-relative",
|
||||||
|
"--text",
|
||||||
|
"--unified=0",
|
||||||
|
"--inter-hunk-context=0",
|
||||||
|
"--diff-algorithm=myers",
|
||||||
|
"--no-indent-heuristic",
|
||||||
|
oid_base,
|
||||||
|
oid_head,
|
||||||
|
"--",
|
||||||
|
),
|
||||||
|
remaining_diff,
|
||||||
|
budget,
|
||||||
|
cancelled,
|
||||||
|
cleanup,
|
||||||
|
)
|
||||||
|
remaining_diff -= len(patch)
|
||||||
|
hunks = [
|
||||||
|
(int(a), int(b) if b else 1, int(c), int(d) if d else 1)
|
||||||
|
for a, b, c, d in re.findall(rb"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", patch, re.M)
|
||||||
|
]
|
||||||
|
if before != after and not hunks:
|
||||||
|
raise SnapshotInputError("diff_hunks_unavailable")
|
||||||
|
pairs: list[tuple[int, int]] = []
|
||||||
|
old_cursor = new_cursor = 0
|
||||||
|
for old_start, old_count, new_start, new_count in hunks:
|
||||||
|
# Git uses the preceding line for a zero-width edit interval.
|
||||||
|
old_index = old_start - 1 if old_count else old_start
|
||||||
|
new_index = new_start - 1 if new_count else new_start
|
||||||
|
if (
|
||||||
|
old_index < old_cursor
|
||||||
|
or new_index < new_cursor
|
||||||
|
or old_index + old_count > len(old_lines)
|
||||||
|
or new_index + new_count > len(new_lines)
|
||||||
|
):
|
||||||
|
raise SnapshotInputError("invalid_diff_hunks")
|
||||||
|
pairs.extend(_equal_line_pairs(old_lines, new_lines, old_cursor, old_index, new_cursor, new_index))
|
||||||
|
if old_count:
|
||||||
|
spans.append(ChangedSpan(path_base.hex(), old_start, old_start + old_count - 1, "base"))
|
||||||
|
if new_count:
|
||||||
|
spans.append(ChangedSpan(path_head.hex(), new_start, new_start + new_count - 1, "head"))
|
||||||
|
old_cursor, new_cursor = old_index + old_count, new_index + new_count
|
||||||
|
pairs.extend(_equal_line_pairs(old_lines, new_lines, old_cursor, len(old_lines), new_cursor, len(new_lines)))
|
||||||
|
old_counts, new_counts = Counter(old_lines), Counter(new_lines)
|
||||||
|
ambiguous = False
|
||||||
|
for old_index, new_index in () if ambiguous_rename else pairs:
|
||||||
|
_check_budget(budget, cancelled)
|
||||||
|
content = old_lines[old_index]
|
||||||
|
if old_counts[content] != 1 or new_counts[content] != 1:
|
||||||
|
ambiguous = True
|
||||||
|
continue
|
||||||
|
mappings.append(LineCorrespondence(path_base.hex(), path_head.hex(), old_index + 1, old_index + 1, new_index + 1))
|
||||||
|
if ambiguous:
|
||||||
|
boundaries.append(label + ":ambiguous_line_correspondence")
|
||||||
|
if before and after and not any(m.base_path == path_base.hex() for m in mappings):
|
||||||
|
boundaries.append(label + ":line_correspondence_unavailable")
|
||||||
|
except (SnapshotInputError, ValueError) as error:
|
||||||
|
boundaries.append(label + ":" + str(error))
|
||||||
|
if str(error) in ("deadline_exhausted", "cancelled", "git_cleanup_deadline_exhausted"):
|
||||||
|
break
|
||||||
|
except (SnapshotInputError, ValueError) as error:
|
||||||
|
boundaries.append(str(error))
|
||||||
|
declared = {p.hex() for p in declaration_paths or ()}
|
||||||
|
return ChangeContext(
|
||||||
|
comparison.base_oid,
|
||||||
|
comparison.head_oid,
|
||||||
|
tuple(changed),
|
||||||
|
tuple(deleted),
|
||||||
|
tuple(spans),
|
||||||
|
tuple(renames),
|
||||||
|
tuple(p for p in changed if p in declared),
|
||||||
|
(),
|
||||||
|
tuple(boundaries),
|
||||||
|
"filesystem-bytes-hex",
|
||||||
|
tuple(mappings),
|
||||||
|
)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def materialize(
|
def materialize(
|
||||||
self, commit_oid: str, *, budget: ExecutionBudget | None = None,
|
self,
|
||||||
limits: SnapshotLimits | None = None, scratch_parent: Path | None = None,
|
commit_oid: str,
|
||||||
|
*,
|
||||||
|
budget: ExecutionBudget | None = None,
|
||||||
|
limits: SnapshotLimits | None = None,
|
||||||
|
scratch_parent: Path | None = None,
|
||||||
cancelled: Callable[[], bool] | None = None,
|
cancelled: Callable[[], bool] | None = None,
|
||||||
) -> Iterator[MaterializedSnapshot]:
|
) -> Iterator[MaterializedSnapshot]:
|
||||||
"""Own one disposable tracked-object tree, with no checkout/filter execution.
|
"""Own one disposable tracked-object tree, with no checkout/filter execution.
|
||||||
@@ -208,8 +443,9 @@ class SnapshotAdapter:
|
|||||||
try:
|
try:
|
||||||
if self._bounded_git(("cat-file", "-t", commit_oid), 64, budget, cancelled, cleanup_deadline) != b"commit\n":
|
if self._bounded_git(("cat-file", "-t", commit_oid), 64, budget, cancelled, cleanup_deadline) != b"commit\n":
|
||||||
raise SnapshotInputError("unsupported_snapshot_object")
|
raise SnapshotInputError("unsupported_snapshot_object")
|
||||||
listing = self._bounded_git(("ls-tree", "-r", "-z", "--full-tree", commit_oid),
|
listing = self._bounded_git(
|
||||||
limits.max_tree_bytes, budget, cancelled, cleanup_deadline)
|
("ls-tree", "-r", "-z", "--full-tree", commit_oid), limits.max_tree_bytes, budget, cancelled, cleanup_deadline
|
||||||
|
)
|
||||||
records = listing.split(b"\0")
|
records = listing.split(b"\0")
|
||||||
if records[-1] != b"":
|
if records[-1] != b"":
|
||||||
raise SnapshotInputError("truncated_tree_metadata")
|
raise SnapshotInputError("truncated_tree_metadata")
|
||||||
@@ -223,9 +459,14 @@ class SnapshotAdapter:
|
|||||||
mode, kind, oid = metadata.split(b" ")
|
mode, kind, oid = metadata.split(b" ")
|
||||||
path_hex = path.hex()
|
path_hex = path.hex()
|
||||||
components = path.split(b"/")
|
components = path.split(b"/")
|
||||||
if (not path or path.startswith(b"/") or any(part in (b"", b".", b"..") for part in components)
|
if (
|
||||||
or len(path) > limits.max_path_bytes or len(components) > limits.max_path_depth
|
not path
|
||||||
or any(part.lower() == b".git" for part in components)):
|
or path.startswith(b"/")
|
||||||
|
or any(part in (b"", b".", b"..") for part in components)
|
||||||
|
or len(path) > limits.max_path_bytes
|
||||||
|
or len(components) > limits.max_path_depth
|
||||||
|
or any(part.lower() == b".git" for part in components)
|
||||||
|
):
|
||||||
boundaries.append(SnapshotBoundary(path_hex or None, "unsupported_path"))
|
boundaries.append(SnapshotBoundary(path_hex or None, "unsupported_path"))
|
||||||
continue
|
continue
|
||||||
if mode == b"120000":
|
if mode == b"120000":
|
||||||
@@ -256,27 +497,33 @@ class SnapshotAdapter:
|
|||||||
with target.open("xb") as output:
|
with target.open("xb") as output:
|
||||||
output.write(data)
|
output.write(data)
|
||||||
target.chmod(0o555 if mode == b"100755" else 0o444)
|
target.chmod(0o555 if mode == b"100755" else 0o444)
|
||||||
files.append(SnapshotFile(path_hex, blob_oid, mode.decode("ascii"), size,
|
files.append(SnapshotFile(path_hex, blob_oid, mode.decode("ascii"), size, hashlib.sha256(data).hexdigest()))
|
||||||
hashlib.sha256(data).hexdigest()))
|
|
||||||
except SnapshotInputError as error:
|
except SnapshotInputError as error:
|
||||||
boundaries.append(SnapshotBoundary(path_hex, str(error)))
|
boundaries.append(SnapshotBoundary(path_hex, str(error)))
|
||||||
if str(error) in ("deadline_exhausted", "cancelled", "git_output_limit", "git_cleanup_deadline_exhausted"):
|
if str(error) in ("deadline_exhausted", "cancelled", "git_output_limit", "git_cleanup_deadline_exhausted"):
|
||||||
break
|
break
|
||||||
except (SnapshotInputError, OSError, ValueError) as error:
|
except (SnapshotInputError, OSError, ValueError) as error:
|
||||||
boundaries.append(SnapshotBoundary(None, str(error) or type(error).__name__))
|
boundaries.append(SnapshotBoundary(None, str(error) or type(error).__name__))
|
||||||
yield MaterializedSnapshot(root, SnapshotManifest(commit_oid, self.object_format, tuple(files),
|
yield MaterializedSnapshot(
|
||||||
tuple(boundaries), tree_complete, total))
|
root, SnapshotManifest(commit_oid, self.object_format, tuple(files), tuple(boundaries), tree_complete, total)
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
_remove_scratch(root, _cleanup_deadline(budget, cleanup_deadline))
|
_remove_scratch(root, _cleanup_deadline(budget, cleanup_deadline))
|
||||||
|
|
||||||
def _bounded_git(self, args: tuple[str, ...], limit: int, budget: ExecutionBudget,
|
def _bounded_git(
|
||||||
cancelled: Callable[[], bool] | None, cleanup_deadline: list[float]) -> bytes:
|
self,
|
||||||
|
args: tuple[str, ...],
|
||||||
|
limit: int,
|
||||||
|
budget: ExecutionBudget,
|
||||||
|
cancelled: Callable[[], bool] | None,
|
||||||
|
cleanup_deadline: list[float],
|
||||||
|
) -> bytes:
|
||||||
_check_budget(budget, cancelled)
|
_check_budget(budget, cancelled)
|
||||||
command = ["git", "-c", "protocol.allow=never", "-c", "core.hooksPath=" + os.devnull,
|
command = ["git", "-c", "protocol.allow=never", "-c", "core.hooksPath=" + os.devnull, "-C", str(self.repository), *args]
|
||||||
"-C", str(self.repository), *args]
|
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
process = subprocess.Popen(
|
||||||
stdin=subprocess.DEVNULL, env=self._env, start_new_session=True)
|
command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, env=self._env, start_new_session=True
|
||||||
|
)
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
raise SnapshotInputError("git_launch_failed") from error
|
raise SnapshotInputError("git_launch_failed") from error
|
||||||
output = bytearray()
|
output = bytearray()
|
||||||
@@ -322,6 +569,8 @@ class SnapshotLimits:
|
|||||||
max_tree_bytes: int = 4 * 1024 * 1024
|
max_tree_bytes: int = 4 * 1024 * 1024
|
||||||
max_path_bytes: int = 4095
|
max_path_bytes: int = 4095
|
||||||
max_path_depth: int = 64
|
max_path_depth: int = 64
|
||||||
|
max_diff_bytes: int = 8 * 1024 * 1024
|
||||||
|
max_mapping_lines: int = 100000
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
for value in vars(self).values():
|
for value in vars(self).values():
|
||||||
@@ -364,6 +613,7 @@ def _remove_scratch(root: Path, deadline: float) -> None:
|
|||||||
os.rmdir(name, dir_fd=descriptor)
|
os.rmdir(name, dir_fd=descriptor)
|
||||||
else:
|
else:
|
||||||
os.unlink(name, dir_fd=descriptor)
|
os.unlink(name, dir_fd=descriptor)
|
||||||
|
|
||||||
if root.is_symlink():
|
if root.is_symlink():
|
||||||
root.unlink()
|
root.unlink()
|
||||||
return
|
return
|
||||||
@@ -373,3 +623,46 @@ def _remove_scratch(root: Path, deadline: float) -> None:
|
|||||||
finally:
|
finally:
|
||||||
os.close(descriptor)
|
os.close(descriptor)
|
||||||
root.rmdir()
|
root.rmdir()
|
||||||
|
|
||||||
|
|
||||||
|
def _diff_records(raw: bytes, max_files: int) -> list[tuple[str, str, str, str, str, bytes | None, bytes | None]]:
|
||||||
|
"""Parse only NUL-framed raw metadata; quoted patch filenames are never parsed."""
|
||||||
|
if raw and not raw.endswith(b"\0"):
|
||||||
|
raise SnapshotInputError("truncated_diff_metadata")
|
||||||
|
fields = iter(raw.split(b"\0")[:-1])
|
||||||
|
result = []
|
||||||
|
try:
|
||||||
|
for metadata in fields:
|
||||||
|
old_mode, new_mode, old_oid, new_oid, status = metadata.decode("ascii").split(" ")
|
||||||
|
if not old_mode.startswith(":") or status[0] not in "ADMRT":
|
||||||
|
raise SnapshotInputError("unsupported_diff_metadata")
|
||||||
|
first = next(fields)
|
||||||
|
if not first:
|
||||||
|
raise SnapshotInputError("empty_diff_path")
|
||||||
|
second = next(fields) if status.startswith("R") else first
|
||||||
|
if not second:
|
||||||
|
raise SnapshotInputError("empty_diff_path")
|
||||||
|
result.append(
|
||||||
|
(old_mode[1:], new_mode, old_oid, new_oid, status[0], None if status == "A" else first, None if status == "D" else second)
|
||||||
|
)
|
||||||
|
if len(result) > max_files:
|
||||||
|
raise SnapshotInputError("file_count_limit")
|
||||||
|
except (ValueError, StopIteration, IndexError) as error:
|
||||||
|
raise SnapshotInputError("invalid_diff_metadata") from error
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _source_lines(data: bytes) -> list[bytes]:
|
||||||
|
if not data:
|
||||||
|
return []
|
||||||
|
# Keep LF in each record so a final-newline change cannot look unchanged.
|
||||||
|
lines = data.split(b"\n")
|
||||||
|
return [line + b"\n" for line in lines[:-1]] + ([lines[-1]] if lines[-1] else [])
|
||||||
|
|
||||||
|
|
||||||
|
def _equal_line_pairs(
|
||||||
|
old: list[bytes], new: list[bytes], old_start: int, old_end: int, new_start: int, new_end: int
|
||||||
|
) -> list[tuple[int, int]]:
|
||||||
|
if old[old_start:old_end] != new[new_start:new_end]:
|
||||||
|
raise SnapshotInputError("diff_line_identity_mismatch")
|
||||||
|
return [(old_start + offset, new_start + offset) for offset in range(old_end - old_start)]
|
||||||
|
|||||||
@@ -122,7 +122,10 @@ def _case(
|
|||||||
and record.get("coverage") == "complete"
|
and record.get("coverage") == "complete"
|
||||||
)
|
)
|
||||||
negative = any(
|
negative = any(
|
||||||
q.get("status") == "answered" and q.get("outcome") == "negative" and q.get("target") == target and isinstance(q.get("witness"), str)
|
q.get("status") == "answered"
|
||||||
|
and q.get("outcome") == "negative"
|
||||||
|
and q.get("target") == target
|
||||||
|
and isinstance(q.get("witness"), str)
|
||||||
and bool(q["witness"].strip())
|
and bool(q["witness"].strip())
|
||||||
for q in queries.values()
|
for q in queries.values()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Approved task 1.4 contracts: persist identities without inventing completed analysis."""
|
"""Approved task 1.4 contracts: persist identities without inventing completed analysis."""
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
import json
|
import json
|
||||||
from dataclasses import FrozenInstanceError, replace
|
from dataclasses import FrozenInstanceError, replace
|
||||||
@@ -35,9 +36,11 @@ def comparison() -> PushComparison:
|
|||||||
def test_config_round_trip_and_ordinary_scan_compatibility(tmp_path: Path) -> None:
|
def test_config_round_trip_and_ordinary_scan_compatibility(tmp_path: Path) -> None:
|
||||||
baseline = load_config()
|
baseline = load_config()
|
||||||
path = tmp_path / "settings.toml"
|
path = tmp_path / "settings.toml"
|
||||||
path.write_text('[push]\ncomparison_base = "refs/heads/main"\ndeadline_seconds = 11.5\n'
|
path.write_text(
|
||||||
'cancellation_allowance_seconds = 1\ncache_max_bytes = 4096\nmode = "blocking"\n'
|
'[push]\ncomparison_base = "refs/heads/main"\ndeadline_seconds = 11.5\n'
|
||||||
'incomplete_coverage_policy = "block"\n')
|
'cancellation_allowance_seconds = 1\ncache_max_bytes = 4096\nmode = "blocking"\n'
|
||||||
|
'incomplete_coverage_policy = "block"\n'
|
||||||
|
)
|
||||||
configured = load_config(path)
|
configured = load_config(path)
|
||||||
assert configured.push == PushConfig("refs/heads/main", 11.5, 1, 4096, "blocking", "block")
|
assert configured.push == PushConfig("refs/heads/main", 11.5, 1, 4096, "blocking", "block")
|
||||||
assert PushConfig.from_payload(json.loads(json.dumps(config_payload(configured)["push"]))) == configured.push
|
assert PushConfig.from_payload(json.loads(json.dumps(config_payload(configured)["push"]))) == configured.push
|
||||||
@@ -48,19 +51,32 @@ def test_config_round_trip_and_ordinary_scan_compatibility(tmp_path: Path) -> No
|
|||||||
assert baseline.push == PushConfig()
|
assert baseline.push == PushConfig()
|
||||||
assert baseline.push.deadline_seconds == 30
|
assert baseline.push.deadline_seconds == 30
|
||||||
assert baseline.push.cancellation_allowance_seconds == 2
|
assert baseline.push.cancellation_allowance_seconds == 2
|
||||||
assert baseline.push.cache_max_bytes == 2 * 1024 ** 3
|
assert baseline.push.cache_max_bytes == 2 * 1024**3
|
||||||
assert baseline.push.mode == "advisory"
|
assert baseline.push.mode == "advisory"
|
||||||
assert baseline.push.incomplete_coverage_policy == "allow"
|
assert baseline.push.incomplete_coverage_policy == "allow"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("setting", [
|
@pytest.mark.parametrize(
|
||||||
'push = "bad"', 'push = []', '[push]\ndeadline_seconds = true', '[push]\ndeadline_seconds = "30"',
|
"setting",
|
||||||
'[push]\ndeadline_seconds = nan', '[push]\ndeadline_seconds = inf', '[push]\ndeadline_seconds = 0',
|
[
|
||||||
'[push]\ncancellation_allowance_seconds = -1', '[push]\ncache_max_bytes = 0',
|
'push = "bad"',
|
||||||
'[push]\ncache_max_bytes = 1.5', '[push]\ncache_max_bytes = true', '[push]\nmode = "nag"',
|
"push = []",
|
||||||
'[push]\nincomplete_coverage_policy = "ignore"', '[push]\ncomparison_base = ""',
|
"[push]\ndeadline_seconds = true",
|
||||||
'[push]\ncomparison_base = 1', '[push]\ndeadline_second = 60',
|
'[push]\ndeadline_seconds = "30"',
|
||||||
])
|
"[push]\ndeadline_seconds = nan",
|
||||||
|
"[push]\ndeadline_seconds = inf",
|
||||||
|
"[push]\ndeadline_seconds = 0",
|
||||||
|
"[push]\ncancellation_allowance_seconds = -1",
|
||||||
|
"[push]\ncache_max_bytes = 0",
|
||||||
|
"[push]\ncache_max_bytes = 1.5",
|
||||||
|
"[push]\ncache_max_bytes = true",
|
||||||
|
'[push]\nmode = "nag"',
|
||||||
|
'[push]\nincomplete_coverage_policy = "ignore"',
|
||||||
|
'[push]\ncomparison_base = ""',
|
||||||
|
"[push]\ncomparison_base = 1",
|
||||||
|
"[push]\ndeadline_second = 60",
|
||||||
|
],
|
||||||
|
)
|
||||||
def test_push_configuration_rejects_unsafe_coercion(tmp_path: Path, setting: str) -> None:
|
def test_push_configuration_rejects_unsafe_coercion(tmp_path: Path, setting: str) -> None:
|
||||||
path = tmp_path / "settings.toml"
|
path = tmp_path / "settings.toml"
|
||||||
path.write_text(setting)
|
path.write_text(setting)
|
||||||
@@ -70,8 +86,17 @@ def test_push_configuration_rejects_unsafe_coercion(tmp_path: Path, setting: str
|
|||||||
|
|
||||||
def test_contract_round_trips_preserve_partition_ref_and_comparison_identity() -> None:
|
def test_contract_round_trips_preserve_partition_ref_and_comparison_identity() -> None:
|
||||||
update = PushUpdate("refs/heads/topic", "a" * 64, "refs/heads/main", "b" * 64)
|
update = PushUpdate("refs/heads/topic", "a" * 64, "refs/heads/main", "b" * 64)
|
||||||
context = ChangeContext("b" * 64, "a" * 64, ("src/api.js",), ("src/guard.js",),
|
context = ChangeContext(
|
||||||
(ChangedSpan("src/guard.js", 3, 8, "base"),), (), ("package.json",), (), ("dynamic dispatch",))
|
"b" * 64,
|
||||||
|
"a" * 64,
|
||||||
|
("src/api.js",),
|
||||||
|
("src/guard.js",),
|
||||||
|
(ChangedSpan("src/guard.js", 3, 8, "base"),),
|
||||||
|
(),
|
||||||
|
("package.json",),
|
||||||
|
(),
|
||||||
|
("dynamic dispatch",),
|
||||||
|
)
|
||||||
for value in (update, comparison(), context, scope()):
|
for value in (update, comparison(), context, scope()):
|
||||||
assert type(value).from_payload(json.loads(json.dumps(value.to_payload()))) == value
|
assert type(value).from_payload(json.loads(json.dumps(value.to_payload()))) == value
|
||||||
assert question() != question("php", "src/api.js")
|
assert question() != question("php", "src/api.js")
|
||||||
@@ -82,15 +107,21 @@ def test_contract_round_trips_preserve_partition_ref_and_comparison_identity() -
|
|||||||
update.local_oid = "other" # type: ignore[misc]
|
update.local_oid = "other" # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("finding,coverage,disposition", [
|
@pytest.mark.parametrize(
|
||||||
("none", "incomplete", "allow"), ("none", "unavailable", "block"),
|
"finding,coverage,disposition",
|
||||||
("actionable", "incomplete", "allow"), ("actionable", "complete_within_scope", "block"),
|
[
|
||||||
])
|
("none", "incomplete", "allow"),
|
||||||
|
("none", "unavailable", "block"),
|
||||||
|
("actionable", "incomplete", "allow"),
|
||||||
|
("actionable", "complete_within_scope", "block"),
|
||||||
|
],
|
||||||
|
)
|
||||||
def test_result_statuses_are_independent(finding: str, coverage: str, disposition: str) -> None:
|
def test_result_statuses_are_independent(finding: str, coverage: str, disposition: str) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"analyses": [ComparisonAnalysis(comparison(), (scope(),), (question(),), coverage).to_payload()],
|
"analyses": [ComparisonAnalysis(comparison(), (scope(),), (question(),), coverage).to_payload()],
|
||||||
"finding_status": finding,
|
"finding_status": finding,
|
||||||
"coverage_status": coverage, "push_disposition": disposition,
|
"coverage_status": coverage,
|
||||||
|
"push_disposition": disposition,
|
||||||
"actionable_defect_ids": ["defect-1"] if finding == "actionable" else [],
|
"actionable_defect_ids": ["defect-1"] if finding == "actionable" else [],
|
||||||
}
|
}
|
||||||
result = PushResult.from_payload(payload)
|
result = PushResult.from_payload(payload)
|
||||||
@@ -104,8 +135,11 @@ def test_incomplete_scope_cannot_be_serialized_as_complete_negative() -> None:
|
|||||||
result = ComparisonAnalysis(comparison(), (scope(),), (question(),), "complete_within_scope")
|
result = ComparisonAnalysis(comparison(), (scope(),), (question(),), "complete_within_scope")
|
||||||
assert ComparisonAnalysis.from_payload(result.to_payload()) == result
|
assert ComparisonAnalysis.from_payload(result.to_payload()) == result
|
||||||
deferred = DeferredQuestion(question("php", "index.php"), "deadline", ("unanswered",))
|
deferred = DeferredQuestion(question("php", "index.php"), "deadline", ("unanswered",))
|
||||||
for changed in (replace(scope(), deferred=(deferred,)), replace(scope(), unresolved_boundaries=("vendor call",)),
|
for changed in (
|
||||||
replace(scope(), population_complete=False)):
|
replace(scope(), deferred=(deferred,)),
|
||||||
|
replace(scope(), unresolved_boundaries=("vendor call",)),
|
||||||
|
replace(scope(), population_complete=False),
|
||||||
|
):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
replace(result, scopes=(changed,))
|
replace(result, scopes=(changed,))
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
@@ -118,19 +152,32 @@ def test_incomplete_scope_cannot_be_serialized_as_complete_negative() -> None:
|
|||||||
replace(scope(), deferred=(DeferredQuestion(question(), "deadline", ()),))
|
replace(scope(), deferred=(DeferredQuestion(question(), "deadline", ()),))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", [
|
@pytest.mark.parametrize(
|
||||||
{"local_ref": "x", "local_oid": "a", "remote_ref": "y", "remote_oid": "b", "extra": 1},
|
"value",
|
||||||
{"local_ref": "x", "local_oid": 1, "remote_ref": "y", "remote_oid": "b"},
|
[
|
||||||
{"local_ref": "x", "local_oid": "a", "remote_ref": "y"},
|
{"local_ref": "x", "local_oid": "a", "remote_ref": "y", "remote_oid": "b", "extra": 1},
|
||||||
])
|
{"local_ref": "x", "local_oid": 1, "remote_ref": "y", "remote_oid": "b"},
|
||||||
|
{"local_ref": "x", "local_oid": "a", "remote_ref": "y"},
|
||||||
|
],
|
||||||
|
)
|
||||||
def test_malformed_identity_payloads_fail(value: dict[str, object]) -> None:
|
def test_malformed_identity_payloads_fail(value: dict[str, object]) -> None:
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
PushUpdate.from_payload(value)
|
PushUpdate.from_payload(value)
|
||||||
|
|
||||||
|
|
||||||
def test_graph_provenance_round_trip_and_live_lease_separation() -> None:
|
def test_graph_provenance_round_trip_and_live_lease_separation() -> None:
|
||||||
identity = GraphIdentity("source-digest", "declarations", "exclusions", "api", "javascript",
|
identity = GraphIdentity(
|
||||||
"4.0.625", "jssrc2cpg", "4.0.625", ("dataflowOss-v1",), (("option", "value"),))
|
"source-digest",
|
||||||
|
"declarations",
|
||||||
|
"exclusions",
|
||||||
|
"api",
|
||||||
|
"javascript",
|
||||||
|
"4.0.625",
|
||||||
|
"jssrc2cpg",
|
||||||
|
"4.0.625",
|
||||||
|
("dataflowOss-v1",),
|
||||||
|
(("option", "value"),),
|
||||||
|
)
|
||||||
artifact = GraphArtifact(identity, "graph-sha256", 128, GraphCensus(1, 2, 3), "complete", ())
|
artifact = GraphArtifact(identity, "graph-sha256", 128, GraphCensus(1, 2, 3), "complete", ())
|
||||||
assert GraphArtifact.from_payload(json.loads(json.dumps(artifact.to_payload()))) == artifact
|
assert GraphArtifact.from_payload(json.loads(json.dumps(artifact.to_payload()))) == artifact
|
||||||
assert replace(identity, exclusions_digest="changed") != identity
|
assert replace(identity, exclusions_digest="changed") != identity
|
||||||
@@ -189,10 +236,16 @@ def test_deletion_only_result_is_explicitly_not_applicable() -> None:
|
|||||||
replace(result, coverage_status="complete_within_scope")
|
replace(result, coverage_status="complete_within_scope")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("field,value", [
|
@pytest.mark.parametrize(
|
||||||
("finding_status", "clean"), ("coverage_status", "secure"), ("push_disposition", "warn"),
|
"field,value",
|
||||||
("finding_status", True), ("coverage_status", None),
|
[
|
||||||
])
|
("finding_status", "clean"),
|
||||||
|
("coverage_status", "secure"),
|
||||||
|
("push_disposition", "warn"),
|
||||||
|
("finding_status", True),
|
||||||
|
("coverage_status", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
def test_result_rejects_unknown_status_vocabulary(field: str, value: object) -> None:
|
def test_result_rejects_unknown_status_vocabulary(field: str, value: object) -> None:
|
||||||
payload = PushResult((), "none", "not_applicable", "allow", ()).to_payload()
|
payload = PushResult((), "none", "not_applicable", "allow", ()).to_payload()
|
||||||
payload[field] = value
|
payload[field] = value
|
||||||
@@ -201,8 +254,19 @@ def test_result_rejects_unknown_status_vocabulary(field: str, value: object) ->
|
|||||||
|
|
||||||
|
|
||||||
def test_graph_partial_provenance_stays_partial_and_options_are_preserved() -> None:
|
def test_graph_partial_provenance_stays_partial_and_options_are_preserved() -> None:
|
||||||
identity = GraphIdentity("sources", "decls", "excludes", "core", "c", "4.0.625", "c2cpg", "4.0.625",
|
identity = GraphIdentity(
|
||||||
("dataflowOss-v1",), (("depth", "2"),), (("include", "src"),))
|
"sources",
|
||||||
|
"decls",
|
||||||
|
"excludes",
|
||||||
|
"core",
|
||||||
|
"c",
|
||||||
|
"4.0.625",
|
||||||
|
"c2cpg",
|
||||||
|
"4.0.625",
|
||||||
|
("dataflowOss-v1",),
|
||||||
|
(("depth", "2"),),
|
||||||
|
(("include", "src"),),
|
||||||
|
)
|
||||||
artifact = GraphArtifact(identity, "digest", 0, GraphCensus(0, 0, 0), "partial", ("source unreadable",))
|
artifact = GraphArtifact(identity, "digest", 0, GraphCensus(0, 0, 0), "partial", ("source unreadable",))
|
||||||
restored = GraphArtifact.from_payload(json.loads(json.dumps(artifact.to_payload())))
|
restored = GraphArtifact.from_payload(json.loads(json.dumps(artifact.to_payload())))
|
||||||
assert restored == artifact
|
assert restored == artifact
|
||||||
|
|||||||
+235
-23
@@ -1,4 +1,5 @@
|
|||||||
"""Exercise the snapshot resolver with real local Git objects, not mocked answers."""
|
"""Exercise the snapshot resolver with real local Git objects, not mocked answers."""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -41,7 +42,9 @@ def test_preserves_every_ref_and_distinct_comparisons(repo: Path) -> None:
|
|||||||
result = SnapshotAdapter(repo).resolve_updates(line(head, base) + line(head, other, "force") + line(head, base, "alias"))
|
result = SnapshotAdapter(repo).resolve_updates(line(head, base) + line(head, other, "force") + line(head, base, "alias"))
|
||||||
assert result.targets == (head,)
|
assert result.targets == (head,)
|
||||||
assert [(c.head_oid, c.base_oid, c.refs) for c in result.comparisons] == [
|
assert [(c.head_oid, c.base_oid, c.refs) for c in result.comparisons] == [
|
||||||
(head, base, ("refs/heads/main", "refs/heads/alias")), (head, other, ("refs/heads/force",))]
|
(head, base, ("refs/heads/main", "refs/heads/alias")),
|
||||||
|
(head, other, ("refs/heads/force",)),
|
||||||
|
]
|
||||||
assert [r.disposition for r in result.updates] == ["ready"] * 3
|
assert [r.disposition for r in result.updates] == ["ready"] * 3
|
||||||
assert len(result.updates) == 3
|
assert len(result.updates) == 3
|
||||||
|
|
||||||
@@ -119,9 +122,16 @@ def test_missing_and_unsupported_objects_are_explicit(repo: Path) -> None:
|
|||||||
|
|
||||||
def test_bad_protocol_never_silently_skips_lines(repo: Path) -> None:
|
def test_bad_protocol_never_silently_skips_lines(repo: Path) -> None:
|
||||||
from openultrasast.push.snapshot import SnapshotInputError
|
from openultrasast.push.snapshot import SnapshotInputError
|
||||||
|
|
||||||
head = commit(repo, "head")
|
head = commit(repo, "head")
|
||||||
for invalid in ("\n", "only three fields\n", line(head[:-1], head), line(head, head, "bad..ref"),
|
for invalid in (
|
||||||
line("0" * len(head), head), f"(delete) {head} refs/heads/main {head}\n"):
|
"\n",
|
||||||
|
"only three fields\n",
|
||||||
|
line(head[:-1], head),
|
||||||
|
line(head, head, "bad..ref"),
|
||||||
|
line("0" * len(head), head),
|
||||||
|
f"(delete) {head} refs/heads/main {head}\n",
|
||||||
|
):
|
||||||
with pytest.raises(SnapshotInputError):
|
with pytest.raises(SnapshotInputError):
|
||||||
SnapshotAdapter(repo).resolve_updates(line(head, head) + invalid)
|
SnapshotAdapter(repo).resolve_updates(line(head, head) + invalid)
|
||||||
|
|
||||||
@@ -234,25 +244,35 @@ def binary_commit(repo: Path, entries: list[tuple[bytes, bytes, bytes]]) -> str:
|
|||||||
oid = data
|
oid = data
|
||||||
kind = b"commit"
|
kind = b"commit"
|
||||||
else:
|
else:
|
||||||
oid = subprocess.run(["git", "-C", str(repo), "hash-object", "-w", "--stdin"],
|
oid = subprocess.run(
|
||||||
input=data, capture_output=True, check=True).stdout.rstrip(b"\n")
|
["git", "-C", str(repo), "hash-object", "-w", "--stdin"], input=data, capture_output=True, check=True
|
||||||
|
).stdout.rstrip(b"\n")
|
||||||
kind = b"blob"
|
kind = b"blob"
|
||||||
assert int(git(repo, "cat-file", "-s", oid.decode())) == len(data)
|
assert int(git(repo, "cat-file", "-s", oid.decode())) == len(data)
|
||||||
records.append(mode + b" " + kind + b" " + oid + b"\t" + path + b"\0")
|
records.append(mode + b" " + kind + b" " + oid + b"\t" + path + b"\0")
|
||||||
tree = subprocess.run(["git", "-C", str(repo), "mktree", "-z"], input=b"".join(records),
|
tree = (
|
||||||
capture_output=True, check=True).stdout.decode().strip()
|
subprocess.run(["git", "-C", str(repo), "mktree", "-z"], input=b"".join(records), capture_output=True, check=True)
|
||||||
|
.stdout.decode()
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
return git(repo, "commit-tree", tree, input="binary fixture")
|
return git(repo, "commit-tree", tree, input="binary fixture")
|
||||||
|
|
||||||
|
|
||||||
def test_snapshot_exact_binary_paths_and_no_filters(repo: Path) -> None:
|
def test_snapshot_exact_binary_paths_and_no_filters(repo: Path) -> None:
|
||||||
import os
|
import os
|
||||||
entries = [(b"100644", path, data) for path, data in [
|
|
||||||
(b"handler.php", b"<?php echo $_GET['x'];\n"),
|
entries = [
|
||||||
(b"api.js", b"app.post('/x', (req,res)=>res.send(req.body.x));\n"),
|
(b"100644", path, data)
|
||||||
(b"newline\nname\t.js", b"\x00\xffbinary\r\n"), ("\u03b1.js".encode(), b"unicode"),
|
for path, data in [
|
||||||
(b"\xff.js", b"invalid UTF8 name"), (b" ", b"space"),
|
(b"handler.php", b"<?php echo $_GET['x'];\n"),
|
||||||
(b".gitattributes", b"* filter=trap\n"),
|
(b"api.js", b"app.post('/x', (req,res)=>res.send(req.body.x));\n"),
|
||||||
]]
|
(b"newline\nname\t.js", b"\x00\xffbinary\r\n"),
|
||||||
|
("\u03b1.js".encode(), b"unicode"),
|
||||||
|
(b"\xff.js", b"invalid UTF8 name"),
|
||||||
|
(b" ", b"space"),
|
||||||
|
(b".gitattributes", b"* filter=trap\n"),
|
||||||
|
]
|
||||||
|
]
|
||||||
oid = binary_commit(repo, entries)
|
oid = binary_commit(repo, entries)
|
||||||
sentinel = repo / "FILTER_RAN"
|
sentinel = repo / "FILTER_RAN"
|
||||||
git(repo, "config", "filter.trap.smudge", "touch " + str(sentinel))
|
git(repo, "config", "filter.trap.smudge", "touch " + str(sentinel))
|
||||||
@@ -268,24 +288,38 @@ def test_snapshot_exact_binary_paths_and_no_filters(repo: Path) -> None:
|
|||||||
|
|
||||||
def test_snapshot_omits_symlinks_gitlinks_and_lfs(repo: Path) -> None:
|
def test_snapshot_omits_symlinks_gitlinks_and_lfs(repo: Path) -> None:
|
||||||
target = commit(repo, "submodule")
|
target = commit(repo, "submodule")
|
||||||
oid = binary_commit(repo, [
|
oid = binary_commit(
|
||||||
(b"120000", b"outside", b"/etc/passwd"),
|
repo,
|
||||||
(b"160000", b"submodule", target.encode()),
|
[
|
||||||
(b"100644", b"large.js", b"version https://git-lfs.github.com/spec/v1\noid sha256:" + b"0" * 64 + b"\nsize 3\n"),
|
(b"120000", b"outside", b"/etc/passwd"),
|
||||||
])
|
(b"160000", b"submodule", target.encode()),
|
||||||
|
(b"100644", b"large.js", b"version https://git-lfs.github.com/spec/v1\noid sha256:" + b"0" * 64 + b"\nsize 3\n"),
|
||||||
|
],
|
||||||
|
)
|
||||||
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
||||||
assert not snapshot.manifest.complete
|
assert not snapshot.manifest.complete
|
||||||
assert snapshot.manifest.tree_complete
|
assert snapshot.manifest.tree_complete
|
||||||
assert {b.reason for b in snapshot.manifest.boundaries} == {
|
assert {b.reason for b in snapshot.manifest.boundaries} == {
|
||||||
"symlink_not_materialized", "gitlink_not_materialized", "lfs_content_unavailable"}
|
"symlink_not_materialized",
|
||||||
|
"gitlink_not_materialized",
|
||||||
|
"lfs_content_unavailable",
|
||||||
|
}
|
||||||
assert list(snapshot.root.iterdir()) == []
|
assert list(snapshot.root.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("limit,reason", [("max_files", "file_count_limit"),
|
@pytest.mark.parametrize(
|
||||||
("max_tree_bytes", "git_output_limit"), ("max_blob_bytes", "source_byte_limit"),
|
"limit,reason",
|
||||||
("max_total_bytes", "source_byte_limit"), ("max_path_bytes", "unsupported_path")])
|
[
|
||||||
|
("max_files", "file_count_limit"),
|
||||||
|
("max_tree_bytes", "git_output_limit"),
|
||||||
|
("max_blob_bytes", "source_byte_limit"),
|
||||||
|
("max_total_bytes", "source_byte_limit"),
|
||||||
|
("max_path_bytes", "unsupported_path"),
|
||||||
|
],
|
||||||
|
)
|
||||||
def test_snapshot_limits_are_explicit(repo: Path, limit: str, reason: str) -> None:
|
def test_snapshot_limits_are_explicit(repo: Path, limit: str, reason: str) -> None:
|
||||||
from openultrasast.push.snapshot import SnapshotLimits
|
from openultrasast.push.snapshot import SnapshotLimits
|
||||||
|
|
||||||
oid = binary_commit(repo, [(b"100644", b"one.js", b"123"), (b"100644", b"two.php", b"456")])
|
oid = binary_commit(repo, [(b"100644", b"one.js", b"123"), (b"100644", b"two.php", b"456")])
|
||||||
with SnapshotAdapter(repo).materialize(oid, limits=SnapshotLimits(**{limit: 1})) as snapshot:
|
with SnapshotAdapter(repo).materialize(oid, limits=SnapshotLimits(**{limit: 1})) as snapshot:
|
||||||
root = snapshot.root
|
root = snapshot.root
|
||||||
@@ -301,6 +335,7 @@ def test_snapshot_failure_preserves_live_state_and_cleans(repo: Path, failure: s
|
|||||||
|
|
||||||
from openultrasast.model.contracts import ExecutionBudget
|
from openultrasast.model.contracts import ExecutionBudget
|
||||||
from openultrasast.push.snapshot import SnapshotInputError
|
from openultrasast.push.snapshot import SnapshotInputError
|
||||||
|
|
||||||
oid = commit(repo, "object bytes")
|
oid = commit(repo, "object bytes")
|
||||||
git(repo, "update-ref", "HEAD", oid)
|
git(repo, "update-ref", "HEAD", oid)
|
||||||
(repo / "file.js").write_bytes(b"staged")
|
(repo / "file.js").write_bytes(b"staged")
|
||||||
@@ -313,10 +348,12 @@ def test_snapshot_failure_preserves_live_state_and_cleans(repo: Path, failure: s
|
|||||||
(repo / ".git/objects" / blob[:2] / blob[2:]).unlink()
|
(repo / ".git/objects" / blob[:2] / blob[2:]).unlink()
|
||||||
before = {p.relative_to(repo): p.read_bytes() for p in repo.rglob("*") if p.is_file()}
|
before = {p.relative_to(repo): p.read_bytes() for p in repo.rglob("*") if p.is_file()}
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
||||||
def cancel() -> bool:
|
def cancel() -> bool:
|
||||||
nonlocal calls
|
nonlocal calls
|
||||||
calls += 1
|
calls += 1
|
||||||
return failure == "cancel" and calls >= 5
|
return failure == "cancel" and calls >= 5
|
||||||
|
|
||||||
budget = ExecutionBudget(time.monotonic() + (-1 if failure == "deadline" else 10), 2.0)
|
budget = ExecutionBudget(time.monotonic() + (-1 if failure == "deadline" else 10), 2.0)
|
||||||
root = None
|
root = None
|
||||||
try:
|
try:
|
||||||
@@ -337,6 +374,7 @@ def test_snapshot_failure_preserves_live_state_and_cleans(repo: Path, failure: s
|
|||||||
|
|
||||||
def test_snapshot_rejects_live_scratch_parent(repo: Path) -> None:
|
def test_snapshot_rejects_live_scratch_parent(repo: Path) -> None:
|
||||||
from openultrasast.push.snapshot import SnapshotInputError
|
from openultrasast.push.snapshot import SnapshotInputError
|
||||||
|
|
||||||
oid = commit(repo, "input")
|
oid = commit(repo, "input")
|
||||||
for parent in (repo, repo / ".git", repo / ".git/objects"):
|
for parent in (repo, repo / ".git", repo / ".git/objects"):
|
||||||
with pytest.raises(SnapshotInputError, match="outside"), SnapshotAdapter(repo).materialize(oid, scratch_parent=parent):
|
with pytest.raises(SnapshotInputError, match="outside"), SnapshotAdapter(repo).materialize(oid, scratch_parent=parent):
|
||||||
@@ -359,13 +397,16 @@ def test_snapshot_missing_tree_and_unsupported_object_are_not_clean(repo: Path)
|
|||||||
|
|
||||||
def test_snapshot_refuses_oversize_blob_before_reading_it(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_snapshot_refuses_oversize_blob_before_reading_it(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
from openultrasast.push.snapshot import SnapshotLimits
|
from openultrasast.push.snapshot import SnapshotLimits
|
||||||
|
|
||||||
oid = commit(repo, "large payload")
|
oid = commit(repo, "large payload")
|
||||||
adapter = SnapshotAdapter(repo)
|
adapter = SnapshotAdapter(repo)
|
||||||
original = adapter._bounded_git
|
original = adapter._bounded_git
|
||||||
commands = []
|
commands = []
|
||||||
|
|
||||||
def capture(args, *rest):
|
def capture(args, *rest):
|
||||||
commands.append(args)
|
commands.append(args)
|
||||||
return original(args, *rest)
|
return original(args, *rest)
|
||||||
|
|
||||||
monkeypatch.setattr(adapter, "_bounded_git", capture)
|
monkeypatch.setattr(adapter, "_bounded_git", capture)
|
||||||
with adapter.materialize(oid, limits=SnapshotLimits(max_blob_bytes=1)) as snapshot:
|
with adapter.materialize(oid, limits=SnapshotLimits(max_blob_bytes=1)) as snapshot:
|
||||||
assert not snapshot.manifest.complete
|
assert not snapshot.manifest.complete
|
||||||
@@ -392,14 +433,17 @@ def test_snapshot_deadline_kills_slow_git_and_cleans(repo: Path, monkeypatch: py
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from openultrasast.model.contracts import ExecutionBudget
|
from openultrasast.model.contracts import ExecutionBudget
|
||||||
|
|
||||||
oid = commit(repo, "input")
|
oid = commit(repo, "input")
|
||||||
adapter = SnapshotAdapter(repo)
|
adapter = SnapshotAdapter(repo)
|
||||||
real_popen = subprocess.Popen
|
real_popen = subprocess.Popen
|
||||||
processes = []
|
processes = []
|
||||||
|
|
||||||
def slow_git(command, **kwargs):
|
def slow_git(command, **kwargs):
|
||||||
process = real_popen([sys.executable, "-c", "import time; time.sleep(30)"], **kwargs)
|
process = real_popen([sys.executable, "-c", "import time; time.sleep(30)"], **kwargs)
|
||||||
processes.append(process)
|
processes.append(process)
|
||||||
return process
|
return process
|
||||||
|
|
||||||
monkeypatch.setattr(subprocess, "Popen", slow_git)
|
monkeypatch.setattr(subprocess, "Popen", slow_git)
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
with adapter.materialize(oid, budget=ExecutionBudget(start + 0.1, 1.0)) as snapshot:
|
with adapter.materialize(oid, budget=ExecutionBudget(start + 0.1, 1.0)) as snapshot:
|
||||||
@@ -412,6 +456,7 @@ def test_snapshot_deadline_kills_slow_git_and_cleans(repo: Path, monkeypatch: py
|
|||||||
|
|
||||||
def test_snapshot_manifest_rejects_forged_census(repo: Path) -> None:
|
def test_snapshot_manifest_rejects_forged_census(repo: Path) -> None:
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
|
||||||
oid = commit(repo, "input")
|
oid = commit(repo, "input")
|
||||||
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
||||||
manifest = snapshot.manifest
|
manifest = snapshot.manifest
|
||||||
@@ -426,6 +471,7 @@ def test_snapshot_manifest_rejects_forged_census(repo: Path) -> None:
|
|||||||
|
|
||||||
def test_snapshot_subdirectory_adapter_cannot_scratch_in_live_parent(repo: Path) -> None:
|
def test_snapshot_subdirectory_adapter_cannot_scratch_in_live_parent(repo: Path) -> None:
|
||||||
from openultrasast.push.snapshot import SnapshotInputError
|
from openultrasast.push.snapshot import SnapshotInputError
|
||||||
|
|
||||||
oid = commit(repo, "input")
|
oid = commit(repo, "input")
|
||||||
nested = repo / "nested"
|
nested = repo / "nested"
|
||||||
nested.mkdir()
|
nested.mkdir()
|
||||||
@@ -436,12 +482,178 @@ def test_snapshot_subdirectory_adapter_cannot_scratch_in_live_parent(repo: Path)
|
|||||||
def test_snapshot_write_failure_cleans_partial_scratch(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_snapshot_write_failure_cleans_partial_scratch(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
oid = commit(repo, "input")
|
oid = commit(repo, "input")
|
||||||
original = Path.open
|
original = Path.open
|
||||||
|
|
||||||
def broken(path, mode="r", *args, **kwargs):
|
def broken(path, mode="r", *args, **kwargs):
|
||||||
if mode == "xb":
|
if mode == "xb":
|
||||||
raise OSError("scratch_write_failed")
|
raise OSError("scratch_write_failed")
|
||||||
return original(path, mode, *args, **kwargs)
|
return original(path, mode, *args, **kwargs)
|
||||||
|
|
||||||
monkeypatch.setattr(Path, "open", broken)
|
monkeypatch.setattr(Path, "open", broken)
|
||||||
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
with SnapshotAdapter(repo).materialize(oid) as snapshot:
|
||||||
assert not snapshot.manifest.complete
|
assert not snapshot.manifest.complete
|
||||||
assert any(b.reason == "scratch_write_failed" for b in snapshot.manifest.boundaries)
|
assert any(b.reason == "scratch_write_failed" for b in snapshot.manifest.boundaries)
|
||||||
assert not snapshot.root.exists()
|
assert not snapshot.root.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def comparison(base: str | None, head: str):
|
||||||
|
from openultrasast.push.contracts import PushComparison
|
||||||
|
|
||||||
|
return PushComparison(head, base, "supplied_remote_tip", ("refs/heads/main",))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"extension,prefix,guard,sink",
|
||||||
|
[
|
||||||
|
(b"php", b"<?php\n", b" require_permission($user);\n", b" execute($input);\n"),
|
||||||
|
(b"js", b"", b" requirePermission(user);\n", b" execute(input);\n"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_change_context_function_rename_and_removed_guard(repo: Path, extension, prefix, guard, sink) -> None:
|
||||||
|
path = b"handler." + extension
|
||||||
|
old = prefix + b"function oldHandler() {\n" + guard + sink + b"}\n"
|
||||||
|
new = prefix + b"function newHandler() {\n" + sink + b"}\n"
|
||||||
|
base = binary_commit(repo, [(b"100644", path, old)])
|
||||||
|
head = binary_commit(repo, [(b"100644", path, new)])
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||||||
|
assert result.base_revision == base and result.head_revision == head
|
||||||
|
assert result.path_encoding == "filesystem-bytes-hex"
|
||||||
|
assert result.changed_paths == (path.hex(),)
|
||||||
|
base_guard = old.split(b"\n").index(guard.rstrip(b"\n")) + 1
|
||||||
|
assert any(s.side == "base" and s.start_line <= base_guard <= s.end_line for s in result.spans)
|
||||||
|
base_sink = old.split(b"\n").index(sink.rstrip(b"\n")) + 1
|
||||||
|
head_sink = new.split(b"\n").index(sink.rstrip(b"\n")) + 1
|
||||||
|
assert any(
|
||||||
|
m.base_start_line <= base_sink <= m.base_end_line and m.head_start_line + base_sink - m.base_start_line == head_sink
|
||||||
|
for m in result.line_correspondences
|
||||||
|
)
|
||||||
|
assert not any(s.side == "head" and s.start_line <= head_sink <= s.end_line for s in result.spans)
|
||||||
|
assert result.relationships == () # Snapshot evidence cannot invent semantic call/guard edges.
|
||||||
|
assert type(result).from_payload(result.to_payload()) == result
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_context_nul_paths_renames_deletions_and_declarations(repo: Path) -> None:
|
||||||
|
old_path, new_path = b"old\n\xff.js", b"new\t\xfe.js"
|
||||||
|
payload = b"function unchanged() {\n uniqueOperation();\n}\n"
|
||||||
|
base = binary_commit(
|
||||||
|
repo, [(b"100644", old_path, payload), (b"100644", b" ", b"deleted\n"), (b"100644", b"package.json", b'{"mode":"old"}\n')]
|
||||||
|
)
|
||||||
|
head = binary_commit(repo, [(b"100644", new_path, payload), (b"100644", b"package.json", b'{"mode":"new"}\n')])
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=(b"package.json",))
|
||||||
|
assert {(r.base_path, r.head_path) for r in result.renames} == {(old_path.hex(), new_path.hex())}
|
||||||
|
assert result.deleted_paths == (b" ".hex(),)
|
||||||
|
assert result.declaration_paths == (b"package.json".hex(),)
|
||||||
|
assert any(m.base_path == old_path.hex() and m.head_path == new_path.hex() for m in result.line_correspondences)
|
||||||
|
assert any(s.path == b" ".hex() and s.side == "base" for s in result.spans)
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_context_unknown_base_binary_and_ambiguous_lines_are_unresolved(repo: Path) -> None:
|
||||||
|
base = binary_commit(repo, [(b"100644", b"api.js", b"old();\nsink();\nsink();\n"), (b"100644", b"binary", b"\x00old")])
|
||||||
|
head = binary_commit(repo, [(b"100644", b"api.js", b"new();\nsink();\nsink();\n"), (b"100644", b"binary", b"\x00new")])
|
||||||
|
missing = SnapshotAdapter(repo).compare(comparison(None, head))
|
||||||
|
assert "comparison_base_unavailable" in missing.unresolved_boundaries
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison(base, head))
|
||||||
|
assert "declaration_paths_unavailable" in result.unresolved_boundaries
|
||||||
|
assert any("ambiguous_line_correspondence" in b for b in result.unresolved_boundaries)
|
||||||
|
assert any("binary_line_correspondence_unavailable" in b for b in result.unresolved_boundaries)
|
||||||
|
assert not result.line_correspondences
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_context_is_bounded_and_never_executes_diff_helpers(repo: Path) -> None:
|
||||||
|
from openultrasast.push.snapshot import SnapshotLimits
|
||||||
|
|
||||||
|
marker = repo / "DIFF_RAN"
|
||||||
|
git(repo, "config", "diff.external", "touch " + str(marker))
|
||||||
|
git(repo, "config", "diff.trap.textconv", "touch " + str(marker))
|
||||||
|
(repo / ".gitattributes").write_text("* diff=trap\n")
|
||||||
|
base, head = commit(repo, "old\n"), commit(repo, "new\n")
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||||||
|
assert result.spans
|
||||||
|
assert not marker.exists()
|
||||||
|
limited = SnapshotAdapter(repo).compare(comparison(base, head), limits=SnapshotLimits(max_blob_bytes=1))
|
||||||
|
assert any("source_byte_limit" in b for b in limited.unresolved_boundaries)
|
||||||
|
cancelled = SnapshotAdapter(repo).compare(comparison(base, head), cancelled=lambda: True)
|
||||||
|
assert "cancelled" in cancelled.unresolved_boundaries
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_context_moved_repeated_operations_do_not_claim_identity(repo: Path) -> None:
|
||||||
|
base = binary_commit(repo, [(b"100644", b"api.js", b"function a() {\n sink();\n}\nfunction b() {\n sink();\n}\n")])
|
||||||
|
head = binary_commit(repo, [(b"100644", b"api.js", b"function b() {\n sink();\n}\nfunction renamed() {\n sink();\n}\n")])
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||||||
|
assert any("ambiguous_line_correspondence" in b for b in result.unresolved_boundaries)
|
||||||
|
assert not any(m.base_start_line in (2, 5) for m in result.line_correspondences)
|
||||||
|
assert result.decode_path(result.changed_paths[0]) == "api.js"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("limit", ["max_total_bytes", "max_diff_bytes", "max_mapping_lines", "max_files"])
|
||||||
|
def test_change_context_total_limits_are_explicit(repo: Path, limit: str) -> None:
|
||||||
|
from openultrasast.push.snapshot import SnapshotLimits
|
||||||
|
|
||||||
|
base = binary_commit(repo, [(b"100644", b"a.js", b"old\n"), (b"100644", b"b.js", b"old2\n")])
|
||||||
|
head = binary_commit(repo, [(b"100644", b"a.js", b"new\n"), (b"100644", b"b.js", b"new2\n")])
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=(), limits=SnapshotLimits(**{limit: 1}))
|
||||||
|
assert result.unresolved_boundaries
|
||||||
|
assert not result.line_correspondences
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_context_missing_blob_and_invalid_revisions_are_not_clean(repo: Path) -> None:
|
||||||
|
base, head = commit(repo, "old\n"), commit(repo, "new\n")
|
||||||
|
blob = git(repo, "rev-parse", base + ":file.js")
|
||||||
|
(repo / ".git/objects" / blob[:2] / blob[2:]).unlink()
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||||||
|
assert result.unresolved_boundaries
|
||||||
|
assert not result.spans and not result.line_correspondences
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison("HEAD", head), declaration_paths=())
|
||||||
|
assert result.unresolved_boundaries == ("comparison_requires_immutable_commit_oid",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_context_lf_lines_and_final_newline_changes(repo: Path) -> None:
|
||||||
|
# CR, vertical tab and Unicode line separators are source bytes, not LF lines.
|
||||||
|
before = b"first\x0b\xe2\x80\xa8\rline\noperation();\n"
|
||||||
|
after = b"changed\x0b\xe2\x80\xa8\rline\noperation();\n"
|
||||||
|
base = binary_commit(repo, [(b"100644", b"api.js", before)])
|
||||||
|
head = binary_commit(repo, [(b"100644", b"api.js", after)])
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||||||
|
assert [(s.start_line, s.end_line) for s in result.spans] == [(1, 1), (1, 1)]
|
||||||
|
assert [(m.base_start_line, m.head_start_line) for m in result.line_correspondences] == [(2, 2)]
|
||||||
|
no_newline = binary_commit(repo, [(b"100644", b"api.js", after[:-1])])
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison(head, no_newline), declaration_paths=())
|
||||||
|
assert any(s.side == "head" and s.start_line == 2 for s in result.spans)
|
||||||
|
assert not any(m.head_start_line == 2 for m in result.line_correspondences)
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_context_zero_changes_are_real_equal_objects(repo: Path) -> None:
|
||||||
|
oid = commit(repo, "source read by Git\n")
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison(oid, oid), declaration_paths=())
|
||||||
|
assert result.changed_paths == result.spans == result.unresolved_boundaries == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_context_generic_hex_contract_validation(repo: Path) -> None:
|
||||||
|
import os
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
oid = binary_commit(repo, [(b"100644", b" \xff", b"a\n")])
|
||||||
|
new = binary_commit(repo, [(b"100644", b" \xff", b"b\n")])
|
||||||
|
context = SnapshotAdapter(repo).compare(comparison(oid, new), declaration_paths=())
|
||||||
|
assert os.fsencode(context.decode_path(context.changed_paths[0])) == b" \xff"
|
||||||
|
for invalid in ("zz", "00", "FF", "20 ff"):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
replace(context, changed_paths=(invalid,))
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_context_unmatched_move_is_not_proven_new_behavior(repo: Path) -> None:
|
||||||
|
base = binary_commit(repo, [(b"100644", b"old.js", b"function old() {\n guard();\n sink();\n}\n")])
|
||||||
|
head = binary_commit(repo, [(b"100644", b"new.js", b"function renamed() {\n sink();\n newCall();\n}\n")])
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||||||
|
assert not result.renames
|
||||||
|
assert "unmatched_added_deleted_path_correspondence" in result.unresolved_boundaries
|
||||||
|
assert {span.side for span in result.spans} == {"base", "head"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_context_ambiguous_identical_file_renames_preserve_gap(repo: Path) -> None:
|
||||||
|
payload = b"function original() {\n operation();\n}\n"
|
||||||
|
base = binary_commit(repo, [(b"100644", b"a.js", payload), (b"100644", b"b.js", payload)])
|
||||||
|
head = binary_commit(repo, [(b"100644", b"c.js", payload), (b"100644", b"d.js", payload)])
|
||||||
|
result = SnapshotAdapter(repo).compare(comparison(base, head), declaration_paths=())
|
||||||
|
assert result.renames # Git's attribution retained, with its ambiguity alongside.
|
||||||
|
assert not result.line_correspondences
|
||||||
|
assert any("ambiguous_path_rename_correspondence" in b for b in result.unresolved_boundaries)
|
||||||
|
|||||||
Reference in New Issue
Block a user