zwischen 0.1.0 → 0.1.1

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: beb48773e7ec58217010758614752399220a826e232e7af0c4b46a0ae603e304
4
- data.tar.gz: 4bd6a34f9299e1e623843476523e13bd736c5c2c21c19b6e37029d9a63af2810
3
+ metadata.gz: bb7b4a9cea5d36d3f7fe7414f3034d5f3fc6eaa860cec2136b303e9bcea2877c
4
+ data.tar.gz: d45c2d6d9c962508996eca3a618fe54c3e0692dde54b6dad347f020d9251f346
5
5
  SHA512:
6
- metadata.gz: 72d2fda3d959f4b2621e8011fb03359e70b08db713665f30bff2129a98b2c09f19d2243a191d2a07739dec397570eb13a1c924e9a2f4d4ff71331c94ede9df0a
7
- data.tar.gz: 624e96024130b6ab369f2d137d294941808d98c331a6ec1f2ecb1830cdbd5a30750be67a5840d179fd05f7c8ca3b2196c2643a2e32aff2baea025fae2d5e47a7
6
+ metadata.gz: a02881f9e8f76961a655e5d054d91166c3f3842abb8e1ce9fa31252dc2cb742e1a4014699631dbf9116f06d5cc6ef347d54dec09c77313949a8d866b027c6646
7
+ data.tar.gz: bf3dd2d05ab9d21ba880f2d9990ced3d65d97ee5846ee2135d33372feedbd6a9a8c66a8f968e3750d268f475ddc1d1b090fdd1043a8567956c9072d42cbb8a01
@@ -11,6 +11,7 @@ ai:
11
11
  ollama:
12
12
  model: llama3
13
13
  url: http://localhost:11434
14
+ # timeout: 180 # seconds; raise for large local models that load slowly
14
15
 
15
16
  openai:
16
17
  model: gpt-4
@@ -35,11 +36,10 @@ scanners:
35
36
  # enabled: true
36
37
  # semgrep:
37
38
  # enabled: true
38
- # config: p/security-audit # Open ruleset, no login required
39
+ # config: p/security-audit,p/expressjs # Comma-separated open rulesets, no login required
39
40
  # # Other options: p/secrets, p/owasp-top-ten, or path to custom ruleset
40
41
 
41
42
  # Ignored Paths — findings in matching paths are dropped before reporting.
42
-
43
43
  ignore:
44
44
  - "**/vendor/**"
45
45
  - "**/node_modules/**"
data/CHANGELOG.md CHANGED
@@ -7,15 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.1.0] - 2026-06-10
10
+ ## [0.1.1] - 2026-06-11
11
+
12
+ ### Fixed
13
+ - `blocking.severity` is now honored by manual terminal scans — `critical`
14
+ and `none` previously behaved like `high` because the CLI never passed
15
+ config to the terminal reporter
16
+ - Hooks install where git actually executes them: `core.hooksPath`
17
+ (husky/pre-commit setups) and linked worktrees are now resolved via
18
+ `git rev-parse --git-path hooks` — previously the hook was written to
19
+ `.git/hooks` and silently never ran under `core.hooksPath`, and
20
+ worktrees skipped installation entirely
21
+ - `--format json` file paths are project-relative, matching terminal and
22
+ SARIF output
23
+ - AI response parsing strips markdown code fences, so small local models
24
+ that wrap JSON in ``` blocks still produce annotations
25
+ - npm/pip wrappers: `ignore:` globs are enforced, `--format json` emits
26
+ pure JSON with a `summary` key and relative paths, `--format sarif`
27
+ fails fast with a clear error, and pip `zwischen --version` no longer
28
+ crashes
29
+
30
+ ### Changed
31
+ - `scan --changed` now includes staged and untracked files; pre-push
32
+ keeps committed-range semantics since only commits get pushed
33
+
34
+ ## [0.1.0] - 2026-06-11
11
35
 
12
36
  ### Added
13
37
  - Initial release
14
38
  - Gitleaks and Semgrep scanner orchestration with auto-install
15
39
  - AI-powered finding triage via Ollama, OpenAI, or Anthropic
16
40
  - Pre-push git hook that blocks pushes introducing secrets or security issues
41
+ - `--changed` flag to scan only files changed since the default branch
42
+ - SARIF 2.1.0 output (`--format sarif`) for GitHub code scanning
43
+ - Composite GitHub Action (`uses: cjordan223/zwischen@main`)
44
+ - Ignore globs in `.zwischen.yml` enforced at the orchestrator level
17
45
  - Project type detection (Next.js, React, Django, Rails, and more)
18
46
  - npm (`zwischen`) and pip (`zwischen-cli`) wrapper packages
19
47
 
20
- [Unreleased]: https://github.com/cjordan223/zwischen/compare/v0.1.0...HEAD
48
+ [Unreleased]: https://github.com/cjordan223/zwischen/compare/v0.1.1...HEAD
49
+ [0.1.1]: https://github.com/cjordan223/zwischen/compare/v0.1.0...v0.1.1
21
50
  [0.1.0]: https://github.com/cjordan223/zwischen/releases/tag/v0.1.0
data/DEVELOPMENT.md CHANGED
@@ -20,7 +20,7 @@ bin/zwischen
20
20
  -> Scanner::Semgrep
21
21
  -> Finding::Aggregator
22
22
  -> AI::Analyzer, when enabled
23
- -> Reporter::Terminal
23
+ -> Reporter::Terminal (or Reporter::Sarif for --format sarif)
24
24
  ```
