diff --git a/.bumpversion.toml b/.bumpversion.toml index 09669ff..62eae0d 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 [tool.bumpversion] -current_version = "0.12.0" +current_version = "0.13.0" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)((?Pa|b|rc)(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}{pre_l}{pre_n}", diff --git a/.github/ISSUE_TEMPLATE/security_vulnerability.yml b/.github/ISSUE_TEMPLATE/security_vulnerability.yml index e2266c7..1560045 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.12.0" + placeholder: "0.13.0" validations: required: true diff --git a/.gitignore b/.gitignore index 31f81cf..99d1d89 100644 --- a/.gitignore +++ b/.gitignore @@ -187,3 +187,6 @@ Desktop.ini # AI Agents Configuration .github/agents/ .clinerules + +# Architect Planning Sandbox +.architect/ diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index cc0d5e8..9d0eaf7 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -7,7 +7,7 @@ # # repos: # - repo: https://github.com/PythonWoods/zenzic -# rev: v0.12.0 +# rev: v0.13.0 # hooks: # - id: zenzic-verify # quality gate — corrisponde a `just verify` lato zenzic # - id: zenzic-guard # fast staged-file credential scan diff --git a/.zenzic.toml b/.zenzic.toml index ac05f5f..f934b70 100644 --- a/.zenzic.toml +++ b/.zenzic.toml @@ -60,6 +60,15 @@ excluded_dirs = [ "assets", "LICENSES", ] +# excluded_file_patterns = ["*.tmp", "*.log"] +# excluded_assets = ["favicon.ico"] +# excluded_asset_dirs = ["theme/"] +# excluded_build_artifacts = ["pdf/*.pdf"] +# included_dirs = [] +# included_file_patterns = [] + +# --- PLUGINS (Optional) --- +# plugins = [] # --- PLACEHOLDERS & CODE SNIPPETS (Optional) --- # Pattern matching disabled: the README, CHANGELOG, and CONTRIBUTING files @@ -138,18 +147,7 @@ brand_obsolescence = [ # Governance Playbook: # https://zenzic.dev/developers/how-to/release-governance-protocol -# --- EXCLUSION ZONES (Full bypass — use sparingly) --- -# Paths listed here are INVISIBLE to Zenzic: no findings, no audit trail. -# Prefer [governance.per_file_ignores] for targeted suppression with an audit trail. -# excluded_file_patterns = ["*.tmp", "*.log"] -# excluded_assets = ["favicon.ico"] -# excluded_asset_dirs = ["theme/"] -# excluded_build_artifacts = ["pdf/*.pdf"] -# included_dirs = [] -# included_file_patterns = [] -# --- PLUGINS (Optional) --- -# plugins = [] # --- CUSTOM RULES (Optional) --- # Declares project-specific regex-based lint rules applied line-by-line. diff --git a/CHANGELOG.md b/CHANGELOG.md index 481e173..fa005a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,39 +9,31 @@ Versions follow [Semantic Versioning](https://semver.org/). --- -## [0.12.0] - 2026-06-13 - -### Removed - -- **Docusaurus adapter removed (`v0.12.0`)** - Forensic analysis of Docusaurus projects revealed two categories of structural invisibility incompatible with Zenzic's static analysis model: - - **React component-injected IDs**: anchors generated by components such as `` exist only in the rendered DOM, not in Markdown source. - - **MDX partial merging**: anchors defined in imported `_partial-*.mdx` files are resolved at bundle time by Webpack, not statically traceable by a Python AST parser. - - Both patterns are dominant in Docusaurus projects, not edge cases. An adapter that generates structural false positives on the primary usage patterns of its target framework is a reputational liability, not an asset. - Zenzic supports documentation engines whose anchor output is deterministically derivable from Markdown source without executing external runtime code. Docusaurus does not satisfy this criterion. - -## [0.11.0] - 2026-06-13 +## [0.13.0] - 2026-06-19 ### Added -- **Docusaurus Native Routing Emulation:** Full support for `routeBasePath` concatenation, Frontmatter `slug` absolute/relative parsing, and Blog Date Extraction (`YYYY-MM-DD-slug`) to accurately map Docusaurus URLs into the Virtual Site Map without false positive broken links. -- **Dynamic Site Root:** Support for Docusaurus monorepos by dynamically searching upward from docs/ to repo root. -- **RE2 Glob Translator:** High-performance glob translator compiled directly to Google RE2 syntax for compatibility on Python 3.12+. -- **Partial Guard:** Logical routing exclusion of partial files (those starting with `_` or inside `_` folders) in Docusaurus. -- **Breakdown Flag:** Option `--breakdown` for `zenzic score` to show detailed category breakdowns and transparent DQS math. -- **Progress Bar:** Interactive progress indicator (`rich.progress.Progress`) during file scanning and parsing in `zenzic check all`. +- **Active Defense:** Implemented strict TOML schema validation to instantly detect and reject root keys silently swallowed by nested `[tables]` in `.zenzic.toml` and `pyproject.toml`. +- **D.I.A. Compliance:** Added the "TOML Root Key Law" documentation enforcing explicit ordering for configuration boundaries. ### Changed -- **Path-aware Exclusion Engine upgrade (.gitignore semantics):** `excluded_dirs` now evaluates against the repository-relative path if the entry contains a slash (`/`), and globally against the directory basename if it does not. -- **Severity Downgrade for Z106:** Downgraded `Z106` (circular link) severity to `note` and penalty to `0.0`, ensuring circular links never block strict pipelines. -- **Core CI gate hardening:** Removed `pull_request.paths` filters from `.github/workflows/ci.yml` so required `Audit` checks are always created for every PR and cannot remain in expected/pending due to skipped workflow runs. +- **Refined CLI UX:** `inspect codes` now displays Severity and explicit `FATAL`/`HALT` pipeline blockers instead of misleading `0.0` penalties for security and governance-gate codes. `check` command now explicitly prints the final DQS score and gate status (`DQS Final Score: X/100 (Gate Passed/Failed)`) in the report footer. +- **Engine-Neutral Configuration Templates:** Removed Docusaurus from initialized `.zenzic.toml` templates and CLI help descriptions, defaulting to `mkdocs` and `zensical`. +- **Simplification of VSM Routing:** Eradicated Docusaurus-specific slug map initialization and routing rules during Virtual Site Map (VSM) construction. +- **Improved Resolver Robustness:** Standardized site root resolution and monorepo path checks inside `InMemoryPathResolver`. +- **Full documentation migration to Zensical/MkDocs.** + +### Fixed + +- **REUSE compliance updates and Z-Code parity fixes across the bilingual documentation.** --- ## Historical Releases +- v0.12.x archive: [changelogs/v0.12.md](./changelogs/v0.12.md) +- v0.11.x archive: [changelogs/v0.11.md](./changelogs/v0.11.md) - v0.10.x archive: [changelogs/v0.10.md](./changelogs/v0.10.md) - v0.9.x archive: [changelogs/v0.9.md](./changelogs/v0.9.md) - v0.8.x archive: [changelogs/v0.8.md](./changelogs/v0.8.md) diff --git a/CITATION.cff b/CITATION.cff index 1b601ba..942c807 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.12.0 -date-released: 2026-06-13 +version: 0.13.0 +date-released: 2026-06-19 url: "https://zenzic.dev" repository-code: "https://github.com/PythonWoods/zenzic" repository-artifact: "https://pypi.org/project/zenzic/" diff --git a/RELEASE.md b/RELEASE.md index e5a7806..9377a1a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,9 +8,9 @@ | Field | Value | | :------- | :--------- | -| Version | v0.12.0 | +| Version | v0.13.0 | | Codename | Magnetite | -| Date | 2026-06-13 | +| Date | 2026-06-17 | | 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.12.0`) +- [ ] `pyproject.toml` version matches the tag (`0.13.0`) - [ ] `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). @@ -54,11 +54,11 @@ git checkout main git pull origin main # 3. Tag the main branch and push -git tag v0.12.0 +git tag v0.13.0 git push origin main --tags ``` -- [ ] Create GitHub Release from the tag, using the `## v0.12.0` CHANGELOG section as the release body. +- [ ] Create GitHub Release from the tag, using the `## v0.13.0` CHANGELOG section as the release body. ## Changelog Reference diff --git a/ROADMAP.md b/ROADMAP.md index 22077ee..94a64ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -125,6 +125,16 @@ Tactical Bridge: zenzic-doc will migrate to MkDocs Material to immediately resto --- +## v0.14.0 — The Bridge (planned) + +**Theme:** Inversion of Control via TS Plugins. + +### Planned + +- **The Bridge Architecture (Inversion of Control)**: Implementation of ADR-080. Introduces the PrebuiltVSMAdapter to ingest static `.zenzic-vsm.json` routing payloads from dynamic frameworks, and initializes the `@zenzic/plugin-docusaurus` TypeScript bridge. + +--- + ## v1.0.0 — Graphite LTS (planned) **Theme:** Long-Term Support release. Stability, portability, and production confidence. diff --git a/changelogs/v0.11.md b/changelogs/v0.11.md new file mode 100644 index 0000000..1759a1e --- /dev/null +++ b/changelogs/v0.11.md @@ -0,0 +1,21 @@ + + + +# Changelog Archive: v0.11.x + +## [0.11.0] - 2026-06-13 + +### Added + +- **Docusaurus Native Routing Emulation:** Full support for `routeBasePath` concatenation, Frontmatter `slug` absolute/relative parsing, and Blog Date Extraction (`YYYY-MM-DD-slug`) to accurately map Docusaurus URLs into the Virtual Site Map without false positive broken links. +- **Dynamic Site Root:** Support for Docusaurus monorepos by dynamically searching upward from docs/ to repo root. +- **RE2 Glob Translator:** High-performance glob translator compiled directly to Google RE2 syntax for compatibility on Python 3.12+. +- **Partial Guard:** Logical routing exclusion of partial files (those starting with `_` or inside `_` folders) in Docusaurus. +- **Breakdown Flag:** Option `--breakdown` for `zenzic score` to show detailed category breakdowns and transparent DQS math. +- **Progress Bar:** Interactive progress indicator (`rich.progress.Progress`) during file scanning and parsing in `zenzic check all`. + +### Changed + +- **Path-aware Exclusion Engine upgrade (.gitignore semantics):** `excluded_dirs` now evaluates against the repository-relative path if the entry contains a slash (`/`), and globally against the directory basename if it does not. +- **Severity Downgrade for Z106:** Downgraded `Z106` (circular link) severity to `note` and penalty to `0.0`, ensuring circular links never block strict pipelines. +- **Core CI gate hardening:** Removed `pull_request.paths` filters from `.github/workflows/ci.yml` so required `Audit` checks are always created for every PR and cannot remain in expected/pending due to skipped workflow runs. diff --git a/changelogs/v0.12.md b/changelogs/v0.12.md new file mode 100644 index 0000000..ed54116 --- /dev/null +++ b/changelogs/v0.12.md @@ -0,0 +1,16 @@ + + + +# Changelog Archive: v0.12.x + +## [0.12.0] - 2026-06-13 + +### Removed + +- **Docusaurus adapter removed (`v0.12.0`)** + Forensic analysis of Docusaurus projects revealed two categories of structural invisibility incompatible with Zenzic's static analysis model: + - **React component-injected IDs**: anchors generated by components such as `` exist only in the rendered DOM, not in Markdown source. + - **MDX partial merging**: anchors defined in imported `_partial-*.mdx` files are resolved at bundle time by Webpack, not statically traceable by a Python AST parser. + + Both patterns are dominant in Docusaurus projects, not edge cases. An adapter that generates structural false positives on the primary usage patterns of its target framework is a reputational liability, not an asset. + Zenzic supports documentation engines whose anchor output is deterministically derivable from Markdown source without executing external runtime code. Docusaurus does not satisfy this criterion. diff --git a/pyproject.toml b/pyproject.toml index ab79653..edcfede 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "hatchling.build" [project] name = "zenzic" -version = "0.12.0" +version = "0.13.0" description = "Engineering-grade, engine-agnostic static analyzer and credential scanner for Markdown documentation" readme = "README.md" requires-python = ">=3.10" @@ -58,7 +58,6 @@ dependencies = [ zenzic = "zenzic.main:cli_main" [project.entry-points."zenzic.adapters"] -docusaurus = "zenzic.core.adapters:DocusaurusAdapter" mkdocs = "zenzic.core.adapters:MkDocsAdapter" zensical = "zenzic.core.adapters:ZensicalAdapter" standalone = "zenzic.core.adapters:StandaloneAdapter" diff --git a/src/zenzic/__init__.py b/src/zenzic/__init__.py index 2abd7a2..e3ed808 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.12.0" +__version__ = "0.13.0" __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 51deee4..a5ffac5 100644 --- a/src/zenzic/cli/_check.py +++ b/src/zenzic/cli/_check.py @@ -26,6 +26,7 @@ find_unused_assets, scan_docs_references, ) +from zenzic.core.scorer import compute_score from zenzic.core.sovereign_context import sovereign_context from zenzic.core.ui import ZenzicPalette from zenzic.core.validator import ( @@ -1659,6 +1660,40 @@ def check_all( f"Credential scanner (Z201) remains active.[/]" ) + # ── DQS Score injection ──────────────────────────────────────────── + _findings_counts: dict[str, int] = {} + for _f in all_findings: + _findings_counts[_f.code] = _findings_counts.get(_f.code, 0) + 1 + _score_report = compute_score( + _findings_counts, + suppression_count=suppression_audit.total, + suppression_cap=suppression_audit.cap, + ) + if _score_report.security_override: + _dqs_line = ( + f"[bold red]DQS Final Score: 0/100[/bold red] " + f"[{ZenzicPalette.DIM}](Security Override — " + f"{_score_report.security_findings} non-suppressible finding" + f"{'s' if _score_report.security_findings != 1 else ''} detected)[/]" + ) + else: + _pre_errors = sum(1 for _f in all_findings if _f.severity == "error") + _pre_breaches = sum( + 1 for _f in all_findings if _f.severity in {"security_breach", "security_incident"} + ) + _pre_warnings = sum(1 for _f in all_findings if _f.severity == "warning") + _gate_failed = ( + _pre_breaches > 0 or _pre_errors > 0 or (effective_strict and _pre_warnings > 0) + ) + _gate_label = "Gate Failed" if _gate_failed else "Gate Passed" + _gate_style = ZenzicPalette.ERROR if _gate_failed else ZenzicPalette.SUCCESS + _dqs_line = ( + f"[bold {_gate_style}]DQS Final Score: " + f"{_score_report.score}/100[/bold {_gate_style}] " + f"[{ZenzicPalette.DIM}]({_gate_label})[/]" + ) + _footer_lines.insert(0, _dqs_line) + errors, warnings = reporter.render( all_findings, version=__version__, diff --git a/src/zenzic/cli/_inspect.py b/src/zenzic/cli/_inspect.py index 0072713..95b8108 100644 --- a/src/zenzic/cli/_inspect.py +++ b/src/zenzic/cli/_inspect.py @@ -9,7 +9,7 @@ from rich.table import Table from rich.text import Text -from zenzic.core.codes import CODE_NAMES, CORE_SCANNERS +from zenzic.core.codes import CODE_DEFINITIONS, CODE_NAMES, CORE_SCANNERS from zenzic.core.scanner import find_repo_root from zenzic.core.ui import ZenzicPalette from zenzic.models.config import ZenzicConfig @@ -140,13 +140,6 @@ def _inspect_capabilities() -> None: bypass_table.add_column("Bypass Schemes") _BYPASS_ROWS = [ - ( - "docusaurus", - "DocusaurusAdapter", - Text.from_markup( - f"[bold]pathname:[/bold] [{ZenzicPalette.DIM}](static-asset routing escape hatch)[/{ZenzicPalette.DIM}]" - ), - ), ( "mkdocs", "MkDocsAdapter", @@ -208,12 +201,37 @@ def inspect_codes( repo_root = find_repo_root() config, _ = ZenzicConfig.load(repo_root) - def _badge(active: bool) -> str: - return ( - "[bold green][ACTIVE][/bold green]" if active else f"[{ZenzicPalette.DIM}][inactive][/]" - ) + _SEVERITY_STYLE: dict[str, str] = { + "error": "bold red", + "warning": "bold yellow", + "note": "bold blue", + } - rows: dict[str, list[tuple[str, str, str]]] = { + def _severity_markup(code: str) -> str: + defn = CODE_DEFINITIONS.get(code) + if defn is None: + return f"[{ZenzicPalette.DIM}]—[/{ZenzicPalette.DIM}]" + sty = _SEVERITY_STYLE.get(defn.severity, ZenzicPalette.DIM) + return f"[{sty}]{defn.severity}[/{sty}]" + + def _penalty_markup(code: str) -> str: + defn = CODE_DEFINITIONS.get(code) + if defn is None: + return f"[{ZenzicPalette.DIM}]—[/{ZenzicPalette.DIM}]" + # Z0xx (config abort) and Z2xx (security codes) collapse the score to 0 + # and halt the pipeline unconditionally — show FATAL, not 0.0. + if code.startswith("Z0") or code.startswith("Z2"): + return "[bold red]FATAL[/bold red]" + # warning + 0.0 penalty = governance gate / pipeline block (e.g. Z504, + # Z602, Z901, Z902) — show HALT to signal CI exit rather than math cost. + if defn.severity == "warning" and defn.penalty == 0.0: + return "[bold red]HALT[/bold red]" + # note + 0.0 = genuinely informational; never blocks CI (Fail-Visible rule). + if defn.penalty == 0.0: + return f"[{ZenzicPalette.DIM}]0.0[/{ZenzicPalette.DIM}]" + return f"[bold]-{defn.penalty:.1f}[/bold]" + + rows: dict[str, list[tuple[str, str, str, str]]] = { "core": [], "governance": [], "plugin": [], @@ -223,25 +241,36 @@ def _badge(active: bool) -> str: # Core + Governance (from canonical registry) for code in sorted(CODE_NAMES.keys(), key=lambda c: int(c[1:])): if code.startswith("Z6"): - is_active = True - if code == "Z601": - is_active = bool(config.governance.brand_obsolescence) - elif code == "Z602": - is_active = config.governance.i18n_parity - rows["governance"].append((code, CODE_NAMES[code], _badge(is_active))) + rows["governance"].append( + (code, CODE_NAMES[code], _severity_markup(code), _penalty_markup(code)) + ) else: - rows["core"].append((code, CODE_NAMES[code], _badge(True))) + rows["core"].append( + (code, CODE_NAMES[code], _severity_markup(code), _penalty_markup(code)) + ) # Plugin tier (third-party only; core-origin entry points excluded) plugin_infos = [info for info in list_plugin_rules() if info.origin != "zenzic"] for info in plugin_infos: rows["plugin"].append( - (info.rule_id, info.source, _badge(info.source in set(config.plugins))) + ( + info.rule_id, + info.source, + _severity_markup(info.rule_id), + _penalty_markup(info.rule_id), + ) ) # Custom tier (local TOML custom rules) for cr in config.custom_rules: - rows["custom"].append((cr.id, "custom rule", _badge(True))) + rows["custom"].append( + ( + cr.id, + "custom rule", + f"[{ZenzicPalette.DIM}]—[/{ZenzicPalette.DIM}]", + f"[{ZenzicPalette.DIM}]—[/{ZenzicPalette.DIM}]", + ) + ) if tier_normalized == "all": selected_tiers = ["core", "governance", "plugin", "custom"] @@ -260,7 +289,8 @@ def _badge(active: bool) -> str: table.add_column("Tier", style="bold cyan", min_width=12, no_wrap=True) table.add_column("Code", style="bold", min_width=10, no_wrap=True) table.add_column("Name", min_width=20) - table.add_column("Status", min_width=10, no_wrap=True) + table.add_column("Severity", min_width=9, no_wrap=True) + table.add_column("Penalty", min_width=7, no_wrap=True, justify="right") title_map = { "core": "Core", @@ -272,11 +302,15 @@ def _badge(active: bool) -> str: tier_rows = rows[tier_name] if not tier_rows: table.add_row( - title_map[tier_name], "—", "No entries", f"[{ZenzicPalette.DIM}][inactive][/]" + title_map[tier_name], + "—", + "No entries", + f"[{ZenzicPalette.DIM}]—[/{ZenzicPalette.DIM}]", + f"[{ZenzicPalette.DIM}]—[/{ZenzicPalette.DIM}]", ) else: - for code, name, status in tier_rows: - table.add_row(title_map[tier_name], code, name, status) + for code, name, severity, penalty in tier_rows: + table.add_row(title_map[tier_name], code, name, severity, penalty) if idx < len(selected_tiers) - 1: table.add_section() diff --git a/src/zenzic/cli/_shared.py b/src/zenzic/cli/_shared.py index 888d585..82005be 100644 --- a/src/zenzic/cli/_shared.py +++ b/src/zenzic/cli/_shared.py @@ -403,7 +403,7 @@ def _count_docs_assets( """Return ``(docs_count, assets_count)`` for the analysis telemetry line. When *config* is provided and the adapter exposes ``get_locale_source_roots()``, - locale translation trees (e.g. Docusaurus ``i18n/``) are counted in + locale translation trees (e.g. MkDocs or Zensical ``docs-it/``) are counted in ``docs_count`` as well. """ from zenzic.core.discovery import walk_files diff --git a/src/zenzic/cli/_standalone.py b/src/zenzic/cli/_standalone.py index 7934cba..8f2f4fe 100644 --- a/src/zenzic/cli/_standalone.py +++ b/src/zenzic/cli/_standalone.py @@ -320,12 +320,29 @@ def score( total_category_penalties = 0 for cat in report.categories: - if cat.issues == 0: + # Split issues into punitive (penalty > 0) vs. informational (penalty == 0). + from zenzic.core.scorer import _CODE_CATEGORY, _CODE_PENALTY + + info_issue_count = sum( + count + for code, count in report.findings_counts.items() + if _CODE_CATEGORY.get(code) == cat.name + and count > 0 + and _CODE_PENALTY.get(code, 0.0) == 0.0 + ) + punitive_issue_count = cat.issues - info_issue_count + + if punitive_issue_count > 0: + status_icon = f"[red]{emoji('cross')}[/]" + issue_display = f"[red]{punitive_issue_count}[/]" + if info_issue_count > 0: + issue_display += f" [dim](+ {info_issue_count} info)[/dim]" + elif info_issue_count > 0: status_icon = f"[green]{emoji('check')}[/]" - issue_display = f"[green]{cat.issues}[/]" + issue_display = f"[dim]{info_issue_count} info[/dim]" else: - status_icon = f"[red]{emoji('cross')}[/]" - issue_display = f"[red]{cat.issues}[/]" + status_icon = f"[green]{emoji('check')}[/]" + issue_display = f"[green]{cat.issues}[/]" raw_pts = round(cat.raw_penalty) raw_display = f"-{raw_pts}" if raw_pts > 0 else "0" applied_penalty = round(cat.weight * 100 - cat.contribution * 100) @@ -556,6 +573,12 @@ def get_display_category(c: str) -> str: changed_audit = _stamp_file(p, _AUDIT_STAMP_MARKER, audit_url) if changed_score or changed_audit: _shared.console.print(f"[dim]Badge stamped → {p}[/]") + _shared.console.print( + f"\n[bold yellow]NOTICE:[/bold yellow] The DQS badge in " + f"[bold]{p.name}[/bold] was out of date and has been automatically " + f"updated. Pre-commit will exit with 1 to allow staging.\n" + f" [dim]→ Run:[/dim] [bold]git add {p}[/bold] and commit again." + ) if not found_any: _shared.stderr_console.print( "[red]--stamp: no recognized stamp markers found in any configured file " @@ -709,6 +732,24 @@ def diff( current = _run_all_checks(repo_root, docs_root, config, exclusion_mgr, strict=config.strict) delta = current.score - baseline.score + # ── FATAL / HALT semantic detection ────────────────────────────────────── + # Z0xx (config abort) and Z2xx (security) collapse score to 0 unconditionally. + from zenzic.core.codes import CODE_DEFINITIONS + + _fatal_codes = sorted( + c for c in current.findings_counts if c.startswith("Z0") or c.startswith("Z2") + ) + has_fatal = bool(_fatal_codes) or current.security_override + # warnings with 0.0 penalty = governance gate / pipeline block (e.g. Z504). + _halt_codes = sorted( + c + for c in current.findings_counts + if CODE_DEFINITIONS.get(c) + and CODE_DEFINITIONS[c].severity == "warning" + and CODE_DEFINITIONS[c].penalty == 0.0 + ) + has_halt = bool(_halt_codes) and not has_fatal + if output_format == "json": print( json.dumps( @@ -716,6 +757,10 @@ def diff( "baseline": baseline.score, "current": current.score, "delta": delta, + "fatal_override": has_fatal, + "fatal_codes": _fatal_codes, + "halt": has_halt, + "halt_codes": _halt_codes, "categories": [ { "name": cat.name, @@ -821,9 +866,18 @@ def diff( subtotal_delta_display, ) + if has_fatal: + _codes_hint = f" ({', '.join(_fatal_codes)})" if _fatal_codes else "" + _current_score_display = f"[bold red]{current.score}/100 \u26d4 FATAL{_codes_hint}[/]" + elif has_halt: + _codes_hint = f" ({', '.join(_halt_codes)})" if _halt_codes else "" + _current_score_display = f"[bold red]{current.score}/100 \u26a0 HALT{_codes_hint}[/]" + else: + _current_score_display = f"[bold {delta_colour}]{current.score}/100[/]" + body = Text.from_markup( f" Baseline Score: [bold]{baseline.score}/100[/] " - f"Current Score: [bold {delta_colour}]{current.score}/100[/] " + f"Current Score: {_current_score_display} " f"Delta: [{delta_colour}]{sign}{delta}[/]\n" ) _shared.console.print() @@ -845,8 +899,26 @@ def diff( f" Baseline: 100 - {total_base_penalties} = {baseline.score}\n" f" Current: 100 - {total_curr_penalties} = {current.score}" ) + if has_fatal: + _codes_str = ", ".join(_fatal_codes) if _fatal_codes else "security override" + _shared.console.print( + f"\n[bold red]\u26d4 FATAL OVERRIDE:[/bold red]" + f" current state contains pipeline-blocking codes ([bold]{_codes_str}[/bold])." + f" Score collapses to 0 \u2014 pipeline halted non-suppressibly." + ) + elif has_halt: + _shared.console.print( + f"\n[bold red]\u26a0 HALT:[/bold red]" + f" current state contains {len(_halt_codes)} pipeline-blocking" + f" warning(s) ([bold]{', '.join(_halt_codes)}[/bold]). CI pipeline blocked." + ) _shared.console.print() + if has_fatal: + raise typer.Exit(2) + if has_halt: + raise typer.Exit(1) + dropped = -delta if dropped > threshold: _shared.console.print( @@ -909,15 +981,41 @@ def explain( meta_table.add_row("Rule", f"[bold cyan]{rule_id}[/] — {name}") meta_table.add_row("Description", description) meta_table.add_row("Severity", f"[{'red' if severity == 'error' else 'yellow'}]{severity}[/]") + from zenzic.core.codes import CODE_DEFINITIONS as _CODE_DEFS + + _defn = _CODE_DEFS.get(rule_id) + _is_fatal = rule_id.startswith("Z0") or rule_id.startswith("Z2") + _is_halt = ( + _defn is not None and _defn.severity == "warning" and _defn.penalty == 0.0 and not _is_fatal + ) + if is_security: meta_table.add_row( "Scoring Tier", "[bold red]SECURITY GATE[/] — score collapses to 0 on any occurrence" ) + meta_table.add_row("Penalty", "[bold red]FATAL[/bold red] — non-suppressible (Exit 2/3)") + elif _is_fatal: + # Z0xx: config-abort codes that are not in NON_SUPPRESSIBLE_CODES + meta_table.add_row( + "Scoring Tier", "[bold red]FATAL[/] — config abort; pipeline cannot proceed" + ) + meta_table.add_row( + "Penalty", "[bold red]FATAL[/bold red] — pipeline aborted before scoring" + ) elif bucket != "—": cap = weight * 100 penalty_str = f"{penalty:.1f} pt/occurrence" if penalty else "not penalised" meta_table.add_row("Scoring Tier", f"{bucket} (weight {weight:.0%}, cap {cap:.0f} pts)") meta_table.add_row("Penalty", penalty_str) + elif _is_halt: + meta_table.add_row( + "Scoring Tier", + f"[{ZenzicPalette.DIM}]not in DQS penalty table[/] — governance gate", + ) + meta_table.add_row( + "Penalty", + "[bold red]HALT[/bold red] — pipeline-blocking warning; CI exits non-zero regardless of score", + ) else: meta_table.add_row("Scoring Tier", f"[{ZenzicPalette.DIM}]not included in DQS[/]") @@ -1086,7 +1184,7 @@ def init( None, "--engine", help=( - "Override the build engine adapter (mkdocs, zensical, docusaurus, standalone). " + "Override the build engine adapter (mkdocs, zensical, standalone). " "Auto-detected from project files when omitted." ), metavar="ENGINE", @@ -1158,7 +1256,7 @@ def init( _shared.print_footer_hint("init") return - _INIT_VALID_ENGINES = {"mkdocs", "zensical", "docusaurus", "standalone"} + _INIT_VALID_ENGINES = {"mkdocs", "zensical", "standalone"} if engine is not None and engine not in _INIT_VALID_ENGINES: _shared.console.print( f"[red]✘ ERROR:[/] Unknown engine [bold]{engine!r}[/]. " @@ -1483,7 +1581,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.12.0"] +dependencies = ["zenzic>=0.13.0"] [project.entry-points."zenzic.rules"] {project_slug} = "{module_name}.rules:{class_name}" diff --git a/src/zenzic/cli/templates.py b/src/zenzic/cli/templates.py index 00e52d6..4dd48f7 100644 --- a/src/zenzic/cli/templates.py +++ b/src/zenzic/cli/templates.py @@ -63,18 +63,33 @@ "# Z204 Privacy Gate — terms that must never appear in published docs.\n" "# forbidden_patterns = []\n" "\n" + "# --- PLACEHOLDERS & CODE SNIPPETS (Optional) ---\n" + '# placeholder_patterns = ["coming soon", "work in progress", "wip", "todo"]\n' + "# placeholder_max_words = 50\n" + "# snippet_min_lines = 1\n" + "\n" + "# --- EXCLUSION ZONES (Full bypass — use sparingly) ---\n" + "# Paths listed here are INVISIBLE to Zenzic: no findings, no audit trail.\n" + "# Prefer [governance.per_file_ignores] for targeted suppression with an" + " audit trail.\n" + '# excluded_dirs = ["legacy/", "third-party/"]\n' + '# excluded_file_patterns = ["*.tmp", "*.log"]\n' + '# excluded_assets = ["favicon.ico"]\n' + '# excluded_asset_dirs = ["theme/"]\n' + '# excluded_build_artifacts = ["pdf/*.pdf"]\n' + "# included_dirs = []\n" + "# included_file_patterns = []\n" + "\n" + "# --- PLUGINS (Optional) ---\n" + "# plugins = []\n" + "\n" "# --- ENGINE CONTEXT ---\n" "[build_context]\n" 'engine = "{engine}"' - " # Supported: docusaurus, mkdocs, zensical, standalone\n" + " # Supported: mkdocs, zensical, standalone\n" 'base_url = "/"\n' 'default_locale = "en"\n' "\n" - "# --- PLACEHOLDERS & CODE SNIPPETS (Optional) ---\n" - '# placeholder_patterns = ["coming soon", "work in progress", "wip", "todo"]\n' - "# placeholder_max_words = 50\n" - "# snippet_min_lines = 1\n" - "\n" "# --- BRAND INTEGRITY ---\n" "[project_metadata]\n" '# release_name = "YOUR-RELEASE"\n' @@ -133,21 +148,6 @@ "# Cache external link responses to speed up local execution.\n" "cache_ttl_hours = 24\n" "\n" - "# --- EXCLUSION ZONES (Full bypass — use sparingly) ---\n" - "# Paths listed here are INVISIBLE to Zenzic: no findings, no audit trail.\n" - "# Prefer [governance.per_file_ignores] for targeted suppression with an" - " audit trail.\n" - '# excluded_dirs = ["legacy/", "third-party/"]\n' - '# excluded_file_patterns = ["*.tmp", "*.log"]\n' - '# excluded_assets = ["favicon.ico"]\n' - '# excluded_asset_dirs = ["theme/"]\n' - '# excluded_build_artifacts = ["pdf/*.pdf"]\n' - "# included_dirs = []\n" - "# included_file_patterns = []\n" - "\n" - "# --- PLUGINS (Optional) ---\n" - "# plugins = []\n" - "\n" "# --- CUSTOM RULES (Optional) ---\n" "# Declares project-specific regex-based lint rules applied line-by-line.\n" "# [[custom_rules]]\n" @@ -164,7 +164,7 @@ "# strict_parity = true\n" '# require_frontmatter_parity = ["title", "description"]\n' "# [i18n.targets]\n" - '# it = "i18n/it/docusaurus-plugin-content-docs/current"\n' + '# it = "docs-it"\n' "\n" "# --- GATE 4: CI/CD (GitHub Actions, Optional) ---\n" "# Add this workflow snippet to .github/workflows/zenzic.yml\n" @@ -211,7 +211,6 @@ "# - i18n\n" "# ===========================================================================\n" "\n" - "[core]\n" "# ---------------------------------------------------------------------------\n" "# docs_dir\n" "# ---------------------------------------------------------------------------\n" @@ -242,7 +241,7 @@ "# ---------------------------------------------------------------------------\n" "# Mirrors global structure for safe local overrides only when needed.\n" "#\n" - '# engine = "docusaurus"\n' + '# engine = "zensical"\n' '# base_url = "/"\n' '# default_locale = "en"\n' "\n" @@ -356,7 +355,7 @@ "\n" "[tool.zenzic.build_context]\n" "# engine — auto-detected from project files; override with --engine if needed.\n" - "# Supported: docusaurus, mkdocs, zensical, standalone\n" + "# Supported: mkdocs, zensical, standalone\n" 'engine = "{engine}"\n' 'base_url = "/"\n' 'default_locale = "en"\n' @@ -407,7 +406,7 @@ "# strict_parity = true\n" '# require_frontmatter_parity = ["title", "description"]\n' "# [tool.zenzic.i18n.targets]\n" - '# it = "i18n/it/docusaurus-plugin-content-docs/current"\n' + '# it = "docs-it"\n' "\n" "# --- CUSTOM RULES (Optional) ---\n" "# Declares project-specific regex-based lint rules applied line-by-line.\n" diff --git a/src/zenzic/core/adapters/_factory.py b/src/zenzic/core/adapters/_factory.py index 23a2327..5734caa 100644 --- a/src/zenzic/core/adapters/_factory.py +++ b/src/zenzic/core/adapters/_factory.py @@ -41,6 +41,7 @@ from ._base import BaseAdapter from ._mkdocs import MkDocsAdapter +from ._prebuilt import PrebuiltVSMAdapter from ._standalone import StandaloneAdapter from ._zensical import ZensicalAdapter @@ -52,21 +53,26 @@ "mkdocs": MkDocsAdapter, "zensical": ZensicalAdapter, "standalone": StandaloneAdapter, + "prebuilt": PrebuiltVSMAdapter, + "vsm": PrebuiltVSMAdapter, } -def discover_engine(repo_root: Path) -> Literal["mkdocs", "zensical", "standalone"]: +def discover_engine(repo_root: Path) -> Literal["prebuilt", "mkdocs", "zensical", "standalone"]: """Probe *repo_root* for known engine config files and return the canonical engine name. Priority order (Engine Discovery Logic): - 1. ``zensical.toml`` → ``"zensical"`` - 2. ``mkdocs.yml`` → ``"mkdocs"`` - 3. No marker found → ``"standalone"`` (universal fallback mode) + 1. ``.zenzic-vsm.json`` → ``"prebuilt"`` + 2. ``zensical.toml`` → ``"zensical"`` + 3. ``mkdocs.yml`` → ``"mkdocs"`` + 4. No marker found → ``"standalone"`` (universal fallback mode) This function is called when ``BuildContext.engine == "auto"`` (the default), replacing the previous implicit assumption that the engine was MkDocs. """ + if (repo_root / ".zenzic-vsm.json").is_file(): + return "prebuilt" if (repo_root / "zensical.toml").is_file(): return "zensical" if (repo_root / "mkdocs.yml").is_file(): diff --git a/src/zenzic/core/adapters/_prebuilt.py b/src/zenzic/core/adapters/_prebuilt.py new file mode 100644 index 0000000..6314dc1 --- /dev/null +++ b/src/zenzic/core/adapters/_prebuilt.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: 2026 PythonWoods +# SPDX-License-Identifier: Apache-2.0 +"""PrebuiltVSMAdapter — ingests a precomputed .zenzic-vsm.json routing table.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from zenzic.core.adapters._standalone import StandaloneAdapter +from zenzic.core.exceptions import ConfigurationError + + +if TYPE_CHECKING: + from zenzic.core.adapters._base import RouteMetadata + from zenzic.models.config import BuildContext + + +class PrebuiltVSMAdapter(StandaloneAdapter): + """Adapter that reads a static .zenzic-vsm.json file for routing. + + This is used for the Bridge Architecture (ADR-080), where a TS plugin + generates the routing map and Zenzic simply consumes it. + """ + + def __init__( + self, context: BuildContext, docs_root: Path, repo_root: Path | None = None + ) -> None: + self._routes: dict[str, dict[str, str]] = {} + self._has_config = False + + root = repo_root if repo_root else docs_root.parent + vsm_file = root / ".zenzic-vsm.json" + + if vsm_file.is_file(): + self._has_config = True + try: + with vsm_file.open("r", encoding="utf-8") as f: + data = json.load(f) + # Assume JSON is a dict of rel_path -> { "url": "...", "status": "..." } + self._routes = data + except Exception as e: + raise ConfigurationError(f"Failed to parse {vsm_file}: {e}") from e + + @classmethod + def from_repo( + cls, context: BuildContext, docs_root: Path, repo_root: Path + ) -> PrebuiltVSMAdapter: + return cls(context, docs_root, repo_root) + + def has_engine_config(self) -> bool: + return self._has_config + + def get_route_info(self, rel: Path) -> RouteMetadata: + from zenzic.core.adapters._base import RouteMetadata + + rel_str = rel.as_posix() + if rel_str in self._routes: + data = self._routes[rel_str] + return RouteMetadata( + canonical_url=data.get("url", super()._map_url(rel)), + status=data.get("status", "REACHABLE"), # type: ignore + slug=data.get("slug"), + ) + + return RouteMetadata( + canonical_url=super()._map_url(rel), + status="IGNORED" if self._has_config else "REACHABLE", + ) diff --git a/src/zenzic/core/codes.py b/src/zenzic/core/codes.py index 832afa1..5f200fd 100644 --- a/src/zenzic/core/codes.py +++ b/src/zenzic/core/codes.py @@ -391,11 +391,11 @@ class CoreScanner(NamedTuple): non_suppressible=True, ), CoreScanner( - codes="Z101\u2013106", + codes="Z101–106, Z108–109", name="Link Validator", capability=( "Broken links, dead anchors, circular refs, " - "absolute internal links, proactive suggestions" + "absolute internal links, empty link text, external URL validation" ), primary_exit=1, non_suppressible=False, @@ -430,6 +430,23 @@ class CoreScanner(NamedTuple): primary_exit=1, non_suppressible=False, ), + CoreScanner( + codes="Z406", + name="Nav Contract Enforcer", + capability="Navigation contract violation — page presence against declared nav structure", + primary_exit=1, + non_suppressible=False, + ), + CoreScanner( + codes="Z111, Z113–114", + name="Blog Integrity Guard", + capability=( + "Zensical blog integrity — virtual route resolution (tag/author/paginated), " + "duplicate author keys, large pagination set threshold" + ), + primary_exit=1, + non_suppressible=False, + ), CoreScanner( codes="Z107", name="Circular Anchor Guard", diff --git a/src/zenzic/core/scanner.py b/src/zenzic/core/scanner.py index f0236c6..7fa67b0 100644 --- a/src/zenzic/core/scanner.py +++ b/src/zenzic/core/scanner.py @@ -1623,16 +1623,36 @@ def scan_docs_references( # Initialise Visual Progress Bar context if requested. progress = None task_id = None + task_validate_id = None if show_progress: - from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn + from rich.progress import ( + BarColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, + TimeElapsedColumn, + ) progress = Progress( + SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TaskProgressColumn(), + TimeElapsedColumn(), ) progress.start() - task_id = progress.add_task("[cyan]Parsing documents...", total=len(md_files)) + _mode_label = "parallel" if use_parallel else "sequential" + task_id = progress.add_task( + f"[cyan]Parsing[/cyan] [dim]{len(md_files)} files ({_mode_label})...[/dim]", + total=len(md_files), + ) + if validate_links: + task_validate_id = progress.add_task( + "[blue]Validating links...[/blue]", + total=None, # indeterminate until parsing completes + start=False, + ) _t0 = time.monotonic() @@ -1780,7 +1800,23 @@ def scan_docs_references( for _sf in _r.security_findings: if _sf.file_path in _locale_path_remap: _sf.file_path = _locale_path_remap[_sf.file_path] - return reports_seq, validator_seq.validate() + if progress and task_validate_id is not None: + n_external = sum( + 1 + for s in secure_scanners_seq + for url, _ in s.ref_map.definitions.values() + if url.startswith("http") + ) + progress.update( + task_validate_id, + description=f"[blue]Validating links[/blue] [dim]({n_external} external URLs)...[/dim]", + total=1, + ) + progress.start_task(task_validate_id) + link_errors = validator_seq.validate() + if progress and task_validate_id is not None: + progress.advance(task_validate_id) + return reports_seq, link_errors finally: if progress: progress.stop() diff --git a/src/zenzic/core/validator.py b/src/zenzic/core/validator.py index 18dc6d0..8fbe109 100644 --- a/src/zenzic/core/validator.py +++ b/src/zenzic/core/validator.py @@ -885,7 +885,7 @@ async def validate_links_async( # Instantiating inside the file loop would regenerate the map N times, # cancelling the 14× performance gain from the pre-computed flat dict. # allowed_roots extends the credential scanner boundary to authorised locale directories. - resolver_repo_root = getattr(adapter, "_docusaurus_site_root", repo_root) + resolver_repo_root = repo_root resolver = InMemoryPathResolver( docs_root, md_contents, @@ -899,15 +899,6 @@ async def validate_links_async( # It is only meaningful when the adapter has a nav (MkDocs with mkdocs.yml); # for StandaloneAdapter / Zensical every file is REACHABLE by definition. # - # v0.7.x: populate the adapter's slug map BEFORE building the VSM so that - # ``map_url()`` resolves frontmatter ``slug:`` overrides correctly. - # Without this call the slug map is empty and all blog URLs are derived from - # the physical filename — mismatching the URLs that Docusaurus actually serves. - # - # ``set_slug_map()`` is a DocusaurusAdapter-specific method (other adapters - # don't use frontmatter slugs in the same way), hence the hasattr guard. - if hasattr(adapter, "set_slug_map"): - adapter.set_slug_map(md_contents) vsm = build_vsm( adapter, @@ -1157,38 +1148,6 @@ def _source_line(md_file: Path, lineno: int) -> str: if adapter.resolve_asset(Path(asset_str), docs_root) is not None: continue - # ── Root-Relative Fallback (Docusaurus Magic) ──────── - # If a physical Markdown link does not explicitly start with a relative - # marker (./ or ../) or absolute marker (/), Docusaurus magically resolves - # it relative to the docs_root (or plugin root). - if ( - adapter.__class__.__name__ == "DocusaurusAdapter" - and path_part.endswith((".md", ".mdx")) - and not url.startswith(("./", "../", "/")) - ): - try: - plugin_root_name = md_file.relative_to(docs_root).parts[0] - plugin_root = docs_root / plugin_root_name - except (ValueError, IndexError): - plugin_root = docs_root - - fallback_outcome = resolver.resolve(plugin_root / "index.md", url) - if isinstance(fallback_outcome, Resolved): - continue - if isinstance(fallback_outcome, AnchorMissing): - internal_errors.append( - LinkError( - file_path=md_file, - line_no=lineno, - message=f"{label}:{lineno}: anchor '#{fallback_outcome.anchor}' not found in '{fallback_outcome.resolved_file.name}'", - source_line=_source_line(md_file, lineno), - error_type="Z102", - col_start=link.col_start, - match_text=link.match_text, - ) - ) - continue - # Suppress errors for build-time generated artifacts # (e.g. PDFs from to-pdf plugin, ZIPs assembled in CI). # Assets outside docs_root (e.g. from locale file links) diff --git a/src/zenzic/models/config.py b/src/zenzic/models/config.py index d983b9c..932e76e 100644 --- a/src/zenzic/models/config.py +++ b/src/zenzic/models/config.py @@ -125,7 +125,7 @@ class BuildContext(BaseModel): asset and page paths correctly. """ - engine: Literal["mkdocs", "zensical", "standalone", "auto"] = Field( + engine: Literal["prebuilt", "vsm", "mkdocs", "zensical", "standalone", "auto"] = Field( default="auto", description="The build engine used by the documentation. Can be 'mkdocs', 'zensical', 'standalone', or 'auto'.", ) @@ -728,6 +728,57 @@ def _build_from_data(cls, data: dict[str, Any]) -> ZenzicConfig: filtered_data["i18n"] = I18nConfig(extra_sources=extra, **i18n_filtered) return cls(**filtered_data) + @staticmethod + def _validate_no_swallowed_root_keys(data: dict[str, Any]) -> None: + """Active Defense: intercept root keys swallowed by TOML tables.""" + root_keys = frozenset( + { + "docs_dir", + "strict", + "fail_under", + "exit_zero", + "respect_vcs_ignore", + "validate_same_page_anchors", + "excluded_external_urls", + "forbidden_patterns", + "excluded_dirs", + "placeholder_patterns", + "placeholder_max_words", + "snippet_min_lines", + "excluded_file_patterns", + "excluded_assets", + "excluded_asset_dirs", + "excluded_build_artifacts", + "included_dirs", + "included_file_patterns", + "plugins", + "custom_rules", + } + ) + + for table_name, value in data.items(): + tables_to_check = [] + if isinstance(value, dict): + tables_to_check.append(value) + elif isinstance(value, list) and value and isinstance(value[0], dict): + tables_to_check.extend(value) + + for table in tables_to_check: + swallowed = set(table.keys()) & root_keys + if swallowed: + from rich.markup import escape + + from zenzic.core.exceptions import ConfigurationError + + swallowed_key = next(iter(swallowed)) + table_str = escape(f"[{table_name}]") + tables_str = escape("[tables]") + raise ConfigurationError( + f"FATAL CONFIGURATION ERROR: The root key '{swallowed_key}' was found inside " + f"the '{table_str}' section. In TOML, root keys must be declared at the " + f"absolute top of the file before any {tables_str} are opened." + ) + @classmethod def load(cls, repo_root: Path) -> tuple[ZenzicConfig, bool]: """Load configuration following the Agnostic Citizen priority chain. @@ -770,6 +821,7 @@ def load(cls, repo_root: Path) -> tuple[ZenzicConfig, bool]: "Fix the TOML syntax error and re-run Zenzic.", context={"config_path": str(zenzic_toml)}, ) from exc + cls._validate_no_swallowed_root_keys(data) config = cls._build_from_data(data) cls._apply_local_toml(config, repo_root) return config, True @@ -791,6 +843,7 @@ def load(cls, repo_root: Path) -> tuple[ZenzicConfig, bool]: tool_section = pyproject_data.get("tool", {}) zenzic_section = tool_section.get("zenzic", {}) if zenzic_section: + cls._validate_no_swallowed_root_keys(zenzic_section) config = cls._build_from_data(zenzic_section) cls._apply_local_toml(config, repo_root) return config, True diff --git a/tests/test_cli.py b/tests/test_cli.py index 7c5d2c2..e0a4fba 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1173,6 +1173,181 @@ def test_init_interactive_prompt_chooses_standalone( assert "[tool.zenzic]" not in content +# --------------------------------------------------------------------------- +# diff — FATAL / HALT semantic parity +# --------------------------------------------------------------------------- + + +def _diff_baseline_json(tmp_path: Path, score: int = 100) -> Path: + """Write a minimal baseline JSON snapshot for --base tests.""" + payload = { + "score": score, + "threshold": 0, + "categories": [ + { + "name": "structural", + "weight": 0.30, + "issues": 0, + "category_score": 1.0, + "contribution": 0.30, + }, + { + "name": "navigation", + "weight": 0.25, + "issues": 0, + "category_score": 1.0, + "contribution": 0.25, + }, + { + "name": "content", + "weight": 0.20, + "issues": 0, + "category_score": 1.0, + "contribution": 0.20, + }, + { + "name": "brand", + "weight": 0.25, + "issues": 0, + "category_score": 1.0, + "contribution": 0.25, + }, + ], + } + p = tmp_path / "baseline.json" + p.write_text(json.dumps(payload), encoding="utf-8") + return p + + +def _fatal_report() -> object: + """ScoreReport simulating a Z201 credential leak (FATAL / security_override).""" + from zenzic.core.scorer import CategoryScore, ScoreReport + + cats = [ + CategoryScore( + name="structural", weight=0.30, issues=0, category_score=0.0, contribution=0.0 + ), + CategoryScore( + name="navigation", weight=0.25, issues=0, category_score=0.0, contribution=0.0 + ), + CategoryScore(name="content", weight=0.20, issues=0, category_score=0.0, contribution=0.0), + CategoryScore(name="brand", weight=0.25, issues=0, category_score=0.0, contribution=0.0), + ] + return ScoreReport( + score=0, + security_override=True, + security_findings=1, + findings_counts={"Z201": 1}, + categories=cats, + ) + + +def _halt_report() -> object: + """ScoreReport simulating a Z504 Quality Regression gate (HALT, warning+0.0 penalty).""" + from zenzic.core.scorer import CategoryScore, ScoreReport + + cats = [ + CategoryScore( + name="structural", weight=0.30, issues=0, category_score=1.0, contribution=0.30 + ), + CategoryScore( + name="navigation", weight=0.25, issues=0, category_score=1.0, contribution=0.25 + ), + CategoryScore(name="content", weight=0.20, issues=0, category_score=1.0, contribution=0.20), + CategoryScore(name="brand", weight=0.25, issues=0, category_score=1.0, contribution=0.25), + ] + return ScoreReport(score=100, findings_counts={"Z504": 1}, categories=cats) + + +@patch("zenzic.cli._shared._build_exclusion_manager") +@patch("zenzic.cli._standalone._run_all_checks") +@patch("zenzic.cli._standalone.ZenzicConfig.load", return_value=(_CFG, True)) +@patch("zenzic.cli._standalone.find_repo_root", return_value=_ROOT) +def test_diff_fatal_z201_exits_2(_root, _cfg, mock_run, _excl, tmp_path: Path) -> None: + """zenzic diff exits 2 (non-suppressible) when current state has Z201.""" + mock_run.return_value = _fatal_report() + baseline = _diff_baseline_json(tmp_path, score=100) + result = runner.invoke(app, ["diff", "--base", str(baseline)]) + assert result.exit_code == 2 + + +@patch("zenzic.cli._shared._build_exclusion_manager") +@patch("zenzic.cli._standalone._run_all_checks") +@patch("zenzic.cli._standalone.ZenzicConfig.load", return_value=(_CFG, True)) +@patch("zenzic.cli._standalone.find_repo_root", return_value=_ROOT) +def test_diff_fatal_output_surfaces_fatal_banner( + _root, _cfg, mock_run, _excl, tmp_path: Path +) -> None: + """zenzic diff text output shows FATAL OVERRIDE banner and Z201 code.""" + mock_run.return_value = _fatal_report() + baseline = _diff_baseline_json(tmp_path, score=100) + result = runner.invoke(app, ["diff", "--base", str(baseline)]) + assert "FATAL" in result.stdout + assert "Z201" in result.stdout + # Standard numeric delta must NOT be mistaken for the whole story + assert "REGRESSION" not in result.stdout + + +@patch("zenzic.cli._shared._build_exclusion_manager") +@patch("zenzic.cli._standalone._run_all_checks") +@patch("zenzic.cli._standalone.ZenzicConfig.load", return_value=(_CFG, True)) +@patch("zenzic.cli._standalone.find_repo_root", return_value=_ROOT) +def test_diff_fatal_json_fields(_root, _cfg, mock_run, _excl, tmp_path: Path) -> None: + """zenzic diff --format json includes fatal_override, fatal_codes, halt, halt_codes.""" + mock_run.return_value = _fatal_report() + baseline = _diff_baseline_json(tmp_path, score=100) + result = runner.invoke(app, ["diff", "--format", "json", "--base", str(baseline)]) + assert result.exit_code == 2 + data = json.loads(result.stdout) + assert data["fatal_override"] is True + assert "Z201" in data["fatal_codes"] + assert data["halt"] is False + assert data["halt_codes"] == [] + + +@patch("zenzic.cli._shared._build_exclusion_manager") +@patch("zenzic.cli._standalone._run_all_checks") +@patch("zenzic.cli._standalone.ZenzicConfig.load", return_value=(_CFG, True)) +@patch("zenzic.cli._standalone.find_repo_root", return_value=_ROOT) +def test_diff_halt_z504_exits_1(_root, _cfg, mock_run, _excl, tmp_path: Path) -> None: + """zenzic diff exits 1 and surfaces HALT when current state has Z504 (pipeline gate).""" + mock_run.return_value = _halt_report() + baseline = _diff_baseline_json(tmp_path, score=100) + result = runner.invoke(app, ["diff", "--base", str(baseline)]) + assert result.exit_code == 1 + assert "HALT" in result.stdout + assert "Z504" in result.stdout + + +@patch("zenzic.cli._shared._build_exclusion_manager") +@patch("zenzic.cli._standalone._run_all_checks") +@patch("zenzic.cli._standalone.ZenzicConfig.load", return_value=(_CFG, True)) +@patch("zenzic.cli._standalone.find_repo_root", return_value=_ROOT) +def test_diff_standard_regression_no_fatal_halt( + _root, _cfg, mock_run, _excl, tmp_path: Path +) -> None: + """A plain score drop (Z101, no Z2xx/Z0xx/halt gate) must not trigger FATAL or HALT.""" + from zenzic.core.scorer import CategoryScore, ScoreReport + + cats = [ + CategoryScore( + name="structural", weight=0.30, issues=2, category_score=0.467, contribution=0.14 + ), + CategoryScore( + name="navigation", weight=0.25, issues=0, category_score=1.0, contribution=0.25 + ), + CategoryScore(name="content", weight=0.20, issues=0, category_score=1.0, contribution=0.20), + CategoryScore(name="brand", weight=0.25, issues=0, category_score=1.0, contribution=0.25), + ] + mock_run.return_value = ScoreReport(score=84, findings_counts={"Z101": 2}, categories=cats) + baseline = _diff_baseline_json(tmp_path, score=100) + result = runner.invoke(app, ["diff", "--base", str(baseline)]) + assert result.exit_code == 1 + assert "FATAL" not in result.stdout + assert "HALT" not in result.stdout + assert "REGRESSION" in result.stdout + + def test_init_standalone_no_engine_detected( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1264,7 +1439,7 @@ def test_init_engine_flag_invalid(tmp_path: Path, monkeypatch: pytest.MonkeyPatc result = runner.invoke(app, ["init", "--engine", "hugo"]) assert result.exit_code == 1 assert "hugo" in result.stdout - assert "docusaurus" in result.stdout # valid engine listed in error + assert "mkdocs" in result.stdout # valid engine listed in error def test_init_pyproject_engine_flag_override( @@ -1277,11 +1452,11 @@ def test_init_pyproject_engine_flag_override( (repo / "pyproject.toml").write_text('[project]\nname = "x"\n', encoding="utf-8") monkeypatch.chdir(repo) - result = runner.invoke(app, ["init", "--pyproject", "--engine", "docusaurus"]) + result = runner.invoke(app, ["init", "--pyproject", "--engine", "zensical"]) assert result.exit_code == 0 content = (repo / "pyproject.toml").read_text(encoding="utf-8") - assert 'engine = "docusaurus"' in content + assert 'engine = "zensical"' in content assert "(manually specified via --engine)" in result.stdout @@ -1399,8 +1574,7 @@ def test_inspect_capabilities_shows_bypass_table() -> None: result = runner.invoke(app, ["inspect", "capabilities"]) assert result.exit_code == 0 assert "Engine-specific Link Bypasses" in result.stdout - assert "pathname:" in result.stdout - assert "docusaurus" in result.stdout + assert "zensical" in result.stdout assert "R21" in result.stdout @@ -1834,3 +2008,31 @@ def test_check_all_progress_bar_activation( content_roots=ANY, show_progress=False, ) + + +def test_templates_root_keys_not_swallowed() -> None: + """Ensure root keys like excluded_dirs are not swallowed by tables in TOML templates.""" + import re + import sys + + if sys.version_info >= (3, 11): + import tomllib + else: + import tomli as tomllib + + from zenzic.cli.templates import GLOBAL_TOML_TEMPLATE, LOCAL_TOML_TEMPLATE + + # Test global template: we uncomment specific root keys to ensure they parse into the root dict. + for key in ["excluded_dirs", "forbidden_patterns", "plugins", "docs_dir"]: + # Uncomment the key + template = re.sub(rf"(?m)^#\s*({key}\s*=.*)", r"\1", GLOBAL_TOML_TEMPLATE) + template = template.format(engine="standalone", hint_name="test") + + data = tomllib.loads(template) + assert key in data, f"'{key}' was swallowed by a table in GLOBAL_TOML_TEMPLATE!" + + # Test local template: forbidden_patterns is already uncommented + local_data = tomllib.loads(LOCAL_TOML_TEMPLATE) + assert "forbidden_patterns" in local_data, ( + "'forbidden_patterns' was swallowed in LOCAL_TOML_TEMPLATE!" + ) diff --git a/tests/test_config.py b/tests/test_config.py index e622548..950ede9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -446,3 +446,61 @@ def test_build_from_data_i18n_with_extra_sources(tmp_path: Path) -> None: assert config.i18n.enabled is True assert len(config.i18n.extra_sources) == 1 assert config.i18n.extra_sources[0].base_source == Path("developers") + + +def test_config_rejects_swallowed_root_keys(tmp_path: Path) -> None: + """A root key placed after a table declaration is swallowed by TOML. + + The config loader must detect this and crash with a fatal error + instead of silently ignoring the configuration. + """ + toml_content = """\ +[network] +cache_ttl_hours = 24 +excluded_dirs = ["docs/"] +""" + (tmp_path / ".zenzic.toml").write_text(toml_content) + + with pytest.raises(ConfigurationError, match="FATAL CONFIGURATION ERROR") as exc_info: + ZenzicConfig.load(tmp_path) + + assert "excluded_dirs" in str(exc_info.value) + assert "network" in str(exc_info.value) + + +def test_config_rejects_swallowed_root_keys_in_pyproject(tmp_path: Path) -> None: + """Namespace isolation audit for pyproject.toml.""" + # Valid pyproject.toml: root keys under [tool.zenzic] + valid_pyproject = """\ +[tool.poetry] +name = "my-project" +[tool.zenzic] +excluded_dirs = ["docs/"] +[tool.zenzic.network] +cache_ttl_hours = 24 +""" + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text(valid_pyproject) + + config, loaded = ZenzicConfig.load(tmp_path) + assert loaded is True + assert "docs/" in config.excluded_dirs + assert config.network.cache_ttl_hours == 24 + + # Invalid pyproject.toml: root key swallowed by [tool.zenzic.network] + invalid_pyproject = """\ +[tool.poetry] +name = "my-project" +[tool.zenzic] +fail_under = 80 +[tool.zenzic.network] +cache_ttl_hours = 24 +excluded_dirs = ["docs/"] +""" + pyproject_file.write_text(invalid_pyproject) + + with pytest.raises(ConfigurationError, match="FATAL CONFIGURATION ERROR") as exc_info: + ZenzicConfig.load(tmp_path) + + assert "excluded_dirs" in str(exc_info.value) + assert "network" in str(exc_info.value) diff --git a/uv.lock b/uv.lock index 9d0920a..277af04 100644 --- a/uv.lock +++ b/uv.lock @@ -2163,7 +2163,7 @@ wheels = [ [[package]] name = "zenzic" -version = "0.12.0" +version = "0.13.0" source = { editable = "." } dependencies = [ { name = "google-re2" },