ruff-sync 0.1.3.dev2__tar.gz → 0.1.4.dev0__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 (111) hide show
  1. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/TESTING.md +7 -1
  2. ruff_sync-0.1.4.dev0/.agents/formatters-architecture.md +219 -0
  3. ruff_sync-0.1.4.dev0/.agents/gitlab-reports.md +614 -0
  4. ruff_sync-0.1.4.dev0/.agents/issue-102-context.md +81 -0
  5. ruff_sync-0.1.4.dev0/.agents/skills/gh-issues/SKILL.md +181 -0
  6. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/skills/ruff-sync-usage/references/ci-integration.md +1 -1
  7. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/AGENTS.md +2 -0
  8. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/PKG-INFO +2 -2
  9. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/README.md +1 -1
  10. ruff_sync-0.1.4.dev0/docs/assets/github-pr-annotation.png +0 -0
  11. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/ci-integration.md +2 -0
  12. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/pre-commit.md +3 -3
  13. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/pre-defined-configs.md +4 -4
  14. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/pyproject.toml +1 -1
  15. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/src/ruff_sync/constants.py +1 -0
  16. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/src/ruff_sync/core.py +123 -116
  17. ruff_sync-0.1.4.dev0/src/ruff_sync/formatters.py +481 -0
  18. ruff_sync-0.1.4.dev0/tests/test_formatters.py +529 -0
  19. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/uv.lock +1 -1
  20. ruff_sync-0.1.3.dev2/src/ruff_sync/formatters.py +0 -243
  21. ruff_sync-0.1.3.dev2/tests/test_formatters.py +0 -154
  22. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/skills/mkdocs-generation/SKILL.md +0 -0
  23. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/skills/mkdocs-generation/examples.md +0 -0
  24. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/skills/mkdocs-generation/templates/api-reference.md +0 -0
  25. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/skills/mkdocs-generation/templates/getting-started.md +0 -0
  26. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/skills/mkdocs-generation/templates/index.md +0 -0
  27. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/skills/mkdocs-generation/templates/mkdocs.yml +0 -0
  28. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/skills/release-notes-generation/SKILL.md +0 -0
  29. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/skills/ruff-sync-usage/SKILL.md +0 -0
  30. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/skills/ruff-sync-usage/references/configuration.md +0 -0
  31. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/skills/ruff-sync-usage/references/troubleshooting.md +0 -0
  32. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.agents/workflows/add-test-case.md +0 -0
  33. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.git-blame-ignore-revs +0 -0
  34. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.github/dependabot.yml +0 -0
  35. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.github/workflows/ci.yaml +0 -0
  36. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.github/workflows/complexity.yaml +0 -0
  37. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.github/workflows/docs.yaml +0 -0
  38. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.gitignore +0 -0
  39. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.pre-commit-config.yaml +0 -0
  40. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/.pre-commit-hooks.yaml +0 -0
  41. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/CONTRIBUTING.md +0 -0
  42. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/LICENSE.md +0 -0
  43. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/codecov.yml +0 -0
  44. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/configs/data-science-engineering/ruff.toml +0 -0
  45. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/configs/fastapi/ruff.toml +0 -0
  46. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/configs/kitchen-sink/ruff.toml +0 -0
  47. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/agent-skill.md +0 -0
  48. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/assets/favicon.png +0 -0
  49. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/assets/logo.png +0 -0
  50. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/assets/ruff_sync_banner.png +0 -0
  51. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/best-practices.md +0 -0
  52. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/configuration.md +0 -0
  53. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/contributing.md +0 -0
  54. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/examples/advanced-config.toml +0 -0
  55. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/examples/basic-config.toml +0 -0
  56. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/gen_ref_pages.py +0 -0
  57. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/index.md +0 -0
  58. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/installation.md +0 -0
  59. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/troubleshooting.md +0 -0
  60. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/url-resolution.md +0 -0
  61. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/docs/usage.md +0 -0
  62. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/mkdocs.yml +0 -0
  63. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/scripts/check_dogfood.sh +0 -0
  64. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/scripts/gitclone_dogfood.sh +0 -0
  65. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/scripts/pull_dogfood.sh +0 -0
  66. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/skills-lock.json +0 -0
  67. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/src/ruff_sync/__init__.py +0 -0
  68. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/src/ruff_sync/__main__.py +0 -0
  69. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/src/ruff_sync/cli.py +0 -0
  70. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/src/ruff_sync/pre_commit.py +0 -0
  71. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tasks.py +0 -0
  72. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/__init__.py +0 -0
  73. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/conftest.py +0 -0
  74. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/multi_upstream_final.toml +0 -0
  75. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/multi_upstream_initial.toml +0 -0
  76. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/multi_upstream_up1.toml +0 -0
  77. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/multi_upstream_up2.toml +0 -0
  78. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/no_changes_final.toml +0 -0
  79. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/no_changes_initial.toml +0 -0
  80. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/no_changes_upstream.toml +0 -0
  81. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/no_dotted_keys_final.toml +0 -0
  82. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/no_dotted_keys_initial.toml +0 -0
  83. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/no_dotted_keys_upstream.toml +0 -0
  84. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/no_ruff_cfg_final.toml +0 -0
  85. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/no_ruff_cfg_initial.toml +0 -0
  86. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/no_ruff_cfg_upstream.toml +0 -0
  87. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/readme_excludes_final.toml +0 -0
  88. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/readme_excludes_initial.toml +0 -0
  89. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/readme_excludes_upstream.toml +0 -0
  90. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/standard_final.toml +0 -0
  91. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/standard_initial.toml +0 -0
  92. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/lifecycle_tomls/standard_upstream.toml +0 -0
  93. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/ruff.toml +0 -0
  94. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_basic.py +0 -0
  95. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_check.py +0 -0
  96. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_config_validation.py +0 -0
  97. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_constants.py +0 -0
  98. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_corner_cases.py +0 -0
  99. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_deprecation.py +0 -0
  100. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_e2e.py +0 -0
  101. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_git_fetch.py +0 -0
  102. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_pre_commit.py +0 -0
  103. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_project.py +0 -0
  104. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_scaffold.py +0 -0
  105. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_serialization.py +0 -0
  106. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_toml_operations.py +0 -0
  107. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_url_handling.py +0 -0
  108. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/test_whitespace.py +0 -0
  109. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/w_ruff_sync_cfg/pyproject.toml +0 -0
  110. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/wo_ruff_cfg/pyproject.toml +0 -0
  111. {ruff_sync-0.1.3.dev2 → ruff_sync-0.1.4.dev0}/tests/wo_ruff_sync_cfg/pyproject.toml +0 -0
