zwischen 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb7b4a9cea5d36d3f7fe7414f3034d5f3fc6eaa860cec2136b303e9bcea2877c
4
- data.tar.gz: d45c2d6d9c962508996eca3a618fe54c3e0692dde54b6dad347f020d9251f346
3
+ metadata.gz: 837e243d24d26e3ba30d4597d89492ef6a039ef80417376176de431e951d8f61
4
+ data.tar.gz: daf7b174e791d29243ed7e93c3d5bc1e1bc6fa68e9c4762c9a74704f27ef7526
5
5
  SHA512:
6
- metadata.gz: a02881f9e8f76961a655e5d054d91166c3f3842abb8e1ce9fa31252dc2cb742e1a4014699631dbf9116f06d5cc6ef347d54dec09c77313949a8d866b027c6646
7
- data.tar.gz: bf3dd2d05ab9d21ba880f2d9990ced3d65d97ee5846ee2135d33372feedbd6a9a8c66a8f968e3750d268f475ddc1d1b090fdd1043a8567956c9072d42cbb8a01
6
+ metadata.gz: 91c5242e5b62143f7853f01813e106c41e0745f1e03a9ccb1831ae3c8efea3cd7699712f76a816a879e12d9ce6f25b7c78690294835db9b252e57bab1483f7da
7
+ data.tar.gz: 9f1a912d0ff0cd8f18c83da3a722c43d7f3ac25d2815ed969c1c5c1f32ae34e46676e3a336fecfd1f6e9834449cbf3361a44e0f6132319aa830332fc93eb9bc5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.2] - 2026-06-11
11
+
12
+ ### Added
13
+ - `zwischen --version` (also `-v` and `zwischen version`)
14
+
15
+ ### Changed
16
+ - Existing non-Zwischen pre-push hooks (husky shims, hand-written scripts)
17
+ are now appended to instead of replaced: the original checks keep
18
+ running before Zwischen's, repeat `init` never double-appends, and
19
+ `zwischen uninstall` strips only the appended block, restoring the
20
+ original hook exactly. A backup copy is still written first.
21
+
10
22
  ## [0.1.1] - 2026-06-11
11
23
 
12
24
  ### Fixed
@@ -45,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
45
57
  - Project type detection (Next.js, React, Django, Rails, and more)
46
58
  - npm (`zwischen`) and pip (`zwischen-cli`) wrapper packages
47
59
 
48
- [Unreleased]: https://github.com/cjordan223/zwischen/compare/v0.1.1...HEAD
60
+ [Unreleased]: https://github.com/cjordan223/zwischen/compare/v0.1.2...HEAD
61
+ [0.1.2]: https://github.com/cjordan223/zwischen/compare/v0.1.1...v0.1.2
49
62
  [0.1.1]: https://github.com/cjordan223/zwischen/compare/v0.1.0...v0.1.1
50
63
  [0.1.0]: https://github.com/cjordan223/zwischen/releases/tag/v0.1.0
data/DEVELOPMENT.md CHANGED
@@ -89,7 +89,7 @@ Package-wrapper parity changes:
89
89
 
90
90
  ## Known Iteration Points
91
91
 
92
- - Ruby `Hooks.handle_existing_hook` has backup/append/skip logic, but `Setup#install_hook` currently backs up and replaces existing non-Zwischen hooks directly.
92
+ - Existing non-Zwischen pre-push hooks (husky shims, hand-written scripts) are backed up and then appended to, not replaced — the original checks keep running, and `zwischen uninstall` strips only the appended block.
93
93
  - Ruby config exposes `severity.fail_on`, but blocking decisions use `blocking.severity`. (`ignore` globs are enforced by the orchestrator.)
94
94
  - npm and pip wrappers do not yet match Ruby feature parity. They do not support `uninstall`, `--only`, `--changed`, `--format sarif`, Ruby's changed-file pre-push filtering, or the Ruby JSON summary shape.
