diffimpactscout 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (44) hide show
  1. {diffimpactscout-0.2.0/src/diffimpactscout.egg-info → diffimpactscout-0.3.0}/PKG-INFO +1 -1
  2. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/setup.py +1 -1
  3. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/__init__.py +1 -1
  4. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/base.py +43 -2
  5. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/checks/base.py +14 -10
  6. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/checks/eslint.py +2 -2
  7. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/checks/prettier.py +2 -2
  8. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/checks/repo_checks.py +36 -6
  9. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/checks/ruff.py +4 -4
  10. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/checks/syntax.py +10 -6
  11. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/cli.py +24 -5
  12. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/config.py +2 -0
  13. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/guard.py +85 -8
  14. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/impact/impact.py +7 -2
  15. diffimpactscout-0.3.0/src/diffimpactscout/impact/reporter.py +191 -0
  16. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0/src/diffimpactscout.egg-info}/PKG-INFO +1 -1
  17. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/tests/test_fixture_smoke.py +2 -0
  18. diffimpactscout-0.2.0/src/diffimpactscout/impact/reporter.py +0 -107
  19. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/LICENSE +0 -0
  20. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/README.md +0 -0
  21. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/pyproject.toml +0 -0
  22. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/setup.cfg +0 -0
  23. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/__main__.py +0 -0
  24. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/checks/__init__.py +0 -0
  25. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/checks/hygiene.py +0 -0
  26. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/detect.py +0 -0
  27. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/env.py +0 -0
  28. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/gitrun.py +0 -0
  29. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/impact/__init__.py +0 -0
  30. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/impact/cache.py +0 -0
  31. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/impact/diff_parser.py +0 -0
  32. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/impact/python_analyzer.py +0 -0
  33. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/impact/route_linker.py +0 -0
  34. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/launcher.py +0 -0
  35. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/profiles/django.json +0 -0
  36. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/profiles/fastapi.json +0 -0
  37. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/profiles/frontend.json +0 -0
  38. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/profiles/generic.json +0 -0
  39. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/profiles/python.json +0 -0
  40. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout/scope.py +0 -0
  41. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout.egg-info/SOURCES.txt +0 -0
  42. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout.egg-info/dependency_links.txt +0 -0
  43. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout.egg-info/entry_points.txt +0 -0
  44. {diffimpactscout-0.2.0 → diffimpactscout-0.3.0}/src/diffimpactscout.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffimpactscout
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Incremental pre-push guard and AST blast-radius impact analyzer for git repositories.
5
5
  Author: DiffImpactScout contributors
6
6
  License: MIT
@@ -7,7 +7,7 @@ with io.open("README.md", encoding="utf-8") as fh:
7
7
 