@@ -51,7 +51,13 @@ def test_merge_scenarios(source, upstream, expected_keys):
51
51
  ### 3.3 No Autouse Fixtures
52
52
  `autouse=True` fixtures are **never allowed**. They hide setup logic and can cause non-obvious side effects or dependencies between tests. All fixtures used by a test must be explicitly requested in the test function's arguments.
53
53
 
54
- ### 3.4 Main Entry Point
54
+ ### 3.4 No unittest.mock
55
+ The use of `unittest.mock` or `MagicMock` is strictly forbidden because it encourages bad design and tests that lie to you. Any kind of patching is discouraged. Instead, follow these patterns:
56
+ 1. **Dependency Injection (DI)**: Design code so that dependencies (like loggers or configurations) are passed in, rather than hard-coded. This allows for simple "Spies" or test-specific objects to be used without patching.
57
+ 2. **Dedicated Libraries**: Use `respx` for HTTP and `pyfakefs` for filesystem operations to mock at the IO layer.
58
+ 3. **Monkeypatch (as a last resort)**: If a dependency is truly external and not easily injectable (e.g., a global function in a library), use the built-in `monkeypatch` fixture. However, always check if the code can be redesigned for DI first.
59
+
60
+ ### 3.5 Main Entry Point
55
61
  Every test file **must** end with a main entry point block. This ensures each file is independently executable as a script (`python tests/test_foo.py`).
56
62
 