95
95
  - npm and pip wrappers default AI provider to Ollama, while Ruby defaults to Claude.
data/README.md CHANGED
@@ -77,7 +77,7 @@ is in [docs/design.md](docs/design.md).
77
77
 
78
78
  | Command | What it does |
79
79
  | --- | --- |
80
- | `zwischen init` | Installs/checks tools, creates config, installs pre-push hook (backs up an existing non-Zwischen hook). |
80
+ | `zwischen init` | Installs/checks tools, creates config, installs pre-push hook. An existing hook (husky, hand-written) is backed up and appended to — its checks keep running. |
81
81
  | `zwischen scan` | Runs enabled scanners, prints a terminal report. |
82
82
  | `zwischen scan --changed` | Scans only files changed since the default branch, including staged and untracked files. |
83
83
  | `zwischen scan --only secrets,sast` | Limits to Gitleaks (`secrets`) and/or Semgrep (`sast`). |
@@ -86,7 +86,8 @@ is in [docs/design.md](docs/design.md).
86
86
  | `zwischen scan --format sarif` | SARIF 2.1.0 for GitHub code scanning. |
87
87
  | `zwischen scan --pre-push` | Quiet hook mode: changed files only, compact output only when blocking. |
88
88
  | `zwischen doctor` | Shows Gitleaks and Semgrep status. |
89
- | `zwischen uninstall` | Removes the hook, optionally config/credentials. |
89
+ | `zwischen uninstall` | Removes the hook (or just the appended block), optionally config/credentials. |
90
+ | `zwischen --version` | Prints the version. |
90
91
 
91
92
  Escape hatches: `git push --no-verify` or `ZWISCHEN_SKIP=1 git push`.
92
93
 
@@ -198,7 +199,7 @@ docs/ Design write-up, triage example, demo GIF
198
199
  ## Development
199
200
 
200
201
  ```bash
201
- bundle exec rspec # 212 examples
202
+ bundle exec rspec # 216 examples
202
203
  ./scripts/test_as_gem.sh # install and exercise as a real gem
203
204
  ```
204
205
 
data/TESTING.md CHANGED
@@ -297,7 +297,12 @@ zwischen init
297
297
  Expected for the current Ruby implementation:
298
298
 
299
299
  - Existing hook is copied to `.git/hooks/pre-push.zwischen.backup` or a timestamped variant.
300
- - New Zwischen hook replaces `.git/hooks/pre-push`.
300
+ - The Zwischen check is appended to `.git/hooks/pre-push` between
301
+ `# >>> Zwischen pre-push hook >>>` markers; the original hook content
302
+ still runs first.
303
+ - Running `zwischen init` again does not append a second block.
304
+ - `zwischen uninstall` strips only the appended block, restoring the
305
+ original hook content.
301
306
 
302
307
 
303
308
  ### Test 6.3: Default Branch Detection
data/lib/zwischen/cli.rb CHANGED
@@ -202,6 +202,12 @@ module Zwischen
202
202
  Setup.uninstall
203
203
  end
204
204
 
205
+ desc "version", "Print the Zwischen version"
206
+ map %w[--version -v] => :version
207
+ def version
208
+ puts "zwischen #{Zwischen::VERSION}"
209
+ end
210
+
205
211
  default_task :scan
206
212
 
207
213
  private
@@ -34,6 +34,11 @@ module Zwischen
34
34
  zwischen_hook?(path)
35
35
  end
36
36
 
37
+ # Delimiters around the block we append to a pre-existing foreign hook,
38
+ # so uninstall can strip exactly our lines and leave the rest intact.
39
+ APPEND_BEGIN = "# >>> #{HOOK_MARKER} >>>"
40
+ APPEND_END = "# <<< #{HOOK_MARKER} <<<"
41
+
37
42
  def self.install(project_root = Dir.pwd)
38
43
  path = hook_path(project_root)
39
44
  hooks_dir = File.dirname(path)
