From fbd240a6bdb95ddc0a8f96aedef389de5eedde17 Mon Sep 17 00:00:00 2001 From: PythonWoods Date: Tue, 28 Jul 2026 10:39:50 +0200 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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) ──────────────────────────────────────────────────────