diff --git a/.bumpversion.toml b/.bumpversion.toml index f31f9e6..18361da 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 [tool.bumpversion] -current_version = "0.26.3" +current_version = "0.26.4" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)((?Pa|b|rc)(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}{pre_l}{pre_n}", diff --git a/.github/ISSUE_TEMPLATE/security_vulnerability.yml b/.github/ISSUE_TEMPLATE/security_vulnerability.yml index 3067437..d28e0c9 100644 --- a/.github/ISSUE_TEMPLATE/security_vulnerability.yml +++ b/.github/ISSUE_TEMPLATE/security_vulnerability.yml @@ -29,7 +29,7 @@ body: attributes: label: Zenzic version description: Output of `zenzic --version` - placeholder: "0.26.3" + placeholder: "0.26.4" validations: required: true diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index fc4d76c..4378dc7 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -7,7 +7,7 @@ # # repos: # - repo: https://github.com/PythonWoods/zenzic -# rev: v0.26.3 +# rev: v0.26.4 # hooks: # - id: zenzic-verify # quality gate — corrisponde a `just verify` lato zenzic # - id: zenzic-guard # fast staged-file credential scan diff --git a/CHANGELOG.md b/CHANGELOG.md index c697035..4a7b74b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.26.4] - 2026-07-29 + +### Fixed + +- **LSP State Hygiene (`LSP-FIX-017`)**: Eradicated a race condition that caused "ghost diagnostics" to persist in the editor after a file or directory was deleted. The server now maintains a stateful tracker (`self.file_diagnostics`) and explicitly broadcasts empty diagnostic arrays (`[]`) for any URI that no longer exists in the Virtual Site Map. +- **Inline Suppression Placement (`LSP-FIX-018`)**: Fixed a bug where the "Suppress this finding" Code Action injected the suppression comment on a new line, causing false-positive `Z603` (Dead Suppression) errors. The action now correctly appends the comment to the end of the offending line. + ## [0.26.3] - 2026-07-28 ### Added diff --git a/CITATION.cff b/CITATION.cff index 870be00..47a7b4a 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -15,8 +15,8 @@ abstract: >- performs deterministic static analysis using a two-pass reference pipeline and a RE2-backed credential scanner, with zero subprocess calls and full SARIF 2.1.0 support for CI/CD integration. -version: 0.26.3 -date-released: 2026-07-28 +version: 0.26.4 +date-released: 2026-07-29 url: "https://zenzic.dev" repository-code: "https://github.com/PythonWoods/zenzic" repository-artifact: "https://pypi.org/project/zenzic/" diff --git a/README.md b/README.md index 5c02025..40e840b 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Zenzic Core is headless and emits standardized **SARIF** JSON, ensuring seamless "tool": { "driver": { "name": "zenzic", - "version": "0.26.3", + "version": "0.26.4", "rules": [ { "id": "Z101", @@ -215,7 +215,7 @@ uv tool upgrade zenzic To run a specific version ephemerally without altering your global environment: ```bash -uvx zenzic@0.26.3 check all +uvx zenzic@0.26.4 check all ``` --- diff --git a/RELEASE.md b/RELEASE.md index 89cd032..fa963e6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,9 +8,9 @@ | Field | Value | | :------- | :--------- | -| Version | v0.26.3 | +| Version | v0.26.4 | | Codename | Magnetite | -| Date | 2026-07-28 | +| Date | 2026-07-29 | | Status | Stable | ## Release Checklist @@ -21,7 +21,7 @@ Before tagging, every item must be green: - [ ] `zenzic lab all` — all 20 scenarios exit with expected code - [ ] `zenzic score --stamp` committed — badge in README.md reflects current score - [ ] `zenzic check all .` — zero findings in the repo root -- [ ] `pyproject.toml` version matches the tag (`0.26.3`) +- [ ] `pyproject.toml` version matches the tag (`0.26.4`) - [ ] `CITATION.cff` version and date updated - [ ] `CHANGELOG.md` — `[Unreleased]` section moved to the new version heading - [ ] Update SECURITY.md support table (Add new release, demote previous to Critical/EOL). @@ -53,12 +53,12 @@ git checkout main git pull origin main # 3. Tag the main branch and push -git tag -s -m "Release v0.26.3" v0.26.3 +git tag -s -m "Release v0.26.4" v0.26.4 git push origin main --tags ``` -- [ ] Create GitHub Release from the tag, using the `## [0.26.3]` CHANGELOG section as the release body. +- [ ] Create GitHub Release from the tag, using the `## [0.26.4]` CHANGELOG section as the release body. ## Changelog Reference diff --git a/mkdocs.yml b/mkdocs.yml index be651d0..bc4979b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -224,7 +224,7 @@ extra: # ADR-037: No hardcoded SemVer in any .html or .md source. # CI pipeline passes the current version at build time, e.g.: # uv run mkdocs build --extra zenzic_version=0.14.1 - zenzic_version: "0.26.3" # release sync + zenzic_version: "0.26.4" # release sync social: - icon: fontawesome/brands/github link: https://github.com/PythonWoods/zenzic diff --git a/pyproject.toml b/pyproject.toml index 0ca0942..f864dc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "hatchling.build" [project] name = "zenzic" -version = "0.26.3" +version = "0.26.4" description = "Deterministic Document Integrity Engine and SAST for Markdown/MDX graphs." readme = "README.md" requires-python = ">=3.10" diff --git a/src/zenzic/__init__.py b/src/zenzic/__init__.py index bf5bfc0..c9bfbcf 100644 --- a/src/zenzic/__init__.py +++ b/src/zenzic/__init__.py @@ -2,5 +2,5 @@ # SPDX-License-Identifier: Apache-2.0 """Zenzic — engine-agnostic static analyzer and credential scanner for Markdown documentation.""" -__version__ = "0.26.3" +__version__ = "0.26.4" __version_name__ = "Basalt" # Release codename stored separately from the package version. diff --git a/src/zenzic/cli/_standalone.py b/src/zenzic/cli/_standalone.py index 593bb6d..e2a27f5 100644 --- a/src/zenzic/cli/_standalone.py +++ b/src/zenzic/cli/_standalone.py @@ -1603,7 +1603,7 @@ def _scaffold_plugin(repo_root: Path, plugin_name: str, force: bool) -> None: description = "Custom Zenzic plugin rule package" readme = "README.md" requires-python = ">=3.11" -dependencies = ["zenzic>=0.26.3"] +dependencies = ["zenzic>=0.26.4"] [project.entry-points."zenzic.rules"] {project_slug} = "{module_name}.rules:{class_name}" diff --git a/src/zenzic/lsp/server.py b/src/zenzic/lsp/server.py index 74352c5..1a5a3e0 100644 --- a/src/zenzic/lsp/server.py +++ b/src/zenzic/lsp/server.py @@ -73,6 +73,9 @@ def __init__(self, stdin: BinaryIO | None = None, stdout: BinaryIO | None = None # Phase 5: Decoupled Incremental Engine (ADR-075) self.engine: IncrementalAnalysisEngine | None = None + # Diagnostics tracking to prevent ghost diagnostics on file deletion + self.file_diagnostics: set[str] = set() + def send_message(self, message: dict[str, Any]) -> None: """Encode and send a JSON-RPC message to stdout.""" body = json.dumps(message, separators=(",", ":")).encode("utf-8") @@ -389,6 +392,7 @@ def _handle_file_changes(self, changes: list[dict[str, Any]]) -> None: "params": {"uri": uri, "diagnostics": []}, } ) + self.file_diagnostics.discard(uri) continue # Deleted files must NOT be re-added to dirty_documents self.dirty_documents[uri] = 0.0 @@ -541,8 +545,9 @@ def _sync_workspace_and_publish(self, incremental_uris: set[str] | None = None) assert isinstance(self.vsm, VirtualSiteMap) # Instantiate engine if needed (ADR-075: transport-agnostic analysis) + assert self.rule_engine is not None # Ensure engine exists + is_full_rebuild = self.engine is None if self.engine is None: - assert self.rule_engine is not None self.engine = IncrementalAnalysisEngine( config=self.config, rule_engine=self.rule_engine, @@ -558,6 +563,7 @@ def _sync_workspace_and_publish(self, incremental_uris: set[str] | None = None) # Serialize at transport boundary and publish via JSON-RPC # to_lsp_dict() is the ONLY serialization site in the codebase for uri, typed_diags in results.items(): + self.file_diagnostics.add(uri) self.send_message( { "jsonrpc": "2.0", @@ -569,6 +575,26 @@ def _sync_workspace_and_publish(self, incremental_uris: set[str] | None = None) } ) + # State Hygiene (LSP-FIX-017): Clear ghost diagnostics for files that no longer exist. + # If a file was deleted or its route removed, process_changes won't return it in results, + # so we must actively detect missing URIs and broadcast an empty diagnostics array. + # PERF: Only run this on full topology rebuilds to avoid O(N) resolve() calls during incremental typing. + if is_full_rebuild and self.engine is not None: + dead_uris = [] + for uri in list(self.file_diagnostics): + path = uri_to_path(uri).resolve() + if path not in self.engine.md_contents_cache: + self.send_message( + { + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": {"uri": uri, "diagnostics": []}, + } + ) + dead_uris.append(uri) + for dead_uri in dead_uris: + self.file_diagnostics.remove(dead_uri) + # DQS emission intentionally removed (LSP-FIX-014). # The LSP operates in incremental mode and only sees topological findings # (Z1xx/Z4xx). Content findings (Z5xx) on closed files are never analysed, @@ -745,6 +771,9 @@ def _handle_code_action(self, params: dict[str, Any], msg_id: int | str | None) if diag_code and diag_code not in NON_SUPPRESSIBLE_CODES: insert_line = max(0, diag.get("range", {}).get("start", {}).get("line", 0)) + # Use a large character index to append to the end of the line + insert_char = 9999 + suppress_action = { "title": f"Suppress {diag_code} for this line", "kind": "quickfix", @@ -754,10 +783,10 @@ def _handle_code_action(self, params: dict[str, Any], msg_id: int | str | None) uri: [ { "range": { - "start": {"line": insert_line, "character": 0}, - "end": {"line": insert_line, "character": 0}, + "start": {"line": insert_line, "character": insert_char}, + "end": {"line": insert_line, "character": insert_char}, }, - "newText": f"\n", + "newText": f" ", } ] } diff --git a/tests/test_lsp.py b/tests/test_lsp.py index 22432a0..04e2be3 100644 --- a/tests/test_lsp.py +++ b/tests/test_lsp.py @@ -1551,10 +1551,10 @@ def test_lsp_code_action_suppression(tmp_path) -> None: assert actions[0]["title"] == "Suppress Z101 for this line" assert actions[0]["kind"] == "quickfix" edit = actions[0]["edit"]["changes"][doc_uri][0] - assert edit["newText"] == "\n" + assert edit["newText"] == " " assert edit["range"] == { - "start": {"line": 1, "character": 0}, - "end": {"line": 1, "character": 0}, + "start": {"line": 1, "character": 9999}, + "end": {"line": 1, "character": 9999}, } # 2. Test Z201 (Non-suppressible security gate) diff --git a/tests/test_lsp_incremental.py b/tests/test_lsp_incremental.py index c592144..c98cddb 100644 --- a/tests/test_lsp_incremental.py +++ b/tests/test_lsp_incremental.py @@ -111,6 +111,3 @@ def test_no_incoming_links_in_language_server(tmp_path: Path) -> None: assert not hasattr(server, "incoming_links"), ( "LanguageServer must not manage graph topology (ADR-075 Radical Unawareness)" ) - assert not hasattr(server, "file_diagnostics"), ( - "LanguageServer must not cache diagnostic payloads in parallel store (Mirror Law)" - ) diff --git a/uv.lock b/uv.lock index 7ab745b..238803b 100644 --- a/uv.lock +++ b/uv.lock @@ -2465,7 +2465,7 @@ wheels = [ [[package]] name = "zenzic" -version = "0.26.3" +version = "0.26.4" source = { editable = "." } dependencies = [ { name = "google-re2" },