25
25
 
26
26
  `zwischen init` follows a separate path:
@@ -34,7 +34,7 @@ CLI#init
34
34
  -> Config.init creates .zwischen.yml
35
35
  ```
36
36
 
37
- `zwischen scan --pre-push` uses `GitDiff.changed_files`, passes those files to scanner adapters, filters findings to changed files again as a safety net, and only prints compact output for blocking findings.
37
+ `zwischen scan --pre-push` uses `GitDiff.changed_files`, passes those files to scanner adapters, filters findings to changed files again as a safety net, and only prints compact output for blocking findings. `zwischen scan --changed` applies the same changed-files scoping to a manual scan with full output.
38
38
 
39
39
  ## Config Contract
40
40
 
@@ -90,8 +90,8 @@ Package-wrapper parity changes:
90
90
  ## Known Iteration Points
91
91
 
92
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.
93
- - Ruby config exposes `ignore` and `severity.fail_on`, but scanner adapters currently do not enforce ignore globs and blocking uses `blocking.severity`.
94
- - npm and pip wrappers do not yet match Ruby feature parity. They do not support `uninstall`, `--only`, Ruby's changed-file pre-push filtering, or the Ruby JSON summary shape.
93
+ - Ruby config exposes `severity.fail_on`, but blocking decisions use `blocking.severity`. (`ignore` globs are enforced by the orchestrator.)
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.
96
96
 
97
97
  ## Verification
@@ -110,12 +110,12 @@ Installed-gem workflow:
110
110
 
111
111
  Then follow `TESTING.md` from a temporary directory outside this repository.
112
112
 
113
- npm wrapper smoke checks:
113
+ npm wrapper smoke checks (mirrors CI):
114
114
 
115
115
  ```bash
116
116
  cd packages/npm
117
- npm test
118
117
  node bin/zwischen.js --help
118
+ npm pack --dry-run
119
119
  ```
120
120
 
121
121
  pip wrapper smoke checks:
@@ -134,21 +134,23 @@ version tag:
134
134
  ```bash
135
135
  # bump lib/zwischen/version.rb, packages/npm/package.json,
136
136
  # packages/pip/pyproject.toml, and CHANGELOG.md first
