From fbd240a6bdb95ddc0a8f96aedef389de5eedde17 Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 10:39:50 +0200 Subject: [PATCH 01/14] fix(rules): resolve extensionless static assets without trailing slash Signed-off-by: PythonWoods --- CHANGELOG.md | 3 +++ src/zenzic/core/rules.py | 4 +++- tests/test_rules.py | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a6f72..4595ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed +- **Extensionless Asset Resolution**: Fixed a bug in `VSMBrokenLinkRule._to_canonical_url` where extensionless files (e.g., `LICENSE`, `Makefile`) incorrectly received a trailing slash when `use_directory_urls` was active, causing false-positive `Z101` findings. + ## [0.26.1] - 2026-07-27 ### Added diff --git a/src/zenzic/core/rules.py b/src/zenzic/core/rules.py index d046bcc..ef65f52 100644 --- a/src/zenzic/core/rules.py +++ b/src/zenzic/core/rules.py @@ -1587,7 +1587,9 @@ def _to_canonical_url( # Keep static assets at their exact path (no trailing slash rewrite). ext = Path(path).suffix.lower() - is_asset = bool(ext) and ext not in DOC_SUFFIXES and ext not in (".html", ".htm") + is_asset = (bool(ext) and ext not in DOC_SUFFIXES and ext not in (".html", ".htm")) or ( + not ext and Path(path).name not in ("index", "README") + ) # Strip document suffixes so internal links normalize to canonical routes. if ext in DOC_SUFFIXES or (use_directory_urls and ext in (".html", ".htm")): diff --git a/tests/test_rules.py b/tests/test_rules.py index 696611f..093201b 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -1527,6 +1527,10 @@ def _url( # ── rstrip("/") kills ──────────────────────────────────────────────────── + def test_extensionless_static_asset_no_trailing_slash(self) -> None: + """Extensionless files like LICENSE must resolve without trailing slash.""" + assert self._url("LICENSE") == "/LICENSE" + def test_trailing_slash_is_stripped_before_processing(self) -> None: """rstrip(None) / lstrip("/") / rstrip("XX/XX") mutants leave a trailing slash that would produce "//guide//" or cause wrong path splits.""" From 57667d34ad518f3e00159d0a30de291a19861f53 Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 10:42:50 +0200 Subject: [PATCH 02/14] release: bump version to 0.26.2 Signed-off-by: PythonWoods --- .bumpversion.toml | 2 +- .github/ISSUE_TEMPLATE/security_vulnerability.yml | 2 +- .pre-commit-hooks.yaml | 2 +- CHANGELOG.md | 2 ++ CITATION.cff | 4 ++-- README.md | 4 ++-- RELEASE.md | 10 +++++----- mkdocs.yml | 2 +- pyproject.toml | 2 +- src/zenzic/__init__.py | 2 +- src/zenzic/cli/_standalone.py | 2 +- uv.lock | 2 +- 12 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 3f696e4..a36b020 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 [tool.bumpversion] -current_version = "0.26.1" +current_version = "0.26.2" 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 3432e5f..c7963c9 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.1" + placeholder: "0.26.2" validations: required: true diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 106b65d..99e2a64 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.1 +# rev: v0.26.2 # 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 4595ef2..67a70c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.26.2] - 2026-07-28 + ### Fixed - **Extensionless Asset Resolution**: Fixed a bug in `VSMBrokenLinkRule._to_canonical_url` where extensionless files (e.g., `LICENSE`, `Makefile`) incorrectly received a trailing slash when `use_directory_urls` was active, causing false-positive `Z101` findings. diff --git a/CITATION.cff b/CITATION.cff index 85b1e0f..2a0df8b 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.1 -date-released: 2026-07-27 +version: 0.26.2 +date-released: 2026-07-28 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 7c400d8..bed4b43 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.1", + "version": "0.26.2", "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.1 check all +uvx zenzic@0.26.2 check all ``` --- diff --git a/RELEASE.md b/RELEASE.md index b364bfa..77458ca 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,9 +8,9 @@ | Field | Value | | :------- | :--------- | -| Version | v0.26.1 | +| Version | v0.26.2 | | Codename | Magnetite | -| Date | 2026-07-27 | +| Date | 2026-07-28 | | 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.1`) +- [ ] `pyproject.toml` version matches the tag (`0.26.2`) - [ ] `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.1" v0.26.1 +git tag -s -m "Release v0.26.2" v0.26.2 git push origin main --tags ``` -- [ ] Create GitHub Release from the tag, using the `## [0.26.1]` CHANGELOG section as the release body. +- [ ] Create GitHub Release from the tag, using the `## [0.26.2]` CHANGELOG section as the release body. ## Changelog Reference diff --git a/mkdocs.yml b/mkdocs.yml index 2d2532a..e670da7 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.1" # release sync + zenzic_version: "0.26.2" # release sync social: - icon: fontawesome/brands/github link: https://github.com/PythonWoods/zenzic diff --git a/pyproject.toml b/pyproject.toml index 0b0f39e..e0e9901 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "hatchling.build" [project] name = "zenzic" -version = "0.26.1" +version = "0.26.2" 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 c48d840..06f2b39 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.1" +__version__ = "0.26.2" __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 7963ed2..2179f4a 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.1"] +dependencies = ["zenzic>=0.26.2"] [project.entry-points."zenzic.rules"] {project_slug} = "{module_name}.rules:{class_name}" diff --git a/uv.lock b/uv.lock index de85450..8f56418 100644 --- a/uv.lock +++ b/uv.lock @@ -2465,7 +2465,7 @@ wheels = [ [[package]] name = "zenzic" -version = "0.26.1" +version = "0.26.2" source = { editable = "." } dependencies = [ { name = "google-re2" }, From 0ae5fd40b35403762fc30df1bc4a3fd370c20d81 Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 10:57:05 +0200 Subject: [PATCH 03/14] fix(core): resolve type-checking, ruff linting, and extensionless asset pattern resolution Signed-off-by: PythonWoods --- CHANGELOG.md | 2 + src/zenzic/cli/_check.py | 17 +- src/zenzic/cli/_inspect.py | 1 + src/zenzic/core/ast.py | 1 - src/zenzic/core/incremental.py | 37 +- src/zenzic/core/rules.py | 60 +-- src/zenzic/core/scanner.py | 54 ++- src/zenzic/core/validator.py | 755 ------------------------------ src/zenzic/models/vsm.py | 2 +- tests/test_cli_e2e.py | 1 - tests/test_cli_visual.py | 2 - tests/test_gallery_phase2bc.py | 1 - tests/test_redteam_remediation.py | 1 - tests/test_rules.py | 1 - tests/test_validator.py | 2 - 15 files changed, 102 insertions(+), 835 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a70c9..ca245cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [0.26.2] - 2026-07-28 ### Fixed + - **Extensionless Asset Resolution**: Fixed a bug in `VSMBrokenLinkRule._to_canonical_url` where extensionless files (e.g., `LICENSE`, `Makefile`) incorrectly received a trailing slash when `use_directory_urls` was active, causing false-positive `Z101` findings. ## [0.26.1] - 2026-07-27 @@ -23,6 +24,7 @@ Versions follow [Semantic Versioning](https://semver.org/). - **Adapter API Contract (`CORE-FIX-005`)**: Added the `use_directory_urls` property to the `BaseAdapter` contract. This allows adapters to explicitly declare their URL routing mode, eradicating encapsulation violations in the incremental engine. ### Fixed + - **URP Unification (`CORE-REFACTOR-003`)**: Eradicated the legacy CLI link validation pipeline (`validate_links_async`). Both CLI and LSP now evaluate broken internal links exclusively via `VSMBrokenLinkRule.check_vsm` and `PolyglotExtractor`, achieving 100% true validation parity. - **Asset Indexing Parity (`CORE-REFACTOR-006`)**: Upgraded the Virtual Site Map (VSM) builder to explicitly index non-Markdown static assets (e.g., `.png`, `.webp`, `.html`). This eradicates hardcoded directory workarounds and eliminates false-positive `Z101` and `Z104` findings for static assets across all adapters. - **JSON Purity (`CLI-FIX-001`)**: Enforced absolute JSON purity when the `--json` flag is active by routing `fail_under` and `suppression_cap` failure messages to `stderr`. This prevents `JSON.parse()` failures in programmatic consumers. diff --git a/src/zenzic/cli/_check.py b/src/zenzic/cli/_check.py index 44233b0..1767da6 100644 --- a/src/zenzic/cli/_check.py +++ b/src/zenzic/cli/_check.py @@ -1238,10 +1238,23 @@ def _rel(path: Path) -> str: ) ) for rule_f in report.rule_findings: - if rule_f.rule_id in ("Z101", "Z102", "Z103", "Z104", "Z105", "Z106", "Z110", "Z120", "Z121", "Z122", "Z123", "Z124", "Z205"): + if rule_f.rule_id in ( + "Z101", + "Z102", + "Z103", + "Z104", + "Z105", + "Z106", + "Z110", + "Z120", + "Z121", + "Z122", + "Z123", + "Z124", + "Z205", + ): continue findings.append( - Finding( rel_path=rel, line_no=rule_f.line_no, diff --git a/src/zenzic/cli/_inspect.py b/src/zenzic/cli/_inspect.py index 92dcc2e..770f886 100644 --- a/src/zenzic/cli/_inspect.py +++ b/src/zenzic/cli/_inspect.py @@ -401,6 +401,7 @@ def inspect_routes( # ── Pass 1d: include static assets (HTML, webp, images, etc.) ────────────── from zenzic.core.discovery import DOC_SUFFIXES, walk_files + static_assets: set[Path] = set() if docs_root.is_dir(): for fpath in walk_files(docs_root, set(config.excluded_dirs), exclusion_mgr, config): diff --git a/src/zenzic/core/ast.py b/src/zenzic/core/ast.py index ab25f1b..0a86822 100644 --- a/src/zenzic/core/ast.py +++ b/src/zenzic/core/ast.py @@ -97,4 +97,3 @@ class ExtractedLink: col_start: int = 0 suppressed: bool = False html_node: Any | None = None - diff --git a/src/zenzic/core/incremental.py b/src/zenzic/core/incremental.py index 9de7ccb..e998997 100644 --- a/src/zenzic/core/incremental.py +++ b/src/zenzic/core/incremental.py @@ -32,7 +32,7 @@ import os from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from urllib.parse import unquote, urlsplit from urllib.request import url2pathname @@ -41,7 +41,6 @@ AdaptiveRuleEngine, ResolutionContext, RuleFinding, - _extract_inline_links_with_lines, ) from zenzic.core.suppressions import SuppressionTracker from zenzic.core.validator import ( @@ -481,7 +480,6 @@ def _analyze_file( self._run_urp_checks(vsm, path, text, tracker=tracker, extracted_links=extracted_links) ) - # Dead suppression detection findings.extend(tracker.get_dead_suppressions()) @@ -566,7 +564,6 @@ def _run_urp_checks( extracted_links: list[ExtractedLink] | None = None, resolver: Any = None, ) -> list[RuleFinding]: - """Run the Uniform Resolver Pipeline checks on a single file. Covers: Z120, Z121, Z122, Z123, Z124, Z205, Z102, Z105, Z202, Z203. @@ -761,18 +758,31 @@ def _source_line(lineno: int) -> str: ) continue - - # Non-markdown asset validation (Z104) url_clean = url.split("?")[0].split("#")[0].lower() - is_asset = ( - link.node_type in ("image", "html_img") - or any(url_clean.endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".pdf", ".zip", ".tar.gz", ".html")) + is_asset = link.node_type in ("image", "html_img") or any( + url_clean.endswith(ext) + for ext in ( + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".ico", + ".pdf", + ".zip", + ".tar.gz", + ".html", + ) ) if is_asset: if self.config.excluded_build_artifacts: import fnmatch - if any(fnmatch.fnmatch(url, pat) for pat in self.config.excluded_build_artifacts): + + if any( + fnmatch.fnmatch(url, pat) for pat in self.config.excluded_build_artifacts + ): continue rel_url = unquote(parsed.path) @@ -793,8 +803,6 @@ def _source_line(lineno: int) -> str: ) continue - - # Z102 (Local and Cross-file) if parsed.fragment: anchor = parsed.fragment.lower() @@ -824,7 +832,9 @@ def _source_line(lineno: int) -> str: if route is not None and anchor not in route.anchors: # Check adapter i18n anchor fallback - if not self.adapter.resolve_anchor(target_path, anchor, self.anchors_cache, self.docs_root): + if not self.adapter.resolve_anchor( + target_path, anchor, self.anchors_cache, self.docs_root + ): findings.append( RuleFinding( path, @@ -839,7 +849,6 @@ def _source_line(lineno: int) -> str: return findings - # ── Module-level pure functions ─────────────────────────────────────────────── diff --git a/src/zenzic/core/rules.py b/src/zenzic/core/rules.py index ef65f52..89d603c 100644 --- a/src/zenzic/core/rules.py +++ b/src/zenzic/core/rules.py @@ -114,7 +114,6 @@ class ResolutionContext: config: Any = None - # ─── Finding ────────────────────────────────────────────────────────────────── Severity = Literal["error", "warning", "info"] @@ -1117,7 +1116,6 @@ def _extract_inline_links_with_lines(text: str) -> list[tuple[str, int, str]]: return results - class CredentialScannerRule(BaseRule): """Rule wrapper for Z201 Credential Scanner (Tier 1).""" @@ -1383,32 +1381,28 @@ def check_vsm( # Skip static media and non-markdown assets (handled by URP asset checks) url_clean = url.split("?")[0].split("#")[0].lower() - if ( - any( - url_clean.endswith(ext) - for ext in ( - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".svg", - ".ico", - ".pdf", - ".zip", - ".tar.gz", - ".xml", - ".css", - ".json", - ) + if any( + url_clean.endswith(ext) + for ext in ( + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".ico", + ".pdf", + ".zip", + ".tar.gz", + ".xml", + ".css", + ".json", ) ): continue - from zenzic.core.validator import _classify_traversal_intent - if _classify_traversal_intent(url) == "suspicious": continue @@ -1452,9 +1446,13 @@ def check_vsm( if fallback_route is not None: is_fallback_on = True if context.adapter is not None: - is_fallback_on = getattr(context.adapter, "_fallback_to_default", True) + is_fallback_on = getattr( + context.adapter, "_fallback_to_default", True + ) elif context.config is not None: - is_fallback_on = getattr(context.config.build_context, "fallback_to_default", True) + is_fallback_on = getattr( + context.config.build_context, "fallback_to_default", True + ) if is_fallback_on: route = fallback_route except Exception: @@ -1480,8 +1478,6 @@ def check_vsm( ) ) - - elif route.status == "ORPHAN_BUT_EXISTING": if Path(route.source).suffix.lower() in (".md", ".mdx"): violations.append( @@ -1516,7 +1512,6 @@ def check_vsm( ) ) - return violations def _to_canonical_url( @@ -1587,8 +1582,17 @@ def _to_canonical_url( # Keep static assets at their exact path (no trailing slash rewrite). ext = Path(path).suffix.lower() + KNOWN_EXTENSIONLESS_ASSETS = { + "LICENSE", + "COPYING", + "NOTICE", + "MAKEFILE", + "DOCKERFILE", + "CNAME", + "JUSTFILE", + } is_asset = (bool(ext) and ext not in DOC_SUFFIXES and ext not in (".html", ".htm")) or ( - not ext and Path(path).name not in ("index", "README") + not ext and Path(path).name.upper() in KNOWN_EXTENSIONLESS_ASSETS ) # Strip document suffixes so internal links normalize to canonical routes. diff --git a/src/zenzic/core/scanner.py b/src/zenzic/core/scanner.py index b0eb281..8fe5bc1 100644 --- a/src/zenzic/core/scanner.py +++ b/src/zenzic/core/scanner.py @@ -21,7 +21,6 @@ from typing import TYPE_CHECKING, Any from urllib.parse import unquote, urlsplit - from zenzic.core import regex as re from zenzic.core.credentials import ( SecurityFinding, @@ -40,7 +39,6 @@ from zenzic.core.reporter import Finding from zenzic.core.rules import AdaptiveRuleEngine, BaseRule from zenzic.core.validator import LinkValidator, PolyglotExtractor - from zenzic.models.config import ( ZenzicConfig, ) @@ -172,8 +170,6 @@ def find_repo_root(*, fallback_to_cwd: bool = False, search_from: Path | None = ): return candidate - - if fallback_to_cwd: return start @@ -1087,12 +1083,16 @@ def _run_vsm_and_urp_pass( """Run VSM building, VSMBrokenLinkRule, and URP checks over all scanned files.""" from zenzic.core.adapter import get_adapter from zenzic.core.incremental import IncrementalAnalysisEngine - from zenzic.core.rules import AdaptiveRuleEngine, ResolutionContext, RuleFinding, VSMBrokenLinkRule + from zenzic.core.resolver import InMemoryPathResolver, Resolved + from zenzic.core.rules import ( + AdaptiveRuleEngine, + ResolutionContext, + RuleFinding, + VSMBrokenLinkRule, + ) from zenzic.core.validator import ( - InMemoryPathResolver, LinkInfo, PolyglotExtractor, - Resolved, _build_link_graph, _find_cycles_iterative, anchors_in_file, @@ -1143,7 +1143,6 @@ def _run_vsm_and_urp_pass( } resolver = InMemoryPathResolver(docs_root, md_contents, anchors_cache, repo_root=repo_root) - link_graph = _build_link_graph(links_cache, resolver, frozenset(md_contents.keys())) cycle_nodes = set(_find_cycles_iterative(link_graph)) @@ -1157,7 +1156,6 @@ def _run_vsm_and_urp_pass( if not r.file_path.is_file(): continue - try: text = r.file_path.read_text(encoding="utf-8") except OSError: @@ -1172,21 +1170,27 @@ def _run_vsm_and_urp_pass( adapter=adapter, ) - vsm_findings = rule_engine.run_vsm( r.file_path, text, vsm, anchors_cache, context, extracted_links=extracted_links ) urp_findings = inc_engine._run_urp_checks( - vsm, r.file_path, text, tracker=r.suppression_tracker, extracted_links=extracted_links, resolver=resolver + vsm, + r.file_path, + text, + tracker=r.suppression_tracker, + extracted_links=extracted_links, + resolver=resolver, ) if r.suppression_tracker is not None: active_vsm = [ - f for f in vsm_findings + f + for f in vsm_findings if not r.suppression_tracker.is_suppressed(f.line_no, f.rule_id) ] active_urp = [ - f for f in urp_findings + f + for f in urp_findings if not r.suppression_tracker.is_suppressed(f.line_no, f.rule_id) ] else: @@ -1202,7 +1206,10 @@ def _run_vsm_and_urp_pass( match resolver.resolve(r.file_path, link.url): case Resolved(target=target): if target.as_posix() in cycle_nodes: - if r.suppression_tracker is None or not r.suppression_tracker.is_suppressed(link.lineno, "Z106"): + if ( + r.suppression_tracker is None + or not r.suppression_tracker.is_suppressed(link.lineno, "Z106") + ): r.rule_findings.append( RuleFinding( r.file_path, @@ -1218,11 +1225,11 @@ def _run_vsm_and_urp_pass( if config.absolute_path_allowlist: used_allowlist: set[str] = set() - for f, text in md_contents.items(): - for link in PolyglotExtractor().extract_all_links(text): - if link.url.startswith("/"): + for text in md_contents.values(): + for ext_link in PolyglotExtractor().extract_all_links(text): + if ext_link.url.startswith("/"): for prefix in config.absolute_path_allowlist: - if link.url.startswith(prefix): + if ext_link.url.startswith(prefix): used_allowlist.add(prefix) unused = set(config.absolute_path_allowlist) - used_allowlist if unused and reports: @@ -1244,11 +1251,6 @@ def _run_vsm_and_urp_pass( ) - - - - - def _build_rule_engine(config: ZenzicConfig) -> AdaptiveRuleEngine | None: """Construct a :class:`~zenzic.core.rules.AdaptiveRuleEngine` from the config. @@ -1436,7 +1438,6 @@ def scan_docs_references( content_roots: list[Path] | None = None, show_progress: bool = False, ) -> tuple[list[IntegrityReport], list[str]]: - """Run the Three-Phase Pipeline over every .md file in docs/. This is the single unified entry point for all scan modes. The engine @@ -1507,6 +1508,9 @@ def scan_docs_references( if not docs_root.exists() or not docs_root.is_dir(): return [], [] + if config is None: + config, _ = ZenzicConfig.load(docs_root) + rule_engine = _build_rule_engine(config) md_files = list(iter_markdown_sources(docs_root, config, exclusion_manager)) @@ -1653,7 +1657,6 @@ def scan_docs_references( static_assets=static_assets, ) - # Remap locale file paths to their logical display paths. if _locale_path_remap: for _r in reports: @@ -1726,7 +1729,6 @@ def scan_docs_references( static_assets=static_assets, ) - elapsed_seq = time.monotonic() - _t0 if verbose: diff --git a/src/zenzic/core/validator.py b/src/zenzic/core/validator.py index 9dc2c0b..97f86ba 100644 --- a/src/zenzic/core/validator.py +++ b/src/zenzic/core/validator.py @@ -26,12 +26,8 @@ from __future__ import annotations import asyncio -import concurrent.futures -import difflib -import fnmatch import html import json -import os import sys import textwrap import time @@ -45,32 +41,23 @@ from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, NamedTuple -from urllib.parse import urlsplit import httpx import yaml from zenzic.core import regex as re -from zenzic.core.adapter import get_adapter from zenzic.core.ast import ExtractedLink from zenzic.core.discovery import ( DOC_SUFFIXES, - build_content_mounts, - iter_extra_content_markdown_sources, - iter_locale_markdown_sources, iter_markdown_sources, walk_files, ) from zenzic.core.resolver import ( - AnchorMissing, - FileNotFound, InMemoryPathResolver, - PathTraversal, Resolved, ) from zenzic.models.config import ZenzicConfig from zenzic.models.references import ReferenceMap -from zenzic.models.vsm import build_vsm if TYPE_CHECKING: @@ -441,7 +428,6 @@ def extract_all_links(self, text: str) -> list[ExtractedLink]: extracted.sort(key=lambda item: (item.line_no, item.col_start)) return extracted - def _mask_comments(self, text: str) -> str: """Mask HTML and MDX comments with spaces of equal length to preserve offsets.""" text = _POLY_COMMENT_RE.sub(lambda m: " " * len(m.group(0)), text) @@ -760,7 +746,6 @@ def _index_file_for_validation(args: tuple[Path, str]) -> _ValidationPayload: ) - # ─── Pure / I/O-agnostic functions ──────────────────────────────────────────── @@ -789,7 +774,6 @@ def extract_links(text: str) -> list[LinkInfo]: ] - def _extract_empty_link_texts(text: str) -> list[tuple[int, int, str]]: """Return empty-text Markdown links for Z108 detection. @@ -1118,9 +1102,6 @@ async def _bounded_ping(client: httpx.AsyncClient, url: str) -> str | None: return_exceptions=True, ) - - - for url, result in zip(urls_to_check, results, strict=True): if result is None: continue @@ -1141,734 +1122,6 @@ async def _bounded_ping(client: httpx.AsyncClient, url: str) -> str | None: return sorted(errors) -# ─── Main link validator ────────────────────────────────────────────────────── - - -# validate_links_async eradicated in CORE-REFACTOR-003-TRUE-URP-UNIFICATION. - - - - # ── Instantiate the build-engine adapter (locale-aware path resolution) ── - adapter = get_adapter(config.build_context, docs_root, repo_root) - _bypass_schemes = adapter.get_link_scheme_bypasses() - _project_absolute_prefixes: tuple[str, ...] = tuple( - list(adapter.get_absolute_url_prefixes()) + config.absolute_path_allowlist - ) - used_allowlist: set[str] = set() - - # ── Pass 1: read all .md/.mdx files + map all non-doc assets into memory ── - md_contents: dict[Path, str] = {} - for md_file in sorted(iter_markdown_sources(docs_root, config, exclusion_manager)): - try: - content = md_file.read_text(encoding="utf-8") - md_contents[md_file.resolve()] = content - if trackers is not None and md_file.resolve() not in trackers: - from zenzic.core.suppressions import SuppressionTracker - - trackers[md_file.resolve()] = SuppressionTracker(md_file.resolve(), content) - except OSError: - continue - - # ── Pass 1b: include i18n locale files when provided ────────────────────── - # Locale files live outside docs_root (under i18n//…) so they are - # invisible to iter_markdown_sources. Loading them here ensures that: - # • their headings are indexed (anchors_cache), - # • links *within* each translated page are validated, and - # • cross-references from locale files into main docs resolve correctly. - if locale_roots: - for locale_root, locale_name in locale_roots: - for abs_path, _logical_rel in iter_locale_markdown_sources( - locale_root, locale_name, config, exclusion_manager - ): - try: - content = abs_path.read_text(encoding="utf-8") - md_contents[abs_path.resolve()] = content - if trackers is not None and abs_path.resolve() not in trackers: - from zenzic.core.suppressions import SuppressionTracker - - trackers[abs_path.resolve()] = SuppressionTracker( - abs_path.resolve(), content - ) - except OSError: - continue - - # ── Pass 1c: include extra content roots (v0.7.x Multi-Root Discovery) ── - # Plugin-managed content trees that live outside docs_root — most notably - # Docusaurus's blog/ directory. Loading them here means the VSM, anchor - # index, and link resolver all see them as first-class content, closing the - # gap between ``zenzic check`` and the engine's own build-time link check. - extra_content_roots: list[Path] = adapter.get_extra_content_roots(repo_root) - extra_content_mounts = build_content_mounts(extra_content_roots, repo_root=repo_root) - for content_root, url_prefix in extra_content_mounts: - for abs_path, _logical_rel in iter_extra_content_markdown_sources( - content_root, url_prefix, config, exclusion_manager - ): - try: - content = abs_path.read_text(encoding="utf-8") - md_contents[abs_path.resolve()] = content - if trackers is not None and abs_path.resolve() not in trackers: - from zenzic.core.suppressions import SuppressionTracker - - trackers[abs_path.resolve()] = SuppressionTracker(abs_path.resolve(), content) - except OSError: - continue - - # Build the asset map once — eliminates all Path.exists() calls from Pass 2. - # Scanning repo_root (not just docs_root) ensures that @site/static/ assets - # referenced from locale files (or any page) are included in the map. - # System-guardrail directories (.git, node_modules, .venv, …) are pruned by - # the exclusion_manager so the walk remains fast even for large repos. - known_assets: frozenset[str] = frozenset( - str(f.resolve()) - for f in walk_files(repo_root, set(), exclusion_manager, config) - if f.is_file() and not f.is_symlink() and f.suffix not in DOC_SUFFIXES - ) - - # ── Locale file registry + multi-root credential scanner setup ─────────────────────── - # locale_file_set: files loaded from i18n/ directories (outside docs_root). - # Used in Phase 2 to: - # • Force same-page anchor validation regardless of validate_same_page_anchors - # (translation drift is exactly the class of error the i18n integrity check must catch). - # • Never suppress genuine path-traversal attempts (security invariant). - locale_file_set: frozenset[Path] = frozenset( - p for p in md_contents if not p.is_relative_to(docs_root) - ) - # Multi-root allowed list passed to the resolver: docs_root is always in the - # list; locale roots are added so that cross-locale relative links resolve - # within an authorised boundary instead of firing PATH_TRAVERSAL. v0.7.x: - # extra content roots (Docusaurus blog/, …) are also admitted so that - # in-blog relative links and cross-blog↔docs links resolve as authorised. - _allowed_roots: list[Path] = [docs_root] - if locale_roots: - _allowed_roots.extend(locale_root for locale_root, _ in locale_roots) - if extra_content_mounts: - _allowed_roots.extend(root for root, _ in extra_content_mounts) - - # ── Phase 1: parallel index (anchors + resolved links) ──────────────── - # Workers return immutable payloads. The main process only merges maps - # and performs global validation (phase 2), avoiding order-dependent - # false positives for file.md#anchor links. - use_parallel_index = ( - len(md_contents) >= VALIDATION_PARALLEL_THRESHOLD and (os.cpu_count() or 1) > 1 - ) - if use_parallel_index: - with concurrent.futures.ProcessPoolExecutor() as executor: - payloads = list(executor.map(_index_file_for_validation, md_contents.items())) - else: - payloads = [_index_file_for_validation(item) for item in md_contents.items()] - - anchors_cache: dict[Path, set[str]] = {p.file_path: p.anchors for p in payloads} - links_cache: dict[Path, list[LinkInfo]] = {p.file_path: p.links for p in payloads} - source_lines_cache: dict[Path, list[str]] = {p.file_path: p.source_lines for p in payloads} - - # Instantiate the resolver ONCE — _lookup_map is built here, not per-link. - # Instantiating inside the file loop would regenerate the map N times, - # cancelling the 14× performance gain from the pre-computed flat dict. - # allowed_roots extends the credential scanner boundary to authorised locale directories. - resolver_repo_root = repo_root - resolver = InMemoryPathResolver( - docs_root, - md_contents, - anchors_cache, - repo_root=resolver_repo_root, - allowed_roots=_allowed_roots, - ) - - # ── Build the Virtual Site Map (VSM) ────────────────────────────────────── - # The VSM maps every .md file to its canonical URL and routing status. - # It is only meaningful when the adapter has a nav (MkDocs with mkdocs.yml); - # for StandaloneAdapter / Zensical every file is REACHABLE by definition. - # - - vsm = build_vsm( - adapter, - docs_root, - md_contents, - anchors_cache=anchors_cache, - extra_content_roots=extra_content_roots, - repo_root=repo_root, - static_assets={Path(p) for p in known_assets}, - ) - - # ── Phase 1.5: cycle registry (requires resolver + links_cache) ─────────── - # Pre-compute the set of all nodes participating in at least one link cycle. - # This Θ(V+E) DFS runs once here; Phase 2 checks are O(1) per resolved link. - _source_files: frozenset[Path] = frozenset(md_contents) - _link_adj = _build_link_graph(links_cache, resolver, _source_files) - cycle_registry: frozenset[str] = _find_cycles_iterative(_link_adj) - # ───────────────────────────────────────────────────────────────────────── - - # ── Phase 2: validate against global indexes ──────────────────────────── - # Pre-compute known relative paths once for Z104 "Did you mean?" hints. - # No disk I/O — md_contents is already in memory from Pass 1. Files under - # extra content roots (v0.7.x) are admitted with their url_prefix injected - # so suggestions like ``blog/2026-04-12-foo.mdx`` surface for typos. - def _compute_logical_rel(f: Path) -> str | None: - if f.is_relative_to(docs_root): - return f.relative_to(docs_root).as_posix() - for root, prefix in extra_content_mounts: - if f.is_relative_to(root): - inner = f.relative_to(root).as_posix() - return f"{prefix}/{inner}" if prefix else inner - return None - - _known_rel_paths: list[str] = sorted( - rp for rp in (_compute_logical_rel(f) for f in md_contents) if rp is not None - ) - - internal_errors: list[LinkError] = [] - external_entries: list[tuple[str, str, int]] = [] # (url, file_label, lineno) - suppression_html_count = 0 - - # Engine-aware skip schemes: adapters declare their own bypass schemes via - # get_link_scheme_bypasses() — the Core never hardcodes engine names here. - _effective_skip = _SKIP_SCHEMES + tuple(f"{s}:" for s in _bypass_schemes) - - # Pre-compute which absolute prefixes are actually represented in the VSM. - # Scanned prefixes (blog/, docs/, …) get full VSM-lookup validation. - # Unscanned sibling plugins (/developers/ with no markdown files in scope) - # keep the unconditional bypass — they are owned by the project but their - # content is not in md_contents and therefore has no VSM entries to check. - _scanned_vsm_prefixes: frozenset[str] = frozenset( - prefix - for prefix in _project_absolute_prefixes - if any(url.startswith(prefix) for url in vsm) - ) - - def _source_line(md_file: Path, lineno: int) -> str: - """Return the raw source line (1-based) from the pre-split cache.""" - lines = source_lines_cache.get(md_file, []) - idx = lineno - 1 - return lines[idx].strip() if 0 <= idx < len(lines) else "" - - for md_file in md_contents: - # Locale files live outside docs_root — use repo-relative path for labels. - label = ( - str(md_file.relative_to(docs_root)) - if md_file.is_relative_to(docs_root) - else str(md_file.relative_to(repo_root)) - ) - raw_text = md_contents[md_file] - - for lineno, col_start, source_line in _extract_empty_link_texts(raw_text): - internal_errors.append( - LinkError( - file_path=md_file, - line_no=lineno, - message=f"{label}:{lineno}: link label is empty or whitespace-only", - source_line=source_line, - error_type="Z108", - col_start=col_start, - match_text=source_line, - ) - ) - - # ── PolyglotExtractor: HTML Integrity phase (Z120-Z124, Z205) ──────────── - # Analizza ogni tag / nel sorgente Markdown tramite la URP. - # Z205 (FORBIDDEN_SCHEME) ha precedenza assoluta: non sopprimibile, Exit 2. - # data-zenzic-ignore sopprime Z120-Z124 e il resolver (-1.0 pts DQS ciascuno). - _poly_html_urls: set[str] = set() - for node in _POLYGLOT_EXTRACTOR.extract(raw_text): - _source_ctx = _source_line(md_file, node.line_no) - - # Z205 — SECURITY GATE: verificato PRIMA di data-zenzic-ignore - if node.z205_scheme: - internal_errors.append( - LinkError( - file_path=md_file, - line_no=node.line_no, - message=( - f"{label}:{node.line_no}: forbidden scheme " - f"'{node.z205_scheme}' detected in " - f"<{node.tag}> {'href' if node.tag == 'a' else 'src'} " - f"— potential XSS vector (non-suppressible)." - ), - source_line=_source_ctx, - error_type="Z205", - col_start=0, - match_text=node.raw_tag, - ) - ) - continue # blocco immediato: non analizzare oltre il nodo - - # Z124 — OPAQUE_HTML_CONTEXT (blacklisted attrs) - for attr in node.blacklisted_attrs: - internal_errors.append( - LinkError( - file_path=md_file, - line_no=node.line_no, - message=( - f"{label}:{node.line_no}: opaque attribute '{attr}' " - f"detected in <{node.tag}> tag — event-handler or shadow-routing." - ), - source_line=_source_ctx, - error_type="Z124", - col_start=0, - match_text=node.raw_tag, - ) - ) - - # Z120 — UNKNOWN_HTML_ATTRIBUTE - for attr in node.unknown_attrs: - internal_errors.append( - LinkError( - file_path=md_file, - line_no=node.line_no, - message=( - f"{label}:{node.line_no}: unknown attribute '{attr}' " - f"in <{node.tag}> — not in Safe-Core list. " - f"Add to safe list or suppress with data-zenzic-ignore." - ), - source_line=_source_ctx, - error_type="Z120", - col_start=0, - match_text=node.raw_tag, - ) - ) - - # Z121 — MISSING_OR_EMPTY_HREF / src - if node.is_missing_href: - internal_errors.append( - LinkError( - file_path=md_file, - line_no=node.line_no, - message=( - f"{label}:{node.line_no}: <{node.tag}> has no " - f"{'href' if node.tag == 'a' else 'src'} attribute, " - f"or it is empty." - ), - source_line=_source_ctx, - error_type="Z121", - col_start=0, - match_text=node.raw_tag, - ) - ) - continue # nessun href da risolvere - - # Z122 — JUMP_LINK_DETECTED - if node.is_jump_link: - internal_errors.append( - LinkError( - file_path=md_file, - line_no=node.line_no, - message=( - f'{label}:{node.line_no}: href="#" detected — ' - f"placeholder or opaque JS anchor. " - f"Add a real destination or suppress with data-zenzic-ignore." - ), - source_line=_source_ctx, - error_type="Z122", - col_start=0, - match_text=node.raw_tag, - ) - ) - continue - - # Z123 — NON_HTTP_SCHEME (informativo, nessuna risoluzione path) - if node.info_scheme: - internal_errors.append( - LinkError( - file_path=md_file, - line_no=node.line_no, - message=( - f"{label}:{node.line_no}: non-HTTP scheme " - f"'{node.info_scheme}' in <{node.tag}> — " - f"link not resolved by Zenzic (informational)." - ), - source_line=_source_ctx, - error_type="Z123", - col_start=0, - match_text=node.raw_tag, - ) - ) - continue - - if node.suppressed: - suppression_html_count += 1 - # Find the tracker for this file - if trackers and (tracker := trackers.get(md_file.resolve())): - # We must mark the specific 'DATA-ZENZIC-IGNORE' directive on this line as consumed - for d in tracker.directives: - if d.line_no == node.line_no and d.code == "DATA-ZENZIC-IGNORE": - d.consumed = True - # CRITICAL FIX: Do NOT pass node.href to the URP. - continue - - # ── URP: Uniform Resolver Pipeline — href valido, risoluzione standard - if node.href and node.href not in _poly_html_urls: - _poly_html_urls.add(node.href) - raw_parsed = urlsplit(node.href) - if raw_parsed.scheme in ("http", "https"): - external_entries.append((node.href, label, node.line_no)) - elif not node.href.startswith(_effective_skip): - outcome = resolver.resolve(md_file, node.href) - match outcome: - case FileNotFound(path_part=path_part): - _sug = difflib.get_close_matches( - path_part, _known_rel_paths, n=1, cutoff=0.6 - ) - _hint = f" 💡 Did you mean: '{_sug[0]}'?" if _sug else "" - internal_errors.append( - LinkError( - file_path=md_file, - line_no=node.line_no, - message=( - f"{label}:{node.line_no}: " - f"'{path_part}' not found in docs{_hint}" - ), - source_line=_source_ctx, - error_type="Z104", - col_start=0, - match_text=node.raw_tag, - ) - ) - case PathTraversal(): - internal_errors.append( - LinkError( - file_path=md_file, - line_no=node.line_no, - message=( - f"{label}:{node.line_no}: HTML link " - f"'{node.href}' escapes the docs root boundary." - ), - source_line=_source_ctx, - error_type="Z202", - col_start=0, - match_text=node.raw_tag, - ) - ) - case _: - pass # Resolved — OK - - all_links = links_cache.get(md_file, []) - - for link in all_links: - url, lineno = link.url, link.lineno - # Skip non-navigable schemes and bare fragment-only links - if url.startswith(_effective_skip) or url == "#": - continue - - parsed = urlsplit(url) - - # ── External links ──────────────────────────────────────────────── - if parsed.scheme in ("http", "https"): - if parsed.hostname in ("localhost", "127.0.0.1", "0.0.0.0", "::1"): - continue # loopback URLs are not reachable externally; skip silently - external_entries.append((url, label, lineno)) - continue - - # Pure same-page anchor (#section). - # Always validated for locale files: translation drift (e.g. a - # translator updating the link text but not the heading's {#id}) - # is exactly the failure mode the i18n integrity check must catch. - # For main-docs files the check is gated by validate_same_page_anchors - # (disabled by default — anchors can be generated by plugins/macros - # at build time that are invisible at source-scan time). - if not parsed.path: - if ( - config.validate_same_page_anchors or md_file in locale_file_set - ) and parsed.fragment: - anchor = parsed.fragment.lower() - if anchor not in anchors_cache.get(md_file, set()): - internal_errors.append( - LinkError( - file_path=md_file, - line_no=lineno, - message=f"{label}:{lineno}: anchor '#{anchor}' not found in '{label}'", - source_line=_source_line(md_file, lineno), - error_type="Z102", - col_start=link.col_start, - match_text=link.match_text, - ) - ) - continue - - # ── Absolute-path prohibition ───────────────────────────────────── - # Links starting with "/" are environment-dependent: they resolve - # against the server root, not the docs root. This breaks hosting - # in subdirectories (e.g. site.io/docs/) and engine-agnosticism. - # Internal links must always be relative. Full URLs (https://...) - # are handled above as external links and are not affected. - # Rule R16 (CEO-055): ``pathname:///assets/file.html`` is the - # Z105: absolute path — engines declare their own bypass schemes via - # BaseAdapter.get_link_scheme_bypasses(); URLs using those schemes are - # already in _effective_skip and never reach this check. - # - # Multi-instance route prefixes are reported by the active adapter - # via :meth:`BaseAdapter.get_absolute_url_prefixes` — Docusaurus - # projects with sibling ``@docusaurus/plugin-content-docs`` - # instances cross plugin boundaries with absolute URLs (e.g. - # ``/developers/intro``) that the local VSM cannot resolve, but - # the target is still owned by the project. No user-side config - # required (Zero-Config invariant). - if parsed.path.startswith("/") and parsed.scheme not in _bypass_schemes: - for prefix in config.absolute_path_allowlist: - if parsed.path.startswith(prefix): - used_allowlist.add(prefix) - - if any(parsed.path.startswith(prefix) for prefix in _project_absolute_prefixes): - # Z105 suppressed: the absolute path is owned by this project. - # For prefixes whose content was actually scanned into the VSM - # (e.g. /blog/), verify the exact route exists so that a slug - # mismatch (e.g. /blog/wrong-slug vs real /blog/correct-slug) - # is caught. Prefixes with no VSM entries are sibling plugins - # whose content is outside the scan scope — those get the - # unconditional bypass (Zero-Config invariant preserved). - if any(parsed.path.startswith(p) for p in _scanned_vsm_prefixes): - _abs_parts = [p for p in parsed.path.split("/") if p] - if parsed.path.endswith((".md", ".mdx", ".json")): - _canonical = adapter.get_route_info(Path(*_abs_parts)).canonical_url - else: - _canonical = "/" + "/".join(_abs_parts) + "/" if _abs_parts else "/" - - if vsm.get(_canonical) is None: - _suggestions = difflib.get_close_matches( - _canonical.strip("/"), [k.strip("/") for k in vsm], n=1, cutoff=0.6 - ) - _hint = ( - f" 💡 Did you mean: '/{_suggestions[0]}/'?" if _suggestions else "" - ) - internal_errors.append( - LinkError( - file_path=md_file, - line_no=lineno, - message=( - f"{label}:{lineno}: '{url}' not found in the site map{_hint}" - ), - source_line=_source_line(md_file, lineno), - error_type="Z104", - col_start=link.col_start, - match_text=link.match_text, - ) - ) - continue - internal_errors.append( - LinkError( - file_path=md_file, - line_no=lineno, - message=( - f"{label}:{lineno}: '{url}' uses an absolute path — " - "use a relative path (e.g. '../' or './') instead; " - "absolute paths break portability when the site is hosted " - "in a subdirectory" - ), - source_line=_source_line(md_file, lineno), - error_type="Z105", - col_start=link.col_start, - match_text=link.match_text, - ) - ) - continue - - # ── Internal resolution: delegate entirely to InMemoryPathResolver ─ - # The resolver receives the raw href; it handles percent-decoding, - # backslash normalisation, normpath, credential scanner check, and anchor lookup. - # Do NOT pre-process the url before passing it — double-decoding - # would corrupt links with legitimately encoded characters. - match resolver.resolve(md_file, url): - case PathTraversal(): - # Security finding — path escaped the docs root. - # Classify intent: hrefs targeting OS system directories - # are promoted to PATH_TRAVERSAL_SUSPICIOUS (Exit Code 3). - _intent = _classify_traversal_intent(url) - internal_errors.append( - LinkError( - file_path=md_file, - line_no=lineno, - message=f"{label}:{lineno}: '{url}' resolves outside the docs directory", - source_line=_source_line(md_file, lineno), - error_type=("Z203" if _intent == "suspicious" else "Z202"), - col_start=link.col_start, - match_text=link.match_text, - ) - ) - case FileNotFound(path_part=path_part): - # Non-Markdown assets are not tracked in md_contents. Resolve - # the target to a normalised absolute path string and check - # against known_assets — the frozenset built in Pass 1. - # No disk I/O in the hot path. - if path_part.startswith("/"): - asset_str = os.path.normpath( - str(docs_root) + os.sep + path_part.lstrip("/") - ) - elif path_part.startswith("@site/docs/"): - # Docusaurus alias: @site/docs/ maps to docs_root. - asset_str = os.path.normpath( - str(docs_root) + os.sep + path_part[len("@site/docs/") :] - ) - elif path_part.startswith("@site/"): - # Docusaurus alias: @site/ maps to repo_root (site_root in monorepos). - # known_assets is built from repo_root so this resolves correctly. - asset_str = os.path.normpath( - str(resolver_repo_root) + os.sep + path_part[len("@site/") :] - ) - else: - asset_str = os.path.normpath(str(md_file.parent) + os.sep + path_part) - if asset_str not in known_assets: - # Check adapter fallback before reporting: the build engine - # serves the default-locale asset when a locale-specific - # copy is absent. Suppress the error when the fallback exists. - if adapter.resolve_asset(Path(asset_str), docs_root) is not None: - continue - - # Suppress errors for build-time generated artifacts - # (e.g. PDFs from to-pdf plugin, ZIPs assembled in CI). - # Assets outside docs_root (e.g. from locale file links) - # skip this check — artifact patterns are docs-relative. - if Path(asset_str).is_relative_to(docs_root) and any( - fnmatch.fnmatch(Path(asset_str).relative_to(docs_root).as_posix(), pat) - for pat in config.excluded_build_artifacts - ): - continue - _suggestions = difflib.get_close_matches( - path_part, _known_rel_paths, n=1, cutoff=0.6 - ) - _hint = f" 💡 Did you mean: '{_suggestions[0]}'?" if _suggestions else "" - internal_errors.append( - LinkError( - file_path=md_file, - line_no=lineno, - message=f"{label}:{lineno}: '{path_part}' not found in docs{_hint}", - source_line=_source_line(md_file, lineno), - error_type="Z104", - col_start=link.col_start, - match_text=link.match_text, - ) - ) - case AnchorMissing(path_part=path_part, anchor=anchor, resolved_file=resolved_file): - # Mirror the FileNotFound i18n fallback: when a locale file - # exists but lacks the anchor (because headings are translated), - # suppress the error if the anchor is present in the - # default-locale equivalent file. The build engine serves the - # default-locale page for this anchor at build time. - if adapter.resolve_anchor(resolved_file, anchor, anchors_cache, docs_root): - continue - internal_errors.append( - LinkError( - file_path=md_file, - line_no=lineno, - message=f"{label}:{lineno}: anchor '#{anchor}' not found in '{path_part}'", - source_line=_source_line(md_file, lineno), - error_type="Z102", - col_start=link.col_start, - match_text=link.match_text, - ) - ) - case Resolved(target=resolved_target): - # ── CIRCULAR_LINK: resolved target is part of a link cycle ─ - if resolved_target.as_posix() in cycle_registry: - internal_errors.append( - LinkError( - file_path=md_file, - line_no=lineno, - message=( - f"{label}:{lineno}: '{url}' is part of a circular link cycle" - ), - source_line=_source_line(md_file, lineno), - error_type="Z106", - col_start=link.col_start, - match_text=link.match_text, - ) - ) - # ── UNREACHABLE_LINK: file exists but cannot be reached ─── - # Fires when the adapter has a build config and the resolved - # target maps to a route that is either: - # - ORPHAN_BUT_EXISTING: file exists but not in MkDocs nav - # - IGNORED: file in a _private/ dir (Zensical) or an - # unlisted README.md — engine will never serve it - if adapter.has_engine_config(): - try: - target_rel = resolved_target.relative_to(docs_root) - except ValueError: - pass # target outside docs_root — already handled by credential scanner - else: - target_url = adapter.get_route_info(target_rel).canonical_url - route = vsm.get(target_url) - if route is not None and route.status in ( - "ORPHAN_BUT_EXISTING", - "IGNORED", - ): - internal_errors.append( - LinkError( - file_path=md_file, - line_no=lineno, - message=( - f"{label}:{lineno}: '{target_rel.as_posix()}' resolves " - f"to '{target_url}' which exists on disk but is not " - "listed in the site navigation (UNREACHABLE_LINK) — " - "add it to nav in mkdocs.yml or remove the link" - ), - source_line=_source_line(md_file, lineno), - error_type="Z101", - col_start=link.col_start, - match_text=link.match_text, - ) - ) - - # Identify unused allowlist entries (Z110 STALE_ALLOWLIST_ENTRY): - unused_entries = set(config.absolute_path_allowlist) - used_allowlist - origin_file = config.origin_file or (repo_root / ".zenzic.toml") - for entry in sorted(unused_entries): - internal_errors.append( - LinkError( - file_path=origin_file, - line_no=1, - message=f"{origin_file.name}:1: Stale absolute_path_allowlist entry: '{entry}' is never referenced in links.", - source_line="", - error_type="Z110", - ) - ) - - if trackers is not None: - filtered = [] - for e in internal_errors: - t = trackers.get(e.file_path.resolve()) - if t and t.is_suppressed(e.line_no, e.error_type): - continue - filtered.append(e) - internal_errors = filtered - - internal_errors.sort(key=lambda e: e.message) - - if not strict or not check_external: - if structured: - return internal_errors - return [e.message for e in internal_errors] - - # ── Pass 3 (strict only, check_external=True): validate external links ───── - excluded = config.excluded_external_urls - global_tracker = getattr(config, "_global_tracker", None) - if excluded: - filtered_external = [] - for url, label, lineno in external_entries: - matched_prefix = None - for prefix in excluded: - if url.startswith(prefix): - matched_prefix = prefix - break - if matched_prefix: - if global_tracker: - global_tracker.mark_excluded_external_url_used(matched_prefix) - else: - filtered_external.append((url, label, lineno)) - external_entries = filtered_external - ext_error_strs = await _check_external_links(external_entries, config, repo_root) - ext_link_errors = [ - LinkError( - file_path=docs_root, # no single file context for external errors - line_no=0, - message=msg, - source_line="", - error_type="Z109", - ) - for msg in ext_error_strs - ] - all_errors: list[LinkError] = internal_errors + ext_link_errors - if structured: - return all_errors - return [e.message for e in all_errors] - - def generate_virtual_site_map( docs_root: Path, docs_structure: str, @@ -2012,8 +1265,6 @@ def validate_links_structured( locale_roots=locale_roots, ) - - link_errors: list[LinkError] = [] link_codes = { "Z101", @@ -2050,9 +1301,6 @@ def validate_links_structured( ) ) - - - for ext_msg in ext_errors: link_errors.append( LinkError( @@ -2087,8 +1335,6 @@ def validate_links( return sorted([str(e) for e in errors]) - - # ─── Decoupled URP for Language Server (In-Memory) ──────────────────────────── # Removed: Graph topology is the only source of truth. No single-file bypasses. @@ -2305,7 +1551,6 @@ def register(self, url: str, source: Path, line_no: int) -> None: return # do not schedule for HTTP validation self._registrations.setdefault(url, []).append((source, line_no)) - def register_from_map(self, ref_map: ReferenceMap, file_path: Path) -> None: """Register all HTTP/HTTPS URLs found in a :class:`ReferenceMap`. diff --git a/src/zenzic/models/vsm.py b/src/zenzic/models/vsm.py index 072cb6a..6846bd0 100644 --- a/src/zenzic/models/vsm.py +++ b/src/zenzic/models/vsm.py @@ -16,6 +16,7 @@ from __future__ import annotations import logging +from collections.abc import Iterable from dataclasses import dataclass, field from pathlib import Path from typing import Any, Literal @@ -177,7 +178,6 @@ def build_vsm( Returns: ``VSM`` mapping canonical URL → ``Route`` (IGNORED entries omitted). """ - from typing import Iterable ac = anchors_cache or {} extra_mounts = build_content_mounts(list(extra_content_roots or []), repo_root=repo_root) diff --git a/tests/test_cli_e2e.py b/tests/test_cli_e2e.py index f71928f..93a40b0 100644 --- a/tests/test_cli_e2e.py +++ b/tests/test_cli_e2e.py @@ -509,7 +509,6 @@ def test_per_file_ignores_suppress_targeted_code( assert result.exit_code == 0, ( "Expected per-file ignore to suppress Z101 in this file. " - f"Got exit {result.exit_code}.\nOutput:\n{result.stdout}" ) assert "Suppression Audit:" in result.stdout diff --git a/tests/test_cli_visual.py b/tests/test_cli_visual.py index 3aefa17..d05394f 100644 --- a/tests/test_cli_visual.py +++ b/tests/test_cli_visual.py @@ -187,8 +187,6 @@ def test_sandbox_mkdocs_expected_error_types(monkeypatch: pytest.MonkeyPatch) -> assert "Z101" in result.stdout # BROKEN_LINK (VSM miss — target not in site map) - - @pytest.mark.skipif( not _SANDBOX_MKDOCS.exists(), reason="MkDocs sandbox not present", diff --git a/tests/test_gallery_phase2bc.py b/tests/test_gallery_phase2bc.py index b47709e..fba8a7d 100644 --- a/tests/test_gallery_phase2bc.py +++ b/tests/test_gallery_phase2bc.py @@ -108,7 +108,6 @@ def test_z104_finding_message_contains_missing_path(self) -> None: z101_msgs = [f.message for f in findings if f.code == "Z101"] assert any("api/reference.md" in m or "api/reference" in m for m in z101_msgs) - def test_z104_expected_pass_false(self) -> None: assert _GALLERY["z104"].expected_pass is False diff --git a/tests/test_redteam_remediation.py b/tests/test_redteam_remediation.py index 5708f54..fbeaf17 100644 --- a/tests/test_redteam_remediation.py +++ b/tests/test_redteam_remediation.py @@ -397,7 +397,6 @@ def test_context_aware_dotdot_absent_from_vsm_emits_violation(self) -> None: assert len(violations) == 1 assert violations[0].code in ("Z101", "Z104") - def test_context_aware_traversal_escape_returns_none(self) -> None: """A path that escapes docs_root via .. must be silently skipped (no crash).""" vsm = _make_vsm("/etc/") diff --git a/tests/test_rules.py b/tests/test_rules.py index 093201b..0b5a10d 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -490,7 +490,6 @@ def test_html_broken_link_emits_violation(self) -> None: assert violations[0].code == "Z101" assert "missing" in violations[0].message - # ── ORPHAN status → Z002 warning ───────────────────────────────────────── def test_orphan_link_emits_z002_warning(self) -> None: diff --git a/tests/test_validator.py b/tests/test_validator.py index 095d9ed..ded9def 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -10,7 +10,6 @@ import pytest from _helpers import make_mgr -from zenzic.core.ast import ExtractedLink from zenzic.core.validator import ( _MAX_CONCURRENT_REQUESTS, PolyglotExtractor, @@ -157,7 +156,6 @@ def test_extract_all_links_skips_code_fences(self) -> None: assert all_links[0].line_no == 5 - # ─── slug_heading (pure) ────────────────────────────────────────────────────── From d8f577e637b52b16c252a3df49b985a38ee3809b Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 15:10:05 +0200 Subject: [PATCH 04/14] fix(lsp): clear ghost diagnostics on file deletion (LSP-FIX-015) On workspace/didChangeWatchedFiles type=3 (Deleted), the LSP server now: - Evicts the deleted URI from documents, dirty_documents, and overlay. - Sends textDocument/publishDiagnostics with diagnostics:[] immediately. - Uses 'continue' to prevent re-scheduling the deleted file for analysis. Without this fix, VS Code retained stale (ghost) diagnostics in the PROBLEMS panel until the next full workspace scan. Acceptance Criteria (LSP-FIX-015 Fix 1): - Deleting a file immediately clears its errors from the VS Code PROBLEMS panel. Unit test: tests/test_lsp.py::test_file_deletion_clears_ghost_diagnostics Signed-off-by: PythonWoods --- src/zenzic/lsp/server.py | 17 ++++++ tests/test_lsp.py | 111 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/src/zenzic/lsp/server.py b/src/zenzic/lsp/server.py index 8228f07..8de7154 100644 --- a/src/zenzic/lsp/server.py +++ b/src/zenzic/lsp/server.py @@ -373,6 +373,23 @@ def _handle_file_changes(self, changes: list[dict[str, Any]]) -> None: elif change_type == 3: # Deleted if self.engine is not None: self.engine.remove_file_cache(file_path) + # State Hygiene (LSP-FIX-015): evict the deleted URI from all + # in-memory caches so it is never re-scheduled for analysis. + self.documents.documents.pop(uri, None) + self.dirty_documents.pop(uri, None) + if self.overlay is not None: + self.overlay.remove(uri) + # LSP contract: an empty diagnostics array clears stale entries + # from the editor's PROBLEMS panel immediately. Without this, + # VS Code retains ghost diagnostics until the next full scan. + self.send_message( + { + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": {"uri": uri, "diagnostics": []}, + } + ) + continue # Deleted files must NOT be re-added to dirty_documents self.dirty_documents[uri] = 0.0 diff --git a/tests/test_lsp.py b/tests/test_lsp.py index a4d736e..1279784 100644 --- a/tests/test_lsp.py +++ b/tests/test_lsp.py @@ -1305,3 +1305,114 @@ def test_lsp_adapter_watched_config_files_hot_reload(tmp_path) -> None: diags_after = results_after.get(index_uri, []) z103_after = [d for d in diags_after if d.code == "Z103"] assert len(z103_after) == 0, "Z103 should be cleared after hot-reloading mkdocs.yml nav" + + +def test_file_deletion_clears_ghost_diagnostics(tmp_path: "Path") -> None: # noqa: F821 + """LSP-FIX-015 Fix 1 — Deleting a file must clear its diagnostics from the PROBLEMS panel. + + When a file is deleted (workspace/didChangeWatchedFiles type=3), the LSP must + send a ``textDocument/publishDiagnostics`` with an empty diagnostics array ``[]`` + so that VS Code clears stale (ghost) entries from the PROBLEMS panel immediately. + """ + + docs = tmp_path / "docs" + docs.mkdir() + # Create a file with a Z107 circular anchor so there will be prior diagnostics + doc_path = docs / "ghost.md" + doc_path.write_text("[self](#self)\n", encoding="utf-8") + (tmp_path / "mkdocs.yml").write_text( + f"site_name: T\ndocs_dir: {docs}\nnav:\n - Page: ghost.md\n", + encoding="utf-8", + ) + + def _encode(msg: dict) -> bytes: + body = json.dumps(msg, separators=(",", ":")).encode("utf-8") + return f"Content-Length: {len(body)}\r\n\r\n".encode() + body + + def _parse_frames(raw: bytes) -> list[dict]: + """Parse all Content-Length-framed JSON-RPC messages from a byte stream.""" + msgs = [] + offset = 0 + while offset < len(raw): + # Find the double CRLF that terminates the header block + header_end = raw.find(b"\r\n\r\n", offset) + if header_end == -1: + break + header = raw[offset:header_end].decode("ascii", errors="ignore") + content_length = 0 + for line in header.splitlines(): + if line.lower().startswith("content-length:"): + content_length = int(line.split(":", 1)[1].strip()) + break + body_start = header_end + 4 + body_end = body_start + content_length + if body_end > len(raw): + break + try: + msgs.append(json.loads(raw[body_start:body_end])) + except json.JSONDecodeError: + pass + offset = body_end + return msgs + + doc_uri = doc_path.as_uri() + root_uri = tmp_path.as_uri() + + in_stream = io.BytesIO() + # 1. initialize — establishes the repo root and config + in_stream.write( + _encode( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"rootUri": root_uri, "capabilities": {}}, + } + ) + ) + # 2. initialized — triggers _build_vsm_sync() so self.vsm is available + in_stream.write(_encode({"jsonrpc": "2.0", "method": "initialized", "params": {}})) + # 3. didOpen — opens the file so its diagnostics are emitted + in_stream.write( + _encode( + { + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": {"textDocument": {"uri": doc_uri, "text": "[self](#self)\n"}}, + } + ) + ) + # 4. didChangeWatchedFiles type=3 — simulates file deletion + in_stream.write( + _encode( + { + "jsonrpc": "2.0", + "method": "workspace/didChangeWatchedFiles", + "params": {"changes": [{"uri": doc_uri, "type": 3}]}, + } + ) + ) + in_stream.write(_encode({"jsonrpc": "2.0", "method": "exit", "params": {}})) + in_stream.seek(0) + + out_stream = io.BytesIO() + server = LanguageServer(stdin=in_stream, stdout=out_stream) + server.serve() + + out_stream.seek(0) + raw = out_stream.read() + frames = _parse_frames(raw) + + empty_diags_found = any( + frame.get("method") == "textDocument/publishDiagnostics" + and frame.get("params", {}).get("uri") == doc_uri + and frame.get("params", {}).get("diagnostics") == [] + for frame in frames + ) + + assert empty_diags_found, ( + "Expected a textDocument/publishDiagnostics with diagnostics=[] " + "after the file was deleted, but none was found. " + "Ghost diagnostics will remain in the VS Code PROBLEMS panel.\n" + f"Frames received: {[f.get('method') for f in frames]}" + ) From 7368f34f61026b6b9deb6c2e503ca97c06150d2c Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 15:36:00 +0200 Subject: [PATCH 05/14] feat(core): expand remediation for Z505/Z108 and remove Z121 quickfix (CORE-FEAT-001) - Z121 (Missing Href): Marked fixable=False in codes.py. Removed HtmlMissingHrefMutation since missing URLs require human context. - Z505 (Untagged Code Block): Marked fixable=True in codes.py. Implemented UntaggedCodeBlockMutation to inject 'text' language specifier into untagged fenced blocks. - Z108 (Empty Link Text): Marked fixable=True in codes.py. Updated EmptyLinkTextMutation to inject 'TODO' placeholder label. - Updated CLI fix and LSP codeAction dispatchers accordingly. - Added comprehensive unit tests in test_redteam_remediation.py, test_custom_rules.py, test_fix.py, and test_lsp.py. Signed-off-by: PythonWoods --- src/zenzic/cli/_fix.py | 4 +- src/zenzic/core/codes.py | 6 +- src/zenzic/core/mutator.py | 127 ++++++++++++++---------------- src/zenzic/lsp/server.py | 10 +-- tests/test_custom_rules.py | 39 +++++---- tests/test_fix.py | 2 +- tests/test_lsp.py | 20 ++--- tests/test_redteam_remediation.py | 41 ++++++++++ 8 files changed, 146 insertions(+), 103 deletions(-) diff --git a/src/zenzic/cli/_fix.py b/src/zenzic/cli/_fix.py index c317eac..4c4f98c 100644 --- a/src/zenzic/cli/_fix.py +++ b/src/zenzic/cli/_fix.py @@ -78,7 +78,7 @@ def fix( exclusion_mgr = _build_exclusion_manager(config, repo_root, docs_root) files = list(iter_markdown_sources(search_dir, config, exclusion_mgr)) - from zenzic.core.mutator import DeadSuppressionMutation, HtmlMissingHrefMutation + from zenzic.core.mutator import DeadSuppressionMutation, UntaggedCodeBlockMutation from zenzic.core.scanner import _scan_single_file modified_count = 0 @@ -96,7 +96,7 @@ def fix( mutator = Mutator( [ EmptyLinkTextMutation(), - HtmlMissingHrefMutation(), + UntaggedCodeBlockMutation(), DeadSuppressionMutation(dead_lines), ] ) diff --git a/src/zenzic/core/codes.py b/src/zenzic/core/codes.py index 2f1bf0b..a4b37d3 100644 --- a/src/zenzic/core/codes.py +++ b/src/zenzic/core/codes.py @@ -184,7 +184,7 @@ class ZenzicExitCode: "Z105": CodeDefinition("error", 2.0, "structural"), # ABSOLUTE_PATH "Z106": CodeDefinition("note", 0.0, None), # CIRCULAR_LINK — informational "Z107": CodeDefinition("error", 1.0, "structural"), # CIRCULAR_ANCHOR - "Z108": CodeDefinition("error", 1.0, "structural"), # EMPTY_LINK_TEXT + "Z108": CodeDefinition("error", 1.0, "structural", fixable=True), # EMPTY_LINK_TEXT "Z109": CodeDefinition("error", 3.0, "structural"), # EXTERNAL_LINK_BROKEN "Z110": CodeDefinition("warning", 1.0, "structural"), # STALE_ALLOWLIST_ENTRY "Z111": CodeDefinition( @@ -203,7 +203,7 @@ class ZenzicExitCode: # Z120/Z122 are warnings; Z121/Z124 are errors (exit 1); Z123 is informational. # All Z12x codes are suppressible via data-zenzic-ignore (-1.0 pts DQS each). "Z120": CodeDefinition("warning", 1.0, "html_hygiene"), # UNKNOWN_HTML_ATTRIBUTE - "Z121": CodeDefinition("error", 1.0, "structural", fixable=True), # MISSING_OR_EMPTY_HREF + "Z121": CodeDefinition("error", 1.0, "structural"), # MISSING_OR_EMPTY_HREF "Z122": CodeDefinition("warning", 1.0, "html_hygiene"), # JUMP_LINK_DETECTED "Z123": CodeDefinition("note", 0.0, None), # NON_HTTP_SCHEME — informational "Z124": CodeDefinition("error", 1.0, "structural"), # OPAQUE_HTML_CONTEXT @@ -233,7 +233,7 @@ class ZenzicExitCode: "Z502": CodeDefinition("warning", 1.0, "content"), # SHORT_CONTENT "Z503": CodeDefinition("warning", 10.0, "content"), # SNIPPET_ERROR "Z504": CodeDefinition("warning", 0.0, None), # QUALITY_REGRESSION — governance gate - "Z505": CodeDefinition("warning", 1.0, "content"), # UNTAGGED_CODE_BLOCK + "Z505": CodeDefinition("warning", 1.0, "content", fixable=True), # UNTAGGED_CODE_BLOCK "Z506": CodeDefinition("error", 5.0, "content"), # MALFORMED_FRONTMATTER # ── Z6xx — Governance ───────────────────────────────────────────────────── "Z601": CodeDefinition("warning", 2.0, "brand"), # BRAND_OBSOLESCENCE (escalates exponentially) diff --git a/src/zenzic/core/mutator.py b/src/zenzic/core/mutator.py index e17f92c..b5d4275 100644 --- a/src/zenzic/core/mutator.py +++ b/src/zenzic/core/mutator.py @@ -7,9 +7,13 @@ import copy from typing import Protocol +from zenzic.core import regex from zenzic.core.ast import CodeSpanNode, LinkNode, Node, TextNode +_FENCE_OPEN_RE = regex.compile(r"^(?P[`~]{3,})(?P.*)$") + + class Mutation(Protocol): """Protocol for AST mutations.""" @@ -37,7 +41,7 @@ def _has_text_content(node: Node) -> bool: class EmptyLinkTextMutation: - """Z108 Auto-Fix: Injects placeholder text into empty links.""" + """Z108 Auto-Fix: Injects placeholder 'TODO' text into empty links.""" def apply(self, node: Node) -> bool: mutated = False @@ -45,7 +49,7 @@ def apply(self, node: Node) -> bool: is_empty = not any(_has_text_content(child) for child in node.children) if is_empty: - node.children = [TextNode(text="MISSING LINK LABEL")] + node.children = [TextNode(text="TODO")] mutated = True for child in node.children: @@ -55,6 +59,61 @@ def apply(self, node: Node) -> bool: return mutated +class UntaggedCodeBlockMutation: + """Z505 Auto-Fix: Injects 'text' language specifier into untagged fenced code blocks.""" + + def apply(self, node: Node) -> bool: + from zenzic.core.ast import Document + from zenzic.core.parser import parse, serialize + + if isinstance(node, Document): + text = serialize(node) + lines = text.splitlines(keepends=True) + new_lines = [] + mutated = False + inside = False + open_char = "" + open_count = 0 + + for line in lines: + line_clean = line.rstrip("\r\n") + m = _FENCE_OPEN_RE.match(line_clean) + if not inside: + if m: + fence = m.group("fence") + info = m.group("info").strip() + has_tag = bool(info) + inside = True + open_char = fence[0] + open_count = len(fence) + if not has_tag: + rest = line[len(fence) :].lstrip(" \t") + line = f"{fence}text{rest}" + mutated = True + else: + if m: + fence = m.group("fence") + info = m.group("info").strip() + if fence[0] == open_char and len(fence) >= open_count and not info: + inside = False + open_char = "" + open_count = 0 + + new_lines.append(line) + + if mutated: + new_doc = parse("".join(new_lines)) + node.children = new_doc.children + return True + return False + + mutated = False + for child in node.children: + if self.apply(child): + mutated = True + return mutated + + class Mutator: """Engine that applies a list of Mutations to an AST.""" @@ -74,70 +133,6 @@ def mutate(self, ast: Node) -> tuple[Node, bool]: return new_ast, changed -def fix_missing_or_empty_href(attrs: str, tag: str) -> tuple[str, bool]: - if tag != "a": - return attrs, False - from zenzic.core.validator import _RE_POLY_ATTR - - attrs_dict = {} - for m in _RE_POLY_ATTR.finditer(attrs): - key = m.group("key").lower() - val = m.group("val") - if val is not None: - if (val.startswith('"') and val.endswith('"')) or ( - val.startswith("'") and val.endswith("'") - ): - val = val[1:-1] - attrs_dict[key] = (val, m.start(), m.end()) - - if "href" not in attrs_dict: - new_attrs = attrs.rstrip() + ' href="#"' - return new_attrs, True - - val, start, end = attrs_dict["href"] - if val is None or val.strip() == "": - prefix = attrs[:start] - suffix = attrs[end:] - new_attrs = prefix + 'href="#"' + suffix - return new_attrs, True - - return attrs, False - - -class HtmlMissingHrefMutation: - """Z121 Auto-Fix: Injects href="#" into missing or empty tag href attributes.""" - - def apply(self, node: Node) -> bool: - mutated = False - from zenzic.core.ast import TextNode - - if isinstance(node, TextNode): - from zenzic.core.validator import _RE_POLY_TAG - - text = node.text - new_text = "" - last_idx = 0 - for m in _RE_POLY_TAG.finditer(text): - tag = m.group(1).lower() - attrs_str = m.group("attrs") - - if tag == "a": - new_attrs, changed = fix_missing_or_empty_href(attrs_str, tag) - if changed: - new_text += text[last_idx : m.start()] + f"" - last_idx = m.end() - mutated = True - - if mutated: - new_text += text[last_idx:] - node.text = new_text - - for child in node.children: - if self.apply(child): - mutated = True - return mutated - - class DeadSuppressionMutation: """Z603 Auto-Fix: Removes dead inline suppression comments and attributes.""" diff --git a/src/zenzic/lsp/server.py b/src/zenzic/lsp/server.py index 8de7154..a69addf 100644 --- a/src/zenzic/lsp/server.py +++ b/src/zenzic/lsp/server.py @@ -675,9 +675,9 @@ def _handle_code_action(self, params: dict[str, Any], msg_id: int | str | None) from zenzic.core.mutator import ( DeadSuppressionMutation, EmptyLinkTextMutation, - HtmlMissingHrefMutation, Mutation, Mutator, + UntaggedCodeBlockMutation, ) from zenzic.core.parser import parse, serialize @@ -698,16 +698,16 @@ def _handle_code_action(self, params: dict[str, Any], msg_id: int | str | None) mutations: list[Mutation] = [] title_desc = "" - if code == "Z121": - mutations.append(HtmlMissingHrefMutation()) - title_desc = 'Inject placeholder href="#"' + if code == "Z505": + mutations.append(UntaggedCodeBlockMutation()) + title_desc = "Inject language specifier ('text')" elif code == "Z603": line_no = diag.get("range", {}).get("start", {}).get("line", 0) + 1 mutations.append(DeadSuppressionMutation({line_no})) title_desc = "Remove dead inline suppression" elif code == "Z108": mutations.append(EmptyLinkTextMutation()) - title_desc = "Inject placeholder link text" + title_desc = "Inject placeholder link text ('TODO')" else: continue diff --git a/tests/test_custom_rules.py b/tests/test_custom_rules.py index 57b4675..8ac02a5 100644 --- a/tests/test_custom_rules.py +++ b/tests/test_custom_rules.py @@ -177,24 +177,31 @@ def visit_html_node(self, node, file_path): assert "AWESOME-101" in rule_ids -def test_autofix_z121_and_z603(tmp_path: Path) -> None: - """Test autofixes for missing/empty href (Z121) and dead suppression (Z603).""" - from zenzic.core.mutator import DeadSuppressionMutation, HtmlMissingHrefMutation, Mutator +def test_autofix_z505_z108_z603(tmp_path: Path) -> None: + """Test autofixes for untagged code blocks (Z505), empty link text (Z108), and dead suppression (Z603).""" + from zenzic.core.mutator import ( + DeadSuppressionMutation, + EmptyLinkTextMutation, + Mutator, + UntaggedCodeBlockMutation, + ) from zenzic.core.parser import parse, serialize - # 1. Z121 Auto-Fix tests - z121_inputs = [ - 'test', - 'test', - 'test', - ] - mutator_z121 = Mutator([HtmlMissingHrefMutation()]) - for inp in z121_inputs: - ast = parse(inp) - new_ast, changed = mutator_z121.mutate(ast) - assert changed - res = serialize(new_ast) - assert 'href="#"' in res + # 1. Z505 Auto-Fix tests + z505_input = "```\nprint('hello')\n```\n" + mutator_z505 = Mutator([UntaggedCodeBlockMutation()]) + ast505 = parse(z505_input) + new_ast505, changed505 = mutator_z505.mutate(ast505) + assert changed505 + assert serialize(new_ast505) == "```text\nprint('hello')\n```\n" + + # 2. Z108 Auto-Fix tests + z108_input = "[](https://example.com)\n" + mutator_z108 = Mutator([EmptyLinkTextMutation()]) + ast108 = parse(z108_input) + new_ast108, changed108 = mutator_z108.mutate(ast108) + assert changed108 + assert serialize(new_ast108) == "[TODO](https://example.com)\n" # 2. Z603 Auto-Fix tests (Dead suppression) text_with_dead = ( diff --git a/tests/test_fix.py b/tests/test_fix.py index 2b3afac..8fa3df4 100644 --- a/tests/test_fix.py +++ b/tests/test_fix.py @@ -79,7 +79,7 @@ def test_formatted_empty_link_validation_and_mutation() -> None: assert changed, f"Expected mutator to change: {text}" serialized = serialize(new_ast) - assert serialized == "[MISSING LINK LABEL](url)", f"Got: {serialized}" + assert serialized == "[TODO](url)", f"Got: {serialized}" def test_polyglot_extractor_comment_masking() -> None: diff --git a/tests/test_lsp.py b/tests/test_lsp.py index 1279784..995a1e4 100644 --- a/tests/test_lsp.py +++ b/tests/test_lsp.py @@ -748,14 +748,14 @@ def test_lsp_drops_out_of_bounds_markdown_did_open(tmp_path) -> None: assert in_uri in server.dirty_documents -def test_lsp_code_action_z121(tmp_path) -> None: - """Verify textDocument/codeAction returns valid CodeAction WorkspaceEdit for Z121 fix.""" +def test_lsp_code_action_z505(tmp_path) -> None: + """Verify textDocument/codeAction returns valid CodeAction WorkspaceEdit for Z505 fix.""" server = LanguageServer() out_stream = io.BytesIO() server.stdout = out_stream doc_uri = (tmp_path / "docs" / "index.md").as_uri() - doc_text = "Link without href\n" + doc_text = "```\ncode\n```\n" # Open document server.handle_message( @@ -769,7 +769,7 @@ def test_lsp_code_action_z121(tmp_path) -> None: out_stream.seek(0) out_stream.truncate(0) - # Request code action for Z121 diagnostic + # Request code action for Z505 diagnostic server.handle_message( { "jsonrpc": "2.0", @@ -779,18 +779,18 @@ def test_lsp_code_action_z121(tmp_path) -> None: "textDocument": {"uri": doc_uri}, "range": { "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 24}, + "end": {"line": 0, "character": 3}, }, "context": { "diagnostics": [ { "range": { "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 24}, + "end": {"line": 0, "character": 3}, }, - "code": "Z121", + "code": "Z505", "source": "Zenzic", - "message": "[Z121] Missing or empty href attribute", + "message": "[Z505] Fenced code block has no language specifier", } ] }, @@ -808,12 +808,12 @@ def test_lsp_code_action_z121(tmp_path) -> None: actions = response["result"] assert len(actions) == 1 action = actions[0] - assert action["title"] == 'Fix Z121: Inject placeholder href="#"' + assert action["title"] == "Fix Z505: Inject language specifier ('text')" assert action["kind"] == "quickfix" assert doc_uri in action["edit"]["changes"] edits = action["edit"]["changes"][doc_uri] assert len(edits) == 1 - assert '' in edits[0]["newText"] + assert "```text" in edits[0]["newText"] def test_lsp_code_action_unfixable(tmp_path) -> None: diff --git a/tests/test_redteam_remediation.py b/tests/test_redteam_remediation.py index fbeaf17..e2bc3a3 100644 --- a/tests/test_redteam_remediation.py +++ b/tests/test_redteam_remediation.py @@ -670,3 +670,44 @@ def test_total_length_preserved(self) -> None: raw = "A" * n result = _obfuscate_secret(raw) assert len(result) == n, f"len mismatch for n={n}: {result!r}" + + +# ─── CORE-FEAT-001: Remediation Expansion Verification ─────────────────────── + + +class TestRemediationExpansion: + """CORE-FEAT-001: Verification of Atomic Mutator refinement and remediation expansion.""" + + def test_z121_is_not_fixable(self) -> None: + """Z121 (missing href) requires human context and must NOT be marked fixable.""" + from zenzic.core.codes import CODE_DEFINITIONS + + assert not CODE_DEFINITIONS["Z121"].fixable + + def test_z505_is_fixable_and_injects_text(self) -> None: + """Z505 (untagged code block) is fixable and injects 'text' specifier.""" + from zenzic.core.codes import CODE_DEFINITIONS + from zenzic.core.mutator import Mutator, UntaggedCodeBlockMutation + from zenzic.core.parser import parse, serialize + + assert CODE_DEFINITIONS["Z505"].fixable + + untagged = "```\ndef foo():\n pass\n```\n" + ast = parse(untagged) + new_ast, changed = Mutator([UntaggedCodeBlockMutation()]).mutate(ast) + assert changed + assert serialize(new_ast) == "```text\ndef foo():\n pass\n```\n" + + def test_z108_is_fixable_and_injects_todo(self) -> None: + """Z108 (empty link text) is fixable and injects 'TODO' placeholder.""" + from zenzic.core.codes import CODE_DEFINITIONS + from zenzic.core.mutator import EmptyLinkTextMutation, Mutator + from zenzic.core.parser import parse, serialize + + assert CODE_DEFINITIONS["Z108"].fixable + + empty_link = "[](https://zenzic.dev)\n" + ast = parse(empty_link) + new_ast, changed = Mutator([EmptyLinkTextMutation()]).mutate(ast) + assert changed + assert serialize(new_ast) == "[TODO](https://zenzic.dev)\n" From 9693ef34a3d6cf5091a44ccf2bec847b7a983f03 Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 15:59:20 +0200 Subject: [PATCH 06/14] fix(lsp): refine codeAction routing for Z108, Z505, Z603 (LSP-FIX-016) - Explicitly route Z108, Z505, and Z603 diagnostics to EmptyLinkTextMutation, UntaggedCodeBlockMutation, and DeadSuppressionMutation. - Complete removal of Z121 handler. - Added test_lsp_code_action_z108 to tests/test_lsp.py. Signed-off-by: PythonWoods --- src/zenzic/lsp/server.py | 26 +++++++-------- tests/test_lsp.py | 68 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/src/zenzic/lsp/server.py b/src/zenzic/lsp/server.py index a69addf..13ca83c 100644 --- a/src/zenzic/lsp/server.py +++ b/src/zenzic/lsp/server.py @@ -685,29 +685,29 @@ def _handle_code_action(self, params: dict[str, Any], msg_id: int | str | None) for diag in diagnostics: raw_code = diag.get("code") - code = str(raw_code) if raw_code is not None else "" - if not code and "message" in diag: + diag_code = str(raw_code) if raw_code is not None else "" + if not diag_code and "message" in diag: m = re.search(r"\[(Z\d{3})\]", str(diag["message"])) if m: - code = m.group(1) + diag_code = m.group(1) - defn = CODE_DEFINITIONS.get(code) + defn = CODE_DEFINITIONS.get(diag_code) if not defn or not getattr(defn, "fixable", False): continue mutations: list[Mutation] = [] - title_desc = "" + title = "" - if code == "Z505": + if diag_code == "Z108": + mutations.append(EmptyLinkTextMutation()) + title = "Fix Z108: Inject placeholder link text ('TODO')" + elif diag_code == "Z505": mutations.append(UntaggedCodeBlockMutation()) - title_desc = "Inject language specifier ('text')" - elif code == "Z603": + title = "Fix Z505: Inject language specifier ('text')" + elif diag_code == "Z603": line_no = diag.get("range", {}).get("start", {}).get("line", 0) + 1 mutations.append(DeadSuppressionMutation({line_no})) - title_desc = "Remove dead inline suppression" - elif code == "Z108": - mutations.append(EmptyLinkTextMutation()) - title_desc = "Inject placeholder link text ('TODO')" + title = "Fix Z603: Remove dead inline suppression" else: continue @@ -730,7 +730,7 @@ def _handle_code_action(self, params: dict[str, Any], msg_id: int | str | None) } action = { - "title": f"Fix {code}: {title_desc}", + "title": title, "kind": "quickfix", "diagnostics": [diag], "edit": { diff --git a/tests/test_lsp.py b/tests/test_lsp.py index 995a1e4..6ecee1a 100644 --- a/tests/test_lsp.py +++ b/tests/test_lsp.py @@ -816,6 +816,74 @@ def test_lsp_code_action_z505(tmp_path) -> None: assert "```text" in edits[0]["newText"] +def test_lsp_code_action_z108(tmp_path) -> None: + """Verify textDocument/codeAction returns valid CodeAction WorkspaceEdit for Z108 fix.""" + server = LanguageServer() + out_stream = io.BytesIO() + server.stdout = out_stream + + doc_uri = (tmp_path / "docs" / "index.md").as_uri() + doc_text = "[](https://example.com)\n" + + # Open document + server.handle_message( + { + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": {"textDocument": {"uri": doc_uri, "text": doc_text}}, + } + ) + + out_stream.seek(0) + out_stream.truncate(0) + + # Request code action for Z108 diagnostic + server.handle_message( + { + "jsonrpc": "2.0", + "id": 101, + "method": "textDocument/codeAction", + "params": { + "textDocument": {"uri": doc_uri}, + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 23}, + }, + "context": { + "diagnostics": [ + { + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 23}, + }, + "code": "Z108", + "source": "Zenzic", + "message": "[Z108] Link text is empty or contains only whitespace", + } + ] + }, + }, + } + ) + + out_stream.seek(0) + raw_output = out_stream.read().decode("utf-8") + assert "Content-Length:" in raw_output + body_str = raw_output.split("\r\n\r\n")[1] + response = json.loads(body_str) + + assert response["id"] == 101 + actions = response["result"] + assert len(actions) == 1 + action = actions[0] + assert action["title"] == "Fix Z108: Inject placeholder link text ('TODO')" + assert action["kind"] == "quickfix" + assert doc_uri in action["edit"]["changes"] + edits = action["edit"]["changes"][doc_uri] + assert len(edits) == 1 + assert "[TODO](https://example.com)" in edits[0]["newText"] + + def test_lsp_code_action_unfixable(tmp_path) -> None: """Verify textDocument/codeAction returns empty list for unfixable diagnostics.""" server = LanguageServer() From aa0789b7936f37bc70b21f49ff7314fb6f41174e Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 16:05:24 +0200 Subject: [PATCH 07/14] docs(changelog): prepare v0.26.3 release notes Signed-off-by: PythonWoods --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca245cd..3fc5312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed +- **LSP State Hygiene (`LSP-FIX-015`)**: Eradicated "ghost diagnostics" by ensuring the LSP server explicitly sends an empty diagnostics array (`[]`) to VS Code when a file is deleted, clearing the PROBLEMS panel. +- **Code Action Routing (`LSP-FIX-016`)**: Fixed a routing bug in the LSP server that prevented Quick Fixes for `Z108` (Empty Link Text) and `Z505` (Untagged Code Block) from appearing in the editor. +- **Z603 Parity (`LSP-FIX-015`)**: Ensured HTML comment suppressions (``) are correctly evaluated for "dead" status in the LSP engine. + ## [0.26.2] - 2026-07-28 ### Fixed From d10f18b2ca129982cadc0f30e9d6698ef06d71b9 Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 16:27:29 +0200 Subject: [PATCH 08/14] ci(security): configure CodeQL analysis Signed-off-by: PythonWoods --- .github/workflows/codeql.yml | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 192cf25..ac685ec 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,22 +1,20 @@ # SPDX-FileCopyrightText: 2026 PythonWoods # SPDX-License-Identifier: Apache-2.0 -name: Zenzic Docs CodeQL +name: Zenzic Core CodeQL on: push: - branches: - - main + branches: [ main ] paths: - 'src/**' - - 'scripts/**' + - 'tests/**' - '.github/workflows/codeql.yml' pull_request: - branches: - - main + branches: [ main ] paths: - 'src/**' - - 'scripts/**' + - 'tests/**' - '.github/workflows/codeql.yml' schedule: - cron: '24 3 * * 1' @@ -34,20 +32,19 @@ jobs: strategy: fail-fast: false matrix: - language: - - javascript-typescript + language: [ python ] steps: - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3 + uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3 + uses: github/codeql-action/analyze@v3 \ No newline at end of file From 89f081106f2ab35c5464e3e464775c9cf4fbca4c Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 16:38:16 +0200 Subject: [PATCH 09/14] ci(core): exclude python 3.14 from windows test matrix to save CI cycles Signed-off-by: PythonWoods --- .github/workflows/ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c62cae..71c7dae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,11 +28,9 @@ jobs: # and use a stable anchor for Windows/macOS. os: [ubuntu-latest, windows-latest] python-version: ["3.10", "3.14"] - # include: - # - os: windows-latest - # python-version: "3.12" - # - os: macos-latest - # python-version: "3.12" + exclude: + - os: windows-latest + python-version: "3.14" steps: - name: Checkout Repository From 6d22987e8a174c70484f694f334c079c019f21db Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 16:44:02 +0200 Subject: [PATCH 10/14] docs(core): update fixable z-codes reference to Z108, Z505, Z603 (DOCS-ALIGN-001) Signed-off-by: PythonWoods --- docs/editor/vscode.md | 6 ++++++ docs/reference/cli.md | 4 ++-- docs/reference/finding-codes.md | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/editor/vscode.md b/docs/editor/vscode.md index 44c0e44..6d23351 100644 --- a/docs/editor/vscode.md +++ b/docs/editor/vscode.md @@ -63,6 +63,12 @@ If you use a custom virtual environment or isolated installation, configure `zen |---|---|---|---| | `zenzic.executablePath` | `string` | `"zenzic"` | Absolute path or binary name for the Zenzic executable. | +## Inline Diagnostics & Code Actions + +The extension exposes real-time LSP diagnostics directly in the PROBLEMS panel and editor margin. + +Zenzic provides automated Quick Fixes for specific structural and content findings (e.g., injecting placeholder text for empty links `Z108`, adding language tags to code blocks `Z505`, and removing dead suppressions `Z603`). + ## Domain Boundaries & Supported Files To uphold **Domain-Aware Discovery** and **Radical Unawareness**: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ef88389..c3e631a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -595,8 +595,8 @@ Zenzic is read-only by default. Auto-fixing is an explicit, opt-in operation pro Currently, `zenzic fix` supports auto-fixing: -- **Z108 (EMPTY_LINK_TEXT):** Converts a structural accessibility error into a content debt warning (`Z501`), injecting the `[MISSING LINK LABEL]` keyword. You must subsequently resolve these placeholders. -- **Z121 (MISSING_OR_EMPTY_HREF):** Converts a structural HTML integrity error into an HTML hygiene warning (`Z122`) by injecting `href="#"` (safe self-reference). +- **Z108 (EMPTY_LINK_TEXT):** Injects a placeholder label for empty link text. +- **Z505 (UNTAGGED_CODE_BLOCK):** Injects default `text` language specifier for untagged fenced code blocks. - **Z603 (DEAD_SUPPRESSION):** Cleanly extracts dead/unused inline suppression comments (``) and `data-zenzic-ignore` HTML attributes without corrupting the surrounding text. `zenzic clean assets` respects `excluded_assets`, `excluded_dirs`, and diff --git a/docs/reference/finding-codes.md b/docs/reference/finding-codes.md index d858362..45540ea 100644 --- a/docs/reference/finding-codes.md +++ b/docs/reference/finding-codes.md @@ -307,7 +307,7 @@ An HTML `` tag contains unknown or malformed attributes. ### Z121: MISSING_HREF {#z121} -**Severity:** `error` · **Penalty:** −8.0 pts (Structural) · **Exit:** 1 · **Suppressible:** Yes · **Fixable:** Yes · [↗ Gallery](../tutorials/examples/z1xx-links/z121-missing-href.md) +**Severity:** `error` · **Penalty:** −8.0 pts (Structural) · **Exit:** 1 · **Suppressible:** Yes · [↗ Gallery](../tutorials/examples/z1xx-links/z121-missing-href.md) An HTML `` tag is missing the required `href` attribute. @@ -589,7 +589,7 @@ The Snippet Guard identified a syntax error in a fenced code block marked with a ### Z505: UNTAGGED_CODE_BLOCK {#z505} -**Severity:** `warning` · **Penalty:** −1.0 pt (Content) · **Exit:** 1 · **Suppressible:** Yes · [↗ Gallery](../tutorials/examples/z5xx-content/z505-untagged-code-block.md) +**Severity:** `warning` · **Penalty:** −1.0 pt (Content) · **Exit:** 1 · **Suppressible:** Yes · **Fixable:** Yes · [↗ Gallery](../tutorials/examples/z5xx-content/z505-untagged-code-block.md) A fenced code block has no language specifier. Syntax highlighters, the Snippet Guard (Z503), and screen readers cannot process it. Some engine-specific metadata (e.g. `` ```python title="file.py" showLineNumbers ``) is fully supported and never flagged. From 8faee739ab82e735d230331b1b56d7568e442f37 Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 16:53:20 +0200 Subject: [PATCH 11/14] feat(lsp): implement inline suppression code actions with security gate (LSP-FEAT-003) Signed-off-by: PythonWoods --- docs/editor/vscode.md | 2 + docs/reference/finding-codes.md | 3 + src/zenzic/lsp/server.py | 101 +++++++++++-------- tests/test_lsp.py | 166 ++++++++++++++++++++++++++++++-- 4 files changed, 223 insertions(+), 49 deletions(-) diff --git a/docs/editor/vscode.md b/docs/editor/vscode.md index 6d23351..0c3e4f7 100644 --- a/docs/editor/vscode.md +++ b/docs/editor/vscode.md @@ -69,6 +69,8 @@ The extension exposes real-time LSP diagnostics directly in the PROBLEMS panel a Zenzic provides automated Quick Fixes for specific structural and content findings (e.g., injecting placeholder text for empty links `Z108`, adding language tags to code blocks `Z505`, and removing dead suppressions `Z603`). +In addition, Zenzic offers automated "Suppress this finding" Code Actions (``) for all suppressible diagnostics. Hovering over a finding allows you to insert an inline suppression directive on the line above with a single click. To enforce security governance, suppression Code Actions are intentionally disabled for Security findings (`Z2xx`), which must be remediated at the source. + ## Domain Boundaries & Supported Files To uphold **Domain-Aware Discovery** and **Radical Unawareness**: diff --git a/docs/reference/finding-codes.md b/docs/reference/finding-codes.md index 45540ea..98071d0 100644 --- a/docs/reference/finding-codes.md +++ b/docs/reference/finding-codes.md @@ -341,6 +341,9 @@ An HTML link has an opaque context or relies on inline scripts for navigation. ## Z2xx — Security (credential scanner) +!!! danger "🔒 NON-SUPPRESSIBLE SECURITY GOVERNANCE" + All `Z2xx` Security findings are strictly **Non-Suppressible** across the Zenzic engine and Language Server Protocol (LSP). `` directives are silently rejected, and the editor will **never** offer inline suppression Code Actions for security findings. Security breaches must be resolved at the source. + ### Z201: CREDENTIAL_SECRET {#z201} !!! danger "🔒 INVIOLABLE — Cannot be suppressed | Exit 2 | DQS collapses to 0/100" diff --git a/src/zenzic/lsp/server.py b/src/zenzic/lsp/server.py index 13ca83c..74352c5 100644 --- a/src/zenzic/lsp/server.py +++ b/src/zenzic/lsp/server.py @@ -671,7 +671,7 @@ def _handle_code_action(self, params: dict[str, Any], msg_id: int | str | None) import re - from zenzic.core.codes import CODE_DEFINITIONS + from zenzic.core.codes import CODE_DEFINITIONS, NON_SUPPRESSIBLE_CODES from zenzic.core.mutator import ( DeadSuppressionMutation, EmptyLinkTextMutation, @@ -692,58 +692,77 @@ def _handle_code_action(self, params: dict[str, Any], msg_id: int | str | None) diag_code = m.group(1) defn = CODE_DEFINITIONS.get(diag_code) - if not defn or not getattr(defn, "fixable", False): - continue - - mutations: list[Mutation] = [] - title = "" - - if diag_code == "Z108": - mutations.append(EmptyLinkTextMutation()) - title = "Fix Z108: Inject placeholder link text ('TODO')" - elif diag_code == "Z505": - mutations.append(UntaggedCodeBlockMutation()) - title = "Fix Z505: Inject language specifier ('text')" - elif diag_code == "Z603": - line_no = diag.get("range", {}).get("start", {}).get("line", 0) + 1 - mutations.append(DeadSuppressionMutation({line_no})) - title = "Fix Z603: Remove dead inline suppression" - else: - continue + if defn and getattr(defn, "fixable", False): + mutations: list[Mutation] = [] + title = "" + + if diag_code == "Z108": + mutations.append(EmptyLinkTextMutation()) + title = "Fix Z108: Inject placeholder link text ('TODO')" + elif diag_code == "Z505": + mutations.append(UntaggedCodeBlockMutation()) + title = "Fix Z505: Inject language specifier ('text')" + elif diag_code == "Z603": + line_no = diag.get("range", {}).get("start", {}).get("line", 0) + 1 + mutations.append(DeadSuppressionMutation({line_no})) + title = "Fix Z603: Remove dead inline suppression" + + if mutations: + try: + ast = parse(content) + mutator = Mutator(mutations) + new_ast, changed = mutator.mutate(ast) + except Exception: + changed = False + + if changed: + new_content = serialize(new_ast) + lines = content.splitlines(keepends=True) + total_lines = max(0, len(lines) - 1) + last_line_len = len(lines[-1]) if lines else 0 + + full_range = { + "start": {"line": 0, "character": 0}, + "end": {"line": total_lines, "character": last_line_len}, + } - try: - ast = parse(content) - mutator = Mutator(mutations) - new_ast, changed = mutator.mutate(ast) - except Exception: - changed = False - - if changed: - new_content = serialize(new_ast) - lines = content.splitlines(keepends=True) - total_lines = max(0, len(lines) - 1) - last_line_len = len(lines[-1]) if lines else 0 - - full_range = { - "start": {"line": 0, "character": 0}, - "end": {"line": total_lines, "character": last_line_len}, - } + action = { + "title": title, + "kind": "quickfix", + "diagnostics": [diag], + "edit": { + "changes": { + uri: [ + { + "range": full_range, + "newText": new_content, + } + ] + } + }, + } + code_actions.append(action) - action = { - "title": title, + if diag_code and diag_code not in NON_SUPPRESSIBLE_CODES: + insert_line = max(0, diag.get("range", {}).get("start", {}).get("line", 0)) + suppress_action = { + "title": f"Suppress {diag_code} for this line", "kind": "quickfix", "diagnostics": [diag], "edit": { "changes": { uri: [ { - "range": full_range, - "newText": new_content, + "range": { + "start": {"line": insert_line, "character": 0}, + "end": {"line": insert_line, "character": 0}, + }, + "newText": f"\n", } ] } }, } - code_actions.append(action) + code_actions.append(suppress_action) self.send_response(msg_id, result=code_actions) diff --git a/tests/test_lsp.py b/tests/test_lsp.py index 6ecee1a..22432a0 100644 --- a/tests/test_lsp.py +++ b/tests/test_lsp.py @@ -806,8 +806,8 @@ def test_lsp_code_action_z505(tmp_path) -> None: assert response["id"] == 100 actions = response["result"] - assert len(actions) == 1 - action = actions[0] + assert len(actions) == 2 + action = [a for a in actions if a["title"].startswith("Fix Z505")][0] assert action["title"] == "Fix Z505: Inject language specifier ('text')" assert action["kind"] == "quickfix" assert doc_uri in action["edit"]["changes"] @@ -874,8 +874,8 @@ def test_lsp_code_action_z108(tmp_path) -> None: assert response["id"] == 101 actions = response["result"] - assert len(actions) == 1 - action = actions[0] + assert len(actions) == 2 + action = [a for a in actions if a["title"].startswith("Fix Z108")][0] assert action["title"] == "Fix Z108: Inject placeholder link text ('TODO')" assert action["kind"] == "quickfix" assert doc_uri in action["edit"]["changes"] @@ -885,7 +885,7 @@ def test_lsp_code_action_z108(tmp_path) -> None: def test_lsp_code_action_unfixable(tmp_path) -> None: - """Verify textDocument/codeAction returns empty list for unfixable diagnostics.""" + """Verify textDocument/codeAction returns empty list for unfixable & non-suppressible diagnostics.""" server = LanguageServer() out_stream = io.BytesIO() server.stdout = out_stream @@ -902,7 +902,7 @@ def test_lsp_code_action_unfixable(tmp_path) -> None: out_stream.seek(0) out_stream.truncate(0) - # Z120 is unfixable (fixable=False) + # Z201 is non-suppressible and has no quick fix server.handle_message( { "jsonrpc": "2.0", @@ -918,9 +918,9 @@ def test_lsp_code_action_unfixable(tmp_path) -> None: "start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 9}, }, - "code": "Z120", + "code": "Z201", "source": "Zenzic", - "message": "[Z120] Relative link error", + "message": "[Z201] Security breach", } ] }, @@ -1484,3 +1484,153 @@ def _parse_frames(raw: bytes) -> list[dict]: "Ghost diagnostics will remain in the VS Code PROBLEMS panel.\n" f"Frames received: {[f.get('method') for f in frames]}" ) + + +def test_lsp_code_action_suppression(tmp_path) -> None: + """Verify textDocument/codeAction generates Inline Suppression CodeActions (LSP-FEAT-003). + + 1. Suppressible code (Z101): returns 'Suppress Z101 for this line'. + 2. Non-suppressible code (Z201): returns no suppression action. + 3. Fixable + Suppressible code (Z108): returns both Quick Fix and Suppression action. + """ + server = LanguageServer() + out_stream = io.BytesIO() + server.stdout = out_stream + + doc_uri = (tmp_path / "docs" / "index.md").as_uri() + doc_text = ( + "[](https://example.com)\n[Broken link](missing.md)\nAWS_SECRET_KEY=AKIAIOSFODNN7EXAMPLE\n" + ) + + # Open document + server.handle_message( + { + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": {"textDocument": {"uri": doc_uri, "text": doc_text}}, + } + ) + + # 1. Test Z101 (Suppressible only) + out_stream.seek(0) + out_stream.truncate(0) + + server.handle_message( + { + "jsonrpc": "2.0", + "id": 201, + "method": "textDocument/codeAction", + "params": { + "textDocument": {"uri": doc_uri}, + "range": { + "start": {"line": 1, "character": 0}, + "end": {"line": 1, "character": 24}, + }, + "context": { + "diagnostics": [ + { + "range": { + "start": {"line": 1, "character": 0}, + "end": {"line": 1, "character": 24}, + }, + "code": "Z101", + "source": "Zenzic", + "message": "[Z101] Target file missing.md does not exist", + } + ] + }, + }, + } + ) + + out_stream.seek(0) + raw = out_stream.read().decode("utf-8") + resp = json.loads(raw.split("\r\n\r\n")[1]) + actions = resp["result"] + assert len(actions) == 1 + 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["range"] == { + "start": {"line": 1, "character": 0}, + "end": {"line": 1, "character": 0}, + } + + # 2. Test Z201 (Non-suppressible security gate) + out_stream.seek(0) + out_stream.truncate(0) + + server.handle_message( + { + "jsonrpc": "2.0", + "id": 202, + "method": "textDocument/codeAction", + "params": { + "textDocument": {"uri": doc_uri}, + "range": { + "start": {"line": 2, "character": 0}, + "end": {"line": 2, "character": 40}, + }, + "context": { + "diagnostics": [ + { + "range": { + "start": {"line": 2, "character": 0}, + "end": {"line": 2, "character": 40}, + }, + "code": "Z201", + "source": "Zenzic", + "message": "[Z201] Hardcoded credential secret detected", + } + ] + }, + }, + } + ) + + out_stream.seek(0) + raw = out_stream.read().decode("utf-8") + resp = json.loads(raw.split("\r\n\r\n")[1]) + assert resp["result"] == [], "Z201 Security findings must NOT offer suppression Code Actions" + + # 3. Test Z108 (Fixable + Suppressible) + out_stream.seek(0) + out_stream.truncate(0) + + server.handle_message( + { + "jsonrpc": "2.0", + "id": 203, + "method": "textDocument/codeAction", + "params": { + "textDocument": {"uri": doc_uri}, + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 23}, + }, + "context": { + "diagnostics": [ + { + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 23}, + }, + "code": "Z108", + "source": "Zenzic", + "message": "[Z108] Link text is empty", + } + ] + }, + }, + } + ) + + out_stream.seek(0) + raw = out_stream.read().decode("utf-8") + resp = json.loads(raw.split("\r\n\r\n")[1]) + actions = resp["result"] + assert len(actions) == 2 + titles = [a["title"] for a in actions] + assert "Fix Z108: Inject placeholder link text ('TODO')" in titles + assert "Suppress Z108 for this line" in titles From 88d04d7e5510cea0492204080dee3f9fd959a1ba Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 16:56:29 +0200 Subject: [PATCH 12/14] docs(changelog): prepare v0.26.3 release notes Signed-off-by: PythonWoods --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc5312..4e6fe67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,12 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **Suppression Code Actions (`LSP-FEAT-003`)**: The LSP server now dynamically generates "Suppress this finding" Code Actions for all suppressible diagnostics. Clicking the action automatically injects the correct `` comment above the offending line. This feature is strictly disabled for `NON_SUPPRESSIBLE_CODES` (Z2xx Security findings) to enforce the security gate. + ### Fixed + - **LSP State Hygiene (`LSP-FIX-015`)**: Eradicated "ghost diagnostics" by ensuring the LSP server explicitly sends an empty diagnostics array (`[]`) to VS Code when a file is deleted, clearing the PROBLEMS panel. - **Code Action Routing (`LSP-FIX-016`)**: Fixed a routing bug in the LSP server that prevented Quick Fixes for `Z108` (Empty Link Text) and `Z505` (Untagged Code Block) from appearing in the editor. - **Z603 Parity (`LSP-FIX-015`)**: Ensured HTML comment suppressions (``) are correctly evaluated for "dead" status in the LSP engine. From 585f6a3eeb4bffaf508457b501177fbb731c8e6b Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 16:57:35 +0200 Subject: [PATCH 13/14] ci(security): configure CodeQL analysis Signed-off-by: PythonWoods --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ac685ec..79ef4e4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,4 +47,4 @@ jobs: uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 \ No newline at end of file + uses: github/codeql-action/analyze@v3 From 6ff451e4cf3363608f4accce997bc17c9d4fbdbf Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 17:07:08 +0200 Subject: [PATCH 14/14] release: bump version to 0.26.3 Signed-off-by: PythonWoods --- .bumpversion.toml | 2 +- .github/ISSUE_TEMPLATE/security_vulnerability.yml | 2 +- .pre-commit-hooks.yaml | 2 +- CHANGELOG.md | 2 ++ CITATION.cff | 2 +- README.md | 4 ++-- RELEASE.md | 8 ++++---- mkdocs.yml | 2 +- pyproject.toml | 2 +- src/zenzic/__init__.py | 2 +- src/zenzic/cli/_standalone.py | 2 +- uv.lock | 2 +- 12 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index a36b020..f31f9e6 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 [tool.bumpversion] -current_version = "0.26.2" +current_version = "0.26.3" 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 c7963c9..3067437 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.2" + placeholder: "0.26.3" validations: required: true diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 99e2a64..fc4d76c 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.2 +# rev: v0.26.3 # 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 4e6fe67..c697035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.26.3] - 2026-07-28 + ### Added - **Suppression Code Actions (`LSP-FEAT-003`)**: The LSP server now dynamically generates "Suppress this finding" Code Actions for all suppressible diagnostics. Clicking the action automatically injects the correct `` comment above the offending line. This feature is strictly disabled for `NON_SUPPRESSIBLE_CODES` (Z2xx Security findings) to enforce the security gate. diff --git a/CITATION.cff b/CITATION.cff index 2a0df8b..870be00 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -15,7 +15,7 @@ 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.2 +version: 0.26.3 date-released: 2026-07-28 url: "https://zenzic.dev" repository-code: "https://github.com/PythonWoods/zenzic" diff --git a/README.md b/README.md index bed4b43..5c02025 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.2", + "version": "0.26.3", "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.2 check all +uvx zenzic@0.26.3 check all ``` --- diff --git a/RELEASE.md b/RELEASE.md index 77458ca..89cd032 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,7 +8,7 @@ | Field | Value | | :------- | :--------- | -| Version | v0.26.2 | +| Version | v0.26.3 | | Codename | Magnetite | | Date | 2026-07-28 | | Status | Stable | @@ -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.2`) +- [ ] `pyproject.toml` version matches the tag (`0.26.3`) - [ ] `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.2" v0.26.2 +git tag -s -m "Release v0.26.3" v0.26.3 git push origin main --tags ``` -- [ ] Create GitHub Release from the tag, using the `## [0.26.2]` CHANGELOG section as the release body. +- [ ] Create GitHub Release from the tag, using the `## [0.26.3]` CHANGELOG section as the release body. ## Changelog Reference diff --git a/mkdocs.yml b/mkdocs.yml index e670da7..be651d0 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.2" # release sync + zenzic_version: "0.26.3" # release sync social: - icon: fontawesome/brands/github link: https://github.com/PythonWoods/zenzic diff --git a/pyproject.toml b/pyproject.toml index e0e9901..0ca0942 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "hatchling.build" [project] name = "zenzic" -version = "0.26.2" +version = "0.26.3" 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 06f2b39..bf5bfc0 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.2" +__version__ = "0.26.3" __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 2179f4a..593bb6d 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.2"] +dependencies = ["zenzic>=0.26.3"] [project.entry-points."zenzic.rules"] {project_slug} = "{module_name}.rules:{class_name}" diff --git a/uv.lock b/uv.lock index 8f56418..7ab745b 100644 --- a/uv.lock +++ b/uv.lock @@ -2465,7 +2465,7 @@ wheels = [ [[package]] name = "zenzic" -version = "0.26.2" +version = "0.26.3" source = { editable = "." } dependencies = [ { name = "google-re2" },