8
8
  setup(
9
9
  name="diffimpactscout",
10
- version="0.2.0",
10
+ version="0.3.0",
11
11
  description="Incremental pre-push guard and AST blast-radius impact analyzer for git repositories.",
12
12
  long_description=long_description,
13
13
  long_description_content_type="text/markdown",
@@ -1,3 +1,3 @@
1
1
  """diffimpactscout: incremental pre-push guard and AST blast-radius impact analyzer."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -2,13 +2,54 @@
2
2
 
3
3
  Example: when the repo has an upstream/sprint/12 ref, diffs are measured
4
4
  against it instead of origin/master.
5
+
6
+ ================================================================================
7
+ ARCHITECTURAL OVERVIEW: 2-DOT TREE DIFF FOR INCREMENTAL CHECKS
8
+ ================================================================================
9
+
10
+ Why a 2-dot tree diff (`git diff <anchor> HEAD`):
11
+
12
+ In standard Git workflows a 3-dot diff (`git diff <anchor>...HEAD`) computes
13
+ changes relative to the common merge-base ancestor of the two tips. Whenever a
14
+ developer syncs their branch with upstream (merge, rebase, or squash), the
15
+ merge-base moves and the 3-dot range silently grows to include every commit
16
+ introduced by upstream since the branch diverged. Every pre-existing upstream
17
+ violation (a stale trailing space, a long line, an old lint error) then shows up
18
+ as a "new" issue in the pre-push guard, producing false positives the developer
19
+ did not author and cannot reasonably fix in that push.
20
+
21
+ A 2-dot tree diff (`git diff <anchor> HEAD`, no `...`) compares the exact
22
+ filesystem snapshot ("tree") of the resolved upstream anchor directly against
23
+ the `HEAD` tree. Consequences:
24
+
25
+ 1. Zero Upstream Blame: any file or line that exists byte-identically in the
26
+ anchor tree contributes no diff entries. Only files and lines actually
27
+ added or changed by the developer are passed to the checks.
28
+ 2. Sync Agnostic: behaves identically whether the branch was synced with
29
+ `git merge`, `git rebase`, or `git merge --squash`, because it never walks
30
+ commit ancestry, only the two tip trees.
31
+ 3. Shallow-Clone Safe: computing the name-only diff needs just the two tip
32
+ trees (plus any blobs referenced in the range), so it works on shallow
33
+ clones that lack full commit history.
34
+
35
+ Sprint branch resolution:
36
+
37
+ In sprint-based development models (e.g. Contify) work is branched from active
38
+ sprint branches such as `refs/remotes/upstream/sprint/33.1` rather than from
39
+ `master`. The resolver inspects `refs/remotes/upstream/master` plus the top
40
+ `DEFAULT_MAX_SPRINT_CANDIDATES` (5) most recent sprint branches sorted by
41
+ committer date, and picks the candidate whose tree is closest to `HEAD` (the
42
+ fewest changed files on a `--diff-filter=ACMRT` name-only diff). The closest
43
+ tree means the smallest honest change-set, which is what incremental checks
44
+ want to target.
45
+ ================================================================================
5
46
  """
6
47
 
7
48
  import diffimpactscout.gitrun as gitrun
8
49
 
9
50
  MASTER_REF = "refs/remotes/upstream/master"
10
51
  SPRINT_PREFIX = "sprint/"
11
- DEFAULT_MAX_SPRINT_CANDIDATES = 10
52
+ DEFAULT_MAX_SPRINT_CANDIDATES = 5
12
53
  REMOTE_FALLBACKS = ("main", "master", "develop")
13
54
 
14
55
 
@@ -16,7 +57,7 @@ def resolve_change_base(root, limit=DEFAULT_MAX_SPRINT_CANDIDATES):
16
57
  """Return the remote ref closest to HEAD, or None.
17
58
 
18
59
  `limit` caps how many `sprint/*` refs are considered: only the
19
- `limit` (default 10) most-recent ones by committer date, unlike the
60
+ `limit` (default 5) most-recent ones by committer date, unlike the
20
61
  bash original dev_base.sh which considered every sprint branch.
21
62
  """
22
63
  for remote in _remote_order(root):
@@ -97,7 +97,9 @@ class ExternalCheck(Check):
97
97
  output = (out or err).decode("utf-8", errors="replace").strip()
98
98
  if not output:
99
99
  output = "exit code %s" % proc.returncode
100
- issues.append(CheckIssue(".", 0, 0, self.id, output))
100
+ else:
101
+ output = "%s (exit code %s)" % (output, proc.returncode)
102
+ issues.append(CheckIssue(".", None, None, self.id, output))
101
103
  return CheckResult(issues=issues, fixed=fixed, skipped=skipped, warned=warned)
102
104
  for path in files or []:
103
105
  argv = self._argv_for(path)
@@ -116,7 +118,9 @@ class ExternalCheck(Check):
116
118
  output = (out or err).decode("utf-8", errors="replace").strip()
117
119
  if not output:
118
120
  output = "exit code %s" % proc.returncode
119
- issues.append(CheckIssue(path, 0, 0, self.id, output))
121
+ else:
122
+ output = "%s (exit code %s)" % (output, proc.returncode)
123
+ issues.append(CheckIssue(path, None, None, self.id, output))
120
124
  return CheckResult(issues=issues, fixed=fixed, skipped=skipped, warned=warned)
121
125
 
122
126
 
@@ -151,7 +155,7 @@ class CheckContext(object):
151
155
 
152
156
 
153
157
  class CheckIssue(object):
154
- def __init__(self, path, line, column, code, message):
158
+ def __init__(self, path, line=None, column=None, code=None, message=None):
155
159
  self.path = path
156
160
  self.line = line
157
161
  self.column = column
@@ -159,13 +163,13 @@ class CheckIssue(object):
159
163
  self.message = message
160
164
 
161
165
  def format(self):
162
- return "%s:%s:%s: %s %s" % (
163
- self.path,
164
- self.line,
165
- self.column,
166
- self.code,
167
- self.message,
168
- )
166
+ if self.line is None and self.column is None:
167
+ location = self.path
168
+ else:
169
+ line = "-" if self.line is None else str(self.line)
170
+ column = "-" if self.column is None else str(self.column)
171
+ location = "%s:%s:%s" % (self.path, line, column)
172
+ return "%s: %s %s" % (location, self.code, self.message)
169
173
 
170
174
 
171
175
  class CheckResult(object):
@@ -155,8 +155,8 @@ class EslintCheck(Check):
155
155
  issues.append(
156
156
  CheckIssue(
157
157
  path,
158
- line or 0,
159
- column or 0,
158
+ line,
159
+ column,
160
160
  rule_id or self.id,
161
161
  message.get("message") or "",
162
162
  )
@@ -93,8 +93,8 @@ class PrettierCheck(Check):
93
93
  issues.append(
94
94
  CheckIssue(
95
95
  path,
96
- 0,
97
- 0,
96
+ None,
97
+ None,
98
98
  self.id,
99
99
  "%s is not formatted. Fix with: npx prettier --write %s"
100
100
  % (path, path),
@@ -1,11 +1,14 @@
1
- """Repo-wide checks for oversized files and leaked private keys.
1
+ """Repo-wide checks for oversized files, leaked private keys, and case-colliding file paths.
2
2
 
3
- Example: a 300 MB binary is flagged with a git-lfs hint, and any file
4
- containing a private key block always blocks the push.
3
+ Example: a 300 MB binary is flagged with a git-lfs hint, any file
4
+ containing a private key block always blocks the push, and a pushed
5
+ `FILE.TXT` colliding with a tracked `file.txt` is flagged because the two
6
+ names clobber each other on case-insensitive filesystems (macOS, Windows).
5
7
  """
6
8
 
7
9
  import os
8
10
 
11
+ import diffimpactscout.gitrun as gitrun
9
12
  from diffimpactscout.checks.base import (
10
13
  Check,
11
14
  CheckIssue,
@@ -23,6 +26,33 @@ PRIVATE_KEY_MARKERS = (
23
26
  )
24
27
 
25
28
 
29
+ @register
30
+ class CaseConflictCheck(Check):
31
+ id = "repo/case-conflict"
32
+ scoped = "files"
33
+
34
+ def run(self, context, files):
35
+ tracked = gitrun.git_nul(["ls-files", "-z"], context.root)
36
+ by_lower = {}
37
+ for path in tracked:
38
+ by_lower.setdefault(path.lower(), []).append(path)
39
+ issues = []
40
+ for path in files or []:
41
+ for other in by_lower.get(path.lower(), ()):
42
+ if other != path:
43
+ issues.append(
44
+ CheckIssue(
45
+ path,
46
+ None,
47
+ None,
48
+ self.id,
49
+ "case conflict detected with %s" % other,
50
+ )
51
+ )
52
+ break
53
+ return CheckResult(issues=issues)
54
+
55
+
26
56
  @register
27
57
  class LargeFilesCheck(Check):
28
58
  id = "repo/large-files"
@@ -61,8 +91,8 @@ class LargeFilesCheck(Check):
61
91
  issues.append(
62
92
  CheckIssue(
63
93
  path,
64
- 0,
65
- 0,
94
+ None,
95
+ None,
66
96
  self.id,
67
97
  "file size %d kB exceeds limit of %d kB; consider git lfs"
68
98
  % (size_kb, self.max_kb),
@@ -89,7 +119,7 @@ class PrivateKeyCheck(Check):
89
119
  for marker in PRIVATE_KEY_MARKERS:
90
120
  if marker in data:
91
121
  issues.append(
92
- CheckIssue(path, 0, 0, self.id, "private key detected")
122
+ CheckIssue(path, None, None, self.id, "private key detected")
93
123
  )
94
124
  break
95
125
  return CheckResult(issues=issues)
@@ -157,8 +157,8 @@ class RuffCheck(Check):
157
157
  issues.append(
158
158
  CheckIssue(
159
159
  path,
160
- row or 0,
161
- location.get("column") or 0,
160
+ row,
161
+ location.get("column"),
162
162
  violation.get("code") or self.id,
163
163
  violation.get("message") or "",
164
164
  )
@@ -215,8 +215,8 @@ class RuffFormatCheck(Check):
215
215
  issues.append(
216
216
  CheckIssue(
217
217
  path,
218
- 0,
219
- 0,
218
+ None,
219
+ None,
220
220
  self.id,
221
221
  "formatting issue in changed lines:\n%s\nFix with: ruff format %s"
222
222
  % (_truncate(kept), path),
@@ -38,8 +38,8 @@ class JsonSyntaxCheck(Check):
38
38
  issues.append(
39
39
  CheckIssue(
40
40
  path,
41
- getattr(exc, "lineno", 0) or 0,
42
- getattr(exc, "colno", 0) or 0,
41
+ getattr(exc, "lineno", None),
42
+ getattr(exc, "colno", None),
43
43
  self.id,
44
44
  "invalid JSON: %s" % exc,
45
45
  )
@@ -69,8 +69,8 @@ class AstSyntaxCheck(Check):
69
69
  issues.append(
70
70
  CheckIssue(
71
71
  path,
72
- exc.lineno or 0,
73
- exc.offset or 0,
72
+ exc.lineno,
73
+ exc.offset,
74
74
  self.id,
75
75
  "invalid python syntax: %s" % (exc.msg or "syntax error"),
76
76
  )
@@ -78,7 +78,11 @@ class AstSyntaxCheck(Check):
78
78
  except ValueError as exc:
79
79
  issues.append(
80
80
  CheckIssue(
81
- path, 0, 0, self.id, "invalid python source: %s" % exc
81
+ path,
82
+ None,
83
+ None,
84
+ self.id,
85
+ "invalid python source: %s" % exc,
82
86
  )
83
87
  )
84
88
  return CheckResult(issues=issues)
@@ -111,7 +115,7 @@ class MergeConflictCheck(Check):
111
115
  line = lines[lineno - 1]
112
116
  if self._is_marker(line):
113
117
  issues.append(
114
- CheckIssue(path, lineno, 0, self.id, "conflict marker found")
118
+ CheckIssue(path, lineno, None, self.id, "conflict marker found")
115
119
  )
116
120
  return CheckResult(issues=issues)
117
121
 
@@ -46,6 +46,11 @@ def _parser():
46
46
  p_impact.add_argument("--staged", action="store_true")
47
47
  p_impact.add_argument("--fast", action="store_true")
48
48
  p_impact.add_argument("--json", action="store_true")
49
+ p_impact.add_argument(
50
+ "--markdown",
51
+ action="store_true",
52
+ help="emit a Markdown table instead of the plain ASCII table",
53
+ )
49
54
  p_impact.set_defaults(func=_cmd_impact)
50
55
 
51
56
  p_check = sub.add_parser(
@@ -148,6 +153,7 @@ def _cmd_impact(args):
148
153
  staged=args.staged,
149
154
  fast=args.fast,
150
155
  json_out=args.json,
156
+ markdown=args.markdown,
151
157
  )
152
158
 
153
159
 
@@ -243,6 +249,20 @@ def _prompt_choice(text, choices, default):
243
249
  return default
244
250
 
245
251
 
252
+ def _prompt_blocking(default):
253
+ sys.stdout.write(
254
+ "Guard blocking mode: strict (block the push) or warn (report only)? "
255
+ "[strict/Warn] (default: %s): " % default
256
+ )
257
+ sys.stdout.flush()
258
+ answer = _read_answer()
259
+ if answer in ("strict", "s"):
260
+ return "strict"
261
+ if answer in ("warn", "w"):
262
+ return "warn"
263
+ return default
264
+
265
+
246
266
  def _check_id(entry):
247
267
  if isinstance(entry, dict):
248
268
  return entry.get("id", "?")
@@ -334,10 +354,7 @@ def _ask_overrides(data, args):
334
354
  data["guard"] = merged["guard"]
335
355
  data["impact"] = merged["impact"]
336
356
  if not args.blocking:
337
- strict = _prompt_yes_default(
338
- "Block the push when checks report issues? (strict mode) [y/N]", False
339
- )
340
- data["guard"]["blocking"] = "strict" if strict else "warn"
357
+ data["guard"]["blocking"] = _prompt_blocking("strict")
341
358
  hint = _LINT_HINT.get(profile)
342
359
  if hint:
343
360
  keep = _prompt_yes_default(
@@ -385,11 +402,13 @@ def _cmd_install_hooks(args):
385
402
  detected = detect.detect_stack(root)
386
403
  profile = _resolve_profile(args, detected)
387
404
  data = _build_config_data(profile, args)
405
+ interactive = not args.yes and _is_tty()
406
+ if interactive and not args.blocking:
407
+ data["guard"]["blocking"] = "strict"
388
408
  sys.stdout.write(_render_preview(detected, data))
389
409
  note = _other_stack_note(root, profile)
390
410
  if note:
391
411
  sys.stderr.write("diffimpactscout: %s\n" % note)
392
- interactive = not args.yes and _is_tty()
393
412
  if interactive:
394
413
  proceed = _prompt_yes_default("Proceed? [Y/n]", True)
395
414
  if not proceed:
@@ -21,6 +21,7 @@ DEFAULT_IGNORE_PATHS = [
21
21
  "**/dist/**",
22
22
  "**/build/**",
23
23
  "**/.git/**",
24
+ "**/.impact_analysis_cache.json",
24
25
  ]
25
26
 
26
27
  DEFAULT_GUARD_CHECKS = [
@@ -32,6 +33,7 @@ DEFAULT_GUARD_CHECKS = [
32
33
  {"id": "syntax/merge-conflict"},
33
34
  {"id": "repo/large-files", "args": ["--maxkb=250000"]},
34
35
  {"id": "repo/private-key"},
36
+ {"id": "repo/case-conflict"},
35
37
  ]
36
38
 
37
39
 
@@ -10,6 +10,7 @@ import diffimpactscout.env as env
10
10
  import diffimpactscout.gitrun as gitrun
11
11
  import diffimpactscout.scope as scope
12
12
  from diffimpactscout.checks.base import CheckContext, make_check
13
+ from diffimpactscout.config import is_excluded
13
14
 
14
15
  DIFF_FILTER = "ACMRT"
15
16
 
@@ -48,6 +49,77 @@ def _warn(text):
48
49
  sys.stderr.write("diffimpactscout: warning: %s\n" % text)
49
50
 
50
51
 
52
+ _FIX_HINTS = {
53
+ "ruff": "ruff check --fix {path}",
54
+ "ruff-format": "ruff format {path}",
55
+ "eslint": "eslint --fix {path}",
56
+ "prettier": "prettier --write {path}",
57
+ }
58
+
59
+
60
+ def _fix_hint(check_id, path):
61
+ template = _FIX_HINTS.get(check_id)
62
+ if not template or not path:
63
+ return None
64
+ return template.format(path=path)
65
+
66
+
67
+ def _print_header(anchor, num_files, mode):
68
+ print("=" * 70)
69
+ print(" DiffImpactScout Guard")
70
+ print(
71
+ " Diff Base: %s | %d changed file(s) | %s mode"
72
+ % (anchor or "none", num_files, mode)
73
+ )
74
+ print("=" * 70)
75
+
76
+
77
+ def _print_check_result(check, result):
78
+ if result.has_issues():
79
+ print(" [FAIL] %s" % check.id)
80
+ for issue in result.issues:
81
+ print(" %s" % issue.format())
82
+ hint = _fix_hint(check.id, result.issues[0].path)
83
+ if hint:
84
+ print(" --> Fix with: %s" % hint)
85
+ elif result.fixed:
86
+ print(" [FIXED] %s" % check.id)
87
+ for path in result.fixed:
88
+ print(" [fixed] %s" % path)
89
+ elif result.warned:
90
+ print(" [SKIP] %s" % check.id)
91
+ else:
92
+ print(" [PASS] %s" % check.id)
93
+
94
+
95
+ def _print_summary(ran, total_issues, total_fixed):
96
+ print("-" * 70)
97
+ print(
98
+ " diffimpactscout: %d check(s), %d issue(s), %d fixed"
99
+ % (ran, total_issues, total_fixed)
100
+ )
101
+
102
+
103
+ def _print_verdict(blocked, total_issues, mode, always_blocked):
104
+ if blocked:
105
+ if always_blocked:
106
+ verdict = (
107
+ "[BLOCKED] push rejected (%s mode; hard-block check: %d issue(s))"
108
+ % (mode, total_issues)
109
+ )
110
+ else:
111
+ verdict = "[BLOCKED] push rejected (strict mode: %d issue(s))" % total_issues
112
+ elif total_issues:
113
+ verdict = "[ALLOWED] push allowed (%s mode: %d issue(s) reported)" % (
114
+ mode,
115
+ total_issues,
116
+ )
117
+ else:
118
+ verdict = "[ALLOWED] push allowed (%s mode: no issues)" % mode
119
+ print(" Verdict: %s" % verdict)
120
+ print("=" * 70)
121
+
122
+
51
123
  def run_guard(root, cfg, staged=False, all_files=False, files=None):
52
124
  if _skip_requested():
53
125
  return 0
@@ -67,6 +139,8 @@ def run_guard(root, cfg, staged=False, all_files=False, files=None):
67
139
  anchor = dev_scope.scope_base(from_ref, to_ref)
68
140
  file_set = dev_scope.dev_files(anchor, from_ref, to_ref)
69
141
 
142
+ file_set = [p for p in file_set if not is_excluded(p, cfg, root)]
143
+
70
144
  mode = _guard_mode(cfg)
71
145
  ctx = CheckContext(
72
146
  root=root,
@@ -78,9 +152,13 @@ def run_guard(root, cfg, staged=False, all_files=False, files=None):
78
152
  echo=True,
79
153
  )
80
154
 
155
+ _print_header(anchor, len(file_set), mode)
156
+
81
157
  ran = 0
82
158
  total_issues = 0
159
+ total_fixed = 0
83
160
  blocked = False
161
+ always_blocked = False
84
162
 
85
163
  for entry in cfg.get("guard", {}).get("checks") or []:
86
164
  check = None
@@ -96,21 +174,20 @@ def run_guard(root, cfg, staged=False, all_files=False, files=None):
96
174
  check_files = [] if check.scoped == "repo" else file_set
97
175
  try:
98
176
  result = check.run(ctx, check_files)
99
- for issue in result.issues:
100
- print(issue.format())
101
- total_issues += 1
102
- for path in result.fixed:
103
- print("[fixed] %s" % path)
177
+ total_issues += len(result.issues)
178
+ total_fixed += len(result.fixed)
104
179
  for text in result.warned:
105
180
  _warn(text)
106
181
  should_block = check.always_block or (check.blocking and mode == "strict")
107
182
  if should_block and result.has_issues():
108
183
  blocked = True
184
+ if check.always_block:
185
+ always_blocked = True
186
+ _print_check_result(check, result)
109
187
  except Exception as exc:
110
188
  _warn("check %r failed: %s" % (check.id, exc))
111
189
  continue
112
190
 
113
- print(
114
- "diffimpactscout: %d check(s), %d issue(s)" % (ran, total_issues)
115
- )
191
+ _print_summary(ran, total_issues, total_fixed)
192
+ _print_verdict(blocked, total_issues, mode, always_blocked)
116
193
  return 1 if blocked else 0
@@ -30,7 +30,7 @@ _ENTITY_KINDS = {
30
30
  _DEFAULT_KINDS = ("name",)
31
31
 
32
32
 
33
- def run_impact(root, cfg, staged=False, fast=False, json_out=False):
33
+ def run_impact(root, cfg, staged=False, fast=False, json_out=False, markdown=False):
34
34
  if _skip_requested():
35
35
  return 0
36
36
  impact_cfg = cfg.get("impact") or {}
@@ -82,7 +82,9 @@ def run_impact(root, cfg, staged=False, fast=False, json_out=False):
82
82
  + "\n"
83
83
  )
84
84
  else:
85
- sys.stdout.write(reporter.render_report(rows, unresolved, changed_count))
85
+ sys.stdout.write(
86
+ reporter.render_report(rows, unresolved, changed_count, markdown=markdown)
87
+ )
86
88
  strict_env = os.environ.get("IMPACT_CHECK_STRICT")
87
89
  tty = reporter.interactive_tty()
88
90
  block, reason = reporter.should_block(strict_env, tty)
@@ -174,6 +176,9 @@ def _compose_rows(root, impact_cfg, profile, entities, analyses, changed_paths,
174
176
  row["severity"] = reporter.classify_severity(
175
177
  layers.get(name) or (), deleted, changed_paths, row.get("path")
176
178
  )
179
+ row["reason"] = reporter.severity_reason(
180
+ layers.get(name) or (), deleted, changed_paths, row.get("path")
181
+ )
177
182
  row["action"] = _action(row["severity"], deleted)
178
183
  return rows, unresolved
179
184
 
@@ -0,0 +1,191 @@
1
+ """Render the impact report, classify severity, and decide whether a push should be blocked.
2
+
3
+ The terminal/stdout default is a plain aligned ASCII table that matches the
4
+ guard report's visual style; a Markdown table is available via an explicit
5
+ `--markdown` output mode for piping into PR comments or docs pages.
6
+
7
+ Example: a changed view referenced from a template and a frontend service
8
+ gets High severity, while a reference inside the changed file gets Low.
9
+ """
10
+
11
+ import sys
12
+
13
+ import diffimpactscout.env as env
14
+
15
+ _HEADER = "| # | Impacted File Path | Module / Subsystem | Category | Detected Reference / Usage | Severity | Action Required |"
16
+ _SEPARATOR = "| --- | --- | --- | --- | --- | --- | --- |"
17
+ _COLUMNS = ("#", "Impacted File Path", "Module / Subsystem", "Category", "Detected Reference / Usage", "Severity", "Action Required")
18
+
19
+
20
+ def classify_severity(layers, deleted_renamed, changed_paths, ref_path):
21
+ if deleted_renamed:
22
+ return "High"
23
+ if len(set(layers or ())) >= 2:
24
+ return "High"
25
+ if ref_path in (changed_paths or ()):
26
+ return "Low"
27
+ if not ref_path or not changed_paths:
28
+ return "Low"
29
+ return "Medium"
30
+
31
+
32
+ def severity_reason(layers, deleted_renamed, changed_paths, ref_path):
33
+ if deleted_renamed:
34
+ return "symbol was deleted or renamed in this change-set"
35
+ layers = set(layers or ())
36
+ if len(layers) >= 2:
37
+ return "referenced from multiple layers (%s)" % " + ".join(sorted(layers))
38
+ if ref_path in (changed_paths or ()):
39
+ return "reference is inside the changed file itself"
40
+ if not ref_path or not changed_paths:
41
+ return "no external reference beyond the change-set"
42
+ return "referenced from a file outside the change-set"
43
+
44
+
45
+ def _cell(row, key):
46
+ if isinstance(row, dict):
47
+ value = row.get(key)
48
+ else:
49
+ value = getattr(row, key, None)
50
+ if value is None:
51
+ return ""
52
+ return str(value)
53
+
54
+
55
+ def _row_values(row, index):
56
+ return [str(index)] + [_cell(row, k) for k in ("path", "module", "category", "ref", "severity", "action")]
57
+
58
+
59
+ def _counts(rows):
60
+ counts = {"High": 0, "Medium": 0, "Low": 0}
61
+ for row in rows:
62
+ sev = _cell(row, "severity")
63
+ if sev in counts:
64
+ counts[sev] += 1
65
+ return counts
66
+
67
+
68
+ def _summary_line(rows, changed_count):
69
+ counts = _counts(rows or [])
70
+ return "Summary: High: %d, Medium: %d, Low: %d (%d changed file(s))" % (
71
+ counts["High"],
72
+ counts["Medium"],
73
+ counts["Low"],
74
+ changed_count,
75
+ )
76
+
77
+
78
+ def _rationale_lines(rows):
79
+ lines = []
80
+ for i, row in enumerate(rows or [], start=1):
81
+ reason = _cell(row, "reason")
82
+ if not reason:
83
+ continue
84
+ label = "%s -> %s" % (_cell(row, "path") or "-", _cell(row, "ref") or "-")
85
+ lines.append(" %d. %s - %s (%s)" % (i, _cell(row, "severity") or "?", label, reason))
86
+ return lines
87
+
88
+
89
+ def _ascii_table(rows):
90
+ values = [_row_values(row, i) for i, row in enumerate(rows or [], start=1)]
91
+ widths = []
92
+ for i in range(len(_COLUMNS)):
93
+ width = len(_COLUMNS[i])
94
+ for vals in values:
95
+ width = max(width, len(vals[i]))
96
+ widths.append(width)
97
+ header = " " + " ".join(c.ljust(widths[i]) for i, c in enumerate(_COLUMNS))
98
+ body = []
99
+ for vals in values:
100
+ body.append(" " + " ".join(v.ljust(widths[i]) for i, v in enumerate(vals)))
101
+ return header, body
102
+
103
+
104
+ def _render_ascii(rows, unresolved, changed_count):
105
+ lines = []
106
+ lines.append("=" * 70)
107
+ lines.append(" Impact Analysis Report")
108
+ lines.append(" Blast radius: %d changed file(s)" % changed_count)
109
+ lines.append("=" * 70)
110
+ lines.append("")
111
+ header, body = _ascii_table(rows)
112
+ lines.append(header)
113
+ for line in body:
114
+ lines.append(line)
115
+ if unresolved:
116
+ lines.append("")
117
+ lines.append(" Unresolved references (manual check required):")
118
+ for ref in unresolved:
119
+ lines.append(" - %s" % str(ref))
120
+ rationale = _rationale_lines(rows)
121
+ if rationale:
122
+ lines.append("")
123
+ lines.append(" Severity rationale:")
124
+ lines.extend(rationale)
125
+ lines.append("")
126
+ lines.append("-" * 70)
127
+ lines.append(" " + _summary_line(rows, changed_count))
128
+ lines.append("=" * 70)
129
+ return "\n".join(lines) + "\n"
130
+
131
+
132
+ def _render_markdown(rows, unresolved, changed_count):
133
+ lines = []
134
+ lines.append("# Impact Analysis Report")
135
+ lines.append("")
136
+ lines.append(_HEADER)
137
+ lines.append(_SEPARATOR)
138
+ for i, row in enumerate(rows or [], start=1):
139
+ cells = [str(i)] + [_cell(row, k).replace("|", "\\|") for k in ("path", "module", "category", "ref", "severity", "action")]
140
+ lines.append("| " + " | ".join(cells) + " |")
141
+ if unresolved:
142
+ lines.append("")
143
+ lines.append("## Unresolved references (manual check required)")
144
+ for ref in unresolved:
145
+ lines.append("- %s" % str(ref))
146
+ rationale = _rationale_lines(rows)
147
+ if rationale:
148
+ lines.append("")
149
+ lines.append("## Severity rationale")
150
+ for line in rationale:
151
+ lines.append("- " + line.lstrip())
152
+ lines.append("")
153
+ lines.append(_summary_line(rows, changed_count))
154
+ return "\n".join(lines) + "\n"
155
+
156
+
157
+ def render_report(rows, unresolved, changed_count, markdown=False):
158
+ if markdown:
159
+ return _render_markdown(rows, unresolved, changed_count)
160
+ return _render_ascii(rows, unresolved, changed_count)
161
+
162
+
163
+ def interactive_tty():
164
+ return sys.stdin.isatty()
165
+
166
+
167
+ def confirm(prompt_text):
168
+ sys.stdout.write(prompt_text + " ")
169
+ sys.stdout.flush()
170
+ answer = (sys.stdin.readline() or "").strip().lower()
171
+ if answer in ("", "y", "yes"):
172
+ return True
173
+ return False
174
+
175
+
176
+ def _is_true(value):
177
+ return env.is_true(value)
178
+
179
+
180
+ def _skip_requested():
181
+ return env.skip_requested()
182
+
183
+
184
+ def should_block(strict_env, tty):
185
+ if _skip_requested():
186
+ return (False, "skip env var set; impact check skipped")
187
+ if tty:
188
+ return (True, "interactive; prompt user to proceed")
189
+ if _is_true(strict_env):
190
+ return (True, "strict mode; blocking in non-interactive context")
191
+ return (False, "non-interactive; report shown as warning, commit not blocked")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffimpactscout
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Incremental pre-push guard and AST blast-radius impact analyzer for git repositories.
5
5
  Author: DiffImpactScout contributors
6
6
  License: MIT
@@ -89,11 +89,13 @@ CHECKERS = {
89
89
 
90
90
 
91
91
  def test_gitrepo_scenarios(gitrepo):
92
+ """Verifies that the gitrepo fixture is valid and matches its expected scenario state."""
92
93
  assert gitrepo.git_ok("rev-parse", "--verify", "HEAD")
93
94
  CHECKERS[gitrepo.scenario](gitrepo)
94
95
 
95
96
 
96
97
  def test_repo_helpers_rm_mv_merge(tmp_path):
98
+ """Verifies that the GitRepo helper methods for merge, rm, mv, and commit behave correctly."""
97
99
  repo = GitRepo.init(str(tmp_path / "repo"))
98
100
  repo.write("a.txt", "a\n")
99
101
  repo.commit("A")
@@ -1,107 +0,0 @@
1
- """Render the impact report table, classify severity, and decide whether a push should be blocked.
2
-
3
- Example: a changed view referenced from a template and a frontend service
4
- gets High severity, while a reference inside the changed file gets Low.
5
- """
6
-
7
- import sys
8
-
9
- import diffimpactscout.env as env
10
-
11
- _HEADER = "| # | Impacted File Path | Module / Subsystem | Category | Detected Reference / Usage | Severity | Action Required |"
12
- _SEPARATOR = "| --- | --- | --- | --- | --- | --- | --- |"
13
-
14
-
15
- def classify_severity(layers, deleted_renamed, changed_paths, ref_path):
16
- if deleted_renamed:
17
- return "High"
18
- if len(set(layers or ())) >= 2:
19
- return "High"
20
- if ref_path in (changed_paths or ()):
21
- return "Low"
22
- if not ref_path or not changed_paths:
23
- return "Low"
24
- return "Medium"
25
-
26
-
27
- def _cell(row, key):
28
- if isinstance(row, dict):
29
- value = row.get(key)
30
- else:
31
- value = getattr(row, key, None)
32
- if value is None:
33
- return ""
34
- return str(value).replace("|", "\\|")
35
-
36
-
37
- def _counts(rows):
38
- counts = {"High": 0, "Medium": 0, "Low": 0}
39
- for row in rows:
40
- sev = _cell(row, "severity")
41
- if sev in counts:
42
- counts[sev] += 1
43
- return counts
44
-
45
-
46
- def render_report(rows, unresolved, changed_count):
47
- lines = []
48
- lines.append("# Impact Analysis Report")
49
- lines.append("")
50
- lines.append(_HEADER)
51
- lines.append(_SEPARATOR)
52
- for i, row in enumerate(rows or [], start=1):
53
- lines.append(
54
- "| %d | %s | %s | %s | %s | %s | %s |"
55
- % (
56
- i,
57
- _cell(row, "path"),
58
- _cell(row, "module"),
59
- _cell(row, "category"),
60
- _cell(row, "ref"),
61
- _cell(row, "severity"),
62
- _cell(row, "action"),
63
- )
64
- )
65
- if unresolved:
66
- lines.append("")
67
- lines.append("## Unresolved references (manual check required)")
68
- for ref in unresolved:
69
- lines.append("- %s" % str(ref))
70
- lines.append("")
71
- counts = _counts(rows or [])
72
- lines.append(
73
- "Summary: %d changed file(s); High: %d, Medium: %d, Low: %d"
74
- % (changed_count, counts["High"], counts["Medium"], counts["Low"])
75
- )
76
- return "\n".join(lines) + "\n"
77
-
78
-
79
- def interactive_tty():
80
- return sys.stdin.isatty()
81
-
82
-
83
- def confirm(prompt_text):
84
- sys.stdout.write(prompt_text + " ")
85
- sys.stdout.flush()
86
- answer = (sys.stdin.readline() or "").strip().lower()
87
- if answer in ("", "y", "yes"):
88
- return True
89
- return False
90
-
91
-
92
- def _is_true(value):
93
- return env.is_true(value)
94
-
95
-
96
- def _skip_requested():
97
- return env.skip_requested()
98
-
99
-
100
- def should_block(strict_env, tty):
101
- if _skip_requested():
102
- return (False, "skip env var set; impact check skipped")
103
- if tty:
104
- return (True, "interactive; prompt user to proceed")
105
- if _is_true(strict_env):
106
- return (True, "strict mode; blocking in non-interactive context")
107
- return (False, "non-interactive; report shown as warning, commit not blocked")
File without changes