137
- git tag v0.1.0
138
- git push origin v0.1.0
137
+ git tag vX.Y.Z
138
+ git push origin vX.Y.Z
139
139
  ```
140
140
 
141
- One-time registry setup (first release only):
141
+ Registry setup (already configured as of v0.1.0; reference for new registries
142
+ or token rotation):
142
143
 
143
- 1. **RubyGems** — add a *pending trusted publisher* for the `zwischen` gem at
144
- <https://rubygems.org/profile/oidc/pending_trusted_publishers>:
144
+ 1. **RubyGems** — trusted publisher on the `zwischen` gem:
145
145
  repository `cjordan223/zwischen`, workflow `release.yml`, environment `release`.
146
- 2. **PyPI** — add a *pending trusted publisher* for the `zwischen-cli` project at
147
- <https://pypi.org/manage/account/publishing/> with the same repository,
148
- workflow, and environment. (The bare `zwischen` name is taken on PyPI by an
149
- unrelated package, so the distribution is `zwischen-cli`; the installed
150
- command is still `zwischen`.)
151
- 3. **npm** create an automation token at npmjs.com and save it as the
152
- `NPM_TOKEN` repository secret.
153
- 4. **GitHub** create a `release` environment in the repo settings
154
- (Settings Environments) so the OIDC claims match.
146
+ 2. **PyPI** — trusted publisher on the `zwischen-cli` project, same
147
+ repository, workflow, and environment. (The bare `zwischen` name is taken
148
+ on PyPI by an unrelated package, so the distribution is `zwischen-cli`;
149
+ the installed command is still `zwischen`.)
150
+ 3. **npm** granular access token saved as the `NPM_TOKEN` repository
151
+ secret. Must have read/write on the `zwischen` package and **bypass 2FA**
152
+ enabled, or CI publishes fail with `EOTP`. When rotating, set the secret
153
+ via the interactive prompt (`gh secret set NPM_TOKEN`) never pass the
154
+ token as a command argument.
155
+ 4. **GitHub** — the `release` environment in repo settings, so OIDC claims
156
+ match the workflows.
data/README.md CHANGED
@@ -67,8 +67,11 @@ model via Ollama.
67
67
 
68
68
  Manual scans use AI when `--ai` is passed or config enables it. Pre-push
69
69
  scans stay scanner-only unless `ai.pre_push_enabled: true` — blocking
70
- decisions should be fast and deterministic. The design rationale is in
71
- [docs/design.md](docs/design.md).
70
+ decisions should be fast and deterministic. Annotation quality is
71
+ model-dependent: larger models give reliably structured, accurate triage,
72
+ while very small local models may fail to annotate. If AI is unavailable or
73
+ unparseable, scans always fall back to raw findings. The design rationale
74
+ is in [docs/design.md](docs/design.md).
72
75
 
73
76
  ## Commands
74
77
 
@@ -76,7 +79,7 @@ decisions should be fast and deterministic. The design rationale is in
76
79
  | --- | --- |
77
80
  | `zwischen init` | Installs/checks tools, creates config, installs pre-push hook (backs up an existing non-Zwischen hook). |
78
81
  | `zwischen scan` | Runs enabled scanners, prints a terminal report. |
79
- | `zwischen scan --changed` | Scans only files changed since the default branch. |
82
+ | `zwischen scan --changed` | Scans only files changed since the default branch, including staged and untracked files. |
80
83
  | `zwischen scan --only secrets,sast` | Limits to Gitleaks (`secrets`) and/or Semgrep (`sast`). |
81
84
  | `zwischen scan --ai <provider>` | Adds AI prioritization, fix suggestions, false-positive detection. |
82
85
  | `zwischen scan --format json` | Machine-readable summary + findings. |
@@ -195,7 +198,7 @@ docs/ Design write-up, triage example, demo GIF
195
198
  ## Development
196
199
 
197
200
  ```bash
198
- bundle exec rspec # 196+ examples
201
+ bundle exec rspec # 212 examples
199
202
  ./scripts/test_as_gem.sh # install and exercise as a real gem