57
63
  ```python
@@ -0,0 +1,219 @@
1
+ # Formatter Pipeline Architecture
2
+
3
+ > **Purpose**: Reference document for agents and developers extending or maintaining
4
+ > `ruff_sync.formatters`. Covers the streaming vs. accumulating formatter taxonomy,
5
+ > the `finalize()` contract, how to add a new formatter, and the fingerprint strategy
6
+ > required by structured CI report formats (GitLab Code Quality, SARIF).
7
+ >
8
+ > **Source of truth**: `src/ruff_sync/formatters.py` and `src/ruff_sync/constants.py`.
9
+
10
+ ---
11
+
12
+ ## Overview
13
+
14
+ All human-readable and machine-readable output from the `check` and `pull`
15
+ commands flows through a single **`ResultFormatter` protocol**. This keeps
16
+ `core.py` and `cli.py` agnostic about the output medium — the same drift
17
+ detection logic emits text to a terminal, GitHub Actions workflow commands, or
18
+ a GitLab Code Quality JSON report.
19
+
20
+ ```
21
+ core.py / cli.py
22
+
23
+ │ fmt.error(message, file_path=..., drift_key="lint.select")
24
+ │ fmt.warning(...)
25
+ │ fmt.finalize()
26
+
27
+ ResultFormatter (Protocol)
28
+ ├── TextFormatter → stderr/stdout, human-readable
29
+ ├── GithubFormatter → ::error file=...:: workflow commands
30
+ ├── JsonFormatter → NDJSON, one record per line
31
+ └── GitlabFormatter → GitLab Code Quality JSON array (pending)
32
+ ```
33
+
34
+ ---
35
+
36
+ ## The `ResultFormatter` Protocol
37
+
38
+ Defined in `src/ruff_sync/formatters.py`.
39
+
40
+ ### Methods
41
+
42
+ | Method | When to call | Notes |
43
+ |---|---|---|
44
+ | `note(message)` | Status output that always prints (e.g. "Checking…") | Streaming; never buffered |
45
+ | `info(message, logger)` | Informational progress | Streaming |
46
+ | `success(message)` | Positive outcome (e.g. "In sync ✓") | Streaming |
47
+ | `error(message, file_path, logger, check_name, drift_key)` | Config drift or failure | See §Semantic Fields |
48
+ | `warning(message, file_path, logger, check_name, drift_key)` | Non-fatal issue (e.g. stale pre-commit hook) | See §Semantic Fields |
49
+ | `debug(message, logger)` | Verbose internal state | Streaming |
50
+ | `diff(diff_text)` | Unified diff between upstream and local | Intentionally ignored by structured formatters |
51
+ | `finalize()` | **Always called in a `try…finally` by `core.py`** | No-op for streaming; writes report for accumulating |
52
+
53
+ ### Semantic Fields on `error()` and `warning()`
54
+
55
+ ```python
56
+ def error(
57
+ self,
58
+ message: str,
59
+ file_path: pathlib.Path | None = None,
60
+ logger: logging.Logger | None = None,
61
+ check_name: str = "ruff-sync/config-drift", # machine-readable rule ID
62
+ drift_key: str | None = None, # e.g. "lint.select"
63
+ ) -> None: ...
64
+ ```
65
+
66
+ - **`check_name`** — machine-readable rule identifier used by structured
67
+ formatters (GitLab `check_name`, SARIF `ruleId`). Defaults to
68
+ `"ruff-sync/config-drift"`. Use a distinct name for different issue
69
+ classes; e.g. `"ruff-sync/precommit-stale"`.
70
+ - **`drift_key`** — the dotted TOML key that caused the drift (e.g.
71
+ `"lint.select"`). Used by accumulating formatters to build **stable,
72
+ per-key fingerprints** without re-parsing the message string. Pass
73
+ `None` when the drift cannot be attributed to a single key.
74
+
75
+ ---
76
+
77
+ ## Formatter Taxonomy
78
+
79
+ ### Streaming Formatters
80
+
81
+ Emit output immediately on each call. `finalize()` is a no-op.
82
+
83
+ | Class | Format | Use |
84
+ |---|---|---|
85
+ | `TextFormatter` | Human text | Default terminal output |
86
+ | `GithubFormatter` | `::error file=…::` | GitHub Actions inline annotations |
87
+ | `JsonFormatter` | NDJSON | Machine consumption, piping, `jq` |
88
+
89
+ ### Accumulating Formatters *(pending implementation)*
90
+
91
+ Collect issues internally and flush a single structured document in
92
+ `finalize()`.
93
+
94
+ | Class | Format | Use |
95
+ |---|---|---|
96
+ | `GitlabFormatter` | GitLab Code Quality JSON array | `artifacts: reports: codequality:` |
97
+ | `SarifFormatter` *(future)* | SARIF v2.1.0 | GitHub Advanced Security, other SAST tooling |
98
+
99
+ Key difference: accumulating formatters **must** have `finalize()` called
100
+ to produce any output. The `try…finally` pattern in `cli.py` guarantees
101
+ this even when `UpstreamError` or another exception occurs.
102
+
103
+ ---
104
+
105
+ ## Adding a New Formatter
106
+
107
+ 1. **Add the format value** to `OutputFormat` in `src/ruff_sync/constants.py`:
108
+ ```python
109
+ class OutputFormat(str, enum.Enum):
110
+ TEXT = "text"
111
+ JSON = "json"
112
+ GITHUB = "github"
113
+ GITLAB = "gitlab"
114
+ SARIF = "sarif" # new
115
+ ```
116
+
117
+ 2. **Implement the class** in `src/ruff_sync/formatters.py`. For a
118
+ streaming formatter, all methods emit immediately and `finalize` is a
119
+ no-op. For an accumulating formatter:
120
+ - Collect issues in `self._issues: list[...]` during `error()` / `warning()`.
121
+ - Write the document in `finalize()` (to `stdout` — the caller pipes to
122
+ a file).
123
+
124
+ 3. **Add a `case`** in `get_formatter`:
125
+ ```python
126
+ def get_formatter(output_format: OutputFormat) -> ResultFormatter:
127
+ match output_format:
128
+ case OutputFormat.SARIF:
129
+ return SarifFormatter()
130
+ ...
131
+ ```
132
+ The `match` statement is **exhaustive** — mypy will error if a new
133
+ `OutputFormat` value is not handled.
134
+
135
+ 4. **Add tests** in `tests/test_formatters.py`. At minimum:
136
+ - `finalize()` on an empty formatter produces the correct "no issues"
137
+ document (e.g. `[]` for GitLab, a valid SARIF run with zero results).
138
+ - `error()` produces a record with the correct severity.
139
+ - Fingerprints are stable across two identical calls.
140
+ - `finalize()` on a streaming formatter emits nothing.
141
+
142
+ ---
143
+
144
+ ## `finalize()` Call Site
145
+
146
+ `finalize()` is always called in a `try…finally` block within `core.py`’s
147
+ `check()` and `pull()` coroutines:
148
+
149
+ ```python
150
+ fmt = get_formatter(args.output_format)
151
+ try:
152
+ ...
153
+ finally:
154
+ fmt.finalize() # no-op for streaming; flushes JSON for accumulating
155
+ ```
156
+
157
+ `finalize()` is always called unconditionally — **do not** guard it with
158
+ `isinstance` or `hasattr` checks. Every formatter in the protocol provides
159
+ the method.
160
+
161
+ ---
162
+
163
+ ## Fingerprint Strategy (Accumulating Formatters)
164
+
165
+ Stable fingerprints are required so CI platforms can track whether an issue
166
+ is newly introduced or already resolved between branches.
167
+
168
+ ```python
169
+ import hashlib
170
+
171
+ def _make_fingerprint(upstream_url: str, local_file: str, drift_key: str | None) -> str:
172
+ if drift_key:
173
+ raw = f"ruff-sync:drift:{upstream_url}:{local_file}:{drift_key}"
174
+ else:
175
+ raw = f"ruff-sync:drift:{upstream_url}:{local_file}"
176
+ return hashlib.md5(raw.encode()).hexdigest()
177
+ ```
178
+
179
+ **Rules:**
180
+ - Must be deterministic — same inputs → same fingerprint every run.
181
+ - Must be unique per logical issue (different keys → different fingerprints).
182
+ - Must **not** include timestamps, UUIDs, or any runtime-variable data.
183
+
184
+ ---
185
+
186
+ ## Severity Mapping
187
+
188
+ | ruff-sync scenario | GitLab severity | SARIF level |
189
+ |---|---|---|
190
+ | Config key value differs | `major` | `error` |
191
+ | Key missing from local | `major` | `error` |
192
+ | Extra local key absent upstream | `minor` | `warning` |
193
+ | Pre-commit hook version stale | `minor` | `warning` |
194
+ | Upstream unreachable | `blocker` | `error` |
195
+
196
+ ---
197
+
198
+ ## Exit Codes (Unchanged by Formatter Choice)
199
+
200
+ | Code | Meaning |
201
+ |---|---|
202
+ | 0 | In sync |
203
+ | 1 | Config drift detected |
204
+ | 2 | CLI usage error (argparse) |
205
+ | 3 | Pre-commit hook drift |
206
+ | 4 | Upstream unreachable |
207
+
208
+ CI jobs should use `when: always` (GitLab) or `if: always()` (GitHub) to
209
+ upload structured reports regardless of exit code.
210
+
211
+ ---
212
+
213
+ ## References
214
+
215
+ - [`src/ruff_sync/formatters.py`](../src/ruff_sync/formatters.py)
216
+ - [`src/ruff_sync/constants.py`](../src/ruff_sync/constants.py)
217
+ - [`tests/test_formatters.py`](../tests/test_formatters.py)
218
+ - [`.agents/gitlab-reports.md`](./gitlab-reports.md) — GitLab Code Quality implementation spec
219
+ - [`issue-102-context.md`](./issue-102-context.md) — full feature context for Issue #102