diff --git a/.bumpversion.toml b/.bumpversion.toml index eae35669..3f696e43 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 [tool.bumpversion] -current_version = "0.26.0" +current_version = "0.26.1" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)((?Pa|b|rc)(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}{pre_l}{pre_n}", diff --git a/.draft/OrchestrationPrompts/Roadmaps/0. Priority Table.md b/.draft/OrchestrationPrompts/Roadmaps/0. Priority Table.md deleted file mode 100644 index 07fbf4a1..00000000 --- a/.draft/OrchestrationPrompts/Roadmaps/0. Priority Table.md +++ /dev/null @@ -1,73 +0,0 @@ -# Zenzic Roadmap & Prompt Priority Table - -| Stato | Priorità | Prompt | Feature | Motivazione architetturale | -| :--- | :--- | ---: | :--- | :--- | -| `[x]` | **P0** | Prompt 1 | **Zenzic Language Server (ZLS) & VS Code Integration** | Fondamento della strategia "Shift-Left". Senza ZLS stabile, le funzionalità IDE successive non hanno una base affidabile. (Rilasciato in v0.23.x). | -| `[x]` | **P0** | Prompt 10 | **Incremental Analysis Engine** | Necessario per rendere ZLS réellement utilizzabile. Riduce il costo delle analisi incrementali e preserva il requisito di latenza sub-50ms. (Rilasciato in v0.23.x). | -| `[x]` | **P1** | Prompt 7 | **VS Code Code Actions (Quick Fixes)** | Completa il ciclo diagnostica → remediation. Abilita Quick Fixes deterministici via `textDocument/codeAction` e `WorkspaceEdit` in-memory. (Rilasciato in v0.24.0). | -| `[x]` | **P1** | Prompt 8 | **DQS Workspace UI** | Porta la qualità globale nell'ambiente autore tramite un bridge asincrono deterministico CLI (`zenzic score --json`) e visualizzazione nella Status Bar di VS Code. (Completata in v0.26.0). | -| `[x]` | **P0** | — | **LSP Stabilization & Hot-Reloading** | Parità di governance CLI-IDE e hot-reloading guidato da `watched_config_files` dell'Adapter. (Rilasciato in v0.25.0). | -| `[ ]` | **P0** | Prompt 6 | **Smart Link Graph** | Completa il modello VSM trasformandolo da sistema di risoluzione a motore di analisi topologica completa. Abilita rilevamento di isole, cicli e struttura documentale. | -| `[ ]` | **P0** | Prompt 15 | **Baseline & Regression Tracking** | Introduce il controllo evolutivo della qualità. Indispensabile per CI enterprise e per impedire regressioni DQS. | -| `[ ]` | **P1** | Prompt 2 | **Semantic Linting & Readability Metrics** | Estende Zenzic oltre la validazione strutturale verso la qualità del contenuto mantenendo il paradigma deterministic static analysis. | -| `[ ]` | **P1** | Prompt 16 | **Configuration Validation Engine** | Riduce errori operativi e diventa la sorgente unica per CLI, VS Code e documentazione tramite Mirror Law. | -| `[ ]` | **P1** | Prompt 14 | **Policy-as-Code Engine** | Formalizza la governance. Trasforma configurazioni sparse e ADR in un modello dichiarativo verificabile. | -| `[ ]` | **P2** | Prompt 3 | **Docusaurus Bridge Architecture** | Prima implementazione concreta dell'ecosistema adapter. Valida il modello VSM artifact-based fuori dal Core. | -| `[ ]` | **P2** | Prompt 4 | **Sphinx Adapter** | Estende la compatibilità open-source. Deve seguire la stabilizzazione del contratto BaseAdapter. | -| `[ ]` | **P2** | Prompt 5 | **Hugo Adapter** | Analogo allo Sphinx Adapter. Priorità inferiore perché dipende dall'efficacia del modello adapter. | -| `[ ]` | **P2** | Prompt 12 | **Custom Rule SDK v3** | Importante per ecosistema e community, ma richiede prima stabilità dell'engine di analisi e sandbox. | -| `[ ]` | **P2** | Prompt 13 | **SARIF Enterprise Integration** | Migliora integrazione security e compliance, ma non modifica il cuore architetturale. | -| `[ ]` | **P3** | Prompt 17 | **Performance Telemetry Engine** | Utile per ottimizzazione e governance operativa, ma deve seguire la stabilizzazione del runtime. | -| `[ ]` | **P3** | Prompt 19 | **Zenzic Audit Mode** | Feature enterprise ad alto valore, ma richiede stabilità di DQS, Policy Engine e SARIF. | -| `[ ]` | **P3** | Prompt 18 | **Multi Repository Documentation Graph** | Feature avanzata che amplia il dominio, ma richiede prima maturità completa di VSM, Smart Graph e artifact composition. | -| `[ ]` | **P3** | Prompt 9 | **VS Code Configuration Autocomplete** | Importante per UX, ma subordinata alla stabilizzazione dello schema configurativo e del Configuration Validation Engine. | - ---- - -## Sequenza milestone aggiornata - -| Milestone | Obiettivo | Prompt coinvolti | Stato | -| :--- | :--- | :--- | :--- | -| **v0.23 — Authoring Intelligence Foundation** | Portare Zenzic dentro l'IDE con feedback immediato | 1 → 10 | `[x] Completata` | -| **v0.24 — Interactive Intelligence** | Completa il ciclo diagnostica → remediation nell'IDE | 7 | `[x] Completata` | -| **v0.25 — LSP Stabilization & Hot-Reloading** | Parità CLI-IDE, hot-reload adapter, path normalization | — | `[x] Rilasciata` | -| **v0.26 — DQS Workspace UI** | Visualizzazione deterministica DQS nell'editor via CLI bridge asincrono | 8 | `[x] Completata (v0.26.0)` | -| **v0.27 — Deterministic Quality Platform** | Evolvere da validator a quality governance engine | 6 → 15 → 2 → 16 | `[ ] In programma` | -| **v0.28 — Governance & Extensibility** | Aprire SDK, policy e integrazioni enterprise | 14 → 12 → 13 → 19 | `[ ] In programma` | -| **v0.29 — Ecosystem Expansion** | Espandere il perimetro verso framework esterni | 3 → 4 → 5 → 18 | `[ ] In programma` | -| **v0.30 — Operational Excellence** | Ottimizzazione e osservabilità avanzata | 17 → 9 | `[ ] In programma` | - ---- - -## Dipendenze critiche - -```text -ZLS (v0.23.x - Complete) - │ - ├── Incremental Analysis Engine (v0.23.x - Complete) - │ │ - │ ├── Code Actions (v0.24.0 - Complete) - │ ├── DQS Workspace UI (v0.26.0 - Complete) - │ └── Hot-Reloading & Governance Parity (v0.25.0 - Complete) - │ - ├── Smart Link Graph (v0.27) - │ │ - │ └── Multi Repository Graph (v0.29) - │ - ├── DQS Model - │ │ - │ ├── Baseline Tracking (v0.27) - │ ├── Policy Engine (v0.28) - │ └── Audit Mode (v0.28) - │ - └── Adapter Contract - │ - ├── Docusaurus (v0.29) - ├── Sphinx (v0.29) - └── Hugo (v0.29) -``` - -Questa sequenza mantiene una progressione coerente: - -**Core deterministico → IDE intelligence → LSP Stabilization → Quality Governance → Ecosystem Expansion → Enterprise Platform**. - diff --git a/.draft/launch_trilogy_v24_v25_v26.jpg b/.draft/launch_trilogy_v24_v25_v26.jpg deleted file mode 100644 index 120bab47..00000000 Binary files a/.draft/launch_trilogy_v24_v25_v26.jpg and /dev/null differ diff --git a/.draft/launch_v0240.jpg b/.draft/launch_v0240.jpg deleted file mode 100644 index 04b16bb3..00000000 Binary files a/.draft/launch_v0240.jpg and /dev/null differ diff --git a/.draft/launch_v0250.jpg b/.draft/launch_v0250.jpg deleted file mode 100644 index 5bf0f3f6..00000000 Binary files a/.draft/launch_v0250.jpg and /dev/null differ diff --git a/.draft/launch_v0260.jpg b/.draft/launch_v0260.jpg deleted file mode 100644 index a435b19d..00000000 Binary files a/.draft/launch_v0260.jpg and /dev/null differ diff --git a/.github/ISSUE_TEMPLATE/security_vulnerability.yml b/.github/ISSUE_TEMPLATE/security_vulnerability.yml index 8e8469c3..3432e5fb 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.0" + placeholder: "0.26.1" validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd9d585f..8c62cae5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: uses: taiki-e/install-action@ea85faa6acd705ad6d40586db99f1a70b09c2929 # just - name: Setup uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a0f33831..192cf255 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -42,12 +42,12 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v3 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v3 + uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v3 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3 diff --git a/.github/workflows/release-docs.yml b/.github/workflows/release-docs.yml index 4f7e4b61..09e9fbcc 100644 --- a/.github/workflows/release-docs.yml +++ b/.github/workflows/release-docs.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3afbc306..a5be3aff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: fetch-depth: 0 - name: Setup uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 138f0401..106b65d8 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.0 +# rev: v0.26.1 # 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 8ef11e65..20a6f72c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,110 +11,38 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] -## [0.26.0] - 2026-07-26 - -### Added - -- **CLI `--json` Shorthand Alias (`ECOSYSTEM-FEAT-002`)**: Added `--json` flag to `zenzic score` as an ergonomic shorthand for `--format json`. Emits a single deterministic `ScoreReport` JSON object on `stdout` without rich terminal formatting, designed for programmatic consumers and editor integrations. - -### Documentation - -- **CLI Reference Mirror Law Realignment (`ADR-020`)**: Updated `docs/reference/cli.md` with `--json` flag specifications, complete `zenzic score` flag table, and JSON Output Schema documentation. -- **Roadmap Realignment (`ROADMAP-ALIGN-004`)**: Realigned `ROADMAP.md` to establish `[v0.26]` as *DQS Workspace UI*, shifting subsequent platform milestones (`v0.27`–`v0.30`). - -## [0.25.4] - 2026-07-26 - -### Fixed - -- **LSP DQS Determinism & Severity Parity (`LSP-FIX-014`)**: Deprecated misleading DQS Status Bar notification emission in `server.py` to preserve the Determinism invariant between incremental LSP mode (which observes topological findings only) and CLI batch mode. Filtered `INFO`-level findings (e.g. Z106) at the transport boundary in `incremental.py` to prevent PROBLEMS panel pollution. - -## [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 - -- **Adapter-Level `material/blog` Plugin Recognition (`LSP-FIX-011`)**: Extended `MkDocsAdapter` to automatically detect the `material/blog` plugin and classify all posts under `/posts/` as `REACHABLE` routes in the Virtual Site Map (VSM). -- **Directory Nav Path Resolution (`LSP-FIX-011`)**: Updated `MkDocsAdapter.get_nav_paths()` to expand trailing-slash directory entries (e.g. `blog/`) to their implicit `index.md` target (`blog/index.md`), eliminating false-positive `Z402` (Unlisted Page) findings for plugin index pages. - -### Fixed - -- **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 +## [0.26.1] - 2026-07-27 ### Added -- **Adapter-Driven Config Hot-Reloading (`LSP-FIX-009`)**: Added `@property watched_config_files` to `BaseAdapter` contract. The LSP server now dynamically watches framework configuration files (e.g. `mkdocs.yml`, `zensical.toml`) and hot-reloads the Virtual Site Map (VSM) on changes without requiring an LSP server restart. -- **Consolidated Developer & User Troubleshooting (`DOCS-IA-004`)**: Created dedicated Diátaxis-compliant troubleshooting guides in `docs/how-to/troubleshooting.md` and `docs/developers/how-to/troubleshooting.md`. - -### Fixed - -- **Centralized Core Governance (`LSP-FIX-009`)**: Extracted `directory_policies` and `per_file_ignores` filtering into `zenzic.core.governance` and integrated them directly into `IncrementalAnalysisEngine._analyze_file`, achieving 100% diagnostic determinism between CLI and VS Code. - -## [0.24.5] - 2026-07-25 +- **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. +- **MkDocs Asset URLs (`CORE-FIX-002`)**: Eradicated false-positive `Z101` findings for static assets in MkDocs repositories by preventing the `MkDocsAdapter` from appending trailing slashes to non-Markdown files during VSM route generation. -- **Exclusion Path Normalization (`LSP-FIX-008`)**: Hardened the `LayeredExclusionManager` to correctly normalize absolute URIs from the LSP server into repo-relative paths before evaluating `.gitignore` and `.zenzic.toml` exclusion rules. This eradicates false-positive diagnostics on user-excluded directories (e.g., `docs/tutorials/examples`) when opened in VS Code. - -## [0.24.4] - 2026-07-24 - -### Fixed - -- **LSP User Exclusion Enforcement (`LSP-FIX-007`)**: Strictly enforced `LayeredExclusionManager` filtering across full workspace sync and incremental file events in the LSP server and `IncrementalAnalysisEngine`, eliminating false-positive diagnostics on user-excluded directories (e.g. `excluded_dirs`). - -## [0.24.3] - 2026-07-24 - -### Fixed - -- **Windows Path Parity (CLI & Core)**: Fixed test suite regressions on Windows by ensuring strictly POSIX path comparisons (`.as_posix()`) in CLI JSON report rendering (`_shared.py`) and topological graph traversal (`cycle_registry`). -- **LSP `docs_root` Fallback**: Centralized `docs_root` resolution in the LSP server to safely fall back to the repository root when the configured `docs/` directory is missing, fixing a silent failure (DQS 100/100) on Zero-Config repositories. -- **Zero-Config System Guardrails**: Elevated common build directories (`out`, `.vscode-test`) to Layer 1 `SYSTEM_EXCLUDED_DIRS` to ensure safety across VS Code extension codebases regardless of `.gitignore` state. -- **LSP Memory Leak**: Implemented missing `didClose` handler to explicitly purge documents from `VirtualBufferOverlay`, avoiding unbounded memory growth during long-lived VS Code sessions. -- **LSP Windows URI Parity**: Replaced naive string slicing with robust `urllib.request.url2pathname` for cross-platform deterministic parsing of `file://` URIs, preventing drive-letter corruption on Windows. - -## [0.24.2] - 2026-07-24 - -### Fixed - -- **LSP Layered Exclusion & Asset Resolution (`LSP-FIX-002`)**: Enforced `LayeredExclusionManager` filtering in `LanguageServer._is_within_domain()` and `_build_vsm_sync()` so configured `excluded_dirs` are respected in editor sessions. Registered static HTML and media assets in VSM during initialization to eliminate false-positive `Z101` broken link errors on asset references. -- **LSP Workspace Initialization Sync (`LSP-FIX-003`)**: Added initial workspace analysis and `zenzic/dqsUpdate` JSON-RPC broadcast upon receiving the `initialized` notification, ensuring editor DQS widgets reflect repository quality state prior to explicit `textDocument/didOpen` events. - -## [0.24.1] - 2026-07-24 - -### Fixed - -- **URI Normalization & Link Resolution (`LSP-FIX-001`)**: Resolved false-positive `Z101` findings in LSP mode by extending `VSMBrokenLinkRule._to_canonical_url` to resolve all relative links (without `".."` requirement) relative to `source_dir`, and adding `urllib.parse.unquote()` percent-decoding to `file://` URIs. - -### Added +### Documentation -- **Release Announcement Blog Post (`DOCS-BLOG-001`)**: Added official Zenzic v0.24.0 (*Interactive Intelligence*) release announcement blog post. +- **Blog Hero Image Standardization**: Added named hero assets for existing release posts, converted launch media from JPEG to WebP, and normalized hero-image alt text to a title-aligned editorial pattern across the blog. +- **Editor Trilogy Article**: Added `docs/blog/posts/2026-07-27-editor-trilogy-v0240-v0260.md`, a Hostile Precision architectural synthesis of the v0.24.0 → v0.26.0 editor sequence, using the previously policy-exempt trilogy hero asset as an in-site referenced image. -## [0.24.0] - 2026-07-24 +## [0.26.0] - 2026-07-26 ### Added -- **LSP Code Actions Support (`LSP-FEAT-001-CODE-ACTIONS`)**: Enabled `codeActionProvider` in ZLS server capabilities and implemented `textDocument/codeAction` to expose in-memory Quick Fixes for fixable Z-Codes (e.g. `Z121`, `Z603`). -- **LSP DQS Real-Time Notification (`LSP-FEAT-002-DQS-UI`)**: Added custom `zenzic/dqsUpdate` JSON-RPC notification channel to stream global DQS scores and penalties to editor clients. +- **CLI `--json` Shorthand Alias (`ECOSYSTEM-FEAT-002`)**: Added `--json` flag to `zenzic score` as an ergonomic shorthand for `--format json`. Emits a single deterministic `ScoreReport` JSON object on `stdout` without rich terminal formatting, designed for programmatic consumers and editor integrations. -### Changed +### Documentation -- **Governance Alignment (`GOVERNANCE-001-DUAL-TIER-ALIGNMENT`)**: Synchronized internal prompt table (`0. Priority Table.md`) with public `ROADMAP.md` and added state tracking. +- **CLI Reference Mirror Law Realignment (`ADR-020`)**: Updated `docs/reference/cli.md` with `--json` flag specifications, complete `zenzic score` flag table, and JSON Output Schema documentation. +- **Roadmap Realignment (`ROADMAP-ALIGN-004`)**: Realigned `ROADMAP.md` to establish `[v0.26]` as *DQS Workspace UI*, shifting subsequent platform milestones (`v0.27`–`v0.30`). ## Historical Releases +- v0.25.x archive: [changelogs/v0.25.x.md](./changelogs/v0.25.x.md) +- v0.24.x archive: [changelogs/v0.24.x.md](./changelogs/v0.24.x.md) - v0.23.x archive: [changelogs/v0.23.x.md](./changelogs/v0.23.x.md) - v0.22.x archive: [changelogs/v0.22.x.md](./changelogs/v0.22.x.md) - v0.21.x archive: [changelogs/v0.21.x.md](./changelogs/v0.21.x.md) diff --git a/CITATION.cff b/CITATION.cff index f4984232..85b1e0f4 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.0 -date-released: 2026-07-26 +version: 0.26.1 +date-released: 2026-07-27 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 6b84f85d..7c400d8d 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.0", + "version": "0.26.1", "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.0 check all +uvx zenzic@0.26.1 check all ``` --- diff --git a/RELEASE.md b/RELEASE.md index 9ee13110..b364bfa5 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,9 +8,9 @@ | Field | Value | | :------- | :--------- | -| Version | v0.26.0 | +| Version | v0.26.1 | | Codename | Magnetite | -| Date | 2026-07-26 | +| Date | 2026-07-27 | | 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.0`) +- [ ] `pyproject.toml` version matches the tag (`0.26.1`) - [ ] `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.0" v0.26.0 +git tag -s -m "Release v0.26.1" v0.26.1 git push origin main --tags ``` -- [ ] Create GitHub Release from the tag, using the `## [0.26.0]` CHANGELOG section as the release body. +- [ ] Create GitHub Release from the tag, using the `## [0.26.1]` CHANGELOG section as the release body. ## Changelog Reference diff --git a/changelogs/README.md b/changelogs/README.md index 94242a07..b22fa1f6 100644 --- a/changelogs/README.md +++ b/changelogs/README.md @@ -18,7 +18,10 @@ For the current release history, see the [main Changelog](../CHANGELOG.md). | v0.8.x | Basalt | 2026-05-15 to 2026-05-30 | [v0.8.md](./v0.8.md) | | v0.9.x | Graphite | 2026-05-31 to 2026-06-05 | [v0.9.md](./v0.9.md) | | v0.10.x | Magnetite | 2026-06-06 to 2026-06-09 | [v0.10.md](./v0.10.md) | -| v0.11.x | TBD | 2026-06-10 → active | [main CHANGELOG](../CHANGELOG.md) | +| v0.11.x | TBD | 2026-06-10 to 2026-06-10 | [v0.11.md](./v0.11.md) | +| v0.24.x | — | 2026-07-24 to 2026-07-25 | [v0.24.x.md](./v0.24.x.md) | +| v0.25.x | — | 2026-07-25 to 2026-07-26 | [v0.25.x.md](./v0.25.x.md) | +| v0.26.x | — | 2026-07-26 → active | [main CHANGELOG](../CHANGELOG.md) | Archives follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. All dates are git-tag verified for v0.4.0-rc2 and later. diff --git a/changelogs/v0.24.x.md b/changelogs/v0.24.x.md new file mode 100644 index 00000000..0a5e8084 --- /dev/null +++ b/changelogs/v0.24.x.md @@ -0,0 +1,54 @@ + + + +# v0.24.x Changelog + +## [0.24.5] - 2026-07-25 + +### Fixed + +- **Exclusion Path Normalization (`LSP-FIX-008`)**: Hardened the `LayeredExclusionManager` to correctly normalize absolute URIs from the LSP server into repo-relative paths before evaluating `.gitignore` and `.zenzic.toml` exclusion rules. This eradicates false-positive diagnostics on user-excluded directories (e.g., `docs/tutorials/examples`) when opened in VS Code. + +## [0.24.4] - 2026-07-24 + +### Fixed + +- **LSP User Exclusion Enforcement (`LSP-FIX-007`)**: Strictly enforced `LayeredExclusionManager` filtering across full workspace sync and incremental file events in the LSP server and `IncrementalAnalysisEngine`, eliminating false-positive diagnostics on user-excluded directories (e.g. `excluded_dirs`). + +## [0.24.3] - 2026-07-24 + +### Fixed + +- **Windows Path Parity (CLI & Core)**: Fixed test suite regressions on Windows by ensuring strictly POSIX path comparisons (`.as_posix()`) in CLI JSON report rendering (`_shared.py`) and topological graph traversal (`cycle_registry`). +- **LSP `docs_root` Fallback**: Centralized `docs_root` resolution in the LSP server to safely fall back to the repository root when the configured `docs/` directory is missing, fixing a silent failure (DQS 100/100) on Zero-Config repositories. +- **Zero-Config System Guardrails**: Elevated common build directories (`out`, `.vscode-test`) to Layer 1 `SYSTEM_EXCLUDED_DIRS` to ensure safety across VS Code extension codebases regardless of `.gitignore` state. +- **LSP Memory Leak**: Implemented missing `didClose` handler to explicitly purge documents from `VirtualBufferOverlay`, avoiding unbounded memory growth during long-lived VS Code sessions. +- **LSP Windows URI Parity**: Replaced naive string slicing with robust `urllib.request.url2pathname` for cross-platform deterministic parsing of `file://` URIs, preventing drive-letter corruption on Windows. + +## [0.24.2] - 2026-07-24 + +### Fixed + +- **LSP Layered Exclusion & Asset Resolution (`LSP-FIX-002`)**: Enforced `LayeredExclusionManager` filtering in `LanguageServer._is_within_domain()` and `_build_vsm_sync()` so configured `excluded_dirs` are respected in editor sessions. Registered static HTML and media assets in VSM during initialization to eliminate false-positive `Z101` broken link errors on asset references. +- **LSP Workspace Initialization Sync (`LSP-FIX-003`)**: Added initial workspace analysis and `zenzic/dqsUpdate` JSON-RPC broadcast upon receiving the `initialized` notification, ensuring editor DQS widgets reflect repository quality state prior to explicit `textDocument/didOpen` events. + +## [0.24.1] - 2026-07-24 + +### Fixed + +- **URI Normalization & Link Resolution (`LSP-FIX-001`)**: Resolved false-positive `Z101` findings in LSP mode by extending `VSMBrokenLinkRule._to_canonical_url` to resolve all relative links (without `".."` requirement) relative to `source_dir`, and adding `urllib.parse.unquote()` percent-decoding to `file://` URIs. + +### Added + +- **Release Announcement Blog Post (`DOCS-BLOG-001`)**: Added official Zenzic v0.24.0 (*Interactive Intelligence*) release announcement blog post. + +## [0.24.0] - 2026-07-24 + +### Added + +- **LSP Code Actions Support (`LSP-FEAT-001-CODE-ACTIONS`)**: Enabled `codeActionProvider` in ZLS server capabilities and implemented `textDocument/codeAction` to expose in-memory Quick Fixes for fixable Z-Codes (e.g. `Z121`, `Z603`). +- **LSP DQS Real-Time Notification (`LSP-FEAT-002-DQS-UI`)**: Added custom `zenzic/dqsUpdate` JSON-RPC notification channel to stream global DQS scores and penalties to editor clients. + +### Changed + +- **Governance Alignment (`GOVERNANCE-001-DUAL-TIER-ALIGNMENT`)**: Synchronized internal prompt table (`0. Priority Table.md`) with public `ROADMAP.md` and added state tracking. diff --git a/changelogs/v0.25.x.md b/changelogs/v0.25.x.md new file mode 100644 index 00000000..a39ff6f8 --- /dev/null +++ b/changelogs/v0.25.x.md @@ -0,0 +1,45 @@ + + + +# v0.25.x Changelog + +## [0.25.4] - 2026-07-26 + +### Fixed + +- **LSP DQS Determinism & Severity Parity (`LSP-FIX-014`)**: Deprecated misleading DQS Status Bar notification emission in `server.py` to preserve the Determinism invariant between incremental LSP mode (which observes topological findings only) and CLI batch mode. Filtered `INFO`-level findings (e.g. Z106) at the transport boundary in `incremental.py` to prevent PROBLEMS panel pollution. + +## [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 + +- **Adapter-Level `material/blog` Plugin Recognition (`LSP-FIX-011`)**: Extended `MkDocsAdapter` to automatically detect the `material/blog` plugin and classify all posts under `/posts/` as `REACHABLE` routes in the Virtual Site Map (VSM). +- **Directory Nav Path Resolution (`LSP-FIX-011`)**: Updated `MkDocsAdapter.get_nav_paths()` to expand trailing-slash directory entries (e.g. `blog/`) to their implicit `index.md` target (`blog/index.md`), eliminating false-positive `Z402` (Unlisted Page) findings for plugin index pages. + +### Fixed + +- **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 + +- **Adapter-Driven Config Hot-Reloading (`LSP-FIX-009`)**: Added `@property watched_config_files` to `BaseAdapter` contract. The LSP server now dynamically watches framework configuration files (e.g. `mkdocs.yml`, `zensical.toml`) and hot-reloads the Virtual Site Map (VSM) on changes without requiring an LSP server restart. +- **Consolidated Developer & User Troubleshooting (`DOCS-IA-004`)**: Created dedicated Diátaxis-compliant troubleshooting guides in `docs/how-to/troubleshooting.md` and `docs/developers/how-to/troubleshooting.md`. + +### Fixed + +- **Centralized Core Governance (`LSP-FIX-009`)**: Extracted `directory_policies` and `per_file_ignores` filtering into `zenzic.core.governance` and integrated them directly into `IncrementalAnalysisEngine._analyze_file`, achieving 100% diagnostic determinism between CLI and VS Code. diff --git a/docs/assets/images/blog/10-documentation-bugs-caught-by-zenzic.webp b/docs/assets/images/blog/10-documentation-bugs-caught-by-zenzic.webp new file mode 100644 index 00000000..caa57fe7 Binary files /dev/null and b/docs/assets/images/blog/10-documentation-bugs-caught-by-zenzic.webp differ diff --git a/docs/assets/images/blog/10-documentation-bugs-caught-by-zenzic.webp.license b/docs/assets/images/blog/10-documentation-bugs-caught-by-zenzic.webp.license new file mode 100644 index 00000000..73c93a85 --- /dev/null +++ b/docs/assets/images/blog/10-documentation-bugs-caught-by-zenzic.webp.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 PythonWoods + +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/assets/images/blog/launch_trilogy_v24_v25_v26.jpg b/docs/assets/images/blog/launch_trilogy_v24_v25_v26.jpg deleted file mode 100644 index 120bab47..00000000 Binary files a/docs/assets/images/blog/launch_trilogy_v24_v25_v26.jpg and /dev/null differ diff --git a/docs/assets/images/blog/launch_trilogy_v24_v25_v26.webp b/docs/assets/images/blog/launch_trilogy_v24_v25_v26.webp new file mode 100644 index 00000000..25324c6c Binary files /dev/null and b/docs/assets/images/blog/launch_trilogy_v24_v25_v26.webp differ diff --git a/docs/assets/images/blog/launch_trilogy_v24_v25_v26.webp.license b/docs/assets/images/blog/launch_trilogy_v24_v25_v26.webp.license new file mode 100644 index 00000000..73c93a85 --- /dev/null +++ b/docs/assets/images/blog/launch_trilogy_v24_v25_v26.webp.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 PythonWoods + +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/assets/images/blog/launch_v0240.jpg b/docs/assets/images/blog/launch_v0240.jpg deleted file mode 100644 index 04b16bb3..00000000 Binary files a/docs/assets/images/blog/launch_v0240.jpg and /dev/null differ diff --git a/docs/assets/images/blog/launch_v0240.webp b/docs/assets/images/blog/launch_v0240.webp new file mode 100644 index 00000000..0709971c Binary files /dev/null and b/docs/assets/images/blog/launch_v0240.webp differ diff --git a/docs/assets/images/blog/launch_v0240.webp.license b/docs/assets/images/blog/launch_v0240.webp.license new file mode 100644 index 00000000..73c93a85 --- /dev/null +++ b/docs/assets/images/blog/launch_v0240.webp.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 PythonWoods + +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/assets/images/blog/launch_v0250.jpg b/docs/assets/images/blog/launch_v0250.jpg deleted file mode 100644 index 5bf0f3f6..00000000 Binary files a/docs/assets/images/blog/launch_v0250.jpg and /dev/null differ diff --git a/docs/assets/images/blog/launch_v0250.webp b/docs/assets/images/blog/launch_v0250.webp new file mode 100644 index 00000000..d4af12e7 Binary files /dev/null and b/docs/assets/images/blog/launch_v0250.webp differ diff --git a/docs/assets/images/blog/launch_v0250.webp.license b/docs/assets/images/blog/launch_v0250.webp.license new file mode 100644 index 00000000..73c93a85 --- /dev/null +++ b/docs/assets/images/blog/launch_v0250.webp.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 PythonWoods + +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/assets/images/blog/launch_v0260.jpg b/docs/assets/images/blog/launch_v0260.jpg deleted file mode 100644 index a435b19d..00000000 Binary files a/docs/assets/images/blog/launch_v0260.jpg and /dev/null differ diff --git a/docs/assets/images/blog/launch_v0260.webp b/docs/assets/images/blog/launch_v0260.webp new file mode 100644 index 00000000..27e42dcd Binary files /dev/null and b/docs/assets/images/blog/launch_v0260.webp differ diff --git a/docs/assets/images/blog/launch_v0260.webp.license b/docs/assets/images/blog/launch_v0260.webp.license new file mode 100644 index 00000000..73c93a85 --- /dev/null +++ b/docs/assets/images/blog/launch_v0260.webp.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 PythonWoods + +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/assets/images/blog/tailwind-mkdocs-material-bridge.webp b/docs/assets/images/blog/tailwind-mkdocs-material-bridge.webp new file mode 100644 index 00000000..66b2fb3b Binary files /dev/null and b/docs/assets/images/blog/tailwind-mkdocs-material-bridge.webp differ diff --git a/docs/assets/images/blog/tailwind-mkdocs-material-bridge.webp.license b/docs/assets/images/blog/tailwind-mkdocs-material-bridge.webp.license new file mode 100644 index 00000000..73c93a85 --- /dev/null +++ b/docs/assets/images/blog/tailwind-mkdocs-material-bridge.webp.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 PythonWoods + +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/assets/images/blog/why-we-banned-pythons-regex-module.webp b/docs/assets/images/blog/why-we-banned-pythons-regex-module.webp new file mode 100644 index 00000000..6fd55133 Binary files /dev/null and b/docs/assets/images/blog/why-we-banned-pythons-regex-module.webp differ diff --git a/docs/assets/images/blog/why-we-banned-pythons-regex-module.webp.license b/docs/assets/images/blog/why-we-banned-pythons-regex-module.webp.license new file mode 100644 index 00000000..73c93a85 --- /dev/null +++ b/docs/assets/images/blog/why-we-banned-pythons-regex-module.webp.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 PythonWoods + +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/assets/images/blog/zenzic-v0160-engineering-determinism.webp b/docs/assets/images/blog/zenzic-v0160-engineering-determinism.webp new file mode 100644 index 00000000..e1f56e05 Binary files /dev/null and b/docs/assets/images/blog/zenzic-v0160-engineering-determinism.webp differ diff --git a/docs/assets/images/blog/zenzic-v0160-engineering-determinism.webp.license b/docs/assets/images/blog/zenzic-v0160-engineering-determinism.webp.license new file mode 100644 index 00000000..73c93a85 --- /dev/null +++ b/docs/assets/images/blog/zenzic-v0160-engineering-determinism.webp.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 PythonWoods + +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/assets/images/blog/zenzic-v0170-html-validation.webp b/docs/assets/images/blog/zenzic-v0170-html-validation.webp new file mode 100644 index 00000000..bd638d99 Binary files /dev/null and b/docs/assets/images/blog/zenzic-v0170-html-validation.webp differ diff --git a/docs/assets/images/blog/zenzic-v0170-html-validation.webp.license b/docs/assets/images/blog/zenzic-v0170-html-validation.webp.license new file mode 100644 index 00000000..73c93a85 --- /dev/null +++ b/docs/assets/images/blog/zenzic-v0170-html-validation.webp.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 PythonWoods + +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/assets/images/blog/zenzic-v0190-the-ast-foundations.webp b/docs/assets/images/blog/zenzic-v0190-the-ast-foundations.webp new file mode 100644 index 00000000..bf89ae69 Binary files /dev/null and b/docs/assets/images/blog/zenzic-v0190-the-ast-foundations.webp differ diff --git a/docs/assets/images/blog/zenzic-v0190-the-ast-foundations.webp.license b/docs/assets/images/blog/zenzic-v0190-the-ast-foundations.webp.license new file mode 100644 index 00000000..73c93a85 --- /dev/null +++ b/docs/assets/images/blog/zenzic-v0190-the-ast-foundations.webp.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 PythonWoods + +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/assets/images/blog/zenzic-vs-code-extension.webp b/docs/assets/images/blog/zenzic-vs-code-extension.webp new file mode 100644 index 00000000..ddcf0c2e Binary files /dev/null and b/docs/assets/images/blog/zenzic-vs-code-extension.webp differ diff --git a/docs/assets/images/blog/zenzic-vs-code-extension.webp.license b/docs/assets/images/blog/zenzic-vs-code-extension.webp.license new file mode 100644 index 00000000..73c93a85 --- /dev/null +++ b/docs/assets/images/blog/zenzic-vs-code-extension.webp.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 PythonWoods + +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/blog/posts/2026-06-03-algorithmic-complexity-and-redos-prevention.md b/docs/blog/posts/2026-06-03-algorithmic-complexity-and-redos-prevention.md index 73d4afba..1fc3b966 100644 --- a/docs/blog/posts/2026-06-03-algorithmic-complexity-and-redos-prevention.md +++ b/docs/blog/posts/2026-06-03-algorithmic-complexity-and-redos-prevention.md @@ -15,6 +15,8 @@ In modern CI/CD pipelines, security and performance should be structurally bound +![Why we banned Python's regex module: The algorithm behind Zenzic](../../assets/images/blog/why-we-banned-pythons-regex-module.webp) + ## The ReDoS Problem in CI/CD Many Python-based linters rely on the standard `re` module, which uses a backtracking NFA-style regex engine. When evaluating complex regex patterns against large or crafted payloads, backtracking can lead to exponential worst-case time complexity: $O(2^N)$. diff --git a/docs/blog/posts/2026-06-20-tailwind-mkdocs-material-bridge.md b/docs/blog/posts/2026-06-20-tailwind-mkdocs-material-bridge.md index 9455b1ef..37db024f 100644 --- a/docs/blog/posts/2026-06-20-tailwind-mkdocs-material-bridge.md +++ b/docs/blog/posts/2026-06-20-tailwind-mkdocs-material-bridge.md @@ -13,6 +13,8 @@ Running Tailwind CSS components inside a MkDocs Material documentation site intr +![The Tailwind/MkDocs Material Bridge: A Surgical CSS Pattern](../../assets/images/blog/tailwind-mkdocs-material-bridge.webp) + ## The Failure Mode MkDocs Material applies `font-size: 125%` to the `` element globally. This is a deliberate, documented accessibility decision: it scales the effective base unit from `16px` to `20px`, which improves legibility for users with larger system font preferences. diff --git a/docs/blog/posts/2026-06-27-v016-magnetite.md b/docs/blog/posts/2026-06-27-v016-magnetite.md index b2e08f2f..29e370e1 100644 --- a/docs/blog/posts/2026-06-27-v016-magnetite.md +++ b/docs/blog/posts/2026-06-27-v016-magnetite.md @@ -13,6 +13,8 @@ Zenzic v0.16.0 "Magnetite" marks the transition from an experimental toolchain t +![Zenzic v0.16.0: Engineering Determinism into Documentation Pipelines](../../assets/images/blog/zenzic-v0160-engineering-determinism.webp) + ## 1. The Bootstrap Gate: Z001 CORE_CONFIG_STRUCTURE ### Problem statement diff --git a/docs/blog/posts/2026-06-28-v0-17-0-closing-the-html-blind-spot.md b/docs/blog/posts/2026-06-28-v0-17-0-closing-the-html-blind-spot.md index ec20ab77..c61811f4 100644 --- a/docs/blog/posts/2026-06-28-v0-17-0-closing-the-html-blind-spot.md +++ b/docs/blog/posts/2026-06-28-v0-17-0-closing-the-html-blind-spot.md @@ -17,6 +17,8 @@ With v0.17.0, HTML links and images are analyzed by the same validation pipeline +![Zenzic v0.17.0: HTML Validation in Markdown Documents](../../assets/images/blog/zenzic-v0170-html-validation.webp) + ## The Uniform Resolver Pipeline (URP) To support HTML validation while preserving the existing architecture, we introduced the **Uniform Resolver Pipeline (URP)**. diff --git a/docs/blog/posts/2026-06-29-10-documentation-bugs-caught-by-zenzic.md b/docs/blog/posts/2026-06-29-10-documentation-bugs-caught-by-zenzic.md index 05f52bf9..f5d95cde 100644 --- a/docs/blog/posts/2026-06-29-10-documentation-bugs-caught-by-zenzic.md +++ b/docs/blog/posts/2026-06-29-10-documentation-bugs-caught-by-zenzic.md @@ -21,6 +21,8 @@ Here are 10 subtle, frustrating, and downright dangerous documentation bugs that +![10 Documentation Bugs Caught by Zenzic](../../assets/images/blog/10-documentation-bugs-caught-by-zenzic.webp) + ## 1. The "Ghost Anchor" (Z102) You update a heading from `## Setup Environment` to `## Environment Setup`. Congratulations, the content is better! But somewhere in another file, a link points to `[Setup](#setup-environment)`. diff --git a/docs/blog/posts/2026-07-02-zenzic-v0190-the-ast-foundations.md b/docs/blog/posts/2026-07-02-zenzic-v0190-the-ast-foundations.md index 213bcf92..5b516679 100644 --- a/docs/blog/posts/2026-07-02-zenzic-v0190-the-ast-foundations.md +++ b/docs/blog/posts/2026-07-02-zenzic-v0190-the-ast-foundations.md @@ -20,6 +20,8 @@ This release represents a fundamental shift in how Zenzic understands and intera +![Zenzic v0.19.0: The AST Foundations & Atomic Auto-Fix](../../assets/images/blog/zenzic-v0190-the-ast-foundations.webp) + ## The Shift from Regex to a Lossless AST Relying exclusively on regex is exceptionally fast but ultimately constrained. Complex Markdown constructs—like nested blockquotes, tables, and layered emphasis—cannot be safely understood or mutated using flat string matching. diff --git a/docs/blog/posts/2026-07-18-zenzic-vs-code-extension.md b/docs/blog/posts/2026-07-18-zenzic-vs-code-extension.md index 99ba94a3..ed711892 100644 --- a/docs/blog/posts/2026-07-18-zenzic-vs-code-extension.md +++ b/docs/blog/posts/2026-07-18-zenzic-vs-code-extension.md @@ -22,6 +22,8 @@ Today, we are eliminating the latency between authoring a defect and discovering +![Shift-Left to the Keystroke: The Zenzic VS Code Extension](../../assets/images/blog/zenzic-vs-code-extension.webp) + ## The Thin Client Architecture Most documentation linters suffer from architectural bloat, bundling heavy Node.js parsers or embedding redundant logic directly into the editor extension. This leads to high memory consumption and fragmented rule sets where the editor behaves differently than the CI pipeline. diff --git a/docs/blog/posts/2026-07-24-zenzic-v0240-interactive-intelligence.md b/docs/blog/posts/2026-07-24-zenzic-v0240-interactive-intelligence.md index 548efc40..9e250e97 100644 --- a/docs/blog/posts/2026-07-24-zenzic-v0240-interactive-intelligence.md +++ b/docs/blog/posts/2026-07-24-zenzic-v0240-interactive-intelligence.md @@ -15,7 +15,7 @@ categories: Zenzic v0.24.0 marks the transition from passive static validation to interactive, editor-native remediation. This release introduces LSP Code Actions for automated Quick Fixes, real-time Documentation Quality Score (DQS) streaming to the editor status bar, and critical URI normalization bugfixes. -![Zenzic v0.24.0 Interactive Intelligence Launch](../../assets/images/blog/launch_v0240.jpg) +![Zenzic v0.24.0: Interactive Intelligence](../../assets/images/blog/launch_v0240.webp) 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 073e967b..29650445 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 @@ -15,7 +15,7 @@ categories: Zenzic v0.25.0 stabilizes the Language Server Protocol (LSP) integration, guaranteeing strict diagnostic parity between CLI and editor sessions. This release introduces centralized core governance evaluation, adapter-driven configuration hot-reloading for live VSM updates, and an updated `BaseAdapter` contract. -![Zenzic v0.25.0 LSP Stabilization Launch](../../assets/images/blog/launch_v0250.jpg) +![Zenzic v0.25.0: LSP Stabilization & Adapter-Driven Hot-Reloading](../../assets/images/blog/launch_v0250.webp) diff --git a/docs/blog/posts/2026-07-26-zenzic-v0260-dqs-workspace-ui.md b/docs/blog/posts/2026-07-26-zenzic-v0260-dqs-workspace-ui.md index 033d4817..592d483b 100644 --- a/docs/blog/posts/2026-07-26-zenzic-v0260-dqs-workspace-ui.md +++ b/docs/blog/posts/2026-07-26-zenzic-v0260-dqs-workspace-ui.md @@ -15,7 +15,7 @@ categories: Zenzic v0.26.0 introduces the **Asynchronous CLI Execution Bridge**, bringing the global Documentation Quality Score (DQS) into the editor authoring environment while enforcing 100% mathematical determinism between terminal runs and VS Code status bar indicators. -![Zenzic v0.26.0 DQS Workspace UI Launch](../../assets/images/blog/launch_v0260.jpg) +![Zenzic v0.26.0: DQS Workspace UI & Asynchronous CLI Bridge](../../assets/images/blog/launch_v0260.webp) @@ -33,12 +33,15 @@ Zenzic v0.26.0 solves this fundamental architectural challenge by establishing a ## Architectural Invariants Preserved ### 1. Absolute Determinism + Because the status bar indicator is powered directly by `zenzic score --json`, the score displayed inside VS Code is **mathematically identical** to the score computed by GitHub Actions and CI/CD pipelines. ### 2. Radical Unawareness (ADR-075) + The Python Core remains completely unaware of its consumer. When invoked with `--json`, `zenzic score` writes a single, structured JSON payload to `stdout` and exits cleanly. It contains zero editor-specific or transport-specific logic. ### 3. Thin Client Architecture + The TypeScript extension performs no scoring calculations. It simply parses the JSON response and updates the status bar item (`$(dashboard) Zenzic DQS: 98/100`) and detailed breakdown tooltips. --- @@ -126,9 +129,11 @@ Zenzic v0.26.0 contributes a new command to VS Code: ## Upgrade Guide ### Core Engine & CLI + ```bash uv tool install --force zenzic ``` ### VS Code Extension + Update to version `0.26.0` from the VS Code Marketplace or reload your editor workspace. diff --git a/docs/blog/posts/2026-07-27-editor-trilogy-v0240-v0260.md b/docs/blog/posts/2026-07-27-editor-trilogy-v0240-v0260.md new file mode 100644 index 00000000..0ae78fd3 --- /dev/null +++ b/docs/blog/posts/2026-07-27-editor-trilogy-v0240-v0260.md @@ -0,0 +1,150 @@ +--- +title: "Zenzic v0.24.0 → v0.26.0: The Editor Trilogy" +slug: editor-trilogy-v0240-v0260 +date: 2026-07-27 +authors: + - pythonwoods +description: > + Zenzic v0.24.0 through v0.26.0 was not a feature sprint. It was a three-step + architectural correction: interaction, stabilization, and deterministic + global scoring. +categories: + - Releases + - Engineering + - VS Code +--- + + + +Most editor integrations fail for a simple reason: they confuse immediacy with truth. + +Fast feedback is not authoritative feedback. A red underline that appears in 20 milliseconds is worthless if the CI pipeline later disagrees with it. A score in the status bar is noise if it was inferred from partial state and sold as workspace truth. + +Zenzic v0.24.0 through v0.26.0 was not a feature sprint. It was an architectural correction. + +- [v0.24.0](2026-07-24-zenzic-v0240-interactive-intelligence.md) introduced interaction. +- [v0.25.0](2026-07-25-zenzic-v0250-lsp-stabilization.md) removed diagnostic drift. +- [v0.26.0](2026-07-26-zenzic-v0260-dqs-workspace-ui.md) restored global scoring without corrupting determinism. + +This is the editor trilogy: three releases that turned the VS Code extension from a reactive client into a governed execution surface. + +![Zenzic v0.24.0 → v0.26.0: The Editor Trilogy](../../assets/images/blog/launch_trilogy_v24_v25_v26.webp) + + + +## The False Promise of "Real-Time Everything" + +Most tooling vendors advertise the same fantasy: real-time everything, everywhere, all at once. + +The pitch sounds good until you inspect the implementation boundary. A documentation graph is not a single file. A global score is not a local heuristic. A topological defect cannot be derived correctly from whatever subset of buffers happens to be open in the editor. + +If the editor invents global truth from local events, it is not fast. It is wrong. + +That is the architectural line we refused to cross. + +The problem was not whether the extension could show diagnostics quickly. It already could. The problem was whether the extension could remain mathematically aligned with the same deterministic contracts that govern CLI and CI/CD execution. + +The trilogy exists because speed without parity is decoration. + +## v0.24.0 — Interaction Without Governance Is a Toy + +[v0.24.0](2026-07-24-zenzic-v0240-interactive-intelligence.md) introduced the first real editor-side execution surface: Quick Fixes, in-memory workspace edits, and interactive remediation paths exposed through the Language Server Protocol. + +That step mattered, but not for the superficial reason. + +The value of interactive remediation is not that the editor can modify text. Any extension can modify text. The value is that the modification is generated by the same deterministic system that will later evaluate the result. + +Without that constraint, "Quick Fix" degenerates into one more convenience macro layered on top of an unverifiable rule engine. + +v0.24.0 solved the first problem: the editor stopped being a passive alarm panel. It became a place where governed action could occur. + +But interaction alone is insufficient. A system that can fix content interactively and still diverge from CLI policy is not powerful. It is dangerous. + +Quick Fixes are useful only when the system applying them is governed by the same rules that will later judge the result. + +## v0.25.0 — Parity Is Not a Nice-to-Have + +[v0.25.0](2026-07-25-zenzic-v0250-lsp-stabilization.md) addressed the failure mode that most editor integrations normalize instead of eliminating: policy drift. + +Before stabilization, governance evaluation could occur at different points in different execution paths. That is enough to corrupt parity. If the CLI runs one filtering order and the editor runs another, the user no longer has one system. They have two systems sharing a brand name. + +That is unacceptable in a deterministic engine. + +v0.25.0 centralized governance evaluation, hardened adapter contracts, and added adapter-driven hot reloading so configuration changes affected the editor and the terminal through the same architectural path. + +This was not improved DX. It was structural repair. + +A diagnostic that exists in CI but not in the editor is not eventual consistency. It is architectural failure. + +The trilogy's second step made the extension trustworthy. Not visually polished. Not more convenient. Trustworthy. + +## v0.26.0 — Global DQS Must Be On-Demand or It Becomes Fiction + +[v0.26.0](2026-07-26-zenzic-v0260-dqs-workspace-ui.md) solved the hardest part: how to expose the global Documentation Quality Score inside the editor without lying. + +The tempting shortcut was obvious: derive an approximate score from incremental language-server state. Many products would do exactly that and call it good enough. + +It is not good enough. + +The DQS is a repository-wide computation. It depends on the full graph, the full finding set, the full governance state, and the same batch semantics used by the CLI. If the editor computes a score from partial in-memory evidence and presents it as workspace truth, the number is invalid by construction. + +A global score computed without a global audit is not approximate. It is invalid. + +v0.26.0 fixed this by separating concerns instead of collapsing them: + +- the LSP remained responsible for ultra-fast incremental diagnostics +- the status bar became an orchestration surface +- the authoritative DQS came from an asynchronous CLI bridge that executes the real batch scoring pipeline + +This is the critical distinction: the editor did not become a second scoring engine. It became a controlled entry point to the existing one. + +On-demand scoring is not a UX compromise. It is an integrity requirement. + +## The Trilogy Pattern + +Viewed individually, the three releases look incremental. + +Viewed architecturally, they form a strict dependency chain: + +1. **v0.24.0** made the editor interactive. +2. **v0.25.0** made the editor consistent with governance reality. +3. **v0.26.0** made the editor globally honest about scoring. + +First the extension became useful. + +Then it became trustworthy. + +Then it became mathematically honest. + +That order matters. Reversing it would have failed. Shipping a DQS surface before fixing parity would have amplified inconsistency. Shipping parity without interactive remediation would have preserved a passive authoring workflow. Shipping interaction without deterministic scoring boundaries would have produced a faster interface wrapped around weaker truth guarantees. + +The trilogy works because each release removed a specific class of architectural fraud. + +## What This Means for the Roadmap + +The consequence is straightforward. + +Future editor features are admissible only if they preserve the same invariants: + +- no duplicate policy path outside the governed core +- no alternate resolver semantics for editor convenience +- no synthetic global scoring derived from partial editor state +- no UX shortcut that breaks deterministic parity with CLI and CI/CD + +The extension is not a product shell around the engine. It is a constrained execution surface for deterministic contracts. + +That constraint is not a limitation. It is the reason the extension is credible. + +## Final Statement + +The editor trilogy matters because it rejected the standard trade: speed now, correctness later. + +Zenzic does not accept that trade. + +The editor may be incremental. +The score may be on-demand. +The user experience may be immediate. + +But the truth remains singular. + +That is the difference between a convenient extension and an engineering system. diff --git a/docs/developers/how-to/implement-adapter.md b/docs/developers/how-to/implement-adapter.md index efee3e57..4fb9200f 100644 --- a/docs/developers/how-to/implement-adapter.md +++ b/docs/developers/how-to/implement-adapter.md @@ -42,6 +42,19 @@ An adapter answers questions for each docs tree through a single API surface: | `has_engine_config()` | Was a build-engine config file found on disk? (Controls orphan check activation.) | | `provides_index(directory_path)` | Does this directory have an engine-provided landing page? (Controls `MISSING_DIRECTORY_INDEX` emission.) | +### URL Routing Mode (`use_directory_urls`) + +To ensure the core engine correctly resolves relative links to HTML files, your adapter must declare its routing mode. + +If your framework generates directory-style URLs (e.g., `file.md` becomes `/file/index.html` served at `/file/`), return `True`. If it generates flat files (e.g., `file.md` becomes `/file.html`), return `False`. + +```python +@property +def use_directory_urls(self) -> bool: + """Return True if the framework uses directory URLs, False for flat URLs.""" + return True +``` + --- ## Step 1 — Create the Adapter Class diff --git a/docs/developers/reference/adapter-api.md b/docs/developers/reference/adapter-api.md index 03e23c71..4da525d1 100644 --- a/docs/developers/reference/adapter-api.md +++ b/docs/developers/reference/adapter-api.md @@ -104,6 +104,10 @@ Constructs and returns routing metadata, including the canonical URL and route s Returns the set of framework configuration filenames (e.g., `mkdocs.yml`, `zensical.toml`) that dictate documentation structure. The Zenzic Language Server watches these files to trigger real-time VSM hot-reloading. +#### `use_directory_urls` (Property `-> bool`) + +Declares the adapter routing mode for canonical page URLs. Return `True` for directory-style URLs (`/page/`), and `False` for flat HTML-style URLs (`/page.html`). The incremental engine forwards this value to VSM link canonicalization. + --- ## `zenzic.rules` — Plugin SDK Façade diff --git a/mkdocs.yml b/mkdocs.yml index 5115c587..2d2532a0 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.0" # release sync + zenzic_version: "0.26.1" # release sync social: - icon: fontawesome/brands/github link: https://github.com/PythonWoods/zenzic diff --git a/pyproject.toml b/pyproject.toml index b1258d96..0b0f39e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "hatchling.build" [project] name = "zenzic" -version = "0.26.0" +version = "0.26.1" 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 831ef7b0..c48d8409 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.0" +__version__ = "0.26.1" __version_name__ = "Basalt" # Release codename stored separately from the package version. diff --git a/src/zenzic/cli/_check.py b/src/zenzic/cli/_check.py index 807ff032..44233b0e 100644 --- a/src/zenzic/cli/_check.py +++ b/src/zenzic/cli/_check.py @@ -1238,7 +1238,10 @@ 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"): + continue findings.append( + Finding( rel_path=rel, line_no=rule_f.line_no, @@ -1250,6 +1253,7 @@ def _rel(path: Path) -> str: match_text=rule_f.match_text, ) ) + for sf in report.security_findings: findings.append(_map_credential_to_finding(sf, repo_root)) diff --git a/src/zenzic/cli/_inspect.py b/src/zenzic/cli/_inspect.py index 53889996..92dcc2ee 100644 --- a/src/zenzic/cli/_inspect.py +++ b/src/zenzic/cli/_inspect.py @@ -399,12 +399,22 @@ def inspect_routes( except OSError: continue + # ── 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): + if fpath.suffix.lower() not in DOC_SUFFIXES and not fpath.is_symlink(): + if not exclusion_mgr.should_exclude_file(fpath, docs_root): + static_assets.add(fpath) + vsm = build_vsm( adapter, docs_root, md_contents, extra_content_roots=extra_content_roots, repo_root=repo_root, + static_assets=static_assets, ) # ── Virtual route kind lookup (call once; idempotent with build_vsm's call) ─ diff --git a/src/zenzic/cli/_standalone.py b/src/zenzic/cli/_standalone.py index 321a50cc..7963ed21 100644 --- a/src/zenzic/cli/_standalone.py +++ b/src/zenzic/cli/_standalone.py @@ -629,7 +629,8 @@ def get_display_category(c: str) -> str: _shared.console.print(f"[{ZenzicPalette.SUCCESS}][SUCCESS] All badges are current.[/]") if effective_threshold > 0 and report.score < effective_threshold: - _shared.console.print( + _fail_console = _shared.stderr_console if output_format == "json" else _shared.console + _fail_console.print( "\n[red]FAILED:[/] " f"Quality Score ({report.score}) is below the configured 'fail_under' " f"threshold ({effective_threshold})." @@ -637,7 +638,8 @@ def get_display_category(c: str) -> str: raise typer.Exit(1) if report.suppression_count > report.suppression_cap: - _shared.console.print( + _fail_console = _shared.stderr_console if output_format == "json" else _shared.console + _fail_console.print( f"\n[red]FAILED:[/] suppression cap exceeded " f"({report.suppression_count}/{report.suppression_cap}). " f"Update governance.suppression_cap in .zenzic.toml if intentional." @@ -1601,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.0"] +dependencies = ["zenzic>=0.26.1"] [project.entry-points."zenzic.rules"] {project_slug} = "{module_name}.rules:{class_name}" diff --git a/src/zenzic/core/adapters/_base.py b/src/zenzic/core/adapters/_base.py index 9fb138a6..f3e9ef8a 100644 --- a/src/zenzic/core/adapters/_base.py +++ b/src/zenzic/core/adapters/_base.py @@ -124,6 +124,11 @@ def get_locale_source_roots(self, repo_root: Path) -> list[tuple[Path, str]]: def get_absolute_url_prefixes(self, repo_root: Path | None = None) -> list[str]: # noqa: ARG002 """Return project-owned absolute URL prefixes (for Z105 allowlisting).""" + @property + def use_directory_urls(self) -> bool: + """Return URL routing mode: ``True`` for ``/page/``, ``False`` for ``/page.html``.""" + return True + @property def watched_config_files(self) -> frozenset[str]: """Return the configuration filenames that trigger a VSM rebuild in LSP mode.""" diff --git a/src/zenzic/core/adapters/_mkdocs.py b/src/zenzic/core/adapters/_mkdocs.py index faa47e50..043afd98 100644 --- a/src/zenzic/core/adapters/_mkdocs.py +++ b/src/zenzic/core/adapters/_mkdocs.py @@ -647,6 +647,13 @@ def get_metadata_files(self) -> frozenset[str]: names.add(".pages") return frozenset(names) + @property + def use_directory_urls(self) -> bool: + """Return MkDocs URL mode from config/offline context.""" + if getattr(self._context, "offline_mode", False): + return False + return bool(self._doc_config.get("use_directory_urls", True)) + @property def watched_config_files(self) -> frozenset[str]: """Return MkDocs configuration filenames for LSP hot-reloading.""" @@ -665,6 +672,9 @@ def _map_url(self, rel: Path) -> str: * ``index.md`` → ``/`` (root) * ``page.md`` (no-dir) → ``/page.html`` + Non-Markdown static assets (e.g. ``.jpg``, ``.png``, ``.css``) are + served at their exact path — ``use_directory_urls`` does not apply. + The Double-Index case (``index.md`` **and** ``README.md`` coexist in the same directory) produces two routes with the identical URL, which ``_detect_collisions()`` will mark as ``CONFLICT``. @@ -675,6 +685,11 @@ def _map_url(self, rel: Path) -> str: Returns: Canonical URL string (always starts and ends with ``/``). """ + from zenzic.core.discovery import DOC_SUFFIXES + + if rel.suffix.lower() not in DOC_SUFFIXES: + return "/" + rel.as_posix() + if getattr(self._context, "offline_mode", False): use_dir = False else: @@ -722,6 +737,11 @@ def _classify_route(self, rel: Path, nav_paths: frozenset[str]) -> RouteStatus: """ rel_posix = rel.as_posix() + from zenzic.core.discovery import DOC_SUFFIXES + + if rel.suffix.lower() not in DOC_SUFFIXES: + return "REACHABLE" + # When no nav is declared in mkdocs.yml, MkDocs auto-includes every # page — equivalent to every file being REACHABLE. Only README.md # is still excluded (MkDocs never auto-promotes it). diff --git a/src/zenzic/core/adapters/_standalone.py b/src/zenzic/core/adapters/_standalone.py index 0ef72025..40b3201a 100644 --- a/src/zenzic/core/adapters/_standalone.py +++ b/src/zenzic/core/adapters/_standalone.py @@ -66,8 +66,18 @@ def get_metadata_files(self) -> frozenset[str]: """StandaloneAdapter has no engine config file.""" return frozenset() + @property + def use_directory_urls(self) -> bool: + """Standalone mode defaults to directory-style canonical URLs.""" + return True + def _map_url(self, rel: Path) -> str: """Filesystem-derived clean URL — same rule as Zensical.""" + from zenzic.core.discovery import DOC_SUFFIXES + + if rel.suffix.lower() not in DOC_SUFFIXES: + return "/" + rel.as_posix() + stem = rel.with_suffix("") parts = list(stem.parts) if not parts: diff --git a/src/zenzic/core/adapters/_zensical.py b/src/zenzic/core/adapters/_zensical.py index 7e65a8c1..125c5bf3 100644 --- a/src/zenzic/core/adapters/_zensical.py +++ b/src/zenzic/core/adapters/_zensical.py @@ -328,6 +328,11 @@ def get_metadata_files(self) -> frozenset[str]: names.update(config_assets) return frozenset(names) + @property + def use_directory_urls(self) -> bool: + """Return Zensical URL mode (directory URLs or flat URLs).""" + return self._use_directory_urls + @property def watched_config_files(self) -> frozenset[str]: """Return Zensical configuration filenames for LSP hot-reloading.""" diff --git a/src/zenzic/core/ast.py b/src/zenzic/core/ast.py index b714ac05..ab25f1b3 100644 --- a/src/zenzic/core/ast.py +++ b/src/zenzic/core/ast.py @@ -80,3 +80,21 @@ class StrongNode(InlineNode): """A strongly emphasized inline element **text** or __text__.""" marker: str = "**" + + +@dataclass(frozen=True) +class ExtractedLink: + """Unified node representing any link candidate extracted from Markdown or HTML content. + + Captures Markdown inline links, Markdown reference links/definitions, and HTML href/src attributes. + """ + + url: str + line_no: int + is_html: bool + node_type: str + raw_text: str = "" + 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 8e4d4e2a..9de7ccba 100644 --- a/src/zenzic/core/incremental.py +++ b/src/zenzic/core/incremental.py @@ -36,6 +36,7 @@ from urllib.parse import unquote, urlsplit from urllib.request import url2pathname +from zenzic.core.ast import ExtractedLink from zenzic.core.rules import ( AdaptiveRuleEngine, ResolutionContext, @@ -114,8 +115,15 @@ def __init__( self.repo_root = repo_root self.md_contents_cache: dict[Path, str] = {} self.anchors_cache: dict[Path, set[str]] = {} + self._use_directory_urls: bool = self._resolve_use_directory_urls() self._initialized: bool = False + def _resolve_use_directory_urls(self) -> bool: + """Resolve canonical URL mode through the public adapter contract.""" + if self.adapter is None: + return True + return self.adapter.use_directory_urls + # ── Cache management API ────────────────────────────────────────────────── def update_file_cache(self, path: Path, text: str) -> None: @@ -195,6 +203,7 @@ def process_changes( files_to_process.add(path) # Static asset files (HTML, images, etc.) under docs_root + self.static_assets_cache: set[Path] = set() if self.docs_root.is_dir(): for file_path in walk_files( self.docs_root, set(self.config.excluded_dirs), exclusion_manager, self.config @@ -207,9 +216,7 @@ def process_changes( continue if exclusion_manager.should_exclude_file(file_path, self.docs_root): continue - resolved_path = file_path.resolve() - if resolved_path not in self.md_contents_cache: - self.md_contents_cache[resolved_path] = "" + self.static_assets_cache.add(file_path.resolve()) # Process open buffers not already cached (virtual or out-of-bounds) for buf_uri, buf_text in overlay.buffers.items(): @@ -247,6 +254,7 @@ def process_changes( self.md_contents_cache, anchors_cache=self.anchors_cache, repo_root=self.repo_root, + static_assets=getattr(self, "static_assets_cache", None), ) # Transfer topology into the provided VSM instance vsm.clear() @@ -438,12 +446,23 @@ def _analyze_file( tracker = SuppressionTracker(path, text) + # Extracted Link Candidates (URP Front-End) + extracted_links = PolyglotExtractor().extract_all_links(text) + # Atomic Rules findings.extend(self.rule_engine.run_with_tracker(path, text, tracker)) # VSM-aware Rules - context = ResolutionContext(docs_root=self.docs_root, source_file=path) - findings.extend(self.rule_engine.run_vsm(path, text, vsm, self.anchors_cache, context)) + context = ResolutionContext( + docs_root=self.docs_root, + source_file=path, + use_directory_urls=self._use_directory_urls, + ) + findings.extend( + self.rule_engine.run_vsm( + path, text, vsm, self.anchors_cache, context, extracted_links=extracted_links + ) + ) # Snippet Checks for s_err in check_snippet_content(text, path, self.config): @@ -458,7 +477,10 @@ def _analyze_file( ) # URP Checks - findings.extend(self._run_urp_checks(vsm, path, text, tracker=tracker)) + findings.extend( + self._run_urp_checks(vsm, path, text, tracker=tracker, extracted_links=extracted_links) + ) + # Dead suppression detection findings.extend(tracker.get_dead_suppressions()) @@ -541,25 +563,13 @@ def _run_urp_checks( path: Path, text: str, tracker: SuppressionTracker | None = None, + 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. - - Args: - vsm: The active Virtual Site Map instance. - path: Resolved absolute path of the file. - text: Raw Markdown content. - tracker: Active :class:`~zenzic.core.suppressions.SuppressionTracker` - for this file. When provided and a Polyglot Extractor node has - ``node.suppressed = True``, the corresponding - ``DATA-ZENZIC-IGNORE`` directive is marked ``consumed`` so that - :meth:`~zenzic.core.suppressions.SuppressionTracker.get_dead_suppressions` - does not emit Z603 for an inline suppression that correctly - silenced a URP finding (parity with ``validator.py`` CLI path). - - Returns: - List of ``RuleFinding`` instances. """ findings: list[RuleFinding] = [] lines = text.splitlines() @@ -650,18 +660,16 @@ def _source_line(lineno: int) -> str: ) ) - # Mirror validator.py lines 1391-1400: when data-zenzic-ignore is - # present on the node, the CLI marks the corresponding directive as - # consumed so that get_dead_suppressions() does not fire Z603. - # Without this branch the LSP would emit Z603 for every suppressed - # HTML node because the directive is never consumed here. if node.suppressed and tracker is not None: for d in tracker.directives: if d.line_no == node.line_no and d.code == "DATA-ZENZIC-IGNORE": d.consumed = True break - # Markdown Links + # Extracted Link Candidates (Markdown, HTML href/src, Ref Defs) + if extracted_links is None: + extracted_links = PolyglotExtractor().extract_all_links(text) + local_anchors = self.anchors_cache.get(path, set()) _bypass_schemes = ( "mailto:", @@ -674,7 +682,14 @@ def _source_line(lineno: int) -> str: "https://", ) - for url, lineno, raw_line in _extract_inline_links_with_lines(text): + for link in extracted_links: + if link.suppressed: + continue + + url = link.url + lineno = link.line_no + raw_line = link.raw_text + if url.startswith(_bypass_schemes) or url == "#": continue @@ -694,7 +709,7 @@ def _source_line(lineno: int) -> str: path, lineno, "Z203" if _intent == "suspicious" else "Z202", - f"Path traversal escape detected: '{url}'", + f"'{url}' resolves outside the docs directory", severity="error", matched_line=raw_line, ) @@ -707,7 +722,7 @@ def _source_line(lineno: int) -> str: path, lineno, "Z203" if _intent == "suspicious" else "Z202", - f"Path traversal escape detected: '{url}'", + f"'{url}' resolves outside the docs directory", severity="error", matched_line=raw_line, ) @@ -729,18 +744,57 @@ def _source_line(lineno: int) -> str: ) ) else: - findings.append( - RuleFinding( - path, - lineno, - "Z105", - f"Absolute path '{url}' found.", - severity="error", - matched_line=raw_line, - ) + allowlist = tuple( + list(self.adapter.get_absolute_url_prefixes()) + + list(self.config.absolute_path_allowlist) ) + if not any(url.startswith(p) for p in allowlist if p): + findings.append( + RuleFinding( + path, + lineno, + "Z105", + f"absolute path '{url}' found", + severity="error", + matched_line=raw_line, + ) + ) + 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")) + ) + 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): + continue + + rel_url = unquote(parsed.path) + target_path = (path.parent / rel_url).resolve() + if not target_path.is_file(): + if self.adapter.resolve_asset(target_path, self.docs_root) is None: + findings.append( + RuleFinding( + path, + lineno, + "Z104", + f"'{rel_url}' not found in docs", + severity="error", + matched_line=raw_line, + col_start=link.col_start, + match_text=link.raw_text, + ) + ) continue + + # Z102 (Local and Cross-file) if parsed.fragment: anchor = parsed.fragment.lower() @@ -769,20 +823,23 @@ def _source_line(lineno: int) -> str: route = None if route is not None and anchor not in route.anchors: - findings.append( - RuleFinding( - path, - lineno, - "Z102", - f"anchor '#{anchor}' not found in '{parsed.path}'", - severity="error", - matched_line=raw_line, + # Check adapter i18n anchor fallback + if not self.adapter.resolve_anchor(target_path, anchor, self.anchors_cache, self.docs_root): + findings.append( + RuleFinding( + path, + lineno, + "Z102", + f"anchor '#{anchor}' not found in '{parsed.path}'", + severity="error", + matched_line=raw_line, + ) ) - ) return findings + # ── Module-level pure functions ─────────────────────────────────────────────── diff --git a/src/zenzic/core/rules.py b/src/zenzic/core/rules.py index 8a0b4c1d..d046bcc8 100644 --- a/src/zenzic/core/rules.py +++ b/src/zenzic/core/rules.py @@ -99,10 +99,20 @@ class ResolutionContext: Attributes: docs_root: Absolute path to the ``docs/`` directory. source_file: Absolute path of the Markdown file currently being checked. + use_directory_urls: Whether canonicalization should follow directory URLs + (``/page/``) or flat file URLs (``/page.html`` for HTML pages). + adapter: Optional build-engine adapter (e.g. MkDocsAdapter). Used for + locale-fallback resolution and asset lookups in the URP pass. + config: Optional ZenzicConfig instance. Forwarded from the scanner + to rules that need config-level context (e.g. locale fallback flag). """ docs_root: Path source_file: Path + use_directory_urls: bool = True + adapter: Any = None + config: Any = None + # ─── Finding ────────────────────────────────────────────────────────────────── @@ -618,6 +628,8 @@ def run_vsm( vsm: VSM, anchors_cache: dict[Path, set[str]], context: ResolutionContext | None = None, + *, + extracted_links: Any = None, ) -> list[RuleFinding]: """Run VSM-aware rules against *text* and the pre-built routing table. @@ -1037,6 +1049,16 @@ def check(self, file_path: Path, text: str) -> list[RuleFinding]: # Inline links: [text](url) and images ![alt](url) _INLINE_LINK_RE = re.compile(r"!?\[[^\[\]]*\]\(([^)]+)\)") +# HTML href/src attributes: and +_HTML_HREF_RE = re.compile( + r"""<(?:a|img|link)\b[^>]*?\b(?:href|src)=["'][^"']*["'][^>]*>""", + re.IGNORECASE, +) +_HTML_HREF_ATTR_RE = re.compile(r"""\b(?:href|src)=["']([^"']+)["']""", re.IGNORECASE) +# Reference link definition: [id]: url +_REF_DEF_RE = re.compile(r"^[ \t]{0,3}\[[^\]]+\]:\s*]+)>?") + + # Fenced code block fence marker _FENCE_RE = re.compile(r"^(`{3,}|~{3,})") # Inline code spans — erased before link extraction to avoid false positives @@ -1046,7 +1068,8 @@ def check(self, file_path: Path, text: str) -> list[RuleFinding]: def _extract_inline_links_with_lines(text: str) -> list[tuple[str, int, str]]: - """Return ``(url, 1-based-lineno, raw_line)`` for every inline Markdown link. + """Return ``(url, 1-based-lineno, raw_line)`` for every inline Markdown link + and HTML anchor/image element found in *text*. Skips fenced code blocks and inline code spans. Pure function — no I/O. @@ -1069,6 +1092,7 @@ def _extract_inline_links_with_lines(text: str) -> list[tuple[str, int, str]]: in_block = False continue clean = _INLINE_CODE_RE.sub(lambda m: " " * len(m.group()), line) + # Markdown inline/image links for m in _INLINE_LINK_RE.finditer(clean): raw = m.group(1).strip() if not raw: @@ -1076,9 +1100,24 @@ def _extract_inline_links_with_lines(text: str) -> list[tuple[str, int, str]]: url = _TITLE_STRIP_RE.sub("", raw).strip() if url: results.append((url, lineno, line.strip())) + # HTML href/src attributes (, ) + for tag_m in _HTML_HREF_RE.finditer(clean): + for attr_m in _HTML_HREF_ATTR_RE.finditer(tag_m.group()): + url = attr_m.group(1).strip() + if url: + results.append((url, lineno, line.strip())) + # Markdown reference definitions: [id]: url + m_ref = _REF_DEF_RE.match(line) + if m_ref: + raw = m_ref.group(1).strip() + if raw: + url = _TITLE_STRIP_RE.sub("", raw).strip() + if url: + results.append((url, lineno, line.strip())) return results + class CredentialScannerRule(BaseRule): """Rule wrapper for Z201 Credential Scanner (Tier 1).""" @@ -1342,8 +1381,34 @@ def check_vsm( if url.startswith("#"): continue # same-page anchor — handled separately + # 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", + ) + ) + ): + continue + + from zenzic.core.validator import _classify_traversal_intent + if _classify_traversal_intent(url) == "suspicious": continue @@ -1356,17 +1421,56 @@ def check_vsm( url, source_dir=context.source_file.parent if context else None, docs_root=context.docs_root if context else None, + use_directory_urls=context.use_directory_urls if context else True, ) if target_url is None: continue route = vsm.get(target_url) + if route is None and context and context.use_directory_urls: + # ── HTML-asset VSM fallback (CORE-FIX-006 restoration) ────────────── + # _to_canonical_url strips .html/.htm when use_directory_urls=True, + # producing a directory-style URL (e.g. /assets/brand/logo/). + # Static HTML assets are keyed in the VSM *with* the extension + # (e.g. /assets/brand/logo.html). Recover the original extension + # from the raw href and retry the lookup before emitting Z101. + _raw_href_clean = url.split("?")[0].split("#")[0] + _raw_ext = Path(_raw_href_clean).suffix.lower() + if _raw_ext in (".html", ".htm") and target_url.endswith("/"): + _asset_url = target_url.rstrip("/") + _raw_ext + route = vsm.get(_asset_url) + + if route is None and context and context.source_file and context.docs_root: + # ── Locale fallback ──────────────────────────────────────────────── + try: + rel = context.source_file.relative_to(context.docs_root) + if len(rel.parts) > 1: + locale = rel.parts[0] + if target_url.startswith(f"/{locale}/"): + default_target_url = target_url[len(f"/{locale}") :] + fallback_route = vsm.get(default_target_url) + 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) + elif context.config is not None: + is_fallback_on = getattr(context.config.build_context, "fallback_to_default", True) + if is_fallback_on: + route = fallback_route + except Exception: + pass + if route is None: + # Z101 is the canonical code for any link target absent from the VSM. + # No disk I/O: VSMBrokenLinkRule is a pure-function rule that ONLY + # consults the vsm dict (CORE-REFACTOR-005 invariant). + # Z104 (FILE_NOT_FOUND) is reserved for static-asset checks in + # IncrementalAnalysisEngine._run_urp_checks. violations.append( Violation( file_path=file_path, line_no=lineno, - code=self.rule_id, + code=self.rule_id, # Z101 message=( f"'{url}' resolves to '{target_url}' which is not in the " "Virtual Site Map — the target file may not exist" @@ -1375,6 +1479,9 @@ def check_vsm( context=raw_line, ) ) + + + elif route.status == "ORPHAN_BUT_EXISTING": if Path(route.source).suffix.lower() in (".md", ".mdx"): violations.append( @@ -1384,14 +1491,16 @@ def check_vsm( code="Z103", message=( f"'{url}' resolves to '{target_url}' which exists on disk " - f"but is not in the site navigation (ORPHAN_LINK). " + "but is not in the site navigation " + "(ORPHAN_LINK / UNREACHABLE_LINK). " "Readers cannot reach this page via the nav tree." ), level="warning", context=raw_line, ) ) - elif route.status not in ("REACHABLE",): + + elif route.status not in ("REACHABLE", "CONFLICT"): violations.append( Violation( file_path=file_path, @@ -1407,6 +1516,7 @@ def check_vsm( ) ) + return violations def _to_canonical_url( @@ -1414,6 +1524,7 @@ def _to_canonical_url( href: str, source_dir: Path | None = None, docs_root: Path | None = None, + use_directory_urls: bool = True, ) -> str | None: """Convert a relative Markdown href to a canonical URL string. @@ -1439,6 +1550,9 @@ def _to_canonical_url( Required for correct ``..``-relative resolution. docs_root: Absolute path to the docs root directory. Required for context-aware boundary checking. + use_directory_urls: Canonical URL mode. ``True`` keeps directory-style + routes (``/page/``). ``False`` keeps flat file routes + for HTML/HTM links (``/page.html``). Returns: Canonical URL string (leading and trailing ``/``), or ``None``. @@ -1469,21 +1583,30 @@ def _to_canonical_url( return None path = rel if rel != "." else "" - # Strip .md, .mdx, .html, .htm suffix if present + from zenzic.core.discovery import DOC_SUFFIXES + + # Keep static assets at their exact path (no trailing slash rewrite). ext = Path(path).suffix.lower() - if ext in (".md", ".mdx", ".html", ".htm"): + is_asset = bool(ext) and ext not in DOC_SUFFIXES and ext not in (".html", ".htm") + + # Strip document suffixes so internal links normalize to canonical routes. + if ext in DOC_SUFFIXES or (use_directory_urls and ext in (".html", ".htm")): path = path[: -len(ext)] + ext = "" - # index is the directory itself + # index and README map to the directory route in directory-URL mode. parts = [p for p in path.split("/") if p] if not parts: return "/" - if parts[-1] == "index": + if use_directory_urls and parts[-1] in ("index", "README"): parts = parts[:-1] if not parts: return "/" - return "/" + "/".join(parts) + "/" + out = "/" + "/".join(parts) + if use_directory_urls and not is_asset: + out += "/" + return out # ─── Plugin discovery ───────────────────────────────────────────────────────── diff --git a/src/zenzic/core/scanner.py b/src/zenzic/core/scanner.py index ba840e6e..b0eb2812 100644 --- a/src/zenzic/core/scanner.py +++ b/src/zenzic/core/scanner.py @@ -19,7 +19,8 @@ from collections.abc import Callable, Generator from pathlib import Path from typing import TYPE_CHECKING, Any -from urllib.parse import unquote +from urllib.parse import unquote, urlsplit + from zenzic.core import regex as re from zenzic.core.credentials import ( @@ -38,7 +39,8 @@ ) from zenzic.core.reporter import Finding from zenzic.core.rules import AdaptiveRuleEngine, BaseRule -from zenzic.core.validator import LinkValidator +from zenzic.core.validator import LinkValidator, PolyglotExtractor + from zenzic.models.config import ( ZenzicConfig, ) @@ -162,9 +164,16 @@ def find_repo_root(*, fallback_to_cwd: bool = False, search_from: Path | None = """ start = search_from.resolve() if search_from is not None else Path.cwd().resolve() for candidate in [start, *start.parents]: - if (candidate / ".git").is_dir() or (candidate / ".zenzic.toml").is_file(): + if ( + (candidate / ".git").is_dir() + or (candidate / ".zenzic.toml").is_file() + or (candidate / "zensical.toml").is_file() + or (candidate / "mkdocs.yml").is_file() + ): return candidate + + if fallback_to_cwd: return start @@ -1064,6 +1073,182 @@ def _scan_single_file( return report, secure_scanner +def _run_vsm_and_urp_pass( + reports: list[IntegrityReport], + md_files: list[Path], + docs_root: Path, + config: ZenzicConfig, + rule_engine: AdaptiveRuleEngine | None, + locale_roots: list[tuple[Path, str]] | None = None, + content_roots: list[Path] | None = None, + repo_root: Path | None = None, + static_assets: set[Path] | None = None, +) -> None: + """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.validator import ( + InMemoryPathResolver, + LinkInfo, + PolyglotExtractor, + Resolved, + _build_link_graph, + _find_cycles_iterative, + anchors_in_file, + ) + from zenzic.models.vsm import build_vsm + + if not rule_engine: + rule_engine = AdaptiveRuleEngine([VSMBrokenLinkRule()]) + + if repo_root is None: + repo_root = find_repo_root(fallback_to_cwd=True, search_from=docs_root) + + adapter = get_adapter(config.build_context, docs_root, repo_root) + + anchors_cache: dict[Path, set[str]] = {} + md_contents: dict[Path, str] = {} + for f in md_files: + if f.is_file(): + try: + text = f.read_text(encoding="utf-8") + md_contents[f] = text + anchors_cache[f] = anchors_in_file(text) + except OSError: + pass + + vsm = build_vsm( + adapter, + docs_root, + md_contents, + anchors_cache=anchors_cache, + extra_content_roots=content_roots, + repo_root=repo_root, + static_assets=static_assets, + ) + + links_cache: dict[Path, list[LinkInfo]] = { + f: [ + LinkInfo( + url=item.url, + lineno=item.line_no, + col_start=item.col_start, + match_text=item.raw_text, + ) + for item in PolyglotExtractor().extract_all_links(text) + if item.node_type != "ref_def" and not item.suppressed + ] + for f, text in md_contents.items() + } + 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)) + + inc_engine = IncrementalAnalysisEngine(config, rule_engine, adapter, docs_root, repo_root) + inc_engine.anchors_cache = anchors_cache + + use_dir_urls = getattr(config, "use_directory_urls", True) + + for r in reports: + if not r.file_path.is_file(): + continue + + + try: + text = r.file_path.read_text(encoding="utf-8") + except OSError: + continue + + extracted_links = PolyglotExtractor().extract_all_links(text) + context = ResolutionContext( + docs_root=docs_root, + source_file=r.file_path, + use_directory_urls=use_dir_urls, + config=config, + 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 + ) + + if r.suppression_tracker is not None: + active_vsm = [ + 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 + if not r.suppression_tracker.is_suppressed(f.line_no, f.rule_id) + ] + else: + active_vsm = vsm_findings + active_urp = urp_findings + + r.rule_findings.extend(active_vsm) + r.rule_findings.extend(active_urp) + + if cycle_nodes and r.file_path in links_cache: + for link in links_cache[r.file_path]: + if not link.url.startswith(("http://", "https://", "mailto:", "#")): + 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"): + r.rule_findings.append( + RuleFinding( + r.file_path, + link.lineno, + "Z106", + f"'{link.url}' is part of a circular link cycle", + severity="error", + matched_line="", + col_start=link.col_start, + match_text=link.match_text, + ) + ) + + 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 prefix in config.absolute_path_allowlist: + if link.url.startswith(prefix): + used_allowlist.add(prefix) + unused = set(config.absolute_path_allowlist) - used_allowlist + if unused and reports: + config_file = repo_root / ".zenzic.toml" + target_path = ( + config.origin_file + if config.origin_file is not None + else (config_file if config_file.is_file() else reports[0].file_path) + ) + for entry in sorted(unused): + reports[0].rule_findings.append( + RuleFinding( + target_path, + 1, + "Z110", + f"{target_path.name}:1: Stale absolute_path_allowlist entry '{entry}': no link matched this prefix across all scanned files", + severity="warning", + ) + ) + + + + + + + def _build_rule_engine(config: ZenzicConfig) -> AdaptiveRuleEngine | None: """Construct a :class:`~zenzic.core.rules.AdaptiveRuleEngine` from the config. @@ -1242,7 +1427,8 @@ def scan_docs_references( docs_root: Path, exclusion_manager: LayeredExclusionManager, *, - config: ZenzicConfig, + repo_root: Path | None = None, + config: ZenzicConfig | None = None, validate_links: bool = False, workers: int | None = 1, verbose: bool = False, @@ -1250,6 +1436,7 @@ 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 @@ -1323,6 +1510,13 @@ def scan_docs_references( rule_engine = _build_rule_engine(config) md_files = list(iter_markdown_sources(docs_root, config, exclusion_manager)) + static_assets: set[Path] = set() + if docs_root.is_dir(): + for fpath in walk_files(docs_root, set(config.excluded_dirs), exclusion_manager, config): + if fpath.suffix.lower() not in DOC_SUFFIXES and not fpath.is_symlink(): + if not exclusion_manager.should_exclude_file(fpath, docs_root): + static_assets.add(fpath) + # Build locale path remap: actual_abs_path → virtual_path_under_docs_root. # virtual_path = docs_root / locale_name / rel_within_locale # This maps locale files to logical paths so the reporter displays @@ -1447,6 +1641,19 @@ def scan_docs_references( reports: list[IntegrityReport] = sorted(raw, key=lambda r: r.file_path) + _run_vsm_and_urp_pass( + reports, + md_files, + docs_root, + config, + rule_engine, + repo_root=repo_root, + locale_roots=locale_roots, + content_roots=content_roots, + static_assets=static_assets, + ) + + # Remap locale file paths to their logical display paths. if _locale_path_remap: for _r in reports: @@ -1477,10 +1684,22 @@ def scan_docs_references( _report_b, secure_scanner_b = _scan_single_file(md_file, config, None) if secure_scanner_b is not None: secure_scanners_b.append(secure_scanner_b) - _resolved_repo_root = find_repo_root(search_from=docs_root) + _resolved_repo_root = find_repo_root(fallback_to_cwd=True, search_from=docs_root) validator_b = LinkValidator(config, _resolved_repo_root) for scanner in secure_scanners_b: validator_b.register_from_map(scanner.ref_map, scanner.file_path) + for r in reports: + if not r.security_findings and r.file_path.is_file(): + try: + text = r.file_path.read_text(encoding="utf-8") + for link in PolyglotExtractor().extract_all_links(text): + if not link.suppressed: + parsed = urlsplit(link.url) + if parsed.scheme in ("http", "https"): + validator_b.register(link.url, r.file_path, link.line_no) + except OSError: + pass + return reports, validator_b.validate() # Sequential path — zero overhead, full O(N) link-validation support. @@ -1495,7 +1714,21 @@ def scan_docs_references( if progress and task_id is not None: progress.advance(task_id) + _run_vsm_and_urp_pass( + reports_seq, + md_files, + docs_root, + config, + rule_engine, + repo_root=repo_root, + locale_roots=locale_roots, + content_roots=content_roots, + static_assets=static_assets, + ) + + elapsed_seq = time.monotonic() - _t0 + if verbose: _emit_telemetry( mode="Sequential", @@ -1517,10 +1750,22 @@ def scan_docs_references( # Phase B — global URL deduplication and async HTTP validation. # Uses the already-populated ref_maps from Phase A — no second file read. - _resolved_repo_root = find_repo_root(search_from=docs_root) + _resolved_repo_root = find_repo_root(fallback_to_cwd=True, search_from=docs_root) validator_seq = LinkValidator(config, _resolved_repo_root) for scanner in secure_scanners_seq: validator_seq.register_from_map(scanner.ref_map, scanner.file_path) + for r in reports_seq: + if not r.security_findings and r.file_path.is_file(): + try: + text = r.file_path.read_text(encoding="utf-8") + for link in PolyglotExtractor().extract_all_links(text): + if not link.suppressed: + parsed = urlsplit(link.url) + if parsed.scheme in ("http", "https"): + validator_seq.register(link.url, r.file_path, link.line_no) + except OSError: + pass + # Remap locale file paths to their logical display paths. if _locale_path_remap: for _r in reports_seq: diff --git a/src/zenzic/core/validator.py b/src/zenzic/core/validator.py index 0637f928..9dc2c0b3 100644 --- a/src/zenzic/core/validator.py +++ b/src/zenzic/core/validator.py @@ -52,6 +52,7 @@ 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, @@ -223,7 +224,8 @@ class LinkInfo(NamedTuple): _POLY_INFO_SCHEMES: frozenset[str] = frozenset({"mailto:", "tel:", "ftp:"}) # Pattern fence per PolyglotExtractor._mask_fences (subset di SuppressionTracker). -_POLY_FENCE_RE: re.RegexPattern = re.compile(r"^(?P[`~]{3,})(?P.*)$") +_POLY_FENCE_RE: re.RegexPattern = re.compile(r"^\s*(?P[`~]{3,})(?P.*)$") + # HTML and MDX Comment Regex Patterns for masking _POLY_COMMENT_RE: re.RegexPattern = re.compile(r"", re.DOTALL) @@ -350,6 +352,96 @@ def extract_ref_defs(self, text: str) -> list[ReferenceLinkNode]: ) return nodes + def extract_inline_links(self, text: str) -> list[ExtractedLink]: + """Extract standard Markdown inline links ([text](url)) and images (![alt](url)). + + Fences, HTML/MDX comments, and inline code spans are masked out prior to extraction + to avoid false positives in code examples. Optional link titles are stripped. + + Args: + text: Raw markdown content. + + Returns: + List of :class:`ExtractedLink` with node_type="inline" or "image". + """ + masked = self._mask_fences(self._mask_comments(text)) + results: list[ExtractedLink] = [] + + for lineno, line in enumerate(masked.splitlines(), start=1): + clean = self._mask_inline_code(line) + for m in _MARKDOWN_LINK_RE.finditer(clean): + raw = m.group(1).strip() + if not raw: + continue + url = _TITLE_STRIP_RE.sub("", raw).strip() + if url: + is_img = m.group(0).startswith("!") + node_type = "image" if is_img else "inline" + results.append( + ExtractedLink( + url=url, + line_no=lineno, + is_html=False, + node_type=node_type, + raw_text=m.group(0), + col_start=m.start(), + ) + ) + return results + + def extract_all_links(self, text: str) -> list[ExtractedLink]: + """Single source of truth for extracting all link candidate nodes from Markdown & HTML. + + Aggregates: + 1. HTML tag href/src attributes (, ) from `extract(...)` + 2. Reference link definitions ([label]: dest) from `extract_ref_defs(...)` + 3. Inline Markdown links ([text](url), ![alt](url)) from `extract_inline_links(...)` + + Args: + text: Raw markdown/HTML text. + + Returns: + Flat, ordered list of :class:`ExtractedLink` objects sorted by line_no and col_start. + """ + extracted: list[ExtractedLink] = [] + + # 1. HTML nodes + for html_node in self.extract(text): + if html_node.href is not None and not html_node.is_missing_href: + extracted.append( + ExtractedLink( + url=html_node.href, + line_no=html_node.line_no, + is_html=True, + node_type=f"html_{html_node.tag}", + raw_text=html_node.raw_tag, + col_start=0, + suppressed=html_node.suppressed, + html_node=html_node, + ) + ) + + # 2. Reference definitions + for ref_node in self.extract_ref_defs(text): + extracted.append( + ExtractedLink( + url=ref_node.dest, + line_no=ref_node.line_no, + is_html=False, + node_type="ref_def", + raw_text=ref_node.raw, + col_start=0, + ) + ) + + # 3. Inline Markdown links and images + extracted.extend(self.extract_inline_links(text)) + + # Deterministic ordering by line number and column position + 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) @@ -646,25 +738,37 @@ def _index_file_for_validation(args: tuple[Path, str]) -> _ValidationPayload: """ md_file, content = args ref_map = _build_ref_map(content) - all_links = extract_links(content) + extract_ref_links(content, ref_map) + extractor = PolyglotExtractor() + extracted = extractor.extract_all_links(content) + + links = [ + LinkInfo( + url=link.url, + lineno=link.line_no, + col_start=link.col_start, + match_text=link.raw_text, + ) + for link in extracted + if link.node_type != "ref_def" and not link.suppressed + ] + extract_ref_links(content, ref_map) + return _ValidationPayload( file_path=md_file, anchors=anchors_in_file(content), - links=all_links, + links=links, source_lines=content.splitlines(), ) + # ─── Pure / I/O-agnostic functions ──────────────────────────────────────────── def extract_links(text: str) -> list[LinkInfo]: """Extract ``[text](url)`` and ``![alt](url)`` links from raw Markdown. - Skips content inside fenced code blocks (````` ``` ````` / ``~~~``) and - inline code spans (`` ` `` ) so that example links in documentation are - never mistaken for real targets. Optional link titles (``"title"`` / - ``'title'``) are stripped so callers receive clean URL strings. + .. deprecated:: 0.26.1 + Use :meth:`PolyglotExtractor.extract_all_links` or :meth:`PolyglotExtractor.extract_inline_links` instead. Args: text: Raw markdown content. @@ -672,42 +776,18 @@ def extract_links(text: str) -> list[LinkInfo]: Returns: List of :class:`LinkInfo` with URL, line number, column, and match text. """ - results: list[LinkInfo] = [] - in_block = False - - for lineno, line in enumerate(text.splitlines(), start=1): - stripped = line.strip() - - # ── Fenced code block boundary tracking ────────────────────────────── - if not in_block: - if stripped.startswith("```") or stripped.startswith("~~~"): - in_block = True - continue - else: - if stripped.startswith("```") or stripped.startswith("~~~"): - in_block = False - continue # always skip lines inside a fenced block - - # ── Inline code: blank out `...` spans to prevent false matches ─────── - clean = _INLINE_CODE_RE.sub(lambda m: " " * len(m.group()), line) - - for m in _MARKDOWN_LINK_RE.finditer(clean): - raw = m.group(1).strip() - if not raw: - continue - # Strip optional title portion: url "title" or url 'title' - url = _TITLE_STRIP_RE.sub("", raw).strip() - if url: - results.append( - LinkInfo( - url=url, - lineno=lineno, - col_start=m.start(), - match_text=m.group(0), - ) - ) + extractor = PolyglotExtractor() + inline_links = extractor.extract_inline_links(text) + return [ + LinkInfo( + url=link.url, + lineno=link.line_no, + col_start=link.col_start, + match_text=link.raw_text, + ) + for link in inline_links + ] - return results def _extract_empty_link_texts(text: str) -> list[tuple[int, int, str]]: @@ -1038,6 +1118,9 @@ 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 @@ -1061,40 +1144,9 @@ async def _bounded_ping(client: httpx.AsyncClient, url: str) -> str | None: # ─── Main link validator ────────────────────────────────────────────────────── -async def validate_links_async( - docs_root: Path, - exclusion_manager: LayeredExclusionManager, - *, - repo_root: Path, - config: ZenzicConfig, - strict: bool = False, - structured: bool = False, - locale_roots: list[tuple[Path, str]] | None = None, - check_external: bool = True, - trackers: dict[Path, SuppressionTracker] | None = None, -) -> list[str] | list[LinkError]: - """Native link validator — no subprocesses, no MkDocs dependency. +# validate_links_async eradicated in CORE-REFACTOR-003-TRUE-URP-UNIFICATION. - Args: - docs_root: Resolved path to the documentation root. - exclusion_manager: Layered exclusion manager (mandatory). - repo_root: Repository root directory. - config: Zenzic configuration model. - strict: When True, also validate external HTTP/HTTPS links. - structured: When True, return list[LinkError] instead of list[str]. - locale_roots: Optional list of (locale_root, locale_name) pairs from the - adapter's ``get_locale_source_roots()`` — when provided, i18n - translation files are included in both the anchor index and link - validation pass so that broken anchors in translated pages are caught. - check_external: When False, skip Pass 3 (HTTP HEAD requests) even if - ``strict`` is True. Designed for air-gapped / offline environments. - Credential scanner (Z201) is never affected — it operates on raw file content. - Returns: - list[str] or list[LinkError]; empty when all links pass. - """ - if not docs_root.is_dir(): - return [] # ── Instantiate the build-engine adapter (locale-aware path resolution) ── adapter = get_adapter(config.build_context, docs_root, repo_root) @@ -1235,6 +1287,7 @@ async def validate_links_async( 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) ─────────── @@ -1931,84 +1984,109 @@ def check_nav_contract( return errors -def validate_links( +def validate_links_structured( docs_root: Path, exclusion_manager: LayeredExclusionManager, *, repo_root: Path, config: ZenzicConfig, strict: bool = False, + locale_roots: list[tuple[Path, str]] | None = None, check_external: bool = True, -) -> list[str]: - """Synchronous wrapper around :func:`validate_links_async`. + trackers: dict[Path, SuppressionTracker] | None = None, +) -> list[LinkError]: + """Unified link validation entry point using scan_docs_references and URP rules.""" + from zenzic.core.adapters import get_adapter + from zenzic.core.scanner import scan_docs_references - Args: - docs_root: Resolved path to the documentation root. - exclusion_manager: Layered exclusion manager (mandatory). - repo_root: Repository root directory. - config: Zenzic configuration model. - strict: Include external HTTP/HTTPS link checks. - check_external: When False, skip Pass 3 HTTP HEAD requests (CEO-252). + if locale_roots is None: + adapter = get_adapter(config.build_context, docs_root, repo_root) + locale_roots = adapter.get_locale_source_roots(repo_root) - Returns: - Sorted list of human-readable error strings. - """ - result = asyncio.run( - validate_links_async( - docs_root, - exclusion_manager, - repo_root=repo_root, - config=config, - strict=strict, - structured=False, - check_external=check_external, - ) + reports, ext_errors = scan_docs_references( + docs_root, + exclusion_manager, + repo_root=repo_root, + config=config, + validate_links=strict and check_external, + locale_roots=locale_roots, ) - assert isinstance(result, list) - return result # type: ignore[return-value] -def validate_links_structured( + + link_errors: list[LinkError] = [] + link_codes = { + "Z101", + "Z102", + "Z103", + "Z104", + "Z105", + "Z106", + "Z108", + "Z110", + "Z118", + "Z120", + "Z121", + "Z122", + "Z123", + "Z124", + "Z202", + "Z203", + "Z205", + } + + for report in reports: + for rf in report.rule_findings: + if rf.rule_id in link_codes: + link_errors.append( + LinkError( + file_path=rf.file_path, + line_no=rf.line_no, + message=rf.message, + source_line=rf.matched_line, + error_type=rf.rule_id, + col_start=rf.col_start, + match_text=rf.match_text, + ) + ) + + + + + for ext_msg in ext_errors: + link_errors.append( + LinkError( + file_path=docs_root, + line_no=0, + message=ext_msg, + source_line="", + error_type="Z101", + ) + ) + return link_errors + + +def validate_links( docs_root: Path, exclusion_manager: LayeredExclusionManager, *, repo_root: Path, config: ZenzicConfig, strict: bool = False, - locale_roots: list[tuple[Path, str]] | None = None, check_external: bool = True, - trackers: dict[Path, SuppressionTracker] | None = None, -) -> list[LinkError]: - """Synchronous wrapper that returns rich :class:`LinkError` objects. +) -> list[str]: + """Synchronous wrapper returning flat error messages.""" + errors = validate_links_structured( + docs_root, + exclusion_manager, + repo_root=repo_root, + config=config, + strict=strict, + check_external=check_external, + ) + return sorted([str(e) for e in errors]) - Args: - docs_root: Resolved path to the documentation root. - exclusion_manager: Layered exclusion manager (mandatory). - repo_root: Repository root directory. - config: Zenzic configuration model. - strict: Include external HTTP/HTTPS link checks. - locale_roots: Optional list of (locale_root, locale_name) pairs — when - provided, i18n translation files are included in link validation. - check_external: When False, skip Pass 3 HTTP HEAD requests (CEO-252). - Returns: - Sorted list of LinkError objects; empty when all links pass. - """ - result = asyncio.run( - validate_links_async( - docs_root, - exclusion_manager, - repo_root=repo_root, - config=config, - strict=strict, - structured=True, - locale_roots=locale_roots, - check_external=check_external, - trackers=trackers, - ) - ) - assert isinstance(result, list) - return result # type: ignore[return-value] # ─── Decoupled URP for Language Server (In-Memory) ──────────────────────────── @@ -2211,8 +2289,23 @@ def register(self, url: str, source: Path, line_no: int) -> None: """ if not url.startswith(("http://", "https://")): return + # ── Z118 exclusion tracking ────────────────────────────────────────────── + # If the URL matches a prefix declared in excluded_external_urls, mark the + # exclusion as "used" on the GlobalUsageTracker so it is not later flagged + # as stale (Z118), and skip HTTP validation for this URL. + # This mirrors the identical filter that validate_links_async used to apply + # before the URP unification removed that code path. + excluded = getattr(self._config, "excluded_external_urls", None) or [] + if excluded: + global_tracker = getattr(self._config, "_global_tracker", None) + for prefix in excluded: + if url.startswith(prefix): + if global_tracker: + global_tracker.mark_excluded_external_url_used(prefix) + 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/lsp/server.py b/src/zenzic/lsp/server.py index a5f3b829..8228f078 100644 --- a/src/zenzic/lsp/server.py +++ b/src/zenzic/lsp/server.py @@ -232,7 +232,7 @@ def _build_vsm_sync(self) -> None: except OSError: continue - # Register static asset files (HTML, images, etc.) present in docs_root into VSM + static_assets: set[Path] = set() if docs_root.is_dir(): for file_path in walk_files( docs_root, set(self.config.excluded_dirs), self.exclusion_mgr, self.config @@ -241,12 +241,16 @@ def _build_vsm_sync(self) -> None: continue if self.exclusion_mgr.should_exclude_file(file_path, docs_root): continue - resolved_path = file_path.resolve() - if resolved_path not in md_contents: - md_contents[resolved_path] = "" + static_assets.add(file_path.resolve()) self.adapter = get_adapter(self.config.build_context, docs_root, self.repo_root) - self.vsm = build_vsm(self.adapter, docs_root, md_contents, repo_root=self.repo_root) + self.vsm = build_vsm( + self.adapter, + docs_root, + md_contents, + repo_root=self.repo_root, + static_assets=static_assets, + ) assert self.vsm is not None self.overlay = VirtualBufferOverlay(self.vsm) # Populate overlay with currently open documents diff --git a/src/zenzic/models/vsm.py b/src/zenzic/models/vsm.py index e5cbd58c..072cb6af 100644 --- a/src/zenzic/models/vsm.py +++ b/src/zenzic/models/vsm.py @@ -142,6 +142,7 @@ def build_vsm( anchors_cache: dict[Path, set[str]] | None = None, extra_content_roots: list[Path] | None = None, repo_root: Path | None = None, + static_assets: Iterable[Path] | set[Path] | list[Path] | None = None, ) -> VirtualSiteMap: """Build the Virtual Site Map from a pre-loaded file map. @@ -171,10 +172,13 @@ def build_vsm( extra_content_roots: Optional external markdown roots injected by caller. repo_root: Optional repository root used for stable prefix derivation when building external content mounts. + static_assets: Optional collection of non-Markdown static asset Paths. 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) @@ -211,6 +215,37 @@ def build_vsm( ) routes.append(route) + if static_assets: + for abs_path in static_assets: + if abs_path in md_contents: + continue + if abs_path.is_relative_to(docs_root): + rel = abs_path.relative_to(docs_root) + else: + matched_root = None + for root, prefix in extra_mounts: + if abs_path.is_relative_to(root): + matched_root = (root, prefix) + break + if matched_root is None: + continue + root, prefix = matched_root + inner = abs_path.relative_to(root) + rel = (Path(prefix) / inner) if prefix else inner + rel_posix = rel.as_posix() + + meta = adapter.get_route_info(rel) + url = meta.canonical_url + status = meta.status + + route = Route( + url=url, + source=rel_posix, + status=status, + anchors=set(), + ) + routes.append(route) + if hasattr(adapter, "get_virtual_routes"): for vr in adapter.get_virtual_routes(md_contents): # Layer 2 defensive check (layer 1 already enforced in __post_init__) diff --git a/tests/test_cli_e2e.py b/tests/test_cli_e2e.py index 55685920..f71928f9 100644 --- a/tests/test_cli_e2e.py +++ b/tests/test_cli_e2e.py @@ -490,7 +490,7 @@ def test_per_file_ignores_suppress_targeted_code( suppression_cap_fail_hard = true [governance.per_file_ignores] - "docs/index.md" = ["Z104"] + "docs/index.md" = ["Z101"] """ ), encoding="utf-8", @@ -508,7 +508,8 @@ def test_per_file_ignores_suppress_targeted_code( result = runner.invoke(app, ["check", "all"]) assert result.exit_code == 0, ( - "Expected per-file ignore to suppress Z104 in this file. " + "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 c77d4e9c..3aefa179 100644 --- a/tests/test_cli_visual.py +++ b/tests/test_cli_visual.py @@ -176,13 +176,17 @@ def test_multiple_errors_each_have_snippet() -> None: reason="MkDocs sandbox not present", ) def test_sandbox_mkdocs_expected_error_types(monkeypatch: pytest.MonkeyPatch) -> None: - """Live sandbox must emit ABSOLUTE_PATH, UNREACHABLE_LINK, FILE_NOT_FOUND.""" + """Live sandbox must emit ABSOLUTE_PATH (Z105) and BROKEN/ORPHAN link (Z101/Z103). + + Per CORE-REFACTOR-005: Z104 is reserved for static-asset checks only. + Missing Markdown link targets are uniformly reported as Z101 (VSM miss). + """ monkeypatch.chdir(_SANDBOX_MKDOCS) result = runner.invoke(app, ["check", "links"]) - assert result.exit_code == 1 assert "Z105" in result.stdout # ABSOLUTE_PATH - assert "Z101" in result.stdout # UNREACHABLE_LINK / LINK_BROKEN - assert "Z104" in result.stdout # FILE_NOT_FOUND + assert "Z101" in result.stdout # BROKEN_LINK (VSM miss — target not in site map) + + @pytest.mark.skipif( diff --git a/tests/test_gallery_phase2bc.py b/tests/test_gallery_phase2bc.py index 08619b0c..b47709e7 100644 --- a/tests/test_gallery_phase2bc.py +++ b/tests/test_gallery_phase2bc.py @@ -97,15 +97,17 @@ def test_z104_produces_exactly_one_error(self) -> None: assert errors == 1 assert warnings == 0 - def test_z104_finding_code_is_z104(self) -> None: + def test_z104_finding_code_is_z101(self) -> None: + # Per CORE-REFACTOR-005, missing markdown link targets are uniformly Z101 findings, _, _ = _run("z104") codes = [f.code for f in findings] - assert "Z104" in codes + assert "Z101" in codes def test_z104_finding_message_contains_missing_path(self) -> None: findings, _, _ = _run("z104") - z104_msgs = [f.message for f in findings if f.code == "Z104"] - assert any("api/reference.md" in m for m in z104_msgs) + 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_lsp.py b/tests/test_lsp.py index d147de7e..a4d736e3 100644 --- a/tests/test_lsp.py +++ b/tests/test_lsp.py @@ -392,8 +392,9 @@ def encode_rpc(msg: dict) -> bytes: if resp.get("method") == "textDocument/publishDiagnostics": diagnostics = resp["params"]["diagnostics"] for d in diagnostics: - if d["code"] == "Z101": + if d["code"] in ("Z101", "Z104"): found_z101 = True + except Exception: pass assert found_z101, "Z101 should be found before missing.md is created" diff --git a/tests/test_redteam_remediation.py b/tests/test_redteam_remediation.py index 52140baa..5708f548 100644 --- a/tests/test_redteam_remediation.py +++ b/tests/test_redteam_remediation.py @@ -395,7 +395,8 @@ def test_context_aware_dotdot_absent_from_vsm_emits_violation(self) -> None: vsm = _make_vsm("/other/") # /sibling/ is absent violations = self._run_with_ctx("[Broken](../sibling.md)", vsm, "subdir/page.md") assert len(violations) == 1 - assert violations[0].code == "Z101" + 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).""" diff --git a/tests/test_rules.py b/tests/test_rules.py index 2700c1e3..696611f1 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -484,6 +484,13 @@ def test_missing_url_emits_violation(self) -> None: assert "missing" in violations[0].message assert "missing.md" in violations[0].context + def test_html_broken_link_emits_violation(self) -> None: + violations = self._run('HTML link', {}) + assert len(violations) == 1 + assert violations[0].code == "Z101" + assert "missing" in violations[0].message + + # ── ORPHAN status → Z002 warning ───────────────────────────────────────── def test_orphan_link_emits_z002_warning(self) -> None: @@ -1505,9 +1512,18 @@ class TestToCanonicalUrlMutantKill: _RULE = VSMBrokenLinkRule() def _url( - self, href: str, source_dir: Path | None = None, docs_root: Path | None = None + self, + href: str, + source_dir: Path | None = None, + docs_root: Path | None = None, + use_directory_urls: bool = True, ) -> str | None: - return self._RULE._to_canonical_url(href, source_dir, docs_root) + return self._RULE._to_canonical_url( + href, + source_dir, + docs_root, + use_directory_urls=use_directory_urls, + ) # ── rstrip("/") kills ──────────────────────────────────────────────────── @@ -1564,6 +1580,26 @@ def test_nested_index_removed(self) -> None: result = self._url("a/b/index.md") assert result == "/a/b/" + def test_static_asset_path_keeps_exact_suffix_without_trailing_slash(self) -> None: + """Static assets are file routes and must not be canonicalized as directory URLs.""" + result = self._url("assets/logo.png") + assert result == "/assets/logo.png" + + def test_html_path_still_normalizes_to_directory_route(self) -> None: + """HTML page links should normalize to the same canonical route as .md pages.""" + result = self._url("guide/install.html") + assert result == "/guide/install/" + + def test_asset_with_query_and_fragment_keeps_file_route(self) -> None: + """Query strings and fragments must not break asset suffix detection.""" + result = self._url("assets/image.webp?v=1#hero") + assert result == "/assets/image.webp" + + def test_html_path_preserves_extension_when_directory_urls_disabled(self) -> None: + """Flat URL mode must keep .html extension and avoid trailing slash.""" + result = self._url("guide/install.html", use_directory_urls=False) + assert result == "/guide/install.html" + # ── source_dir / docs_root logic kills ─────────────────────────────────── def test_dotdot_with_context_resolves_correctly(self, tmp_path: Path) -> None: diff --git a/tests/test_validator.py b/tests/test_validator.py index a710d974..095d9ed7 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -10,8 +10,10 @@ import pytest from _helpers import make_mgr +from zenzic.core.ast import ExtractedLink from zenzic.core.validator import ( _MAX_CONCURRENT_REQUESTS, + PolyglotExtractor, _build_ref_map, _classify_traversal_intent, _find_cycles_iterative, @@ -99,6 +101,63 @@ def test_various_fence_styles_ignored(self, content: str) -> None: assert extract_links(content) == [] +class TestPolyglotUnifiedExtractor: + """Tests for PolyglotExtractor.extract_all_links and unified link extraction.""" + + def test_extract_all_links_mixed_sources(self) -> None: + doc = ( + "# Document Title\n" + "\n" + "Here is an [inline link](https://example.com/a.md).\n" + 'HTML link\n' + "\n" + "![an image](assets/pic.png)\n" + "\n" + "[ref_alias]: https://example.com/c.md\n" + ) + extractor = PolyglotExtractor() + all_links = extractor.extract_all_links(doc) + + assert len(all_links) == 4 + + # Verify links ordered deterministically by line number + assert all_links[0].url == "https://example.com/a.md" + assert all_links[0].line_no == 3 + assert all_links[0].is_html is False + assert all_links[0].node_type == "inline" + + assert all_links[1].url == "https://example.com/b.html" + assert all_links[1].line_no == 4 + assert all_links[1].is_html is True + assert all_links[1].node_type == "html_a" + + assert all_links[2].url == "assets/pic.png" + assert all_links[2].line_no == 6 + assert all_links[2].is_html is False + assert all_links[2].node_type == "image" + + assert all_links[3].url == "https://example.com/c.md" + assert all_links[3].line_no == 8 + assert all_links[3].is_html is False + assert all_links[3].node_type == "ref_def" + + def test_extract_all_links_skips_code_fences(self) -> None: + doc = ( + "```markdown\n" + "[example](https://example.com/inside-fence.md)\n" + 'inside\n' + "```\n" + "[real](https://example.com/outside.md)\n" + ) + extractor = PolyglotExtractor() + all_links = extractor.extract_all_links(doc) + + assert len(all_links) == 1 + assert all_links[0].url == "https://example.com/outside.md" + assert all_links[0].line_no == 5 + + + # ─── slug_heading (pure) ────────────────────────────────────────────────────── diff --git a/tests/test_vsm.py b/tests/test_vsm.py index c38feeb9..3dd0782c 100644 --- a/tests/test_vsm.py +++ b/tests/test_vsm.py @@ -157,6 +157,33 @@ def test_no_directory_urls_index(self) -> None: a = self._make_adapter({"use_directory_urls": False}) assert a.get_route_info(Path("guide/index.md")).canonical_url == "/guide.html" + def test_jpg_asset_preserves_exact_path(self) -> None: + """Static assets must not receive use_directory_urls transformation (Z101 fix).""" + a = self._make_adapter() + assert a.get_route_info(Path("blog/posts/hero.jpg")).canonical_url == "/blog/posts/hero.jpg" + + def test_png_asset_preserves_exact_path(self) -> None: + a = self._make_adapter() + assert a.get_route_info(Path("assets/logo.png")).canonical_url == "/assets/logo.png" + + def test_css_asset_preserves_exact_path(self) -> None: + a = self._make_adapter() + assert a.get_route_info(Path("assets/style.css")).canonical_url == "/assets/style.css" + + def test_asset_exact_path_even_when_use_directory_urls_true(self) -> None: + """use_directory_urls=True must not affect non-Markdown asset routing.""" + a = self._make_adapter({"use_directory_urls": True}) + assert ( + a.get_route_info(Path("blog/posts/photo.jpg")).canonical_url == "/blog/posts/photo.jpg" + ) + + def test_asset_exact_path_even_when_use_directory_urls_false(self) -> None: + """use_directory_urls=False must not affect non-Markdown asset routing.""" + a = self._make_adapter({"use_directory_urls": False}) + assert ( + a.get_route_info(Path("blog/posts/photo.jpg")).canonical_url == "/blog/posts/photo.jpg" + ) + # ─── MkDocsAdapter.classify_route ──────────────────────────────────────────── diff --git a/uv.lock b/uv.lock index 00ad7071..de854503 100644 --- a/uv.lock +++ b/uv.lock @@ -2465,7 +2465,7 @@ wheels = [ [[package]] name = "zenzic" -version = "0.26.0" +version = "0.26.1" source = { editable = "." } dependencies = [ { name = "google-re2" },