200
203
  ```
201
204
 
data/TESTING.md CHANGED
@@ -177,6 +177,30 @@ Expected:
177
177
  - Pre-push mode only reports findings from files returned by `GitDiff.changed_files`.
178
178
  - Uncommitted files outside that diff are not reported.
179
179
 
180
+ ### Test 3.5: SARIF Output
181
+
182
+ ```bash
183
+ zwischen scan --format sarif
184
+ ```
185
+
186
+ Expected:
187
+
188
+ - Prints valid SARIF 2.1.0 JSON (`version`, `runs[0].tool.driver.name == "Zwischen"`).
189
+ - File URIs are project-relative.
190
+ - Exit code still reflects configured blocking behavior.
191
+ - With no findings, prints an empty SARIF document and exits `0`.
192
+
193
+ ### Test 3.6: Changed-Only Manual Scan
194
+
195
+ ```bash
196
+ zwischen scan --changed
197
+ ```
198
+
199
+ Expected:
200
+
201
+ - Only files changed since the default branch are scanned and reported.
202
+ - Exits `0` silently when there are no changed files.
203
+
180
204
  ## Test Suite 4: Blocking Configuration
181
205
 
182
206
  ### Test 4.1: Default Blocking
@@ -347,6 +371,8 @@ Expected:
347
371
  - Test 3.2:
348
372
  - Test 3.3:
349
373
  - Test 3.4:
374
+ - Test 3.5:
375
+ - Test 3.6:
350
376
  - Test 4.1:
351
377
  - Test 4.2:
352
378
  - Test 4.3:
@@ -80,9 +80,11 @@ module Zwischen
80
80
  end
81
81
 
82
82
  def enhance_findings(findings, ai_response)
83
- # Try to parse JSON from the response
84
- # Look for JSON object in the response
85
- json_match = ai_response.match(/\{[\s\S]*\}/m)
83
+ # Models (especially small local ones) often wrap JSON in markdown
84
+ # fences or prose; strip fences first, then extract the outermost
85
+ # JSON object.
86
+ cleaned = ai_response.to_s.gsub(/```(?:json)?/i, "")
87
+ json_match = cleaned.match(/\{[\s\S]*\}/m)
86
88
  return findings unless json_match
87
89
 
88
90
  ai_analysis = JSON.parse(json_match[0])
data/lib/zwischen/cli.rb CHANGED
@@ -91,7 +91,7 @@ module Zwischen
91
91
 
92
92
  changed_files = nil
93
93
  if pre_push || options[:changed]
94
- changed_files = GitDiff.changed_files
94
+ changed_files = GitDiff.changed_files(include_working_tree: !pre_push)
95
95
  changed_files = changed_files.select do |path|
96
96
  candidate = path
97
97
  candidate = File.join(project[:root], candidate) unless Pathname.new(candidate).absolute?
@@ -173,7 +173,7 @@ module Zwischen
173
173
  require "json"
174
174
  puts JSON.pretty_generate({
175
175
  summary: aggregated[:summary],
176
- findings: aggregated[:findings].map(&:to_h)
176
+ findings: aggregated[:findings].map { |f| f.to_h.merge(file: relative_path(f.file, project[:root])) }
177
177
  })
178
178
  blocking_severity = config.blocking_severity
179
179
  exit_code = aggregated[:findings].any? { |f| should_block?(f, blocking_severity, ai_enabled) } ? 1 : 0
@@ -187,7 +187,7 @@ module Zwischen
187
187
  if pre_push
188
188
  exit_code = Reporter::Terminal.report_compact(aggregated, config: config, ai_enabled: ai_enabled)
189
189
  else
190
- exit_code = Reporter::Terminal.report(aggregated, ai_enabled: ai_enabled)
190
+ exit_code = Reporter::Terminal.report(aggregated, config: config, ai_enabled: ai_enabled)
191
191
  end
192
192
  exit exit_code
193
193
  end
@@ -206,6 +206,18 @@ module Zwischen
206
206
 
207
207
  private
208
208
 
209
+ # Scanners may emit absolute (and symlink-resolved) paths; report
210
+ # machine-readable output relative to the project root like the
211
+ # terminal and SARIF reporters do.
212
+ def relative_path(path, project_root)
213
+ expanded = File.expand_path(path.to_s)
214
+ roots = [project_root, (File.realpath(project_root) rescue project_root)].uniq
215
+ roots.each do |root|
216
+ return expanded.delete_prefix("#{root}/") if expanded.start_with?("#{root}/")
217
+ end
218
+ path.to_s
219
+ end
220
+
209
221
  def should_block?(finding, blocking_severity, ai_enabled)
210
222
  return false if ai_enabled && finding.raw_data["ai_false_positive"]
211
223
 
@@ -17,19 +17,31 @@ module Zwischen
17
17
  "HEAD"
18
18
  end
19
19
 
20
- def self.changed_files(remote: nil, local: "HEAD")
20
+ # include_working_tree: also count staged and untracked files. Used by
21
+ # manual `scan --changed`; pre-push keeps committed-range semantics
22
+ # because only committed changes get pushed.
23
+ def self.changed_files(remote: nil, local: "HEAD", include_working_tree: false)
21
24
  branch = remote || default_branch