@@ -41,6 +46,16 @@ module Zwischen
41
46
  # Ensure hooks directory exists
42
47
  FileUtils.mkdir_p(hooks_dir) unless File.directory?(hooks_dir)
43
48
 
49
+ if File.exist?(path)
50
+ # Already present (standalone or appended) — never overwrite, an
51
+ # appended hook also contains the user's own commands.
52
+ return true if zwischen_hook?(path)
53
+
54
+ # A foreign hook (husky shim, hand-written script, ...) keeps
55
+ # working: we append our check instead of replacing the user's.
56
+ return append_to_existing(path)
57
+ end
58
+
44
59
  hook_content = <<~HOOK
45
60
  #!/usr/bin/env bash
46
61
  # #{HOOK_MARKER} - installed by 'zwischen init'
@@ -59,39 +74,24 @@ module Zwischen
59
74
  true
60
75
  end
61
76
 
62
- def self.handle_existing_hook(hook_path, shell)
63
- return :skip unless File.exist?(hook_path)
64
- return :install if zwischen_hook?(hook_path) # Already a Zwischen hook, can overwrite
65
-
66
- shell.say("\n⚠️ A pre-push hook already exists at #{hook_path}", :yellow)
67
- choice = shell.ask("What would you like to do?", limited_to: %w[backup append skip], default: "backup")
68
-
69
- case choice
70
- when "backup"
71
- backup_path = "#{hook_path}.zwischen.backup"
72
- FileUtils.cp(hook_path, backup_path)
73
- shell.say(" ✓ Backed up to #{backup_path}", :green)
74
- :install
75
- when "append"
76
- existing_content = File.read(hook_path)
77
- new_content = <<~APPEND
78
- #{existing_content}
79
-
80
- # #{HOOK_MARKER} - appended by 'zwischen init'
81
- if [ "$ZWISCHEN_SKIP" = "1" ]; then
82
- exit 0
83
- fi
77
+ def self.append_to_existing(path)
78
+ existing = File.read(path)
79
+ return true if existing.include?(HOOK_MARKER) # already appended
80
+
81
+ block = <<~BLOCK
84
82
 
83
+ #{APPEND_BEGIN}
84
+ # appended by 'zwischen init' - your original hook above still runs
85
+ if [ "$ZWISCHEN_SKIP" != "1" ]; then
85
86
  zwischen scan --pre-push || exit $?
86
- APPEND
87
- File.write(hook_path, new_content)
88
- File.chmod(0o755, hook_path)
89
- shell.say(" ✓ Appended Zwischen check to existing hook", :green)
90
- :skip # Don't install new hook, already appended
91
- when "skip"
92
- shell.say(" ↳ Skipping hook installation", :yellow)
93
- :skip
94
- end
87
+ fi
88
+ #{APPEND_END}
89
+ BLOCK
90
+
91
+ File.write(path, existing.chomp + "\n" + block)
92
+ File.chmod(0o755, path)
93
+
94
+ true
95
95
  end
96
96
 
97
97
  def self.uninstall(project_root = Dir.pwd)
@@ -99,7 +99,15 @@ module Zwischen
99
99
  return false unless File.exist?(path)
100
100
  return false unless zwischen_hook?(path)
101
101
 
