diff --git a/.bumpversion.toml b/.bumpversion.toml index 9d99e87d..adca6a64 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 [tool.bumpversion] -current_version = "0.25.2" +current_version = "0.25.3" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)((?Pa|b|rc)(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}{pre_l}{pre_n}", @@ -72,8 +72,8 @@ replace = "- [ ] `pyproject.toml` version matches the tag (`{new_version}`)" [[tool.bumpversion.files]] filename = "RELEASE.md" -search = 'git tag -s -S -m "Release v{current_version}" v{current_version}' -replace = 'git tag -s -S -m "Release v{new_version}" v{new_version}' +search = 'git tag -s -m "Release v{current_version}" v{current_version}' +replace = 'git tag -s -m "Release v{new_version}" v{new_version}' [[tool.bumpversion.files]] diff --git a/.github/ISSUE_TEMPLATE/security_vulnerability.yml b/.github/ISSUE_TEMPLATE/security_vulnerability.yml index bed90689..bc75651d 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.25.2" + placeholder: "0.25.3" validations: required: true diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 82e83427..f757eaf0 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -7,7 +7,7 @@ # # repos: # - repo: https://github.com/PythonWoods/zenzic -# rev: v0.25.2 +# rev: v0.25.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 998fafc0..6f7fd494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,20 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.25.3] - 2026-07-26 + +### Added + +- **AST Reference Link Definition Extraction (`CORE-FIX-001`)**: Upgraded `PolyglotExtractor` to natively extract Markdown Reference Link Definitions (`[label]: dest`) with CommonMark §4.7 first-definition-wins semantics and full code fence masking (`_mask_fences`), eliminating false-positive `Z405` (Unused Asset) findings. +- **Zensical Framework Feature Parity (`ADAPTER-002`/`ADAPTER-003`)**: Upgraded `ZensicalAdapter` with preserved static asset URL mapping, route classification for root/directory index pages (`index.md`) and dynamic blog routes (`/posts/`), and theme asset metadata extraction (`favicon`, `logo`, `extra_css`, `extra_javascript`). + + ## [0.25.2] - 2026-07-26 ### Fixed - **Path Traversal Security Parity (`LSP-FIX-012`)**: Hardened `Z202`/`Z203` path traversal evaluation in `IncrementalAnalysisEngine._run_urp_checks()` by replacing absolute OS filesystem depth checks (`len(path.parents)`) with `docs_root`-boundary escape detection (`os.path.normpath` + `is_relative_to(resolved_docs_root)`). This guarantees 100% security parity between CLI and LSP mode. - ## [0.25.1] - 2026-07-26 ### Added @@ -29,7 +36,6 @@ Versions follow [Semantic Versioning](https://semver.org/). - **Inline HTML Suppression LSP Parity (`LSP-FIX-011`)**: Updated `IncrementalAnalysisEngine._run_urp_checks()` to pass the active `SuppressionTracker` when evaluating Polyglot Extractor nodes. Suppressed HTML elements (`data-zenzic-ignore`) are now correctly marked as consumed, eliminating spurious `Z603` (Dead Suppression) warnings in editor sessions. - ## [0.25.0] - 2026-07-25 ### Added diff --git a/CITATION.cff b/CITATION.cff index 4ed450f3..a9b132ce 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.25.2 +version: 0.25.3 date-released: 2026-07-26 url: "https://zenzic.dev" repository-code: "https://github.com/PythonWoods/zenzic" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ecaf6555..d6f45dd9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ Zenzic is structured across three independent, dedicated repositories: **If you want to contribute to the core analysis engine** (new checks, adapters, bug fixes, CLI features, or performance improvements) — you are in the right place! -> **Brand System** — The visual identity and color palette reference live at +> **Brand System** — The visual identity and color palette reference live at > --- diff --git a/README.md b/README.md index 214c27c3..53d50833 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.25.2", + "version": "0.25.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.25.2 check all +uvx zenzic@0.25.3 check all ``` --- diff --git a/RELEASE.md b/RELEASE.md index b3245dc4..c53d01cb 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,7 +8,7 @@ | Field | Value | | :------- | :--------- | -| Version | v0.25.2 | +| Version | v0.25.3 | | Codename | Magnetite | | Date | 2026-07-26 | | 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.25.2`) +- [ ] `pyproject.toml` version matches the tag (`0.25.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 -S -m "Release v0.25.2" v0.25.2 +git tag -s -m "Release v0.25.3" v0.25.3 git push origin main --tags ``` -- [ ] Create GitHub Release from the tag, using the `## [0.25.2]` CHANGELOG section as the release body. +- [ ] Create GitHub Release from the tag, using the `## [0.25.3]` CHANGELOG section as the release body. ## Changelog Reference diff --git a/docs/blog/posts/2026-07-25-zenzic-v0250-lsp-stabilization.md b/docs/blog/posts/2026-07-25-zenzic-v0250-lsp-stabilization.md index 129604d7..78554832 100644 --- a/docs/blog/posts/2026-07-25-zenzic-v0250-lsp-stabilization.md +++ b/docs/blog/posts/2026-07-25-zenzic-v0250-lsp-stabilization.md @@ -69,11 +69,13 @@ class BaseAdapter(ABC): If you maintain a custom third-party adapter subclassing `BaseAdapter`: 1. Update your class definition to override `watched_config_files`: + ```python @property def watched_config_files(self) -> frozenset[str]: return frozenset({"my-framework.config.json"}) ``` + 2. First-party adapters (`MkDocsAdapter`, `ZensicalAdapter`) have been updated natively. --- diff --git a/docs/developers/how-to/contribute/index.md b/docs/developers/how-to/contribute/index.md index 718837a5..7ce144c8 100644 --- a/docs/developers/how-to/contribute/index.md +++ b/docs/developers/how-to/contribute/index.md @@ -80,7 +80,6 @@ In this section, we guide you through our processes. - [report a bug]: report-a-bug.md [report a docs issue]: report-a-docs-issue.md [request a change]: request-a-change.md diff --git a/docs/developers/how-to/contribute/report-a-bug.md b/docs/developers/how-to/contribute/report-a-bug.md index 395e3f66..9833606a 100644 --- a/docs/developers/how-to/contribute/report-a-bug.md +++ b/docs/developers/how-to/contribute/report-a-bug.md @@ -72,7 +72,6 @@ the issue. | :material-close: __Unclear__ | Anchor validation doesn't work | | :material-close: __Useless__ | Help | - ### Context optional {#context} Provide additional context to help us understand what you were trying to diff --git a/docs/developers/reference/ast-foundations.md b/docs/developers/reference/ast-foundations.md index 57b3489c..c3402eeb 100644 --- a/docs/developers/reference/ast-foundations.md +++ b/docs/developers/reference/ast-foundations.md @@ -17,23 +17,23 @@ Zenzic replaces text matching with a **Lossless Abstract Syntax Tree (AST) Compi
-- :material-text-search:{ .lg .middle } **Unstructured Text Linter** +- :material-text-search:{ .lg .middle } **Unstructured Text Linter** --- - - Treats Markdown as plain text regex lines - - Cannot verify cross-file link targets or heading anchors - - Fails to track directory indexes or navigation topology - - Produces false positives and non-reproducible runs + - Treats Markdown as plain text regex lines + - Cannot verify cross-file link targets or heading anchors + - Fails to track directory indexes or navigation topology + - Produces false positives and non-reproducible runs -- :material-graph-outline:{ .lg .middle } **Zenzic Topological Graph Engine** +- :material-graph-outline:{ .lg .middle } **Zenzic Topological Graph Engine** --- - - Compiles Lossless ASTs and computes a Directed Graph (VSM) - - Verifies link targets, anchor references, and nav contracts statically - - Enforces hard security boundaries (SAST credential scanner) - - Delivers 100% bit-for-bit deterministic execution across platforms + - Compiles Lossless ASTs and computes a Directed Graph (VSM) + - Verifies link targets, anchor references, and nav contracts statically + - Enforces hard security boundaries (SAST credential scanner) + - Delivers 100% bit-for-bit deterministic execution across platforms
diff --git a/docs/developers/reference/zenzic-style.md b/docs/developers/reference/zenzic-style.md index 873fbe43..fc06d844 100644 --- a/docs/developers/reference/zenzic-style.md +++ b/docs/developers/reference/zenzic-style.md @@ -67,6 +67,7 @@ Every icon in the documentation MUST be rendered using native Material for MkDoc ``` Examples: + - `:material-bug-outline:` - `:material-file-document-edit-outline:` - `:material-sparkles:` @@ -79,7 +80,6 @@ Examples: - **Semantic consistency**: If an icon represents "Contribute" on one page, it must be the same icon on every page. - **Uniform syntax**: Every icon in a card grid uses native `:material-*: / :octicons-*:` shortcodes. - --- ## 4. Anchor ID Protocol (ZRT-DOC-004) {#anchor-ids} diff --git a/docs/editor/vscode.md b/docs/editor/vscode.md index ac09dbfe..44c0e44c 100644 --- a/docs/editor/vscode.md +++ b/docs/editor/vscode.md @@ -57,7 +57,6 @@ If you use a custom virtual environment or isolated installation, configure `zen } ``` - ### Supported Settings | Setting | Type | Default | Description | diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 8de2a230..bbc13fe6 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -74,7 +74,6 @@ flowchart TD !!! note "ADR-084 Invariant: Centralized Core Governance" `directory_policies` and `per_file_ignores` are evaluated centrally in `zenzic.core.governance` to ensure bit-for-bit parity between CLI and LSP verification suites. - ### Pass 1 -- Harvest and Credential Scan {#pass-1} Pass 1 reads every `.md` and `.md` file under `docs/` and performs three coordinated operations: diff --git a/docs/explanation/community-index.md b/docs/explanation/community-index.md index fd085bfb..8c16e213 100644 --- a/docs/explanation/community-index.md +++ b/docs/explanation/community-index.md @@ -61,7 +61,6 @@ ecosystem tools referenced in this documentation are third-party projects. - ## License {#license} diff --git a/docs/how-to/configure-ci-cd.md b/docs/how-to/configure-ci-cd.md index 071232fd..70747a76 100644 --- a/docs/how-to/configure-ci-cd.md +++ b/docs/how-to/configure-ci-cd.md @@ -61,7 +61,6 @@ Zenzic enforces a non-negotiable exit code contract across all operating systems === "GitHub Action Wrapper (Recommended)" - The official [`PythonWoods/zenzic-action`](https://github.com/PythonWoods/zenzic-action) provides zero-config integration with automatic SARIF upload: ```yaml title=".github/workflows/zenzic.yml" diff --git a/docs/how-to/install.md b/docs/how-to/install.md index f42e7574..5244534b 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -82,7 +82,6 @@ environment. They are not needed. The linting environment has one dependency: `z uvx zenzic check all ``` - !!! note "Third-party engine adapters" Third-party adapters (e.g. a hypothetical `zenzic-hugo-adapter`) are separate installable packages — not extras of `zenzic` itself. No extra is required for diff --git a/docs/index.md b/docs/index.md index 7ca0ab9f..386b06c5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,7 +12,7 @@ description: "Zenzic is a deterministic document integrity engine and SAST for M -## Treat your Markdown documentation like production code. +## Treat your Markdown documentation like production code Zenzic detects broken links, orphaned pages, credential leaks, and structural integrity issues before they reach production. Every run is bit-for-bit deterministic, zero-LLM, and backed by a non-backtracking RE2 regex engine. @@ -26,7 +26,6 @@ Zenzic is structured into three dedicated delivery mechanisms to support your en - **[VS Code Extension](https://github.com/PythonWoods/zenzic-vscode)**: Real-time LSP client providing sub-50ms inline diagnostics, automated Quick Fixes, and DQS status bar streaming. - **[GitHub Action](https://github.com/PythonWoods/zenzic-action)**: Zero-config CI/CD quality gate with SARIF upload directly to GitHub Code Scanning and PR annotations. - --- ## 🚀 Deterministic 3-Step Quickstart (< 60 Seconds) @@ -59,13 +58,17 @@ FAILED: Hard errors detected. Exit code 1 is mandatory. ## 🛡️ Why Zenzic? ### 100% Determinism + Every Zenzic run is a pure function of its inputs. Given the same repository state and `.zenzic.toml`, the output — finding codes, severity levels, exit code, SARIF structure — is **bit-for-bit identical** across machines, platforms, and time. No probabilistic guessing, no LLM sampling, no network dependencies. ### Documentation Security (SAST) + Zenzic treats documentation as a **security surface**. The tiered code model enforces a hard boundary between quality findings (suppressible, exit 1) and security findings (non-suppressible, exit 2/3): + - **Z201 — Credential Scanner:** Hardcoded tokens, API keys, and secret patterns detected before reaching PRs. - **Z202 / Z203 — Path Traversal Guard:** Filesystem boundary security violations caught at scan boundaries. - **Suppression CAP:** Configurable ceiling on total active `zenzic:ignore` suppressions. ### Zero Hallucinations + Zenzic reports only what is **statically verifiable** in the repository at scan time. Every finding is a falsifiable, reproducible fact — suitable as audit evidence for security reviewers and compliance teams. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7ee10ea6..e5215eee 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -128,7 +128,6 @@ Select a command tab to view its execution flags, default behaviors, and usage e ## Shared Execution Flags - ## Global flags {#global-flags} These flags control Zenzic's signal-to-noise profile across routine scans, CI gates, diff --git a/docs/reference/suppression-policy.md b/docs/reference/suppression-policy.md index 9187ea6f..6f131d3d 100644 --- a/docs/reference/suppression-policy.md +++ b/docs/reference/suppression-policy.md @@ -19,23 +19,23 @@ Zenzic replaces unmonitored ignore tags with a **Managed Technical Debt Governan
-- :material-shield-check:{ .lg .middle } **Managed Technical Debt (Clean/Bounded)** +- :material-shield-check:{ .lg .middle } **Managed Technical Debt (Clean/Bounded)** --- - - Explicitly declared exceptions with audit trails - - Bounded by the strict `suppression_cap` ceiling - - Emits `[MANAGED DEBT]` audit status in CLI & CI - - Deducts Quality Score points to reflect true visibility + - Explicitly declared exceptions with audit trails + - Bounded by the strict `suppression_cap` ceiling + - Emits `[MANAGED DEBT]` audit status in CLI & CI + - Deducts Quality Score points to reflect true visibility -- :material-alert-decagram:{ .lg .middle } **Uncontrolled Architectural Drift** +- :material-alert-decagram:{ .lg .middle } **Uncontrolled Architectural Drift** --- - - Unmonitored ignore tags scattered across codebases - - Hidden security vulnerabilities (credentials, path traversals) - - Zero audit trails or visibility into debt growth - - Causes silent PR breakages and customer-facing 404s + - Unmonitored ignore tags scattered across codebases + - Hidden security vulnerabilities (credentials, path traversals) + - Zero audit trails or visibility into debt growth + - Causes silent PR breakages and customer-facing 404s
diff --git a/mkdocs.yml b/mkdocs.yml index c85fe8cc..a837998e 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.25.2" # release sync + zenzic_version: "0.25.3" # release sync social: - icon: fontawesome/brands/github link: https://github.com/PythonWoods/zenzic diff --git a/pyproject.toml b/pyproject.toml index f75ba28f..fc626cc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "hatchling.build" [project] name = "zenzic" -version = "0.25.2" +version = "0.25.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 5522a668..ed4eaf39 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.25.2" +__version__ = "0.25.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 0470d554..b5765280 100644 --- a/src/zenzic/cli/_standalone.py +++ b/src/zenzic/cli/_standalone.py @@ -1588,7 +1588,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.25.2"] +dependencies = ["zenzic>=0.25.3"] [project.entry-points."zenzic.rules"] {project_slug} = "{module_name}.rules:{class_name}" diff --git a/src/zenzic/core/adapters/_zensical.py b/src/zenzic/core/adapters/_zensical.py index c4d6e58e..7e65a8c1 100644 --- a/src/zenzic/core/adapters/_zensical.py +++ b/src/zenzic/core/adapters/_zensical.py @@ -84,39 +84,80 @@ def _load_zensical_config(repo_root: Path) -> dict[str, Any]: return {} -# ── Infrastructure asset path extraction (Z404) ────────────────────────────── +# ── Infrastructure asset path extraction (Z404 & Z405) ────────────────────── _IMAGE_EXT_RE_ZENSICAL = re.compile(r"\.(?:png|jpg|jpeg|svg|gif|ico|webp)$", re.IGNORECASE) +def _extract_config_declared_assets(doc_config: dict[str, Any]) -> set[str]: + """Extract theme favicon, logo, extra_css, extra_javascript relative asset paths.""" + assets: set[str] = set() + + # Native zensical.toml format + project = doc_config.get("project") or {} + if isinstance(project, dict): + for key in ("favicon", "logo"): + val = project.get(key) + if val and isinstance(val, str) and not val.startswith(("http://", "https://")): + assets.add(val.lstrip("/")) + + # mkdocs.yml format (compat mode) + theme = doc_config.get("theme") or {} + if isinstance(theme, dict): + for key in ("favicon", "logo"): + val = theme.get(key) + if val and isinstance(val, str) and not val.startswith(("http://", "https://")): + assets.add(val.lstrip("/")) + + for key in ("extra_css", "extra_javascript"): + items = doc_config.get(key) or [] + if isinstance(items, list): + for item in items: + if isinstance(item, str) and not item.startswith(("http://", "https://")): + assets.add(item.lstrip("/")) + + return assets + + def check_config_assets(repo_root: Path) -> list[tuple[str, str]]: - """Check that theme assets declared in ``zensical.toml`` exist on disk. + """Check that theme assets declared in ``zensical.toml`` or ``mkdocs.yml`` exist on disk. - Checks ``[project].favicon`` and ``[project].logo`` (file-path values only). - Both fields are resolved relative to ``[project].docs_dir`` (default: ``docs/``). + Checks ``favicon`` and ``logo`` (file-path values only). + Both fields are resolved relative to ``docs_dir`` (default: ``docs/``). Args: - repo_root: Repository root (parent of ``zensical.toml``). + repo_root: Repository root (parent of config file). Returns: List of ``(rel_path, message)`` tuples for each missing asset. Empty list when all referenced assets exist or the config is absent. """ - zensical_cfg = _load_zensical_config(repo_root) - if not zensical_cfg: - return [] - - project = zensical_cfg.get("project") or {} - if not isinstance(project, dict): + config_file = find_zensical_config(repo_root) + if config_file is not None: + cfg = _load_zensical_config(repo_root) + config_src = "zensical" + elif find_mkdocs_config_file(repo_root) is not None: + cfg = load_mkdocs_config(repo_root) + config_src = "mkdocs" + else: return [] - docs_dir = str(project.get("docs_dir") or "docs") - docs_root = repo_root / docs_dir + if config_src == "zensical": + project = cfg.get("project") or {} + docs_dir = str(project.get("docs_dir") or "docs") if isinstance(project, dict) else "docs" + docs_root = repo_root / docs_dir + theme_dict = project if isinstance(project, dict) else {} + else: + docs_dir = str(cfg.get("docs_dir") or "docs") + docs_root = repo_root / docs_dir + theme_dict = cfg.get("theme") or {} + if not isinstance(theme_dict, dict): + theme_dict = {} issues: list[tuple[str, str]] = [] for field_key in ("favicon", "logo"): - value = project.get(field_key) + value = theme_dict.get(field_key) if not value or not isinstance(value, str): continue if not _IMAGE_EXT_RE_ZENSICAL.search(value): @@ -128,7 +169,7 @@ def check_config_assets(repo_root: Path) -> list[tuple[str, str]]: ( rel, f"{field_key} asset not found on disk: '{rel}' " - f"(declared as [project].{field_key}: '{value}' in zensical.toml) [Z404]", + f"(declared as {field_key}: '{value}') [Z404]", ) ) @@ -141,6 +182,7 @@ def _extract_nav_paths(items: object) -> set[str]: Handles nav variants used by both ``zensical.toml`` and ``mkdocs.yml``: * Plain string: ``"page.md"`` + * Directory entry: ``"section/"`` → expands to ``"section/index.md"`` * Titled page: ``{"Title" = "page.md"}`` * Section: ``{"Section" = ["page.md", …]}`` * External URL: ``{"GitHub" = "https://…"}`` — skipped. @@ -155,6 +197,8 @@ def _extract_nav_paths(items: object) -> set[str]: if isinstance(items, str): if items.endswith(".md"): paths.add(items.lstrip("/")) + elif items.endswith("/"): + paths.add(f"{items.lstrip('/')}index.md") return paths if isinstance(items, dict): @@ -169,6 +213,21 @@ def _extract_nav_paths(items: object) -> set[str]: return paths +def _extract_blog_dir_zensical(doc_config: dict[str, Any]) -> str | None: + """Extract blog posts prefix when a blog plugin is active in Zensical/MkDocs config.""" + plugins = doc_config.get("plugins") or [] + if isinstance(plugins, list): + for item in plugins: + if isinstance(item, str) and item in ("blog", "material/blog"): + return "blog/posts" + elif isinstance(item, dict): + for name, cfg in item.items(): + if name in ("blog", "material/blog"): + blog_dir = cfg.get("blog_dir", "blog") if isinstance(cfg, dict) else "blog" + return f"{blog_dir.strip('/')}/posts" + return None + + # ── Adapter ─────────────────────────────────────────────────────────────────── @@ -178,27 +237,6 @@ class ZensicalAdapter(BaseAdapter): The adapter can be constructed from native ``zensical.toml`` config or from ``mkdocs.yml`` input while preserving Zensical routing/classification logic. Navigation is read from ``[project].nav`` (native) or ``nav`` (compat). - - Native ``zensical.toml`` example:: - - [project] - site_name = "My Docs" - nav = [ - "index.md", - {"Guide" = "guide.md"}, - {"API" = ["api/index.md", {"Endpoints" = "api/endpoints.md"}]}, - ] - - Locale information is sourced from ``[build_context]`` in ``.zenzic.toml`` - (the :class:`~zenzic.models.config.BuildContext`). Zensical does not yet - expose its own i18n configuration in ``zensical.toml``; when it does, this - adapter will be updated to read it directly. - - Args: - context: Build context from ``.zenzic.toml``. - docs_root: Resolved absolute path to the ``docs/`` directory. - zensical_config: Parsed config payload from the active input source. - config_source: Internal source marker (``"zensical"`` or ``"mkdocs"``). """ def __init__( @@ -230,6 +268,8 @@ def __init__( self._nav_paths: frozenset[str] = frozenset(_extract_nav_paths(_raw_nav)) # True only when the user supplied an explicit, non-empty nav list. self._has_explicit_nav: bool = bool(_raw_nav) + self._blog_posts_prefix: str | None = _extract_blog_dir_zensical(self._zensical_config) + # Offline Mode Tactical Fix if context.offline_mode: self._use_directory_urls = False @@ -256,20 +296,7 @@ def resolve_anchor( anchors_cache: dict[Path, set[str]], docs_root: Path, ) -> bool: - """Return ``True`` if an anchor miss should be suppressed via i18n fallback. - - Locale configuration is sourced from ``BuildContext`` (``.zenzic.toml``). - - Args: - resolved_file: Absolute path of the locale file whose anchor set - does not contain *anchor*. - anchor: Fragment identifier that was not found (without ``#``). - anchors_cache: Pre-built ``Path`` → anchor slug set mapping. - docs_root: Resolved absolute ``docs/`` root. - - Returns: - ``True`` if the anchor exists in the default-locale equivalent file. - """ + """Return ``True`` if an anchor miss should be suppressed via i18n fallback.""" if not self._fallback_to_default: return False default_file = remap_to_default_locale(resolved_file, docs_root, self._locale_dirs) @@ -295,10 +322,11 @@ def has_engine_config(self) -> bool: return True def get_metadata_files(self) -> frozenset[str]: - """Engine configuration files excluded from Z903.""" - if self._config_source == "mkdocs": - return frozenset({"mkdocs.yml"}) - return frozenset({"zensical.toml"}) + """Engine configuration and infrastructure asset files excluded from Z405/Z903.""" + names: set[str] = {"mkdocs.yml"} if self._config_source == "mkdocs" else {"zensical.toml"} + config_assets = _extract_config_declared_assets(self._zensical_config) + names.update(config_assets) + return frozenset(names) @property def watched_config_files(self) -> frozenset[str]: @@ -312,32 +340,14 @@ def watched_config_files(self) -> frozenset[str]: def _map_url(self, rel: Path) -> str: """Map a physical source path to its Zensical canonical URL. - Zensical always serves clean directory-style URLs. Both ``index.md`` - and ``README.md`` collapse to the parent directory URL (Zensical treats - ``README.md`` as an implicit index). - - With ``use_directory_urls = true`` (default):: - - page.md → /page/ - dir/index.md → /dir/ - dir/README.md → /dir/ (same URL → CONFLICT if both exist) - index.md → / - - With ``use_directory_urls = false``:: - - page.md → /page.html - dir/index.md → /dir/index.html - index.md → /index.html - - Files inside ``_private``-prefixed path segments are mapped normally - here; ``_classify_route()`` marks them ``IGNORED``. + Non-Markdown asset files (e.g. ``.png``, ``.webp``, ``.svg``) preserve + their exact relative path and file extension. + """ + from zenzic.core.discovery import DOC_SUFFIXES - Args: - rel: Path of the source file relative to ``docs_root``. + if rel.suffix.lower() not in DOC_SUFFIXES: + return "/" + rel.as_posix() - Returns: - Canonical URL string with leading slash. - """ if not self._use_directory_urls: # Flat URL mode: preserve suffix, no directory collapsing. return "/" + rel.as_posix() @@ -353,28 +363,36 @@ def _map_url(self, rel: Path) -> str: return "/" + "/".join(parts) + "/" def _classify_route(self, rel: Path, nav_paths: frozenset[str]) -> RouteStatus: - """Classify a Zensical route by filesystem and nav rules. - - Priority chain: - - 1. ``IGNORED`` — any path segment starts with ``_``. - 2. ``ORPHAN_BUT_EXISTING`` — an explicit ``[project].nav`` is defined - in ``zensical.toml`` and *rel* is not listed in it. The file is - served (Zensical is filesystem-based) but is not sidebar-navigable. - 3. ``REACHABLE`` — all other files when no explicit nav is declared. + """Classify a Zensical route by filesystem, asset type, and nav rules.""" + from zenzic.core.discovery import DOC_SUFFIXES - Args: - rel: Source path relative to ``docs_root``. - nav_paths: Frozenset of nav-listed paths from ``get_nav_paths()``. + rel_posix = rel.as_posix() - Returns: - ``'IGNORED'``, ``'ORPHAN_BUT_EXISTING'``, or ``'REACHABLE'``. - """ + # 1. Private directory segments starting with '_' if any(part.startswith("_") for part in rel.parts): return "IGNORED" - if self._has_explicit_nav and rel.as_posix() not in nav_paths: - return "ORPHAN_BUT_EXISTING" - return "REACHABLE" + + # 2. Non-Markdown static assets are served directly — always REACHABLE + if rel.suffix.lower() not in DOC_SUFFIXES: + return "REACHABLE" + + # 3. Root/directory index pages (index.md or README.md at root) + if rel_posix in ("index.md", "README.md"): + return "REACHABLE" + + # 4. Listed in nav or locale shadow + if ( + not self._has_explicit_nav + or rel_posix in nav_paths + or self.is_shadow_of_nav_page(rel, nav_paths) + ): + return "REACHABLE" + + # 5. Blog plugin dynamic routes + if self._blog_posts_prefix and rel_posix.startswith(f"{self._blog_posts_prefix}/"): + return "REACHABLE" + + return "ORPHAN_BUT_EXISTING" def get_nav_paths(self) -> frozenset[str]: """Return ``.md`` paths from ``[project].nav`` in ``zensical.toml``. diff --git a/src/zenzic/core/incremental.py b/src/zenzic/core/incremental.py index 0bea14a1..7fdd5256 100644 --- a/src/zenzic/core/incremental.py +++ b/src/zenzic/core/incremental.py @@ -30,6 +30,7 @@ from __future__ import annotations +import os from pathlib import Path from typing import TYPE_CHECKING from urllib.parse import unquote, urlsplit diff --git a/src/zenzic/core/scanner.py b/src/zenzic/core/scanner.py index ca81bdc2..ba840e6e 100644 --- a/src/zenzic/core/scanner.py +++ b/src/zenzic/core/scanner.py @@ -333,6 +333,9 @@ def _visible_word_count(text: str) -> int: def check_asset_references(text: str, page_dir: str = "") -> set[str]: """Pure function: extract normalised asset paths referenced in markdown text. + Delegates AST Reference Link Definition parsing ([label]: dest) and HTML node + extraction to PolyglotExtractor. + Args: text: Raw markdown content. page_dir: POSIX directory of the page relative to docs root (e.g. ``"guide"``). @@ -341,7 +344,34 @@ def check_asset_references(text: str, page_dir: str = "") -> set[str]: Returns: Set of normalised asset paths relative to docs root. """ + from zenzic.core.validator import PolyglotExtractor + + extractor = PolyglotExtractor() referenced: set[str] = set() + + # 1. AST Reference Link Definitions ([label]: dest) from PolyglotExtractor + for ref_node in extractor.extract_ref_defs(text): + url = ref_node.dest + if not url or url.startswith(("http://", "https://", "data:", "#")): + continue + clean_url = unquote(url.split("?")[0].split("#")[0]) + base = page_dir if page_dir else "." + normalized = posixpath.normpath(posixpath.join(base, clean_url)) + if not normalized.startswith(".."): + referenced.add(normalized) + + # 2. Native HTML tags (, ) from PolyglotExtractor + for html_node in extractor.extract(text): + html_url: str | None = html_node.href + if not html_url or html_url.startswith(("http://", "https://", "data:", "#")): + continue + clean_url = unquote(html_url.split("?")[0].split("#")[0]) + base = page_dir if page_dir else "." + normalized = posixpath.normpath(posixpath.join(base, clean_url)) + if not normalized.startswith(".."): + referenced.add(normalized) + + # 3. Standard inline markdown links [text](url) for match in _MARKDOWN_ASSET_LINK_RE.finditer(text): url = match.group(1) or match.group(2) or match.group(3) if not url or url.startswith(("http://", "https://", "data:", "#")): @@ -497,6 +527,8 @@ def find_unused_assets( if any(part in config.excluded_asset_dirs for part in rel_path.parts): continue rel_posix = rel_path.as_posix() + if rel_posix in adapter_metadata_files or rel_path.name in adapter_metadata_files: + continue if any(fnmatch.fnmatch(rel_posix, pat) for pat in config.excluded_build_artifacts): continue all_assets.add(rel_posix) diff --git a/src/zenzic/core/validator.py b/src/zenzic/core/validator.py index 31ffb9a8..0637f928 100644 --- a/src/zenzic/core/validator.py +++ b/src/zenzic/core/validator.py @@ -270,11 +270,21 @@ class HtmlNodeInfo: raw_tag: str +@dataclass +class ReferenceLinkNode: + """Nodo estratto dal PolyglotExtractor per una definizione di link di riferimento ([label]: dest).""" + + label: str + dest: str + line_no: int + raw: str + + class PolyglotExtractor: - """Estrattore a due stadi per tag HTML nativi in sorgente Markdown/MDX. + """Estrattore a due stadi per tag HTML nativi e definizioni di riferimento Markdown. Implementa la **Uniform Resolver Pipeline** (URP) di Zenzic v0.17.0: - la forma sintattica (Markdown vs HTML) è un dettaglio di trasporto; + la forma sintattica (Markdown vs HTML vs Reference Defs) è un dettaglio di trasporto; l'analisi avviene sul valore risolto del puntamento. **Invarianti (ADR-075 / ADR-020):** @@ -282,8 +292,7 @@ class PolyglotExtractor: * Complessità O(N): RE2/DFA-pure, nessun backtracking, nessun subprocess. * Z205 (FORBIDDEN_SCHEME) è verificato **prima** di ``data-zenzic-ignore`` (sicurezza ha precedenza assoluta sulla soppressione). - * Supporta tag ```` e ````. - * Il carattere ``>`` non è ammesso nei valori degli attributi (chiude il tag). + * Supporta tag ````, ```` e definizioni di riferimento Markdown (CommonMark §4.7). * Fence-skipping obbligatorio: i blocchi ``code``/``pre`` vengono oscurati prima dell'estrazione per evitare falsi positivi in esempi di codice. """ @@ -308,6 +317,39 @@ def extract(self, text: str) -> list[HtmlNodeInfo]: nodes.append(self._parse_node(tag, attrs_str, line_no, m.group(0))) return nodes + def extract_ref_defs(self, text: str) -> list[ReferenceLinkNode]: + """Estrae tutte le definizioni di link di riferimento ([label]: dest) fuori dai blocchi di codice. + + Implementa CommonMark §4.7 Reference Link Definition parsing via PolyglotExtractor. + Fence-skipping obbligatorio tramite _mask_fences() e _mask_comments(). + First-definition-wins per la risoluzione dei duplicati. + """ + masked = self._mask_fences(self._mask_comments(text)) + nodes: list[ReferenceLinkNode] = [] + seen_labels: set[str] = set() + + for lineno, line in enumerate(masked.splitlines(), start=1): + m = _REF_DEF_RE.match(line) + if not m: + continue + label = m.group(1) + if label.startswith("^"): + continue + norm_label = label.lower().strip() + if norm_label in seen_labels: + continue + seen_labels.add(norm_label) + dest = m.group(2).strip() + nodes.append( + ReferenceLinkNode( + label=norm_label, + dest=dest, + line_no=lineno, + raw=line, + ) + ) + return nodes + 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) diff --git a/uv.lock b/uv.lock index d1c27682..23f2c899 100644 --- a/uv.lock +++ b/uv.lock @@ -2465,7 +2465,7 @@ wheels = [ [[package]] name = "zenzic" -version = "0.25.2" +version = "0.25.3" source = { editable = "." } dependencies = [ { name = "google-re2" },