22
25
  remote_ref = "origin/#{branch}"
23
26
 
27
+ files = []
28
+
24
29
  # Try remote diff first
25
- files = `git diff --name-only #{remote_ref}...#{local} 2>/dev/null`.strip.split("\n")
26
- return files.reject(&:empty?) if $?.success? && !files.empty?
30
+ committed = `git diff --name-only #{remote_ref}...#{local} 2>/dev/null`.strip.split("\n")
31
+ if $?.success? && !committed.empty?
32
+ files = committed
33
+ else
34
+ # Fallback: local diff
35
+ local_diff = `git diff --name-only HEAD@{1}...#{local} 2>/dev/null`.strip.split("\n")
36
+ files = local_diff if $?.success?
37
+ end
27
38
 
28
- # Fallback: local diff
29
- files = `git diff --name-only HEAD@{1}...#{local} 2>/dev/null`.strip.split("\n")
30
- return files.reject(&:empty?) if $?.success?
39
+ if include_working_tree
40
+ working = `git status --porcelain 2>/dev/null`.strip.split("\n").map { |l| l[3..]&.strip }.compact
41
+ files |= working if $?.success?
42
+ end
31
43
 
32
- []
44
+ files.reject { |f| f.nil? || f.empty? }
33
45
  rescue StandardError => e
34
46
  warn "Failed to get changed files: #{e.message}" if ENV["DEBUG"]
35
47
  []
@@ -1,12 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "open3"
4
5
 
5
6
  module Zwischen
6
7
  class Hooks
7
8
  HOOK_MARKER = "Zwischen pre-push hook"
8
9
 
10
+ # Resolve the hooks directory through git itself so the hook lands where
11
+ # git will actually execute it — this respects core.hooksPath (husky,
12
+ # pre-commit framework, etc.) and linked worktrees, where .git is a file.
9
13
  def self.hook_path(project_root = Dir.pwd)
14
+ stdout, _stderr, status = Open3.capture3(
15
+ "git", "rev-parse", "--git-path", "hooks", chdir: project_root
16
+ )
17
+ hooks_dir = status.success? ? stdout.strip : nil
18
+ hooks_dir = File.join(".git", "hooks") if hooks_dir.nil? || hooks_dir.empty?
19
+ hooks_dir = File.expand_path(hooks_dir, project_root)
20
+
21
+ File.join(hooks_dir, "pre-push")
22
+ rescue StandardError
10
23
  File.join(project_root, ".git", "hooks", "pre-push")
11
24
  end
12
25
 
@@ -22,8 +22,8 @@ module Zwischen
22
22
  "info" => "ℹ️ INFO"
23
23
  }.freeze
24
24
 
25
- def self.report(aggregated_results, ai_enabled: false)
26
- new(aggregated_results, ai_enabled: ai_enabled).report
25
+ def self.report(aggregated_results, config: nil, ai_enabled: false)
26
+ new(aggregated_results, ai_enabled: ai_enabled, config: config).report
27
27
  end
28
28
 
29
29
  def self.report_compact(aggregated_results, config:, ai_enabled: false)
@@ -113,14 +113,18 @@ module Zwischen
113
113
 
114
114
  def install_hook
115
115
  project_root = Dir.pwd
116
- git_dir = File.join(project_root, ".git")
117
116
 
118
- unless File.directory?(git_dir)
117
+ # File check alone breaks linked worktrees, where .git is a file
118
+ unless File.exist?(File.join(project_root, ".git"))
119
119
  @shell.say(" ⚠️ No .git directory found. Skipping hook installation.", :yellow)
120
120
  return false
121
121
  end
122
122
 
123
123
  hook_path = Hooks.hook_path(project_root)
124
+ default_path = File.expand_path(File.join(project_root, ".git", "hooks", "pre-push"))
125
+ if File.expand_path(hook_path) != default_path
126
+ @shell.say(" ↳ Git hooks are redirected (core.hooksPath or worktree); installing to #{hook_path}", :yellow)
127
+ end
124
128
 
125
129
  if File.exist?(hook_path)
126
130
  if Hooks.zwischen_hook?(hook_path)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zwischen
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
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.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Conner Jordan