102
- File.delete(path)
102
+ content = File.read(path)
103
+ if content.include?(APPEND_BEGIN)
104
+ # We were appended to someone else's hook: strip only our block.
105
+ stripped = content.gsub(/\n?#{Regexp.escape(APPEND_BEGIN)}.*?#{Regexp.escape(APPEND_END)}\n?/m, "\n")
106
+ File.write(path, stripped)
107
+ else
108
+ # The whole file is ours.
109
+ File.delete(path)
110
+ end
103
111
  true
104
112
  end
105
113
  end
@@ -126,12 +126,14 @@ module Zwischen
126
126
  @shell.say(" ↳ Git hooks are redirected (core.hooksPath or worktree); installing to #{hook_path}", :yellow)
127
127
  end
128
128
 
129
+ appending = false
129
130
  if File.exist?(hook_path)
130
131
  if Hooks.zwischen_hook?(hook_path)
131
132
  @shell.say(" ✓ Pre-push hook already installed", :green)
132
133
  return true
133
134
  end
134
135
 
136
+ appending = true
135
137
  backup_path = "#{hook_path}.zwischen.backup"
136
138
  if File.exist?(backup_path)
137
139
  timestamp = Time.now.strftime("%Y%m%d%H%M%S")
@@ -142,7 +144,11 @@ module Zwischen
142
144
  end
143
145
 
144
146
  if Hooks.install(project_root)
145
- @shell.say(" ✓ Installing pre-push hook", :green)
147
+ if appending
148
+ @shell.say(" ✓ Added Zwischen to your existing pre-push hook (original checks still run)", :green)
149
+ else
150
+ @shell.say(" ✓ Installing pre-push hook", :green)
151
+ end
146
152
  true
147
153
  else
148
154
  @shell.say(" ✗ Failed to install hook", :red)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zwischen
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
@@ -0,0 +1,137 @@
1
+ # Zwischen v0.1.1 Round 2 Testing Report
2
+
3
+ Test root: `/tmp/zw2-4u4Pyb`
4
+
5
+ Round 2 was run against published packages from Rubygems, npm, and PyPI. Product source was not modified; this report is the only repo file changed.
6
+
7
+ ## 1. Environment
8
+
9
+ | Item | Observed |
10
+ | --- | --- |
11
+ | OS | Darwin 25.5.0, arm64 |
12
+ | Git | 2.50.1 (Apple Git-155) |
13
+ | Ruby | Homebrew Ruby 4.0.3 |
14
+ | Ruby 3.3 floor | rbenv Ruby 3.3.11 installed during this run |
15
+ | Node / npm | Node v25.9.0 / npm 11.12.1 |
16
+ | Python | 3.14.5 |
17
+ | Docker | 29.1.3, daemon available |
18
+ | act | Not installed |
19
+ | Semgrep | 1.165.0 |
20
+ | Ollama | `127.0.0.1:11434`, model `llama3.2:latest` |
21
+ | Ruby package | `zwischen` 0.1.1 |
22
+ | npm package | `zwischen` 0.1.1 |
23
+ | PyPI package | `zwischen-cli` 0.1.1 |
24
+
25
+ Note: as in round 1, Homebrew Semgrep fails inside the filesystem sandbox with a macOS trust-anchor error. Semgrep-dependent tests were run outside the sandbox, with all test repos and HOME values still under `/tmp/zw2-4u4Pyb`.
26
+
27
+ ## 2. Scorecard
28
+
29
+ | ID | Result | Notes |
30
+ | --- | --- | --- |
31
+ | Registry gate | PASS | Ruby, npm, and PyPI all installed 0.1.1. |
32
+ | A1 | PASS | `blocking.severity: critical` with high-only AWS finding exited 0. |
33
+ | A2 | PASS | `blocking.severity: none` exited 0 and still printed the high finding. |
34
+ | A3 | PASS | `blocking.severity: high` exited 1 for the high finding. |
35
+ | A4 | PASS | npm and pip wrappers honored `ignore:` and omitted `ignored.env`. |
36
+ | A5 | PASS | npm and pip JSON stdout parsed directly, included `summary` and `findings`, and used relative `file` paths. |
37
+ | A6 | PASS | npm and pip SARIF requests exited 2, wrote clear unsupported-wrapper errors to stderr, and left stdout empty. |
38
+ | A7 | PASS | pip `zwischen --version` printed `zwischen, version 0.1.1` and exited 0. |
39
+ | A8 | PASS | Ruby JSON `findings[*].file` values were project-relative. |
40
+ | A9 | PASS | Ruby `scan --changed` reported committed-ahead, staged-only, and untracked secret files. |
41
+ | A10 | PASS | Ollama finding count stayed 10. Attempt 2 produced `Fix`/`Risk` annotations; parse-failure attempts warned and preserved raw findings. Dead-port fail-open preserved findings and exit code. |
42
+ | B1 | PASS | Custom `core.hooksPath` and linked worktree pushes were blocked. Real Husky push was blocked after bypassing Husky's generated failing pre-commit for commit creation. |
43
+ | B2 | PASS | Ruby 3.3.11 installed the gem, `init` installed gitleaks, and demo scan returned 10 findings. |
44
+ | B3 | PASS | Linux Ruby container auto-installed gitleaks, found the secret, and blocked a local push. Node and pip wrapper containers found the planted secret. |
45
+ | B4 | PASS | Offline init exited 0 with manual install hints and created config/hook. Offline scan warned `No scanners available` without crashing. |
46
+ | B5 | PASS with caveat | Generated SARIF validated against SchemaStore SARIF 2.1.0. The exact OASIS raw URL from the test plan returned 404. |
47
+ | B6 | FAIL | Express scans were fine, but 20-file clean pre-push median was 5.14s, above the ~3s budget. |
48
+ | B7 | SKIPPED | `act` is not installed. |
49
+
50
+ ## 3. Benchmarks
51
+
52
+ | Benchmark | Round 1 | Round 2 | Change |
53
+ | --- | ---: | ---: | ---: |
54
+ | Ruby gem install | 3.984s | 4.18s | +4.9% |
55
+ | npm install | 1.116s | 1.18s | +5.7% |
56
+ | pip install | 2.560s | 2.34s | -8.6% |
57
+ | Demo gitleaks-only scan median | 0.408s | 0.387s | -5.1% |
58
+ | Demo gitleaks+Semgrep scan median | 2.096s | 2.292s | +9.4% |
59
+ | Ollama AI scan | 24.936s | 13.210s annotated run | -47.0% |
60
+ | Dead-port AI fail-open | 2.796s | 2.226s | -20.4% |
61
+ | Huge 50MB file scan | 4.609s | not repeated | n/a |
62
+ | Clean pre-push hook, 1-file median | 1.786s | not repeated | n/a |
63
+ | Express full scan, gitleaks-only | n/a | 0.37s, max RSS 82 MB | n/a |
64
+ | Express full scan, gitleaks+Semgrep | n/a | 2.82s, max RSS 354 MB | n/a |
65
+ | Express clean pre-push, 20 files | n/a | median 5.14s, max RSS 247-297 MB | over ~3s budget |
66
+
67
+ No directly comparable round-1 benchmark regressed by more than 25%. The new B6 20-file hook benchmark misses the design budget.
68
+
69
+ ## 4. Bugs / Findings
70
+
71
+ ### Major: 20-file clean pre-push latency exceeds budget
72
+
73
+ Repro:
74
+
75
+ 1. Clone `expressjs/express`.
76
+ 2. Configure Zwischen with gitleaks and Semgrep enabled.
77
+ 3. Push five clean commits, each changing 20 files, to a local bare origin.
78
+ 4. Time each `git push` with `/usr/bin/time -l`.
79
+
80
+ Expected: hook stays near the documented ~3s budget.
81
+
82
+ Actual: runs were 5.50s, 5.19s, 5.11s, 5.07s, and 5.14s; median 5.14s.
83
+
84
+ Evidence: `/tmp/zw2-4u4Pyb/b6_scale.log`.
85
+
86
+ ### Observation: Husky hook is backed up and replaced, not merged
87
+
88
+ In a real Husky v9 setup, `core.hooksPath` was `.husky/_`. `zwischen init` backed up `.husky/_/pre-push` to `.husky/_/pre-push.zwischen.backup` and replaced `.husky/_/pre-push` with the Zwischen hook. A secret push was blocked, and `.husky/pre-commit` remained. This satisfies the blocking assertion, but it is replacement-with-backup rather than inline coexistence.
89
+
90
+ Evidence: `/tmp/zw2-4u4Pyb/b1_hooks.log`, `/tmp/zw2-4u4Pyb/b1_husky_followup.log`.
91
+
92
+ ### External test-plan issue: OASIS SARIF schema URL returns 404
93
+
94
+ The requested URL `https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json` returned 404, as did the same path on `main`. The generated SARIF validated successfully against `https://www.schemastore.org/sarif-2.1.0.json`.
95
+
96
+ Evidence: `/tmp/zw2-4u4Pyb/b5_sarif.log`, `/tmp/zw2-4u4Pyb/b5_schemastore_validation.log`.
97
+
98
+ ## 5. Docs Drift
99
+
100
+ | Claim | Observed |
101
+ | --- | --- |
102
+ | Round-1 fixed bugs should pass in v0.1.1 | Confirmed for A1-A10. |
103
+ | Hook-manager coexistence | Blocking works for custom hooksPath, real Husky, and linked worktree. Real Husky's generated pre-push shim is backed up and replaced. |
104
+ | SARIF official schema URL in test plan | The exact OASIS raw URL returns 404; SchemaStore validation passes. |
105
+ | Hook budget around 3s | New 20-file Express benchmark median is 5.14s. |
106
+
107
+ ## 6. Wrapper Parity Matrix
108
+
109
+ | Feature | Ruby gem | npm wrapper | pip wrapper |
110
+ | --- | --- | --- | --- |
111
+ | Published install at 0.1.1 | PASS | PASS | PASS |
112
+ | `--version` | Falls back to `gem list` | PASS | PASS |
113
+ | `init` | PASS | PASS | PASS |
114
+ | `scan` | PASS | PASS | PASS |
115
+ | `doctor` | PASS | PASS | PASS |
116
+ | `scan --format json` | PASS, pure JSON with relative paths | PASS, pure JSON with `summary` | PASS, pure JSON with `summary` |
117
+ | `scan --format sarif` | PASS | PASS, exits 2 unsupported | PASS, exits 2 unsupported |
118
+ | `ignore:` globs | PASS | PASS | PASS |
119
+ | `uninstall` | PASS | Not supported, accepted gap | Not supported, accepted gap |
120
+ | `--only` | PASS | Not supported, accepted gap | Not supported, accepted gap |
121
+ | `--changed` | PASS | Not supported, accepted gap | Not supported, accepted gap |
122
+
123
+ ## 7. Raw Evidence
124
+
125
+ Key logs and outputs:
126
+
127
+ - `/tmp/zw2-4u4Pyb/a_regressions.log`
128
+ - `/tmp/zw2-4u4Pyb/b1_hooks.log`
129
+ - `/tmp/zw2-4u4Pyb/b1_husky_followup.log`
130
+ - `/tmp/zw2-4u4Pyb/b2_ruby33.log`
131
+ - `/tmp/zw2-4u4Pyb/b3_docker.log`
132
+ - `/tmp/zw2-4u4Pyb/b3_ruby_followup.log`
133
+ - `/tmp/zw2-4u4Pyb/b4_offline.log`
134
+ - `/tmp/zw2-4u4Pyb/b5_sarif.log`
135
+ - `/tmp/zw2-4u4Pyb/b5_schemastore_validation.log`
136
+ - `/tmp/zw2-4u4Pyb/b6_scale.log`
137
+ - `/tmp/zw2-4u4Pyb/round1_compare_bench.log`
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zwischen
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Conner Jordan
@@ -117,6 +117,7 @@ files:
117
117
  - lib/zwischen/scanner/semgrep.rb
118
118
  - lib/zwischen/setup.rb
119
119
  - lib/zwischen/version.rb
120
+ - zwischen-test-report-round2.md
120
121
  - zwischen.gemspec
121
122
  homepage: https://github.com/cjordan223/zwischen
122
123
  licenses: