diff --git a/.bumpversion.toml b/.bumpversion.toml index d2f7699e..d712932e 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -73,11 +73,6 @@ filename = "RELEASE.md" search = "- [ ] `pyproject.toml` version matches the tag (`{current_version}`)" replace = "- [ ] `pyproject.toml` version matches the tag (`{new_version}`)" -[[tool.bumpversion.files]] -filename = "RELEASE.md" -search = 'git tag -s -m "Release v{current_version}" v{current_version}' -replace = 'git tag -s -m "Release v{new_version}" v{new_version}' - [[tool.bumpversion.files]] filename = "RELEASE.md" @@ -85,16 +80,25 @@ search = '(?m)^(\| Date\s+\|\s+)\d{{4}}-\d{{2}}-\d{{2}}(\s*\|)$' replace = '| Date | {now:%Y-%m-%d} |' regex = true -[[tool.bumpversion.files]] -filename = "RELEASE.md" -search = "- [ ] Create GitHub Release from the tag, using the `## [{current_version}]` CHANGELOG section as the release body." -replace = "- [ ] Create GitHub Release from the tag, using the `## [{new_version}]` CHANGELOG section as the release body." - [[tool.bumpversion.files]] filename = ".github/ISSUE_TEMPLATE/security_vulnerability.yml" search = 'placeholder: "{current_version}"' replace = 'placeholder: "{new_version}"' +[[tool.bumpversion.files]] +# The supported-version table on the security page. Registered 2026-09-08 after +# an audit found it unregistered here and in zenzic-vscode, while zenzic-action +# had it right: at a tag both would have gone on claiming 0.30.x was current and +# the new release unsupported -- on the one page a user reads when deciding +# whether to upgrade after a vulnerability (ADR-092). +# The table writes `0.30.x`, not the full version, so the search uses the +# major/minor components; {current_version} would not match anything here. +filename = "SECURITY.md" +search = '''| `{current_major}.{current_minor}.x` (current) | ✅ All security fixes | +| `< {current_major}.{current_minor}` | ❌ Unsupported — upgrade to the current release |''' +replace = '''| `{new_major}.{new_minor}.x` (current) | ✅ All security fixes | +| `< {new_major}.{new_minor}` | ❌ Unsupported — upgrade to the current release |''' + [[tool.bumpversion.files]] filename = ".github/ISSUE_TEMPLATE/bug_report.yml" search = 'placeholder: "{current_version}"' @@ -108,11 +112,6 @@ filename = ".pre-commit-hooks.yaml" search = "rev: v{current_version}" replace = "rev: v{new_version}" -[[tool.bumpversion.files]] -filename = "README.md" -search = '"version": "{current_version}"' -replace = '"version": "{new_version}"' - [[tool.bumpversion.files]] filename = "README.md" search = "uvx zenzic@{current_version} check all" @@ -123,6 +122,11 @@ filename = "README.md" search = "rev: v{current_version}" replace = "rev: v{new_version}" +[[tool.bumpversion.files]] +filename = "README.md" +search = 'docs = ["zenzic~={current_version}"]' +replace = 'docs = ["zenzic~={new_version}"]' + # Manages the "Version baseline" annotation in the CLI reference so it never drifts. [[tool.bumpversion.files]] filename = "docs/reference/cli.md" @@ -153,3 +157,28 @@ replace = 'version: "{new_version}"' filename = "docs/how-to/configure-ci-cd.md" search = "rev: v{current_version}" replace = "rev: v{new_version}" + +[[tool.bumpversion.files]] +filename = "docs/how-to/configure-ci-cd.md" +search = "uvx zenzic@{current_version} check all --ci" +replace = "uvx zenzic@{new_version} check all --ci" + +[[tool.bumpversion.files]] +filename = "docs/how-to/install.md" +search = "rev: v{current_version}" +replace = "rev: v{new_version}" + +[[tool.bumpversion.files]] +filename = "docs/how-to/install.md" +search = '"zenzic~={current_version}"' +replace = '"zenzic~={new_version}"' + +[[tool.bumpversion.files]] +filename = "docs/how-to/install.md" +search = "uvx zenzic@{current_version} check all" +replace = "uvx zenzic@{new_version} check all" + +[[tool.bumpversion.files]] +filename = "src/zenzic/cli/templates.py" +search = "# rev: v{current_version}" +replace = "# rev: v{new_version}" diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 26aad9ba..b17f6989 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -10,7 +10,7 @@ body: attributes: value: | Before opening a feature request, please open a discussion on the - [issue tracker](https://github.com/PythonWoods/zenzic/issues) so we can agree on + [issue tracker](https://github.com/PythonWoods-Dev/zenzic/issues) so we can agree on the approach before you invest time implementing it. - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/security_vulnerability.yml b/.github/ISSUE_TEMPLATE/security_vulnerability.yml index c3537838..b2245904 100644 --- a/.github/ISSUE_TEMPLATE/security_vulnerability.yml +++ b/.github/ISSUE_TEMPLATE/security_vulnerability.yml @@ -13,7 +13,7 @@ body: If this vulnerability could expose user secrets or enable remote code execution, **do NOT open a public issue.** Use GitHub's private - [Security Advisory](https://github.com/PythonWoods/zenzic/security/advisories/new) + [Security Advisory](https://github.com/PythonWoods-Dev/zenzic/security/advisories/new) feature instead, or email **security@pythonwoods.dev**. This public template is appropriate for: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5a0ba901..cc825e58 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -25,7 +25,7 @@ Fixes # ## Architectural Quality Gates (Core Python) -- [ ] **Mirror Law:** I have updated the finding codes catalog, scoring matrices, rule cards, and reference documentation if adding or modifying a rule. +- [ ] **Mirror Law:** I have updated all 10 mandatory targets (including the zenzic init template in templates.py and the VS Code IntelliSense schema in zenzic.schema.json) if adding or modifying a rule. - [ ] **Local Quality Pipeline:** `just verify` (or `pytest tests/` + `zenzic check all --strict`) passes with 100% test pass rate and no DQS regression. - [ ] **Zero Subprocess & Determinism:** No unauthorized subprocess executions, shell wrappers, or non-deterministic file I/O are introduced into the core analysis engine. - [ ] **Custom Rule SDK v3:** If modifying the SDK or rule extensions, changes maintain backward compatibility with `ZenzicRuleV3`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7037092..741c805c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,24 @@ on: - 'uv.lock' - '.github/workflows/ci.yml' pull_request: + # No label-driven types any more: the matrix below no longer depends on a + # label, so `labeled`/`unlabeled` would only start runs that report exactly + # what the previous run reported. + types: [opened, synchronize, reopened] + workflow_dispatch: + +# Three pushes in five minutes paid for three full runs. This cancels the +# superseded ones, which on this workflow is ~19.6 billed minutes each. +# +# NOT on `main`, and that exclusion is the point: a cancelled run reports its +# checks as cancelled, not as passed, and `main` is where the release tag is cut +# from. Cancelling there could leave the branch without a completed required +# check at the moment it matters most. On a pull request the transient state is +# harmless — the superseding run re-reports every context, and a PR with a run +# in flight is not mergeable anyway. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: contents: read @@ -24,16 +42,46 @@ jobs: strategy: fail-fast: false matrix: - # TEMPORARY EXCLUSION: Windows and Python 3.14 are temporarily excluded - # to accelerate CI cycle and isolate environment-specific runners. - # Original configuration: - # os: [ubuntu-latest, windows-latest] - # python-version: ["3.10", "3.14"] - # exclude: - # - os: windows-latest - # python-version: "3.14" - os: [ubuntu-latest] - python-version: ["3.10"] + # The matrix is unconditional, and that is a correctness requirement + # rather than a thoroughness preference. + # + # It briefly widened only on demand, while branch protection required + # contexts that only the wide form emits -- so an ordinary pull request + # could never satisfy them, and one sat with six green checks, `MERGEABLE`, + # and permanently `BLOCKED` on a context nothing would ever report. Either + # the required list matches what an ordinary run emits, or the wide run is + # not optional. Those are the only two coherent configurations; this is the + # second, and it needs nobody to remember anything. + # + # Windows earns its place on evidence, not symmetry: two failures this + # release were Windows-only and invisible on Linux -- a hand-built + # subprocess `env` that dropped `SystemRoot` and stopped Python starting, + # then `text=True` with no explicit codec failing to decode cp1252. Both + # were in test scaffolding, so both would have merged under a + # Linux-only gate. + # + # Measured cost of widening, from the real run at `7664533` rather than an + # estimate: ubuntu/3.14 124 s, ubuntu/3.10 171 s, windows/3.10 214 s. The + # jobs run in parallel, so the wall-clock a developer waits is 214 s + # against 124 s -- **+90 s** -- and the billed cost is ~12 minutes against + # ~2, Windows counting double. + # + # `fail-fast: false` above is kept on a count rather than a preference. + # Across 100 runs of this workflow on the v0.31.0 branch, 21 failed, and in + # 18 of those exactly one matrix job failed -- 16 of them `windows-latest` + # alone. The job that fails most often is also the slowest (214 s against + # 171 s and 124 s), so by the time it fails its siblings have finished and + # cancelling them would save almost nothing. What it would cost is the + # pattern: "only Windows failed" is the diagnosis, and it is legible only + # because the other two ran to completion. + os: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.14"] + exclude: + # Windows is exercised on the floor version only. The interpreter + # difference is what 3.14 tests; the platform difference is what Windows + # tests, and the cross product costs a fourth job to test both at once. + - os: windows-latest + python-version: "3.14" steps: - name: Checkout Repository @@ -53,14 +101,100 @@ jobs: - name: Sync dependencies run: uv sync --all-groups --all-extras --frozen - - name: Run unit tests - run: just test + # pre-commit enforces this too, but only on staged files and only when + # it is not bypassed; a formatter check that never runs in CI let six + # unformatted files reach main. Authoritative version is pinned in + # pyproject.toml to match .pre-commit-config.yaml's ruff-pre-commit rev. + - name: Formatting check (ruff format) + run: uv run ruff format --check src tests + + - name: Run unit tests (coverage enforced, fail_under=80) + run: just test-cov + # The credential scanner is the one path where a test that stops catching a + # regression is a security problem rather than a coverage statistic. The + # harness was configured in pyproject.toml but never invoked anywhere, so no + # score had ever been produced; this step is what makes it real. - name: Run Zenzic Quality Gate - run: uv run zenzic check all --strict --format github-annotations --ci + # --no-external: the PR gate must not depend on third-party uptime. + # Observed on run 34860816951: Audit failed on three platforms with nine + # distinct URLs, every one a github.com timeout (>10 s) from the runner — + # not a broken link. A third-party outage is a real defect but it is not + # this pull request's regression, and it left #233 unmergeable. + # The external surface is swept on a schedule instead — see + # external-link-sweep.yml. + run: uv run zenzic check all --strict --no-external --format github-annotations --ci - name: Build Docs run: uv run mkdocs build --strict + - name: Redirects structure tripwire + run: python3 scripts/check_redirects_structure.py + + - name: Blog link schedule gate + run: uv run python scripts/check_blog_link_schedule.py + + # Determinism across interpreters is a Tier-0 invariant and nothing was + # checking it. `pathlib.PurePath.suffix` changed semantics in 3.12 and the + # engine inherited the change, so the same corpus could yield different + # findings on 3.10 and 3.14 -- for two years, with both legs green, because + # each was only ever compared against itself. This emits one digest per + # Ubuntu leg; the `determinism` job below diffs them. Ubuntu only: the + # comparison is about the interpreter, and adding Windows would conflate + # platform with version, which is precisely the confound that produced two + # wrong diagnoses while this was being found. + - name: Emit interpreter-determinism digest + if: runner.os == 'Linux' + run: uv run python scripts/check_interpreter_determinism.py --emit findings-py${{ matrix.python-version }}.json + + - name: Upload interpreter-determinism digest + if: runner.os == 'Linux' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: determinism-py${{ matrix.python-version }} + path: findings-py${{ matrix.python-version }}.json + retention-days: 7 + - name: Verify Badge Freshness - run: uv run zenzic score --check-stamp --ci + # --no-external, for the same reason the Quality Gate above uses it, and + # because this step was left out of that decision when it was taken. + # Measured on run 35455833729, commit d99a008: this step computed 89/100 + # on Ubuntu 3.10 while the other two legs computed 97 on the same commit, + # and a re-run of that identical commit produced 97 everywhere. The gap is + # exactly one Z104 (penalty 8.0) -- an external URL that did not answer + # from that runner. A badge gate that moves with third-party uptime fails + # a pull request that changed nothing, and stamps a number that is not the + # repository's. The external surface is swept on a schedule instead. + run: uv run zenzic score --check-stamp --ci --no-external + + # ── Interpreter determinism ──────────────────────────────────────────────── + # The same corpus must produce the same findings on every supported Python. + # This costs a download and a diff, not a test run: both digests come from the + # matrix legs that already ran. + # + # Its limit is written into the script and repeated here because a green run + # here is not proof of determinism: it compares findings over the fixtures in + # `examples/`, so a divergence in an input shape no fixture contains is + # invisible to it. The defect that motivated it lived exactly there -- of the 35 + # inputs where 3.10 and 3.14 disagreed, zero appear in this repository. This + # catches the next one, if that one touches a shape a fixture exercises. + determinism: + name: Interpreter Determinism + runs-on: ubuntu-latest + needs: verify + steps: + - name: Checkout Repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download 3.10 digest + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: determinism-py3.10 + + - name: Download 3.14 digest + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: determinism-py3.14 + + - name: Compare findings across interpreters + run: python3 scripts/check_interpreter_determinism.py --compare findings-py3.10.json findings-py3.14.json diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index db120529..62cb054c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,15 +36,15 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.7 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.7 + uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.7 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 diff --git a/.github/workflows/external-corpus.yml b/.github/workflows/external-corpus.yml new file mode 100644 index 00000000..db05beb9 --- /dev/null +++ b/.github/workflows/external-corpus.yml @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2026 PythonWoods +# SPDX-License-Identifier: Apache-2.0 +# +# A high score on our own repository measures agreement between an engine and +# the corpus it grew up with. This is the only independent measurement the +# project has, and it runs here rather than in the pre-push path: it clones a +# repository, and network dependence in a pre-push hook cost this project five +# failed pushes with no diagnosable output. +# +# On pull requests rather than on a schedule. Measured 2026-09-18: 7 s to fetch +# the pinned commit, 3 s to scan, against catching a regression on somebody +# else's documentation before merge instead of the next morning. The corpus is +# pinned, so it cannot move under a pull request. +# +# ── The blind spot, stated here and in the script ──────────────────────────── +# A corpus pinned at a commit exercises the constructs that corpus contains. +# Four defects closed in this cycle moved neither corpus -- nested fences, +# backslash escapes, setext headings, and a frontmatter closed with YAML's +# document-end marker -- and each was covered by a probe and a suite instead. +# One pinned corpus is better than none and is not coverage. + +name: External Corpus + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/**' + - 'scripts/external_corpus_check.py' + - 'scripts/external-corpus-pin.json' + - '.github/workflows/external-corpus.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: external-corpus-${{ github.ref }} + cancel-in-progress: true + +jobs: + corpus: + name: Findings per code, against the pinned corpus + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout Repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + - name: Sync dependencies + run: uv sync --all-groups + + - name: Compare the external corpus per code + run: uv run python3 scripts/external_corpus_check.py diff --git a/.github/workflows/external-link-sweep.yml b/.github/workflows/external-link-sweep.yml new file mode 100644 index 00000000..0995f8ff --- /dev/null +++ b/.github/workflows/external-link-sweep.yml @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: 2026 PythonWoods +# SPDX-License-Identifier: Apache-2.0 + +# The PR gate runs with --no-external. The reason is measured, not assumed: +# on run 34860816951 the Audit job failed on three platforms, and all nine +# distinct URLs were github.com links that timed out (>10 s) from the runner. +# None was broken. A third-party outage is a real defect but it is not the +# pull request's regression, and letting it gate the merge queue left #233 +# unmergeable. The external surface is still checked — here, on a schedule, +# where a failure is triaged rather than sitting in front of someone's merge. +# +# This schedule is this project's arrangement, not a feature of the tool. +name: External Link Sweep + +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +jobs: + sweep: + name: Sweep external links + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout Repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: zenzic + + # The three satellites, because the root-document sweep below covers all + # four and a gate that reads one repository reports "clean" in exactly the + # same words as one that reads everything. Shallow and read-only: their + # `README.md` and `CONTRIBUTING.md` are all it opens. + - name: Checkout zenzic-vscode + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: PythonWoods-Dev/zenzic-vscode + path: zenzic-vscode + - name: Checkout zenzic-action + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: PythonWoods-Dev/zenzic-action + path: zenzic-action + - name: Checkout zenzic-mcp + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: PythonWoods-Dev/zenzic-mcp + path: zenzic-mcp + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + - name: Sync dependencies + working-directory: zenzic + run: uv sync --all-extras + + - name: Check external links + working-directory: zenzic + run: uv run zenzic check links --strict + + # Root-level documentation is first-party content that no other gate + # reads. Measured 2026-09-21, both directions: a broken external link in + # `docs/` fails `zenzic check all` (91/100); the identical link in + # `README.md` passes it (100/100), because `docs_dir` does not name the + # repository root and turning it on here pulls in test sandboxes holding + # planted secrets. The script's own docstring carries that measurement. + # + # It runs here rather than in the merge gate for the reason this whole + # workflow exists: external checking is triaged on a schedule, not left + # sitting in front of someone's merge. + - name: Check root-document links (all four repositories) + working-directory: zenzic + run: | + uv run python scripts/check_root_doc_links.py --self-test + uv run python scripts/check_root_doc_links.py --root .. diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 00000000..34525b64 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2026 PythonWoods +# SPDX-License-Identifier: Apache-2.0 + +# The mutation gate, moved out of the pull-request loop. +# +# It ran on every push as part of `ci.yml` and cost ~3.4 billed minutes of the +# ~19.6 a full run takes — about 17%, paid on every iteration, to measure a +# property that changes rarely. +# +# WHY NOT PURELY NIGHTLY. It guards the Tier-0 credential path, where this cycle +# found two security defects no other check caught. A nightly-only run discovers +# a regression after the merge, on `main`, when undoing it is expensive. Running +# it at the merge point keeps the protection where it counts while removing it +# from the loop a contributor waits on. +# +# SAFE TO MOVE, verified rather than assumed: moving this job makes no pull +# request unmergeable, checked against every repository's merge requirements +# before the change and again after. The job's own comment in `ci.yml` used to +# claim it was "required for the PR by the same rule that requires Audit" — that +# was never true, and the claim went with it. +# +# REGISTRATION. GitHub reads `schedule` from the default branch only, so the +# nightly run begins once this file is on `main`. Until then the push trigger is +# what fires it, which is the half that matters for the release. +name: Mutation Gate + +on: + push: + branches: [main] + schedule: + # 04:40 UTC — off the hour, where scheduled runs queue behind everyone else's. + - cron: "40 4 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mutation-${{ github.ref }} + cancel-in-progress: false + +jobs: + # Single Linux slot: mutmut has no native Windows support (upstream + # boxed/mutmut#397) and the 95.7% floor is measured on one interpreter. + # Running it twice measured the same thing twice. + mutation: + name: Mutation gate (credential scanner) + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install just + uses: taiki-e/install-action@ea85faa6acd705ad6d40586db99f1a70b09c2929 # just + + - name: Setup uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: true + + - name: Set up Python 3.10 + run: uv python install 3.10 + + - name: Sync dependencies + run: uv sync --all-groups --all-extras --frozen + + - name: Mutation gate (credential scanner) + run: just mutation diff --git a/.github/workflows/release-docs.yml b/.github/workflows/release-docs.yml index a8b82056..51d22af1 100644 --- a/.github/workflows/release-docs.yml +++ b/.github/workflows/release-docs.yml @@ -25,6 +25,8 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - name: Setup uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5306da7..82e88981 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,42 @@ jobs: with: fetch-depth: 0 + # Nothing else rejects an unsigned tag. No repository in this ecosystem has + # a ruleset targeting `refs/tags/*` (measured 2026-09-12: all four rulesets + # target branches), and a lightweight `git tag v1.2.3` is accepted by the + # platform, carries no signature of its own, and still triggers this + # workflow -- so the only place the tag's form can be enforced is here, + # before anything is built or published. + # + # Three assertions, because each fails differently: the object must be an + # annotated tag (a lightweight tag resolves to type `commit`), it must carry + # a signature block, and GitHub must actually verify it against a registered + # key. The third is the one that catches a signature made with a key nobody + # trusts, which is indistinguishable from a good one locally. + - name: Verify the release tag is annotated and signed + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + kind="$(git cat-file -t "refs/tags/${tag}")" + if [ "${kind}" != "tag" ]; then + echo "::error::${tag} is a lightweight tag (object type '${kind}'), not an annotated signed tag. Recreate it with 'just release-tag'." + exit 1 + fi + if ! git cat-file tag "refs/tags/${tag}" | grep -qE 'BEGIN (SSH|PGP) SIGNATURE'; then + echo "::error::${tag} carries no signature. Recreate it with 'just release-tag'." + exit 1 + fi + sha="$(git rev-parse "refs/tags/${tag}")" + verified="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.verification.verified')" + reason="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.verification.reason')" + if [ "${verified}" != "true" ]; then + echo "::error::${tag}'s signature is not verified by GitHub (reason: ${reason})." + exit 1 + fi + echo "${tag}: annotated, signed, verified (${reason})." + - name: Setup uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 2128452d..15aa8d58 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Generate SBOM (Syft — spdx-json) - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 + uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 with: format: spdx-json output-file: sbom.spdx.json diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 5d694a82..d749cf2c 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -1,12 +1,24 @@ # SPDX-FileCopyrightText: 2026 PythonWoods # SPDX-License-Identifier: Apache-2.0 +# This workflow used to be twenty-two lines, one step, and two `echo` statements +# announcing that the repository relied on GitHub's native secret scanning — +# which was disabled on every repository in this ecosystem. It reported `success` +# on every commit and had never scanned anything: a green check named `Scan`, in a +# credential scanner's own repository. +# +# It now runs the project's own scanner over every tracked file. The two layers are +# deliberately different and neither subsumes the other: this one applies Zenzic's +# signature set to the checkout, and GitHub's native scanning (enabled on all four +# repositories on 2026-09-12) applies its provider list to the full history. + name: Zenzic Core Secrets + on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] permissions: contents: read @@ -16,7 +28,25 @@ jobs: name: Scan runs-on: ubuntu-latest steps: - - name: Info - run: | - echo "Using GitHub Native Secret Scanning (Settings > Code security)." - echo "Gitleaks disabled due to license requirements for private repos." + - name: Checkout Repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: true + + - name: Sync dependencies (frozen lockfile) + run: uv sync --frozen + + # Nothing is excluded from the scan itself. The deliberate fixtures — the Z201 + # lab scenario and the hero screenshot specimen — are forgiven afterwards, by + # path AND secret type together, so a new credential in a fixture file and a + # different kind of credential in the same fixture file both still fail. A + # directory-level exclusion would be the shape this project already closed once + # at the hook level, where a flag narrowed a scan to an always-empty set. + # + # The script self-tests before rendering any verdict, and one of its cases exists + # solely to assert that the allowlist is not behaving as a path-level bypass. + - name: Scan every tracked file for credentials + run: uv run python scripts/ci_secret_scan.py diff --git a/.gitignore b/.gitignore index 033965c3..4cfd60a6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,31 +16,18 @@ .zenzic.local.toml # ──────────────────────────────────────────────────────────────────────────── -# AI Orchestration & Private Workspace (Zero-Leak Governance) -# ──────────────────────────────────────────────────────────────────────────── -# Private Tech Lead workspace (formerly .draft) -.architect/ -# Local AI routing rules (Trade Secret) -.clinerules -# Cursor AI rules (Trade Secret) -.cursorrules -# AI Primers and Memory ledgers -.github/agents/ -# Legacy draft vaults -.draft/ -/drafts/ -docs/.drafts/ - +# Internal workspace (not published) +# ──────────────────────────────────────────────────────────────────────────── zenzic.code-workspace -.gemini/ -.copilot/ -.cursor/ -.ai/ .claude/ .human/ -.agent/ -.deepcode/ -.redteam/ +# Integrity manifest for the two directories above. It records their file +# paths, sizes and hashes — never their content — but the paths alone would +# publish the shape of a deliberately private tree, so it stays on the same +# side of the boundary. +# Local-only recipes, loaded via just's `import?` when present and silently +# absent otherwise, so a fresh clone's public recipes still all work. +.justfile.local # ──────────────────────────────────────────────────────────────────────────── # Python Build & Distribution @@ -110,6 +97,7 @@ zenzic_report/ .zenzic_cache/ # Zenzic quality scores — derived local metadata .zenzic-score.json +.zenzic-history.jsonl # SARIF outputs — generated by `zenzic check --format sarif` *.sarif @@ -126,7 +114,7 @@ pdf/ # ──────────────────────────────────────────────────────────────────────────── node_modules/ .npm/ -package-lock.json +# package-lock.json is committed: scripts/check_generated_provenance.py needs it to verify the pin # ──────────────────────────────────────────────────────────────────────────── # Logs & Temporary Files @@ -144,9 +132,11 @@ artifacts/ # ──────────────────────────────────────────────────────────────────────────── # IDEs & Editors # ──────────────────────────────────────────────────────────────────────────── -.vscode/ +.vscode/* !.vscode/extensions.json +!.vscode/extensions.json.license !.vscode/settings.json +!.vscode/settings.json.license .idea/ *.iml *.swp @@ -178,19 +168,15 @@ Desktop.ini # Cloudflare Wrangler local cache .wrangler/ +# ──────────────────────────────────────────────────────────────────────────── +# Local scratch files +# ──────────────────────────────────────────────────────────────────────────── +scratch_test/ +zenzic_errors_demo.md + # ============================================================================ # End of .gitignore # ============================================================================ -# AI Agent Private Memory -.clinerules -.github/agents/ - -# AI Agents Configuration -.github/agents/ -.clinerules - -# Architect Planning Sandbox -.architect/ -scratch_test/ -zenzic_errors_demo.md +# Scratch drafts — never committed +.draft/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc deleted file mode 100644 index bd5179a3..00000000 --- a/.markdownlint-cli2.jsonc +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 PythonWoods - * SPDX-License-Identifier: Apache-2.0 - */ -{ - "$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint-cli2/main/schema/markdownlint-cli2-config-schema.json", - "globs": [ - "README.md", - "docs/**/*.md", - "docs/**/*.mdx", - "i18n/**/*.md", - "i18n/**/*.mdx" - ], - "gitignore": true, - "config": { - "default": true, - "MD013": false, - "MD024": { "siblings_only": true }, - "MD025": { "front_matter_title": "" }, - "MD033": false, - "MD036": false, - "MD037": false, - "MD041": false, - "MD003": { "style": "atx" }, - "MD010": { "code_blocks": false }, - "MD046": false - } -} diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml deleted file mode 100644 index da27cfe9..00000000 --- a/.markdownlint-cli2.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# SPDX-FileCopyrightText: 2026 PythonWoods -# SPDX-License-Identifier: Apache-2.0 -# -# markdownlint-cli2 configuration — global defaults + per-file overrides. -# Global rules are in .markdownlint.json; this file adds file-level overrides. -# -# Rules disabled globally in .markdownlint.json: -# MD051 — MkDocs Material { #custom-id } anchor syntax is not resolved statically by -# markdownlint. Zenzic handles these correctly via slug_heading() since v0.4.0. - -gitignore: true - -overrides: - - files: ["CHANGELOG.md"] - config: - # Keep a Changelog uses repeated ### Added / ### Fixed / ### Changed - # headings under each version block — this is intentional and correct. - MD024: false - - files: ["examples/scoring/**/*.md"] - config: - # Scoring fixtures may have intentional heading/frontmatter combinations - # (e.g. security-base64/secret.md: frontmatter title + H1 = Shield Z201 trigger). - MD025: false - - files: ["examples/rules/z108-empty-link/**/*.md"] - config: - # Z108 demo fixture intentionally contains empty link labels []() to trigger - # the Z108 EMPTY_LINK_LABEL rule. MD042 would flag exactly these violations. - MD042: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 851404e0..264dadd3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,11 @@ repos: hooks: - id: markdownlint exclude: ^examples/ - args: ["--fix"] + # Deliberately NOT --fix. `.markdownlint.json` sets `default: true`, so + # every rule a future markdownlint release adds is enabled the moment + # `rev` is bumped. With --fix that would silently rewrite files across + # the corpus on the next run; without it, the same bump surfaces as a + # reviewable failure and each new rule gets a deliberate disposition. # 3. Ruff (linting & formatting) - repo: https://github.com/astral-sh/ruff-pre-commit @@ -65,27 +69,108 @@ repos: # [project.dependencies] including google-re2. No additional_dependencies # needed here (that's only for mirrors-mypy repo hooks that run in # isolated virtualenvs outside the project's uv-managed environment). - entry: uv run mypy src/ + entry: uv run mypy src/ tests/ language: system types: [python] pass_filenames: false # 7. Local Dogfooding: Zenzic Secret Guard on staged Markdown/MDX + # + # The guard is deliberately wider than the analysed corpus: it scans every + # staged Markdown/MDX file, including the 203 that live outside docs/ -- + # README.md, CONTRIBUTING.md, SECURITY.md, .github/, scripts/, changelogs/. + # That breadth is the point, so it is not narrowed to the corpus. + # + # Three paths are exempted here, and the exemption lives in this file rather + # than in .zenzic.toml or the engine. All are fixtures whose whole purpose + # is to carry a credential the scanner detects -- examples/z201-credentials/ + # demonstrates Z201 firing for the tutorial, the hero_specimen sandbox + # backs the test suite, and the readme_capture sandbox (2026-09-17) is the + # four-file fixture the README's regenerated capture runs on -- so the + # scanner detects them and blocks every commit that touches them. These + # three are the complete set: of the tracked Markdown/MDX files, the guard + # flags exactly these and nothing else, checked by running it over all of + # them rather than by assuming (re-run 2026-09-17 when the third was added). + # The engine must not grow a config-named + # exemption to accommodate that -- the security tier is never suppressible + # by any mechanism (discovery.py, iter_security_scan_sources), and a key + # that switches it off for one path is a general bypass with a narrow + # default. Excluding the path at the hook keeps the property intact: the + # fixtures are still scanned by `zenzic guard scan` run directly, still + # report their findings, and still exit 2. Only the commit gate skips + # them, visibly, in a file every diff shows. - repo: local hooks: - id: zenzic-guard name: zenzic guard (Secret Guard) - entry: uv run zenzic guard scan --staged --quiet --no-header + entry: uv run zenzic guard scan --quiet --no-header language: system types_or: [markdown, mdx] + exclude: ^(examples/z201-credentials/|tests/sandboxes/hero_specimen/docs/secrets\.md$|tests/sandboxes/readme_capture/docs/deploy\.md$) + pass_filenames: true + + # 7a. Breaking-change marker (commit-msg stage -- the pre-commit stage never + # sees the message). Requires a `!` in the subject only when the count of + # BREAKING entries in CHANGELOG.md actually increases, so moving an entry + # between sections does not false-positive. Limits are stated in the + # script's own docstring rather than left to be discovered. + - repo: local + hooks: + - id: breaking-change-marker + name: Breaking-change marker (!) in the commit subject + entry: python3 scripts/check_breaking_change_marker.py + language: system + stages: [commit-msg] + always_run: true + + # 7b. Redirects tripwire — structural drift detector for docs/_redirects. + # Not a formatter: it never edits the file, it fails loudly when the file + # changes shape. Added after commit 36c5f70 silently gained 18 blank lines + # through the comment header with no identifiable vector. + - repo: local + hooks: + - id: redirects-structure + name: Redirects structure tripwire + entry: python3 scripts/check_redirects_structure.py + language: system + files: ^docs/_redirects$ + pass_filenames: false + + # 7c. Blog link schedule — staggered-publication invariant for docs/blog/posts/. + # A live (non-draft) post must never link to a still-draft post: a real + # mkdocs build --strict stays clean either way, since the blog plugin just + # excludes drafts from the build rather than treating a link into one as + # broken. This is the only thing that actually enforces the schedule. + - repo: local + hooks: + - id: blog-link-schedule + name: Blog link schedule gate + entry: python3 scripts/check_blog_link_schedule.py + language: system + files: ^docs/blog/posts/.*\.md$ pass_filenames: false - # 8. Final Guard — score gate + badge freshness + full audit (pre-push only) + # 8. Pre-push gate — only the checks a later run cannot undo (~13 s) + # + # This ran `just verify` until 2026-09-18. That was the cause of the push hang + # chased across five attempts in the v0.31.0 cycle, not merely a slow gate: + # git opens the SSH connection to fetch the remote refs it feeds this hook on + # stdin and keeps it open while the hook runs, GitHub drops a connection idle + # for a few minutes, and a 388 s hook (cold verdict cache) therefore failed the + # push with `Connection to github.com closed by remote host` and exit 141, + # having transferred nothing. + # + # What remains is the set whose damage a later run cannot undo — disclosure, + # private-tree secrets, commit identity, AI-attribution trailers — because a + # push publishes and a pushed commit is not rewritten. The suite, the audit, + # the score and the coherence gates run in `just verify` before a commit and + # in CI on every push; `just verify-full` adds the on-demand gates before a + # release. CONTRIBUTING.md, "Which gate runs where", carries the table. - repo: local hooks: - - id: just-verify - name: Final Guard (just verify) - entry: just verify + - id: zenzic-prepush + name: Pre-push gate (irreversible checks only) + entry: just _prepush-checks language: system stages: [pre-push] pass_filenames: false diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 98267044..a8614bce 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -6,7 +6,7 @@ # Usage in your .pre-commit-config.yaml: # # repos: -# - repo: https://github.com/PythonWoods/zenzic +# - repo: https://github.com/PythonWoods-Dev/zenzic # rev: v0.30.0 # hooks: # - id: zenzic-verify # quality gate — corrisponde a `just verify` lato zenzic diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..6e0f11bd --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "pythonwoods.zenzic-vscode", + "ms-python.python", + "ms-python.mypy-type-checker", + "charliermarsh.ruff", + "davidanson.vscode-markdownlint" + ], + "unwantedRecommendations": [ + "ms-python.black-formatter" + ] +} diff --git a/docs/assets/brand/svg/zenzic-badge-score.svg.license b/.vscode/extensions.json.license similarity index 99% rename from docs/assets/brand/svg/zenzic-badge-score.svg.license rename to .vscode/extensions.json.license index 73c93a85..edac8b46 100644 --- a/docs/assets/brand/svg/zenzic-badge-score.svg.license +++ b/.vscode/extensions.json.license @@ -1,3 +1,2 @@ SPDX-FileCopyrightText: 2026 PythonWoods - SPDX-License-Identifier: Apache-2.0 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..46118573 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "mypy-type-checker.importStrategy": "fromEnvironment", + "ruff.importStrategy": "fromEnvironment", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } +} diff --git a/docs/assets/images/blog/10-documentation-bugs-caught-by-zenzic.webp.license b/.vscode/settings.json.license similarity index 99% rename from docs/assets/images/blog/10-documentation-bugs-caught-by-zenzic.webp.license rename to .vscode/settings.json.license index 73c93a85..edac8b46 100644 --- a/docs/assets/images/blog/10-documentation-bugs-caught-by-zenzic.webp.license +++ b/.vscode/settings.json.license @@ -1,3 +1,2 @@ SPDX-FileCopyrightText: 2026 PythonWoods - SPDX-License-Identifier: Apache-2.0 diff --git a/.zenzic-baseline.json b/.zenzic-baseline.json index 5b1bd771..be44347d 100644 --- a/.zenzic-baseline.json +++ b/.zenzic-baseline.json @@ -1,353 +1,11 @@ { "$schema": "https://zenzic.dev/schemas/zenzic-baseline.schema.json", "version": "1.0", - "created_at": "2026-08-12T17:31:03.658489+00:00", - "score": 98, - "findings_count": 341, - "signatures": [ - "002e920b75d0f7bd", - "007b551dfa6b1cc4", - "0097fa0caca2f5cc", - "00ae3dc4cbd5c52b", - "01a01394856591fd", - "02474f66b160d8b1", - "03459fbe529d56db", - "03e5f8dde6e08411", - "03ecd4a25acecb9a", - "044e83d8af566571", - "052d51e29d549ace", - "05b42ec661d04a5b", - "062fd4e415d26b24", - "077e3efe080b78dc", - "080b2359880eacb9", - "0871069d9a0ca071", - "09803f09cf7bdfb9", - "0aa5036732bd32bc", - "0b71a55f6350cbb3", - "0b8f9999ee9d00b9", - "0ba9880c9bf66f0c", - "0c89e5b5a28ec642", - "0d62458800f41c69", - "0e873a15c5896405", - "0ed1f885033b655b", - "0f2aefd041a086bf", - "100111e18fc89cb9", - "100ffc192be8745b", - "103ebc3eb5677510", - "10ce5a1706f36107", - "1152e0d668a09405", - "13372a95123cbe52", - "13db0904c2ab5af7", - "13f98312a89fc9e6", - "14122b0f3766a7c1", - "17f9526d2edc846d", - "193e8cb6d81a817b", - "19efeba3896fcbac", - "1a91c52ffffa254e", - "1a95e8c03cc0e630", - "1c3b135470af5eb9", - "1d15448b7c86f169", - "1de84465e480503b", - "1e73a30da3e6cc15", - "1f62f56fd7d37cc7", - "1fe99f0018b93b3b", - "2045a303eb50586a", - "2064b5c4b7d20131", - "2277404151f829ef", - "22ab6cc6d1e03c11", - "241b1f473b292ca7", - "2502edf88d5da7b3", - "254fa4d5250b530b", - "2674723eb4be144e", - "26a7242f343c0910", - "271796a8e1ec8961", - "27884c5d7cc61942", - "2ab4f0241dd64b7d", - "2adedfb2bca5ef96", - "2b44b5bd75c05df3", - "2b5a598b463c5803", - "2bce6fc652968c81", - "2bffeac1be76c62a", - "2c32fb4b2dc62a94", - "2c3b1065092e3e9a", - "2d3227cae3d1aa53", - "2ddea1a300203dfc", - "2e0a7f4173b34e98", - "2f9d4dd48beadccd", - "2fc7f9626a4046d4", - "301d0fd1df609e6a", - "3058968ae7b58304", - "30e87868745742b7", - "311831710adb2fa6", - "3126acb6cc07abc2", - "3312b6aa27de941f", - "3364fa4b2de21c4a", - "34f223ec257ffca0", - "34ffc7ca42f7fa6f", - "35385b8cdf19bfb8", - "35a839b1b245ddb7", - "3634e34e35029f1a", - "3721b86dc738d04f", - "3768bfe0e60228bb", - "37af0e9c66c5e612", - "38a202dbe4e1d471", - "390d9a0c2c9be518", - "3a21122865bd5570", - "3c85bfa0f71b65eb", - "3c96cf4a6b38f4d3", - "3d318c9dfd5f4a57", - "3d46f1360a6f6696", - "3e38b7c8e0d53b4a", - "3ede1154b8786ff7", - "3f235392a0a14cba", - "4003d35c5aad0719", - "4037784e9419be6f", - "4059f9c138965740", - "40666b05f5f0610d", - "42107d28c17c3684", - "444470d1a570314a", - "447abfea6e4eccfd", - "45edbdd5ffbca81d", - "477db8496c9052cd", - "49ad67ffad66d86d", - "4b49ce3e9c8e91ca", - "4c2e6c3750b853e2", - "4c6e33e2d80adc0a", - "4d2315c8808f7e4e", - "4d41afebdae4a949", - "4d692f73724328e9", - "4f6705ede7484f92", - "4f9fe1bfacda5a9c", - "4ff0855cc4347948", - "50e4db847fe94abd", - "5141e468de7a6b47", - "516abc1c09e784e6", - "52dfaf9a6d4531f2", - "52eef42ac2fb14ef", - "539f72b46651f002", - "547da83ab1c00a77", - "54d78cd08b9bf345", - "569aedf278da9cf9", - "58a5310cb4aab459", - "59149559ac6492d0", - "598591015a819303", - "599e61d3128df6b6", - "5b0429b6bd466897", - "5bd4047806775ed3", - "5c86a540ad33f51c", - "5cedf19c39be6b1a", - "5e4514aa04c2fadc", - "60130acfc25c03b6", - "61861fd91a012d9c", - "628de6e03be0e683", - "637cbe4590840a44", - "6439c000c41eac9f", - "648fa17bf02b2969", - "654057e57f48e5a9", - "654c3de4cab31d90", - "66b474b954d8d84d", - "6766e775285b2d4e", - "67ce2b906c55b769", - "6a13657bd9d5ca62", - "6c6fa494d7727f21", - "6cce5184c926ad88", - "6dc05d277ba03c7d", - "6e1e3cff9dc56f9e", - "6e3a10d18bdb17e1", - "6e49d68af62c67e2", - "714126953a773e3a", - "7246c8c1cf69d23d", - "72478ecff617aa29", - "733244f9be76220c", - "733ea2372b4f2453", - "7400bf1bfc490793", - "74393f243a4f25c7", - "74a8dc0d2a16cd98", - "74c77d34c6075bf8", - "762ea82752967fb9", - "7696caf6f7fdb4c3", - "777e2dfa7390fec1", - "77bb3477b260869d", - "77c92042ef0bf9d8", - "781c3cfcb5cbf137", - "7839fc321bb9ef2e", - "7909e833396e7028", - "793f825738e7c196", - "798bdcd4317005e0", - "7b19b7796339c3c9", - "7b99d61070eaa340", - "7d54bc3836a4ab98", - "7dce31c69353da36", - "7e5cc060e966d53a", - "7eb2aa7becacaa76", - "7eeaa1855be43bf9", - "7f02902ef5754a48", - "8119b1b2d65fa4fb", - "8344815f4fbd1d7a", - "85377f97bd0e1227", - "855ff4cfbb2b0f6c", - "861c993f4c346f64", - "886a9358da829397", - "890564df6220b68d", - "89de70a07175cf12", - "8a79ddb4185b602b", - "8b2a0f1e0a3ab3a3", - "8b73a9277007aee8", - "8cc5fef28249cc2f", - "8e932317947b0715", - "8effae31063acb8b", - "8f0aedfc13262114", - "8f3c75090efa3c29", - "9101206bd2007755", - "911cc96902091fa5", - "918e3da162e1c05d", - "91b395e57fb9bc80", - "9276239189a0064d", - "9371280df880ecfd", - "93b2d827b805facc", - "93d3030dd3b0c894", - "955e95737d58bace", - "95ee1d8501b00636", - "9737f52bc151046b", - "973ac89d24b151d4", - "984866cc8a7bcf57", - "98a2693e2640a640", - "9919805a02449e9f", - "994c796a8a2a4e2f", - "9a6e8c537123ce7e", - "9b5127940b347e08", - "9b8761935f64d59a", - "9ba1cc2fa9544daa", - "9c75bc5af4a933f1", - "9d3454f0336e65d9", - "9e0b6a6b104842a7", - "9e504177c1d699d7", - "9fddefc06d798a85", - "a0028df6774e400e", - "a0ac5738b0b328c9", - "a1851e6a4b66ec76", - "a26f1049147929e3", - "a33053225eca0d90", - "a624fe3507aebb1a", - "a6c0b702ae9f3ae5", - "a87798dfbcae8ca0", - "a8d0a4aee9b0c640", - "a8f5b66f070ef4e5", - "a97bcbf2f8f61bc9", - "aa49978418fa6b54", - "aa49e13e1f5db441", - "ab25423f9ec3c31a", - "ab38383c88b96d79", - "abe30294e74405e9", - "ac3f607bdc58e9d7", - "ac76de7cd9596e7f", - "ad1c97b3fa828ee8", - "ae30171dfd03e741", - "ae6eda0c3452c964", - "aeb762f1cea1f4fb", - "af270ff314effcfa", - "af2cc18b79583301", - "af3b84d085ec89de", - "afc1ab29cdcb6f0a", - "b1390ca2e8efd359", - "b180fe2d5b4f63c4", - "b4581476192a8229", - "b61e3e19846ea8a5", - "b628bf8a915aa1ee", - "b87c75876cd64c61", - "b8c48bea5b334e0c", - "b9481c2dfcc67b0f", - "b9a51c1a7d8b2373", - "ba6663570568a22a", - "ba7b77730a4ae92c", - "bba0863186349540", - "bc9d5a35f6ef2782", - "bd17436aa41810e8", - "bf71a2b2c1790e82", - "c01955750c53199d", - "c073d2a87f2229ee", - "c124b818c0950d6b", - "c1436d587134481b", - "c29feac04785ce9e", - "c77220f0097167ca", - "c776ceb1a725d88f", - "c7fe7570799a274b", - "c92ea45155fcfa17", - "cb8c9a967f2fede0", - "ccfa62c3f6b6cea8", - "cd6a88454fbb5aff", - "ce82d80e4276466c", - "cf331d4e8d460907", - "d029c26dc93149f6", - "d03580e9536eba94", - "d0aec4b4610eaec7", - "d1c8ffdd90668224", - "d212a87dba9385cd", - "d2a5992abe6ce912", - "d2fd012d511a2410", - "d5d63859d82e4ef7", - "d6237fb8f3f35bc2", - "d8a6e7bb18ae9358", - "d8b83167c4d07785", - "d93db88617d32a70", - "dae159423004a017", - "db80be92afb474e0", - "dd9039f064df360c", - "ddc9e24fa0bbc07a", - "ddd4e83ce4199009", - "dde409450674f38d", - "defc23cf8c74a2e3", - "df95d905bb653aba", - "dff0940ee6688e7f", - "dffc2bd7810bed02", - "e02d2a4688137f16", - "e16fe33f2f0b0712", - "e1cec8eeec5f7d7d", - "e2537ab1930f2fbe", - "e25cf721f639faf3", - "e26f2b58b46298d7", - "e2c2b1646e28dde5", - "e32aa067396d0a5e", - "e336be8cab1f6ab8", - "e4ad48474afadd85", - "e4b638c6da6e7e97", - "e4f2b23842c7c3e4", - "e5c15d0612ea8183", - "e7ba0fc19437c889", - "e82632214ccef4de", - "e85d1a1e1911a26d", - "e870e16b121c7369", - "e8824166a211ce39", - "e982add6fe158073", - "ea220a04626c63c0", - "ea8295abe96c268c", - "eb294869ac0c896d", - "eb8f8dfce1fef209", - "ec0c9d9fa01a5e0e", - "ed108544eaccc8fd", - "edf3c2da43f1d57c", - "f1219c250e3757fb", - "f28854b1f7899981", - "f3089271b47959dd", - "f3b7336671f1daf3", - "f4ee566fa6a8bcf5", - "f5e23e6b2bebb40d", - "f610e94a1e0f4887", - "f684e5f7b9ab2f8b", - "f6dd74365d0bf295", - "f73f601f8926f07b", - "f791029517b60491", - "f7e13ddffeb842ad", - "fae3f0ceebbd6a6b", - "fb14b3a96aa851c6", - "fbb215ee4258640f", - "fd35f0ab220b8a8d", - "fd7af9574b8c4118", - "fe101601c49128bf", - "fe3ecb25ecfad8d4", - "fff52560631d52cf" - ], + "created_at": "2026-09-16T09:02:41.633339+00:00", + "score": 96, + "findings_count": 0, + "signatures": [], "metadata": { - "zenzic_version": "0.28.1" + "zenzic_version": "0.30.0" } } diff --git a/.zenzic.toml b/.zenzic.toml index d7549647..8bf7e535 100644 --- a/.zenzic.toml +++ b/.zenzic.toml @@ -17,36 +17,92 @@ # The relative path to your documentation root. # # BEHAVIOR: -# - If commented out (default), Zenzic uses Auto-Discovery to find your -# docs based on the engine (reads docusaurus.config.ts, mkdocs.yml…). +# - If commented out (default), Zenzic uses "docs" as the documentation root. # - Set to "." to scan the entire repository (L1 system exclusions apply). # -# DEFAULT: Auto-discovered +# DEFAULT: "docs" # # docs_dir = "." strict = true -fail_under = 98 +# fail_under = 100 - suppression_cap, the relation the generated configuration states +# (fail_under <= 100 - suppression_cap). With the cap declared below at 4 this is 96: a +# higher floor would fail the project on the debt it declares, with no finding in sight. +fail_under = 96 max_sentence_length = 50 # exit_zero = false # respect_vcs_ignore = true -# validate_same_page_anchors = true excluded_external_urls = [] +# Theme assets (favicon, logos, fonts) consumed by the site's own CSS/HTML +# templates, never referenced from a Markdown page — previously kept quiet +# only as a side effect of docs/how-to/use-brand-system.md's own link list, +# which was deleted as brand-identity content out of scope for a technical +# tool's public docs (see V031_CROSS_REPO_BUG_RESOLUTION_SPRINT addendum). +excluded_asset_dirs = ["overrides", "brand", "fonts"] +excluded_assets = ["favicon.ico"] + # Z204 Privacy Gate — terms that must never appear in published docs. # forbidden_patterns = [] # --- EXCLUSION ZONES (Full bypass — use sparingly) --- +# The blog feeds are generated at build time and live at the site root, so no +# relative spelling from a post can name them: the blog plugin serves +# docs/blog/posts/X.md at /blog/YYYY/MM/DD/slug/, and a correct relative link +# escapes docs_root under path arithmetic that cannot know the permalink. An +# absolute path is the canonical reference -- it is what the feed itself emits +# (mkdocs.yml: stylesheet: /blog/rss.xsl) -- declared here rather than silenced. +absolute_path_allowlist = ["/blog/rss.xml", "/blog/rss-updated.xml"] + excluded_file_patterns = [] -# Paths listed here are INVISIBLE to Zenzic: no findings, no audit trail. + +[governance] +# suppression_cap = 4: the directory-policy pairs this project defends, counted in use and +# measured on 2026-09-16 (`zenzic check all --audit`). One exempts the blog posts' +# unreachable-page finding; three keep tutorial pages that demonstrate the very finding +# they document. The twelve navigation pairs that stood here until 2026-09-16 are gone -- +# see the note above the policy table. The default of 30 is not calibrated for any project +# -- it began as the free allowance of a retired scoring model -- so the cap is declared +# from what this project holds, and a new exemption lands only by raising it on purpose. +suppression_cap = 4 + +# Directory policies: each (pattern, code) pair that silences a finding costs one point and +# counts toward suppression_cap; a pair that silences nothing is reported (Z620). [governance.directory_policies] -"docs/**" = ["Z106"] -"docs/blog/posts/**" = ["Z410", "Z411", "Z513"] -"docs/developers/explanation/adr-vault/records/**" = ["Z410", "Z411"] -"docs/developers/reference/**" = ["Z513"] -"docs/reference/**" = ["Z513"] -"docs/tutorials/examples/**" = ["Z410", "Z411"] -"docs/tutorials/examples/z1xx-links/**" = ["Z107"] +# `"docs/index.md" = ["Z502"]` was removed 2026-09-15. The short-content check became +# opt-in and defaults to off, so the entry suppressed nothing; the dead-policy report +# skips exactly this path by name, which is why it was never flagged. +"docs/blog/posts/**" = ["Z410"] +# Twelve pairs were retired on 2026-09-16: two Z402 and ten Z103. The engine now +# honours MkDocs' not_in_nav, which mkdocs.yml already declared for the ADR records +# and the release runbook, so those pages are reachable-by-intent rather than +# orphans — and with the targets no longer orphaned, the links into them stopped +# being orphan links too. All twelve stopped silencing anything and Z620 reported +# each one. The editorial argument they encoded is not abandoned: it now lives in +# docs/rules/Z103.md, where an instrument can read it and a reader can find it. "docs/tutorials/examples/z5xx-content/**" = ["Z506"] +# z506's page opens with a deliberately malformed frontmatter delimiter ("--" +# instead of "---") -- that malformation IS the fixture. A side effect is that +# the "description:" line then parses as an H2 with no body, so Z512 fires on a +# heading the author never wrote. Suppressed for this one page: fixing it would +# mean repairing the delimiter, which would delete the demonstration. +"docs/tutorials/examples/z5xx-content/z506-malformed-frontmatter.md" = ["Z512"] +# z403's page still trips its own rule -- the image with no alt text IS the +# fixture -- so this stays. z501's twin entry was removed once the fence tracker +# began closing nested fences correctly: the placeholder words it suppressed are +# inside a fenced CLI transcript, which is no longer scanned as prose, so the +# suppression became dead configuration (Z620) rather than a needed exemption. +# z403's entry was removed on 2026-09-19 for the same reason z501's twin was: +# the page quotes a CLI transcript, the `![](diagram.png)` inside it is fenced, +# and Z403 now consults the fence tracker -- so the suppression stopped +# silencing anything and became Z620 dead configuration. +# These two pages quote the real CLI output for the rule they document, and that +# output necessarily contains the payload the rule detects: z501's capture shows +# the placeholder words it found, z403's shows the image tag missing its alt. +# The previous sanitised transcriptions avoided this by accident rather than by +# design -- they were also wrong about the glyphs, the severities and the counts. +# Same shape as Z205's rule page, which teaches a forbidden scheme by showing +# one: the demonstration is the content, and deleting it to satisfy the gate +# would delete the page's reason to exist. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6849aaa2..598d7f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,5 @@ - # Changelog All notable changes to Zenzic are documented here. @@ -11,74 +10,1522 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] -*Upcoming changes for the next release.* +### Added + +- **`.zenzic-vsm.json` Can Declare Its Own Anchors**: a `prebuilt` manifest entry may carry an + `anchors` array, and where it does the engine stops predicting that page's fragments. Zenzic + derives anchors with a replica of Python-Markdown's slugifier; a site rendered with anything + else — `github-slugger` for Astro and Starlight — diverges on every heading the two slugify + differently, and each divergence surfaced as a `Z102` on a link the browser serves. Declared + anchors **replace** the predicted set rather than extending it, so a fragment absent from a + declared set is still reported. The field is optional and absent keeps the previous behaviour, + so existing manifests need no change. Both the full scan and the editor's incremental engine + honour it. +- **`Z102` Says When It Is Predicting**: on a `prebuilt` project, a page whose manifest entry + declares no `anchors` now produces `anchor '#x' not found in 'y' (anchors predicted from + headings: this page declares none in .zenzic-vsm.json, and the prediction replicates + Python-Markdown)`. A renderer divergence and a typo used to read identically. The note appears + only where anchors were in fact predicted. +- **Route Manifest Schema Reference**: `.zenzic-vsm.json`'s contract is now documented as a + versioned reference page (`docs/reference/route-manifest.md`) covering all three fields the engine + reads — `url`, `status` and `slug` — their defaults, and the four measured behaviours: a declared + `prebuilt` engine with no manifest exits `1` with `Z111`; a source the manifest does not list + produces `Z115`; a manifest key with no file is never matched; and an unknown field inside an entry + is accepted silently. The how-to's example showed two of the three fields and is now linked to the + full contract. `slug` is documented as accepted and currently inert on this path. + +### ⚠ Upgrade notice — verify before rolling out + +The changes below alter what a corpus reports or what the tool emits, and several can make a corpus +that passes today fail after upgrading. Items 1-3 add findings, and the two security-tier ones are +**non-suppressible**; none is a regression — each closes a path by which a code was silenced. Items +14, 20 and 21 add findings too: a configuration defect that used to pass in silence, and three +constructs — a `...`-terminated frontmatter, a setext heading, an HTML block — whose content the +engine could not see at all. Items 4, 6, 8, 9, 10, 13 and 22 remove findings, item 7 moves them +both ways, item 5 changes the JSON payload, item 15 changes the wording of one, item 23 adds +a notice without changing any finding, item 24 removes findings on MDX sites, item 25 removes one class of `Z107`, items 26 and 27 +remove findings by making a code opt-in and narrowing a default, and item 28 removes findings inside +tab-indented code. Items 29 and 30 change an exit code: a scan that examined nothing, or that held a +finding it was not showing, used to exit `0`. Item 31 adds one finding, `Z115`, and only under the +`prebuilt` engine. Item 32 turns one warning into an error, and item 33 removes a false +statement the tool printed about its own configuration, and item 34 adds findings in +locale and content-root trees that were never checked at all. Item 35 changes what +`zenzic audit` scans and when it fails. Item 36 turns a silent substitution into an +error for **every** declared engine, which is the item most likely to fail a corpus that +passes today, and items 37 and 38 change what the machine formats carry when a run fails at +configuration. Item 39 adds a flag and changes nothing until it is passed, but it is +listed here because this project's own badge gate now passes it. Item 40 changes three printed +paths on Windows only. Item 41 removes editor diagnostics on +build output. Items 42 and 43 add two machine-readable +fields and change no finding. Items 44 and 45 change the landing page only, item 46 the rendering of 99 list +items across the documentation, item 47 the size of the stylesheet and one unparseable +config example, item 48 removes an opt-in finding from the editor that the CLI already +suppressed, items 49 and 50 change a generated comment and a code comment, items 51 and 52 make a +configuration field and a CLI flag do what they already said they did, and item 53 changes +three diagrams and two path spellings. The list ends +with the breaking changes that are not about findings. +Run the check against your repository before you roll the new version into a gate: + +```bash +zenzic check all +``` + +Every added finding surfaces there with the file, the line and the cause. Exit `2` is a forbidden +scheme or credential; exit `3` is a path traversal. + +**1. The security tier no longer reads the quality tier's masked text.** Comments, inline +math spans and everything after an unterminated code fence are now in security scope. Text +that was invisible to `Z202`/`Z203`/`Z205` is now read. Most sharply, this line exited `0` +and now exits `2`, because two dollar signs on one line masked the span between them: + +```text +Cost is $5 - [c](javascript:alert(1)) - or $10. +``` + +A **closed, well-formed code fence stays out of scope by design**, and that is the remedy for a legitimate example: fence it. + +**2. A site-absolute link whose first segment names an OS system directory now reaches +`Z203` unless it is declared.** The fourteen names are `bin`, `boot`, `dev`, `etc`, `proc`, +`programdata`, `root`, `sbin`, `sys`, `system32`, `usr`, `var`, `windows`, `winnt`. A link +such as `/etc/install` in a repository that has a real `docs/etc/` section now needs an entry +in `absolute_path_allowlist`: + +```toml +# root-level keys go above the first table, or the parser swallows them +absolute_path_allowlist = ["/etc/"] +``` + +**The verdict no longer depends on whether the target exists, and that is the point of the +change rather than a side effect.** The previous behaviour resolved the path and downgraded +the finding when a file happened to be there — which made a file in the repository a +suppression mechanism for a code documented as non-suppressible. Relative links are +unaffected and still need no configuration: they are decided by arithmetic against the +repository root. + +**3. `Z603` now reports dead `data-zenzic-ignore` attributes, which it previously could +not.** This one is a warning rather than a security code, so it only fails a gate under +`--strict` — but it fails one that passes today. The attribute used to be marked consumed +whether or not it suppressed anything, so a decorative one was invisible; it is now reported +wherever it silences nothing. Zenzic's own corpus went from `98/100` to `96/100` on this +change before the two attributes it found were removed. The remedy is to delete the +attribute, and `zenzic fix --apply` now does it (see the auto-fix entry under **Fixed** — it +had never worked before this release). + +`Z603` also stopped over-reporting in the other direction, which needs no action but explains +a count that may drop: a working suppression of a cross-file code such as `Z101` was reported +dead by `zenzic check references`, and no longer is. + +**4. `Z106` (`CIRCULAR_LINK`) is now opt-in and reports nothing by default.** + +*What you will see:* if your baseline contains `Z106` findings, they disappear. If you relied +on the check, it stops running. Nothing else changes — `Z106` is `info` severity with a 0.0 +penalty, so no score and no exit code moves. + +*What to do:* to keep the check, declare it: + +```toml +[policies] +enable_circular_link_check = true +``` + +If you had silenced `Z106` with a `[governance.directory_policies]` entry or a per-file +ignore, that entry is now dead weight and can be removed. + +*Why:* the rule flagged every page participating in a link cycle, which in documentation is +the ordinary shape rather than a defect — an index links to each of its records and every +record links back, and two articles cross-reference each other. Measured against this +project's own documentation it reported **704 findings across 238 of roughly 300 pages**. A +check that flags 79% of a corpus for correct practice is not noisy, it is mis-specified, and +the practical response had been to silence it for an entire directory tree — which is worse +than not running it, because nobody can then tell whether the cycles are harmless or real. + +**5. The six grouped JSON arrays are removed from `check all --format json`.** + +*What you will see:* `links[]`, `orphans[]`, `snippets[]`, `unused_assets[]`, `references[]` +and `nav_contract[]` are gone from the payload and from `zenzic-output.schema.json`. Any tool +reading them gets a `KeyError`. + +*What to do:* read **`findings[]`** instead. It carries everything the six did, and carries it +better — each entry has `rel_path`, `line_no`, `code`, `severity` and `message` as separate +fields. The six could not offer that: `links[]` and `nav_contract[]` held pre-formatted English +prose with no code in it at all, and `orphans[]` and `unused_assets[]` held bare paths, so a CI +could not resolve a finding to a file and a code from them. **The same `findings[]` shape is +emitted by every command** — `check all`, `check links`, `check orphans`, `check snippets`, +`check references`, `check assets`, `check placeholders` — so one parser now reads all of them. +That uniformity is the compensation for the break. + +*Why there is no deprecation period:* a `0.x` version promises no stability, so a deprecation +notice here would declare a commitment stronger than the version number offers. The cost of +carrying one falls on the project rather than the consumer — the deprecated thing stays in the +emitter, the schema and the tests, which is precisely the multi-surface divergence this release +spent its time removing. Measured adoption of the grouped arrays is effectively zero. A notice +was briefly published and withdrawn in this same release rather than left to contradict the +removal. + +*Verified before removal, not assumed:* every item in all six arrays was checked against +`findings[]` across the whole example gallery — **216 items, 216 covered, 0 missing** — per +array, immediately before the change. + +**6. Six editorial-policy checks are now opt-in, so their findings disappear.** + +*What you will see:* `Z511` (sentence length), `Z502` (short content), `Z517` (heading punctuation), +`Z513` (duplicate heading), `Z411` (dead-end page) and `Z401` (missing directory index) report nothing +unless enabled, and baselined findings for them are gone. + +*What to do:* to keep one, set its key under `[policies]`: + +```toml +[policies] +enable_sentence_length_check = true # Z511 +enable_short_content_check = true # Z502 +enable_heading_punctuation_check = true # Z517 +enable_duplicate_heading_check = true # Z513 +enable_dead_end_check = true # Z411 +enable_directory_index_check = true # Z401 +``` + +`zenzic explain ` shows each code's activation, and `zenzic init` writes every opt-in key into +the configuration it generates. `Z514` (generic image alt text) and `Z515` (bare URL) are unchanged +and still on. + +**7. Fenced code is tracked the way CommonMark defines it, which moves findings both ways.** + +*Fewer:* a heading, list or sentence inside a block that nests fences — a page documenting Markdown +by showing it — is no longer read as prose, so `Z516`, `Z513`, `Z510`, `Z520` and `Z511` findings from +inside such blocks disappear. + +*More:* `Z505` now recognises untagged code blocks indented inside admonitions and list items, which +it never saw. Expect its count to rise, and read the new findings as a backlog that was hidden. + +*Security tier:* a credential inside a fenced block is still found; the credential scanner reads raw +lines and never consulted the fence tracker. + +**8. `Z503` no longer reports a snippet that is teaching syntax.** A TOML placeholder key such as +` = ""` and a code block whose body itself opens a fence are recognised as illustrations +and skipped, so those findings disappear. A genuine syntax error beside a placeholder key is still +reported. + +**9. Footnote definitions are no longer parsed as link references.** `[^1]: text` stops producing +`Z101` and `Z302`; every such finding pointed at prose, so nothing worth reporting stops being reported. + +**10. A MkDocs project's build output is no longer scanned by the quality checks.** When `docs_dir` +covers it, `site/` — or whatever `site_dir` names — used to be reported file by file: `Z405` on +generated assets, `Z402` and `Z410` on generated pages. Those findings disappear. The credential scanner +still reads the directory, so a secret in build output still exits `2`. An `excluded_dirs` entry you +added for the build directory is now redundant. + +**11. MkDocs' `not_in_nav` is honoured, so pages declared out-of-nav stop reporting `Z402` — and the +links into them stop reporting `Z103`.** The key is a gitignore-style pattern block (a YAML list is +rejected by MkDocs itself, so Zenzic declares nothing for one). A declared page is still built and +served; upstream only exempts it from the nav-omission diagnostic, and Zenzic now mirrors that. An +undeclared orphan still reports `Z402`. Zensical does not support the key, so nothing changes under +that engine. Directory policies you added for deliberately-unlisted pages may now silence nothing and +be reported as `Z620` — delete them, or the `--strict` gate fails on the dead pairs. + +**12. Zenzic no longer warns that Zensical ignores `validation`, `strict` and `watch`.** Zensical +documents all three as supported in `mkdocs.yml` form, so the warning said the opposite of what the +generator does. The warned set is now the six keys Zensical's own compatibility page lists as +unsupported: `remote_branch`, `remote_name`, `exclude_docs`, `draft_docs`, `not_in_nav`, `hooks`. + +**13. MkDocs' `exclude_docs` and `draft_docs` are honoured, so findings on pages the build omits +disappear.** A page declared by either key is absent from the published site, so a quality finding +about it describes something no reader can reach — measured before the fix: `Z402`, `Z410` and +`Z512` on a file `mkdocs build` never produces. `draft_docs` is treated with **build** semantics: +`mkdocs serve` renders a draft and `mkdocs build` omits it, and Zenzic analyses a repository rather +than a running command, so it takes the published site as the reference. The credential scan is +unaffected — a secret in an unbuilt file still reports `Z201` and still exits `2`. **If you keep a +baseline, regenerate it**: it lists findings that no longer exist. + +**14. A new finding, `Z407` (`INVALID_ENGINE_PATTERN`), reports a pattern that cannot be parsed.** +`not_in_nav`, `exclude_docs` and `draft_docs` take gitignore-style patterns; MkDocs refuses to build +when one is malformed. Zenzic used to say nothing, so a typo silently did nothing and the only trace +was the finding the author expected to be suppressed, still firing. Warning, penalty `0.0` — the +configuration is wrong but no document is worse for it — and not inline-suppressible, because a +config-file defect has no document line to anchor to. A pattern that parses but matches nothing is +**not** reported here; that stays `Z620`. + +**15. `Z620`'s message no longer tells you to delete something that may be load-bearing.** It read +"Remove the dead configuration", which is advice for one of two situations the tool cannot tell +apart: the finding the entry covered has been fixed, or the pattern never matched what its author +meant — a stray bracket makes `docs/[archive.md` a literal no file will ever equal. The message now +names both and says to check before deleting. + +**16. `Z619`'s rule card documented a complexity ceiling that suppresses its own example.** +The card's Configuration section showed `max_document_complexity = 50`, while the fixture the card +demonstrates sets `5`. Measured both ways: the example document emits `Z619` at a ceiling of 5 and +emits nothing at 50 — so a reader following the card's own configuration would have seen no finding +and concluded the rule does not work. The card now documents `5` and says what it is measured against. +No engine behaviour changed; this corrects published documentation that was wrong. + +**17. Two false positives are fixed: an `` carrying `alt=` reported as missing it, and a valid +HTML anchor reported as `Z102`.** Both came from the same defect. The tag patterns behind `Z403` +(`MISSING_ALT`), `Z514` (`GENERIC_IMAGE_ALT`) and `Z102` (`ANCHOR_MISSING`) ended a tag at the first +`>` anywhere in it — including one inside a *quoted attribute value*. A tag whose `src` or `title` +carried `>` before the attribute being read was truncated before that attribute was ever seen. +Measured with control pairs: `described` and +`described` were both reported as having no alt text, while +the same `>` placed *after* `alt=` was clean; `` never registered its +anchor, so a link to `#target` was reported missing against a target that exists. Position, not +content — it is not about SVG or `data:` URIs, which is how it was first seen. **If your corpus +documents HTML with `>` inside an attribute value, findings disappear after upgrading**; nothing new +is reported. The patterns now use the quote-aware, RE2-safe form `validator.py` already introduced +when the same truncation let a `javascript:` href past the security tier. + +**18. Indented code blocks are read as code, so findings inside them disappear.** Four spaces of +indentation opens a code block in CommonMark (§4.4), and the engine scanned its contents as prose: a +link inside one was reported broken, a `TODO` inside one was reported as a placeholder, and the +credential scanner read it too. **Findings disappear after upgrading; nothing new is reported.** +Measured on this repository's own documentation, phantom fence-state divergences went from 452 to 0 +and, on an external corpus, from 32 to 0. + +How much of a document counts as a code block depends on which containers the project has, because +CommonMark measures those four spaces **from the containing block** rather than from column zero — +and `!!!`, `???`, `=== "Tab"` and `:` are not CommonMark, they come from Python-Markdown extensions. +So the engine now reads `markdown_extensions` from your `mkdocs.yml` and recognises a container only +for the extensions you enable. A project that does not enable `pymdownx.details` has `???` read as +ordinary text, which makes the indented lines beneath it a code block — so **findings disappear there +too**, and again none appear. Measured: the same document reports one `Z511` under the full +vocabulary and none when only `admonition` is enabled. + +Both directions remove findings, so a gate that passes today still passes. **What is worth checking +is whether a finding you relied on has gone quiet**, which means it was inside an indented block all +along: + +```bash +zenzic check all +``` + +If your project enables no Markdown extensions, or none that the engine knows about, it falls back to +the four above. That default is deliberate rather than empty — assuming none would classify 1,112 +lines of this repository's documentation as code. + +**19. A backslash escape is honoured, so findings inside escaped constructs disappear.** If your +documentation writes `\\[` to show a literal bracket — teaching Markdown, or documenting a syntax — +the engine was building a link out of it and reporting it broken. Those findings go away. Nothing new +is reported, and `\\\\[text](url)`, which is a literal backslash followed by a real link, still is. + +**20. Two constructs the engine could not see are now read, and both make findings appear.** A +frontmatter block closed with YAML's `...` marker made the engine skip a document's entire body and +report it clean with a passing gate; it is now analysed. And a setext heading — `Title` underlined +with `===` — was invisible to every rule that reads headings; it is now counted. Both directions add +findings that were always due and never reported, so a gate that passes today may not after +upgrading. Neither affects the security tier. + +**21. Two HTML-block defects made whole passages invisible, and both make findings appear.** A +block-level tag opened a region that only its closing tag ended, so prose separated from it by a +blank line was never scanned — 3,620 lines across 85 files in this repository alone. And a tag +merely *named* in backticks opened the same region, hiding everything after it. Both are fixed, both +add findings that were always due, and neither affects the security tier. The second is present in the +published v0.30.0 and reaches anyone whose pages name HTML tags in prose, which is what documentation +about HTML does; the first reaches anyone who wraps content in a `
` with a blank line inside it. + +**22. A tag named in backticks is prose to three more surfaces, so findings disappear.** Heading +slugs, the multiple-`

` check and link extraction each stripped HTML from a line without first +protecting inline code, so a heading like `` ### `` `` slugified to the empty string, a +`` `

` `` named in prose counted as a real heading, and a `` `` `` shown as an +example was followed as a real link. The slug is now what Python-Markdown's `toc` extension +produces, verified against the renderer itself on seven shapes. Measured on an external corpus: +`Z102` 32 → 27 and `Z516` 1 → 0, with every other code unchanged. A fourth site, +`slug_tab_title`, deliberately keeps the old behaviour: it is a byte-for-byte replica of +`pymdownx.slugs`' own pattern, which does not mask either, and matching the renderer is the +contract. Nothing here affects the security tier. + +**23. A declared engine that finds no configuration now says it was replaced.** `engine = "prebuilt"` +with no `.zenzic-vsm.json` present silently became `standalone`, and the run reported what +`standalone` reports — measured on a 421-file Starlight tree as **2,443 findings against 235**, +byte-identical to declaring `standalone` outright. Three surfaces could have said so and none did: +`check all` printed nothing, `doctor` found nothing, and `config explain` displayed the *declared* +engine, which is worse than silence. A notice now names the engine, the file it looked for and the +adapter actually used. It applies to every engine, not just `prebuilt` — an `mkdocs` project with +no `mkdocs.yml` is the same substitution. **The notice is written to stderr**, so `--format json` +and `--format sarif` payloads stay parseable; the pre-existing offline-mode notice shared that code +path and was corrupting stdout, and is fixed by the same change. + +**24. Six MDX constructs no longer produce findings that are wrong.** Zenzic reads `.mdx` +exactly as it reads `.md`, and six of MDX's own constructs were being read as prose: an +`import`/`export` block at the top of a file became a malformed list (`Z520`); an image inside a +fenced block was reported as missing alt text (`Z403`, a limitation this file previously only +declared); a URL in a JSX element written across more than one line was reported as a bare URL +(`Z515`); an orientation link before the file's first heading was reported as a self-referential +anchor (`Z107`); an editor deep link such as `cursor://` or `vscode:` was resolved as a site path +(`Z101`, `Z105`); and `[][]` inside an HTML `` element was read as a link reference +(`Z301`, `Z108`). A reference label containing a code span — ``[`i18n.locales`]`` — is also +resolved now, where the inline-code mask used to destroy the identifier and report a reference +that was defined *and used* as unused (`Z302`). **Measured on a 421-file public Starlight site, +correctly configured: 235 findings before, 93 after.** Of the 93, reading every one of the 39 classified as real found **two** that are not: `Z501`'s default placeholder pattern `\btodo\b` matches the ordinary English of *"inboxes, social networks, todo lists"* and of a sentence about leaving TODO messages. The pattern list is configurable and documented, so the default is a policy rather than a defect, but it is named here because an earlier entry claimed zero false positives and verification falsified it. +Real findings are unaffected in both directions, asserted per cause: a genuine fake list, an +image in prose without alt text, a bare URL in prose, a true self-loop, a broken link, a real +absolute path and a genuinely empty link are all still reported. + +**25. A link written before a page's first heading no longer reports `Z107`.** The rule is +documented as a self-loop — a link that *"navigates the reader to exactly where they already are"* — +and it checks that the link sits inside the section its fragment names. That check was written +`current_heading_slug is not None`, so a link appearing before any heading skipped it and fell +through to a bare comparison of the link's text against its fragment. **If your pages open with an +orientation paragraph that links onward** — `See [configuration](#configuration) below.` above the +first `##` — those findings disappear. They were never self-loops: nothing encloses them. A link +that genuinely points at the section containing it still reports, and the rule card's example is +now such a link rather than the case that no longer applies. + +**26. `Z503` (`SNIPPET_ERROR`) is now opt-in and reports nothing by default.** If your baseline +counts `Z503` findings, they disappear on upgrade; `[policies] enable_snippet_check = true` restores +them, and `zenzic init` writes the key with the code named beside it. **Why it moved**: the rule +parses fenced JSON, YAML, TOML and Python and reports a syntax error, and documentation shows +excerpts. Measured on a 421-file public corpus: 27 findings, of which **nine** were deliberate +fragments of a larger file — the parser saying *"Extra data"* about a snippet that was never whole. +A maintainer would decline a pull request asking them to paste the rest of the file, which is the +test that turned six other codes opt-in. **The remaining sub-case is a parser question, not a flag +question**: roughly seven findings were JSONC — a `//` comment in a `.prettierrc`, a trailing comma +in `tsconfig.json` — and a user who *enables* the flag should still not be told that `tsconfig.json` +is invalid, because JSONC permits both by specification. That is tracked separately and is not +covered by the flag. + +**27. The `Z501` placeholder patterns describe a marker's shape rather than its word.** The +defaults were `\btodo\b`, `\bfixme\b`, `\bwip\b` and `\btbd\b`, and on someone else's +documentation `\btodo\b` matched *"inboxes, social networks, todo lists"* and a sentence about +leaving TODO messages. A marker now has to **open its line** — optionally behind a list bullet, a +block quote or an HTML comment — or be **followed by a colon**. `TODO: write this`, `` +and `- TODO write the guide` still report; a `TODO` in the middle of a sentence no longer does. +`placeholder_patterns` remains configurable, and a custom list is unaffected. + +**28. A tab now opens an indented code block, so findings inside tab-indented code disappear.** +CommonMark §2.2 expands a tab to the next four-column stop, so a line beginning with a tab is four +columns of indentation and is indented code. Zenzic counted characters instead, so `\t` was one +column and the line was read as prose. **If your Markdown indents code with tabs**, findings +reported inside it stop appearing — they were never due. Two spaces followed by a tab count to four the same way, and +three columns still do not open a block. The direction of the old behaviour is worth stating +because it bounds the risk: the engine read **more** than it should, never less, so nothing was +hidden and nothing appears now. + +**29. A scan that examined nothing no longer exits `0`.** A `docs_dir` that does not exist and +a `docs_dir` that exists and holds no Markdown were one answer — `Z906 NO_FILES_FOUND`, +"Audit skipped", exit `0`. They are two different situations and only one of them is fine. +A **missing** directory is a configuration error: `zenzic init` followed by `check all` on a +cloned Astro repository printed "Audit skipped" and exited `0` over 2,604 unexamined sources, +because Astro keeps them under `src/content/docs` and the default is `docs`. That is now +`Z111`, exit `1`, and when a generator is detected the message names it and the directory it +uses rather than offering a menu. A directory that **exists and is empty** is a project in +setup and keeps `Z906` and exit `0`, unchanged. **If a job of yours passes today over a +`docs_dir` that is not there**, it will now fail — which is what it was always doing, said out +loud. + +**30. A finding no longer disappears into "no files found".** The same presentation shortcut +sat above any finding when the page count was zero: a corpus whose every page is excluded +could hold a warning and still print "Audit skipped" over it. The shortcut yielded for the +security tier already; it now yields for a finding of any severity, because a scan holding a +finding examined something. + +**31. `Z115 STALE_ROUTE_MANIFEST` is new, and it names a cause that was previously invisible.** +With the `prebuilt` engine, URLs come from `.zenzic-vsm.json`, written by your site generator. +Add a page without re-running that generator and the page routes as `IGNORED`, so every +correct link pointing at it was reported `Z101` as unreachable — an error on a link you wrote +properly, about a page that is on disk, with nothing anywhere saying the manifest was the +reason. `Z115` is a warning naming the file to regenerate; it reaches the CLI, `--quiet`'s +counts, `--format json` and the editor's diagnostics alike. **It appears only under an engine +that reads a declared routing table** — `standalone` derives each URL from the path it just +read and cannot go stale. + +**32. `Z902 RULE_TIMEOUT` is an error, not a warning.** A rule that exceeded its time limit +did not finish over that file, so the run did not produce the coverage it appears to have +produced — the reference page had already written that down ("Partial results are +untrustworthy") while the severity said otherwise. **If a rule of yours times out**, the run +now exits `1` where it exited `0`. The DQS penalty is unchanged at `0.0`: there is nothing to +score, the absence of the analysis is the finding. `zenzic inspect codes` shows `HALT` for it, +as it already did. Both pinned corpora are unmoved — neither holds a rule that times out. + +**33b. `Z106` and `Z401` were reported on 2026-09-19 as contradicting the severity principle, +and one of them did not.** Enabled on this repository, `Z106 CIRCULAR_LINK` reports **823 +findings across 244 pages**, concentrated on the reference index, the gallery index and the +how-to index. A hub page linking its children while the children link back is required +navigation, not a defect — so the rule measures a shape nobody wants to correct, which is an +observation about the link graph rather than a finding. It stays `note`, stays opt-in, and the +classification that called it a defect is corrected in the record. **No severity changed.** + +**33. The engine no longer announces a substitution that did not happen.** `zenzic check all` +printed `NOTICE: engine 'mkdocs' found no mkdocs.yml, so this run used 'standalone' instead` +on repositories whose `mkdocs.yml` was present and whose analysis was genuinely MkDocs. Two +causes: `check all` did not pass the repository root into the scan, so the security-only pass +built its adapter with the documentation directory in its place and looked for `mkdocs.yml` +inside `docs/`; and the notice read the engine field *after* `auto` resolution had overwritten +it, so a project that declared no engine at all was told its declared engine had been +replaced. The notice still fires where it is true. Both pinned corpora report exactly what +they reported before. + +**34. Links inside a locale tree or an extra content root were never checked.** A source that +lives in one directory and is published from another — a locale tree, or a content root outside +`docs_dir` — is read from its real path and routed under its logical one. The link pass built +its resolution context from the real path, so every target landed outside `docs_dir`, matched no +route, and was dropped **without a finding**. The file's content-tier findings appeared +normally, which is what made it look like the file was being checked. Measured: a page reporting +`Z101` under `docs/` reported nothing at all when mounted. **If you use i18n locale trees or +monorepo content roots**, broken links in those trees now surface — they were always broken. A +locale file's links still resolve inside its own locale tree with the adapter's sibling +fallback; only the mounted-content case changed. + +**35. `zenzic audit` answered three questions differently from every other command.** It took the +repository root to be the working directory instead of searching upward, so run from `docs/` it +audited `docs/` as if it were the repository while `check all` from the same place found the real +one. It silently set `docs_root = repo_root` when `docs_dir` did not exist, producing a DQS score +and a compliance report for a corpus nobody named — now `Z111`, exit `1`, like `check all`. And it +built its exclusion manager directly instead of through the CLI's single factory, so it excluded +neither the engine's output directory nor its metadata files (**a built `site/` tree was audited as +source**) and ran without the `docs_dir` path-traversal guard the factory applies. **If you run +`zenzic audit` from a subdirectory, or over a project whose `docs_dir` is wrong**, its answers +change — to the ones the rest of the CLI was already giving. + +**36. A declared engine that cannot find its own configuration is now a configuration error — +`prebuilt`, `vsm`, `mkdocs` and `zensical` alike.** It used to +fall back to `standalone` and analyse anyway. Measured on Astro's own documentation — 2,604 +pages, one commit — that fallback produced **the same total, the same distribution and the same +exit code** as declaring `standalone` outright: the engine you asked for did not run, and the +only signal was a notice on stderr, which no CI consumer reads. 98% of those findings came from +three codes, all derived from routing that was never resolved. **If you declare `engine = +"prebuilt"`**, the run now stops before reading a page, names the generator it detected, says +how that generator's manifest is derived, links the page that explains it, and names +`standalone` as the way out. A manifest that **exists** and is empty is a different statement +and is unaffected — that is `Z115` per source. + +**The same now holds for every declared engine, and `mkdocs` is the one most likely to affect +you.** `engine = "mkdocs"` with no `mkdocs.yml` or `mkdocs.yaml` beside it used to substitute +the standalone adapter and print the same unread stderr notice; it now stops and names the file +to create. `zensical` already stopped, but reported itself as the generic `Z001` in the JSON +payload — it is `Z111` now, which is the code its own reference page documents. **If a project +of yours declares an engine whose configuration file is not in the repository, it passes today +and will exit `1` after upgrading.** Either add the file, or declare `engine = "standalone"`, +which is what that run was already doing. + +`engine = "auto"` and `engine = "standalone"` are unaffected: nothing was declared, so falling +back is the answer you asked for. + +The error is identical on `check`, `audit`, `guard scan`, `inspect routes`, `clean assets`, +`config explain`, `score` and the action's step log; the language server, which cannot exit, +publishes it as a diagnostic on the configuration file and keeps analysing. -## [0.30.0] - 2026-08-18 +**37. A configuration failure now reaches a SARIF consumer.** The run emitted the error as a +`toolExecutionNotification` with `executionSuccessful: false` — correct SARIF, and invisible to +GitHub code scanning, which surfaces only `result`, `location`, `reportingDescriptor` and a +handful of others (verified against GitHub's own SARIF support page). A failed run therefore +rendered as an empty analysis: nothing scanned, and the pull request said nothing. The same +failure is now also a `result`, anchored on the configuration file, with its rule declared. The +notification stays for consumers that read it. + +**38. A `Z111` reported itself as `Z001` in the JSON payload.** `ZenzicConfigError` carries a +hardcoded code, so a message reading `[Z111]` arrived with `"code": "Z001"` beside it — a +contract contradicting itself, in the payload the action's wrapper parses. The identifier now +matches the message; `tier` and `severity` are unchanged. + +**39. `zenzic score` gains `--no-external`, and this project's badge gate now uses it.** + +*What you will see:* nothing changes unless you pass the flag. With it, `zenzic score` skips +HTTP validation of external URLs, so the number depends only on the repository. + +*Why:* the score had no way to avoid the network — `--strict` does not apply to it and there was +no opt-out — while `zenzic check all` has had `--no-external` since nine github.com timeouts made +a merge queue unmergeable. The gap showed itself on CI run `35455833729`: the badge-freshness step +computed **89/100** on one leg and **97** on the other two for the same commit, and a re-run of +that identical commit produced 97 everywhere. The difference is exactly one `Z104` (penalty 8.0) — +one external host that did not answer from one runner. A badge gate that moves with third-party +uptime fails a pull request that changed nothing, and stamps a number that is not the +repository's. + +*What to do:* if you stamp a badge in CI, pass `--no-external` on both sides — stamping in one +mode and verifying in the other disagrees the first time a remote host is slow. `zenzic diff` has +no such flag, deliberately: it compares against a saved snapshot, and the snapshot does not record +which mode produced it. + +**40. On Windows, three surfaces printed a path with backslashes where every other one used forward slashes.** + +*What you will see:* only on Windows, and only in three places — the `Scanning:` line the CLI prints when you pass a path, and the two suppression-hotspot maps in the governance summary. `docs\guide\index.md` becomes `docs/guide/index.md`, matching what findings, JSON and SARIF have always emitted. + +*Why:* `repo_relative_label()` is the one function that answers "what do we print for this file", and its docstring gives the reason — an absolute or machine-shaped path makes the same finding on the same commit compare unequal between two machines, so no tool can diff two runs. Twenty-three sites answered it themselves instead, and while twenty-two agreed with the authority, the ones using `str()` rather than `.as_posix()` produced native separators. All twenty-three now call it. + +*What to do:* nothing, unless something downstream parsed the hotspot keys expecting `\`. On Linux and macOS nothing changes at all. + +**41. The editor excluded less than the CLI, and this release would have made the gap visible.** + +*What you will see:* in VS Code, findings disappear from files the CLI already ignores — everything under a MkDocs project's declared build output (`site/` by default), the engine's own metadata files, and any page matched by `exclude_docs` or `draft_docs`. + +*Why:* the exclusion set is built from the configuration **and** three layers the adapter contributes, and the adapter layers are easy to omit one at a time because omitting one is silence rather than an error. Seven sites constructed the manager directly; four of them — the language server's three and the incremental engine's one — passed **no adapter layer at all**. Measured on an MkDocs project carrying a built `site/` tree, scanning the repository root: the CLI saw `docs/index.md`, the editor saw `docs/index.md` and `site/index.md`. Items 24 and the `site_dir` change in this same release taught the CLI to skip that tree; without this, the editor would have kept squiggling on generated files that CI says nothing about — one product answering a question two ways. + +*What to do:* nothing. If you had learned to ignore editor diagnostics on generated output, they are gone. `build_exclusion_manager()` now takes the adapter itself rather than its three answers, so a caller cannot supply two of three, and a fourth layer added later reaches every caller without any of them being edited. + +**42. The JSON payload and the SARIF run now say which engine actually ran.** + +*What you will see:* a new top-level `engine` object in `check all --format json`, and the same object as a run-level property in `--format sarif`. It carries `declared` and `resolved`. Nothing was renamed or repurposed to make room for it; `zenzic-output.schema.json` declares it. + +*Why:* a run should say which adapter produced its findings. `declared` and `resolved` differ when you declare `auto`, which is discovery: `resolved` names what was found in the repository. + +*What to do:* nothing. If the engine you declared cannot be built, the run stops with `Z111` and exits `1` (item 36), so there is no payload state to gate on — the exit code is the gate. + +**43. A SARIF file larger than GitHub will show now says so.** + +*What you will see:* when a run produces more than 5,000 results, `runs[0].properties.githubTruncation` appears in the SARIF and a notice goes to stderr naming the exact loss. Below that count, neither appears and nothing changes. + +*Why:* GitHub Code Scanning rejects an upload above **25,000** results and, of what it accepts, **includes only the first 5,000**, ordered by severity — the rest are discarded with no message, so a clean tail and a truncated one look identical. This project's own Astro corpus produces 15,254 findings: a consumer uploading it sees 5,000 and loses 10,254 silently. Zenzic knows the count before it writes the file. + +*What to do:* nothing, unless you upload to Code Scanning and the notice appears. **The file is not truncated** — every result is emitted, and what to do about the limit is your decision: narrow the scan with `--only` or `[governance] directory_policies`, fix the largest class first, or consume the file with something else. + +**44. The landing page presented two opt-in codes as things Zenzic reports by default.** + +*What you will see:* on `zenzic.dev`, the fourteen-code panel now marks `Z401` (missing directory index) and `Z502` (short content) as **opt-in**. Nothing about the engine changed. + +*Why:* both became opt-in in this release and the panel was not updated, so a visitor reading it concluded Zenzic checks those by default. Measured on one corpus: with default configuration the run reports **nothing**; with `enable_directory_index_check` and `enable_short_content_check` declared, it reports `Z401` and `Z502`. The other twelve were re-verified against `CODE_DEFINITIONS` in the same pass and are correct, including the three whose labels named the wrong code entirely when this was last audited. + +*What to do:* nothing. If you assumed either check was running, declare it under `[policies]`. + +**45. The landing page now says which generators Zenzic reads, and how.** + +*What you will see:* a fourth engine panel on `zenzic.dev`, beside MkDocs, Zensical and standalone: generators that publish a route manifest, naming **Astro** and **Docusaurus**, the `prebuilt` engine and `.zenzic-vsm.json`. + +*Why:* the page carried **zero** mentions of `prebuilt`, Astro, Docusaurus or the manifest — measured — while being the surface where someone decides whether the tool serves their stack. The claim is only true now that a declared `prebuilt` without a manifest fails as a configuration error rather than quietly analysing something else, which is item 36 in this same release. + +*What to do:* nothing; it is documentation. + +**46. 99 list items on the published docs stopped mid-sentence, and 19 of them restarted their numbering.** + +*What you will see:* bullets and numbered items that read as one thought again. Most visibly, `developers/how-to/implement-adapter` presented its eight adapter-contract invariants as seven separate lists each numbered "1."; it is now one list numbered 1 to 8. + +*Why:* Python-Markdown needs **four** spaces to keep a paragraph inside a list item across a blank line. Two spaces reads as correct in the source and renders as three elements — the list closes, the continuation becomes a loose top-level paragraph, and a second list opens. The Markdown stays valid, so `markdownlint` passed all 99, the oldest traced to `v0.15.1`. Each was repaired by the shape it actually had: 91 wrapped sentences were rejoined, 8 deliberate paragraphs were indented to four. A gate now holds it — `scripts/check_list_continuation.py`, wired into `just docs-build`, with `--self-test` to check the instrument. + +*What to do:* nothing; it is documentation. + +**47. `minify_css: true` had been doing nothing, and `Z111`'s config example did not parse.** + +*What you will see:* `zenzic.dev` now serves `extra.min.css` at 17,921 bytes instead of `extra.css` at 38,055 — 52.9% off the stylesheet on every page load. And `docs/rules/Z111.md` no longer shows a `.zenzic.toml` example that a reader cannot use. + +*Why:* `mkdocs-minify-plugin` skips every `extra_css` entry not named in `css_files`, so the flag enabled the minifier and the loop never reached a file. `css_files` now names `assets/css/extra.css` and deliberately **not** `zenzic-tailwind.min.css`, which the Tailwind CLI already minified and which `csscompressor` makes 49 bytes *larger*. Renaming the built file broke the legacy `/static/assets/css/extra.css` redirect; `check_redirect_destinations.py` caught it and the rule now points at `extra.min.css`. Separately, `Z111`'s "how to fix" block declared `docs_dir` twice in one TOML document — `Cannot overwrite a value` — which is the one unparseable block among **295** TOML fences in `docs/`. It is now two fences, one per generator, each valid on its own. + +*What to do:* nothing; it is documentation and a build setting. + +**48. The editor reported `Z503` while the CLI suppressed it, so item 26 was only half true.** + +*What you will see:* your editor stops underlining fenced JSON, YAML, TOML and Python blocks unless `[policies] enable_snippet_check = true` is set — which is what item 26 announced. Turn the flag on and both surfaces report it, as before. + +*Why:* item 26 made `Z503` opt-in and put the gate inside `validate_snippets`, "rather than at its three call sites, so the Language Server inherits it". It did not. The server drives `IncrementalAnalysisEngine`, which calls the `check_snippet_content` primitive directly and never passes through that wrapper, so the flag was honoured by `zenzic check` and ignored by the editor and by `zenzic-mcp`. The gate now sits in the primitive, which is the one point every consumer passes through. Measured across all **nine** flag-gated codes on a corpus that triggers each: `Z503` was the only one that leaked, and the other eight are gated where the rule engine is assembled — a function both pipelines share. + +*What to do:* nothing, unless you relied on the editor showing snippet errors without the flag. Set `enable_snippet_check = true` to restore it. + +**49. `zenzic init` offered a `[governance]` value its own schema refuses.** + +*What you will see:* the generated `.zenzic.toml` no longer reads `# suppression_cap_scope = "all" # Options: all, per-file`. It states that `"all"` is the only supported value and that the cap counts the whole corpus. + +*Why:* the field is typed `Literal["all"]`, so a reader who uncommented that line and took the second option got `literal_error: Input should be 'all'` on the next run — verified end to end before the fix, `zenzic check all` refused to start. There is no per-file counterpart in `scorer.py`, so the comment offered a scope that does not exist rather than one not yet wired. The reference page was already correct. A new structural test walks everything `init` writes — live lines and commented ones, including every value named in an enumerating comment — and asserts the schema accepts it; the same form had been wrong once before, an engine list that omitted the engine it annotated. + +*What to do:* nothing, unless you had uncommented that line and set `per-file`, in which case the tool has been refusing to run and `"all"` fixes it. + +**50. Two opt-in codes stay outside the rule engine, and the reason is now in the code.** + +*What you will see:* no behaviour change. `Z106` and `Z411` report exactly as before. + +*Why:* item 48's fix put `Z503`'s gate in the one primitive every consumer calls. `Z106` and `Z411` cannot take that shape, and the reason is specific rather than a cost judgement. Each has **one** algorithm already — `_find_cycles_iterative` and `detect_dead_ends` have a single definition apiece, shared by both pipelines — but each pipeline gates where its own graph exists, and the two graphs are different on purpose: `scanner.py` builds its link graph so that a reference definition counts as a navigable edge, which the VSM's reverse index does not do. A rule-engine member would read the VSM and so would change which cycles and which dead ends exist, rather than relocating the check. `run_vsm` also runs once per file, so a whole-graph walk inside it would be N traversals where there is now one. Both gates now carry that reason, and `tests/test_activation_ssot_structural.py` fails CI if they drift apart. + +*What to do:* nothing; it is a comment and a test. + +**51. `[build_context] base_url` now does what its documentation has always said.** + +*What you will see:* on a site served under a path — `base_url = "/docs/"` — an absolute link written against the deployed path resolves instead of being reported. `[Ref](/docs/reference/page/)` stops raising `Z101` (it is re-based to `/reference/page/` before matching the route map) **and** stops raising `Z105` (the prefix is project-owned). Unset, nothing changes. + +*Why:* the field was declared, written into every generated config, and read by nothing. The reference page told readers *"the adapter uses this value instead of attempting static extraction from the build tool's config file"* — and no adapter read it or performed that extraction, so a project set it and got silence: no error, no warning, no effect. Both codes now read **one** authority, `adapter.get_absolute_url_prefixes()`, so they cannot disagree about where the site starts. Wiring only the allowlist was measured and rejected: `absolute_path_allowlist` already cleared `Z105` alone and left every such link reported broken, which looks like a working feature and is not one. + +**`"/"` means no prefix, and so does leaving it unset.** Stated because it is a deliberate choice that will look like a bug later: every `.zenzic.toml` this tool has generated carried an uncommented `base_url = "/"`, written while the field did nothing. Honouring that as a declared base would re-base every absolute link in every existing project the moment the field started working. The generated template now offers the setting commented out instead of writing a live no-op. + +**Not available with `prebuilt`.** Its routes come from `.zenzic-vsm.json`, which already carries the prefix your build produced, so a second one would be applied twice. The combination raises a configuration error rather than being ignored, and `zenzic init` does not offer the setting when it writes a `prebuilt` config — a template that offers what the chosen engine rejects is the defect item 49 closed. + +*What to do:* nothing, unless you serve docs under a path and have been writing absolute links. Set `base_url` and they will resolve. + +**52. `zenzic init --engine prebuilt` was refused while `--help` listed it.** + +*What you will see:* `--engine` accepts every engine the adapter registry offers — `mkdocs`, `prebuilt`, `standalone`, `vsm`, `zensical`. + +*Why:* the accepted set was a hardcoded `{"mkdocs", "zensical", "standalone"}` while the same command's help text, and the `# Supported:` comment it writes into the generated file, were both derived from `list_adapter_engines()`. So two surfaces advertised `prebuilt` and `vsm` and the third refused them, which reads as a broken tool rather than an unfinished feature. Both build — auto-detection already selected `prebuilt` on a repository carrying `.zenzic-vsm.json`. The set is now derived, so a third-party adapter reaches the gate without that line changing. + +*What to do:* nothing. + +**53. Three hand-drawn diagrams are now Mermaid, and two `docs_root` sites resolve like the other thirteen.** + +*What you will see:* the remediation lifecycle on `auto-fix-philosophy`, the action's invocation chain on `github-action-internals`, and the gate logic on `zenzic-action` render as diagrams in the same visual language as the site's other twelve — same five palette classes, same arrow style. No wording changed. + +*Why:* they were drawn in Unicode box characters. The census that found them also found 63 fences whose box characters are the tool's real output — the gutter bar and caret underline `zenzic check` prints, verified by running it — and those are untouched, because stripping them would make the documentation a false reproduction. `advanced-features`' directory tree stays as text for the same reason: Mermaid represents a filesystem layout worse than a tree does. Separately, `zenzic audit` and `zenzic config explain` built `docs_root` without `.resolve()` while thirteen other sites used it; `docs_dir` accepts `../docs` and `docs/../docs`, so the two spellings could disagree. No divergence was demonstrable, which is why this is recorded as a latent inconsistency closed cheaply rather than as a defect. + +*What to do:* nothing. + +**Breaking changes that are not about findings** — each has its own entry below: + +- CLI usage errors exit `1`, not `2`, which the Exit Code Contract reserves for security breaches. +- `Z301` (`DANGLING_REF`) is a `--strict`-gated warning, not a hard error. +- `--strict`/`-s` is removed from `zenzic score` and `zenzic diff`. +- `[project_metadata].obsolete_names` is removed. +- `Z000`, `Z113`, `Z114` and `Z504` are removed, and `--only` rejects them as invalid codes. +- `STALE_ALLOWLIST_ENTRY` moved from `Z110` to `Z112`; a baseline counting it under `Z110` needs regenerating. +- `Z521`, `Z522` and `Z523` refuse inline suppression; declare them in `.zenzic.toml`. +- Suppression debt counts the declared exceptions in use: a `directory_policies` pair that silences a finding now costs a point, and a suppression that silences nothing no longer does. ### Added -- **Semantic List Heuristics & Auto-Fix**: - - `Z520` (`MALFORMED_LIST_DETECTED`): Detects paragraphs formatted as pseudo-lists using hard newlines and semicolons/commas without Markdown list markers. Supports atomic automated fix (`zenzic fix --apply`). Penalty: 2.0 pts (Content). - - Added `MalformedListMutation` to the core AST `Mutator` engine, automatically transforming malformed paragraph lists into structured Markdown bullet lists. - - Added rule card `docs/rules/Z520.md`, updated `mkdocs.yml` navigation, scoring references, and added interactive lab scenario `z520` (`examples/z520-malformed-list/`). -- **Editorial Style & Prose Quality Rules**: Added 5 advanced editorial style and policy-as-code linting rules: - - `Z518` (`PASSIVE_VOICE_DETECTED`): Detects passive voice constructs in technical prose via non-backtracking RE2 regex heuristic (opt-in via `[policies].enable_passive_voice_check`). Penalty: 1.0 pt (Content). - - `Z519` (`WEASEL_WORDS`): Detects vague or weakening qualifiers (e.g., "clearly", "simply", "obviously") in technical prose (opt-in via `[policies].weasel_words`). Penalty: 1.0 pt (Content). - - `Z617` (`FORBIDDEN_CONTENT_PATTERN`): Enforces organizational terminology standards by detecting forbidden regex patterns in prose (opt-in via `[policies].forbidden_content_patterns`). Penalty: 2.0 pts (Governance). - - `Z618` (`REQUIRED_HEADING_PATTERN`): Enforces structural document templates by ensuring required heading patterns exist in each document (opt-in via `[policies].required_heading_patterns`). Penalty: 3.0 pts (Governance). - - `Z619` (`MAX_DOCUMENT_COMPLEXITY`): Restricts cognitive load and document bloat based on prose word count, heading depth, and link density (opt-in via `[policies].max_document_complexity`). Penalty: 4.0 pts (Governance). -- **Policy-as-Code Configuration Enhancements**: Added 5 new declarative policy keys to the `[policies]` table in `PoliciesConfig` (`forbidden_content_patterns`, `required_heading_patterns`, `max_document_complexity`, `weasel_words`, `enable_passive_voice_check`), complete with `zenzic config explain` introspection table and `zenzic init` template integration. -- **Mirror Law Parity for Editorial Style**: Added dedicated rule cards `docs/rules/Z518.md` through `docs/rules/Z619.md`, updated `mkdocs.yml` navigation, synchronized scoring algorithm/explanation matrices, registered finding reference entries, and added interactive lab acts (`z518`, `z519`, `z617`, `z618`, `z619`). -- **Semantic Linting & Accessibility Rules**: Implemented 5 native AST-based semantic and accessibility (a11y) rules in `zenzic.core`: - - `Z513` (`DUPLICATE_HEADING`): Detects duplicate headings within the same document (case/whitespace/anchor invariant), preventing ambiguous anchor collisions. Penalty: 2.0 pts (Content). - - `Z514` (`GENERIC_IMAGE_ALT_TEXT`): Detects generic filler words in image alt text (`![]()` and ``), enforcing accessibility standards. Penalty: 2.0 pts (Content). - - `Z515` (`BARE_URL_USED`): Detects raw HTTP/HTTPS URLs in prose that are not enclosed in angle brackets or Markdown links. Supports automated fix (`zenzic fix --apply`). Penalty: 1.0 pt (Content). - - `Z516` (`MULTIPLE_H1_HEADINGS`): Enforces a single top-level `#` or `

` heading per document for structural hierarchy. Severity: `error`, Penalty: 5.0 pts (Content). - - `Z517` (`HEADING_PUNCTUATION`): Detects invalid trailing punctuation (`.`, `:`, `;`) on headings. Supports automated fix (`zenzic fix --apply`). Penalty: 1.0 pt (Content). -- **Silent-on-Success Unix Philosophy (`--quiet`, `--no-header`)**: Added `--quiet` (`-q`) and `--no-header` across all subcommands (`guard scan`, `score`, `check all`, `check links`, `check orphans`, `check placeholders`, `check references`, `check structure`, `check snippets`), muting all headers, banners, and footers to emit exactly 0 bytes when checks pass (Exit Code 0). -- **Dogfooding Pre-Commit Secret Guard**: Added native `zenzic-guard` hook to `.pre-commit-config.yaml` and updated `.pre-commit-hooks.yaml` to pass `--quiet --no-header` by default for sub-50ms commit-stage validation. - -### Performance - -- **Sequential Parsing ~3x Speedup**: Eliminated four O(N) redundant passes across the scan pipeline. Baseline on the Zenzic docs corpus (256 files): from ~13 s to ~4.5 s on standard developer hardware. - - | Root cause | Fix | Savings | - | :--- | :--- | :--- | - | `CredentialScannerRule` ran `scan_line_for_secrets()` per-line as a rule after `harvest()` had already scanned the same lines via `scan_lines_with_lookback()` | Removed from rule engine; Z201 findings injected directly from `security_findings` | ~829 ms | - | `directory_policies` compiled `re.compile(translate_glob_to_re2(pattern))` per file × per pattern | Patterns compiled once, cached on the config object as `_compiled_dir_policies` | ~300 ms | - | `cross_check()` re-opened each file from disk via `_iter_content_lines(file_path)` | New `_iter_content_lines_text(text)` + optional `text` parameter on `cross_check()` | ~302 ms | - | Four heading rules each did a full `text.splitlines()` + line iteration pass (Z510, Z513, Z516, Z517) | Replaced with `CombinedHeadingRule` — single pass; anchors collected as zero-cost side effect, skipping `anchors_in_file()` in VSM | ~415 ms | - -- **Z201 single-pass**: LSP path (`_analyze_file`) now performs the credential scan explicitly via `scan_lines_with_lookback()` — consistent with the CLI path where `harvest()` handles it. -- **`_SECRETS` per-pattern quick prefix**: Added `tuple[str, ...]` of distinctive token prefixes per pattern entry; the credential scanner skips RE2 evaluation when the line does not contain the specific prefix. +- **`content_roots`: Reach a Second Documentation Tree from Configuration**: `docs_dir` names one directory, and only the MkDocs adapter derived extra roots from its own configuration — so a generator that publishes from two directories had one of them analysed by nothing, with no configuration a user could write to reach it. Measured on a `create-docusaurus` scaffold: `zenzic check all` read 9 of the repository's 15 Markdown sources, and 4 of the 6 it missed were `blog/`. `content_roots = ["blog"]` closes it, for any engine, without Zenzic learning anything about Docusaurus: the list is merged with whatever the adapter discovers on its own, in one place (`resolve_content_roots`) rather than the ten call sites that each asked the question before. Declaring a root does not widen what may be read — every read still resolves against the repository root, and a root outside it is reported and skipped. -### Fixed +- **`zenzic env` Reports What Zenzic Takes the Project to Be**: two new fields on both the table and `--json`. `engine` is the engine a scan actually runs — `auto` already resolved — so this command and the telemetry line of `zenzic check` cannot give two answers to one question; `engine_source` keeps the distinction resolving would erase (`configured`, `auto-detected`, `default`); `generator` is the documentation generator detected in the repository, or `null`. They can disagree, and reading them together is the point: `"engine": "standalone"` beside `"generator": "astro"` is a working scan of a site map derived from file paths rather than from Astro's own routing. No lookup here can fail the command — `env` is what a user runs when something else is already broken. + +- **The Telemetry Line Names the Detected Generator**: `standalone • Astro • 2606 files …`. The engine was on that line already; what was missing is what it is being pointed at. Detection reads the same `GENERATOR_MARKERS` registry `zenzic init` uses, so setup and every later scan cannot disagree about what the project is. + +- **`zenzic init --interactive`**: asks before it writes — the engine, offered from the adapter registry with the detected one and its reason stated, then each opt-in finding code one at a time, derived from the code registry rather than enumerated by hand, so a code added later appears in the prompt on its own. The data-gated codes are deliberately not asked: they are inert until their `[policies]` data is declared, and that is not a yes-or-no question. Without the flag nothing changes for scripts and CI; answering every prompt with its default produces the file the plain command writes. +- **`just verify` Runs Its Tree-Deterministic Half Once per Tree**: `scripts/verdict_cache.py` records the green verdict of the stages that read nothing but the tree — release contracts, pinning, the docs build, the local gates, `pre-commit --all-files` and `pytest` with coverage — keyed on the tree's content (tracked files, untracked files and the gitignored local trees the gates read), and skips them when the pre-push hook meets the same tree it already verified. Measured before building: those stages took about 400 of the gate's 445 seconds and were paid twice on every compliant push. `pip-audit`, the structural audit and the score run every time, a failing stage records nothing, CI never caches, and `ZENZIC_VERDICT_CACHE=0` forces a full run. +- **The Mutation Gate Keeps Each Mutant's Fate**: `scripts/mutation_gate.py` now runs `mutmut results` after the run and writes it to `mutants/mutmut-results.txt` beside the aggregate stats. The stats file carries only `killed`, `survived` and `no_tests`, so for ten days no survivor had an identity anywhere the gate kept and the per-mutant triage could not start; the missing call was one line. + +- **`zenzic config explain` Shows Which Layer Excludes What**: a new *Exclusion layers* table lists each exclusion layer with its current contents — `L1` system guardrails, `L1b` the build engine's declared output directory, `L2` `included_dirs`, `L3` `excluded_dirs`, `L4` `--exclude-dir` — so a directory that is not being scanned can be traced to the layer that removed it without reading the source. +- **`CODE_DEFINITIONS` Now Records How Each Code Is Activated**: the registry held severity, penalty, category, lifecycle status and fixability, but nothing expressing whether a code runs at all — so four surfaces carried that fact by hand: the generated `.zenzic.toml`, the rule cards, `finding-codes.md`, and a frozenset in the test suite whose own comment said it deliberately avoided deriving from `codes.py`, *because there was nothing there to derive from*. That comment was the defect. Reconciling those four was provisional; without a field they diverge again and nothing notices. `CodeDefinition` gains `activation` and `activation_key`, and all 70 codes are classified from their real gates: **47 default** (runs unless suppressed), **8 flag** (runs only when its `enable_*` key is true), **15 data** (always runs, finds nothing until its `[policies]` collection is declared). The third state is the one that needed representing — it is **not off**: a user cannot tell "no violations" from "nothing configured" by reading output. `zenzic explain ` now shows it, and the frozenset is deleted: its test reads the registry, so a card and the registry cannot disagree silently. Proven rather than asserted — flipping `Z511` from flag to default in the registry fails the card test, and restoring it passes; the same on `Z617` for the data state. +- **`zenzic explain ` Gains an Activation Row**: it reports `on by default`, `opt-in — set [policies] = true`, or `inert — runs, but finds nothing until [policies] is declared`. Placed here rather than in `zenzic inspect codes` because that table is horizontal: a seventh column does not render untruncated at Rich's 80-column default, and the attempt silently dropped the `Name` and `Fixable` headers rather than wrapping. `inspect codes`' docstring had promised "activation status from config" since before this release and never showed it; it now says where to look. + +- **SARIF Results Carry `partialFingerprints` (Two Keys), So an Alert Keeps Its History**: GitHub Code Scanning decides whether two results are the same alert across commits from this field. With it absent, GitHub inferred identity, and an alert could be closed and reopened as a duplicate when unrelated lines shifted above it — a finding nobody touched losing its triage. Each result now carries `primaryLocationLineHash`, GitHub's own documented key, as a SHA-256 of the stripped source line: it hashes the *line* rather than the position, which is what lets it survive that line moving. **`primaryLocationLineHash` is deliberately omitted rather than faked** when the source line is unknown — a file-level finding such as `Z502` (`SHORT_CONTENT`) or `Z411` (`DEAD_END_NODE`) has no excerpt, and a hash over an empty string would give every such finding the same identity, so GitHub would merge unrelated alerts instead of failing to track one. A second key, **`zenzicFindingV1`**, is therefore emitted on *every* result: it is the same identity the `gitlab-codequality` emitter writes as `fingerprint` (shared function, so the two formats cannot drift), built from relative path, code, message, match text and occurrence index, with the line number deliberately absent. No result is ever without `partialFingerprints`. Measured on a probe corpus: 5 results, 5 carrying `zenzicFindingV1`, 1 also carrying `primaryLocationLineHash` and 4 correctly without it. + +- **Two Link Gates That Check the Built Site Rather Than the Source Tree**: `scripts/check_built_site_links.py` resolves every internal link the way a browser does, by parsing the rendered HTML; `scripts/check_redirect_destinations.py` resolves every `docs/_redirects` destination transitively against the build, so a rule pointing at a page a release deletes fails before it ships. Both run from `just docs-build`, and therefore from `verify` and CI. The link checker parses rather than pattern-matches because `minify_html` strips attribute quotes — a regex requiring them finds nothing and reports a clean sweep. Stated limit: MkDocs does not render `.mdx`, so no built-site checker can cover those links; the engine is the only instrument there. -- **Z620 false positives** (`directory_policies` tracking): cached compiled patterns now carry the original glob string, restoring correct `GlobalUsageTracker.mark_directory_policy_used(pattern, code)` invocations. Regression introduced in the `_compiled_dir_policies` caching patch. +- **JSX Link Components Are Now Analysed**: a capitalised tag carrying `to`, `href` or `src` — ``, ``, `` — participates in the link graph and the forbidden-scheme check exactly as `` does. Previously only lowercase `a`/`img`/`link` were recognised, so a broken target inside a component was silently unreported while the product advertised MDX support: the promise exceeded the delivery, which is the defect this closes. **The recognition rule is the JSX convention, not a list of names** — lowercase is an HTML element, capitalised is a component — so ``, `` and a component nobody has invented yet are all covered, and no framework is named anywhere in the engine. The attribute side is deliberately a fixed set (`to`, `href`, `src`): component names are unbounded and inventing one must not create a blind spot, while attribute names are where false positives live — treating every string prop as a URL would resolve `` as a broken link. **A component carrying none of those three props reports nothing**, and its other props are no longer audited as HTML attributes: `Z120` (UNKNOWN_HTML_ATTRIBUTE) and `Z121` (MISSING_OR_EMPTY_HREF, whose own text names `` and ``) are not applicable to a component and would otherwise have fired on every JSX tag with custom props. Not covered, and unchanged: bespoke prop names (``) and expression attributes (`to={"./x"}`). -- **Full Telemetry Disclosure & Unified Progress Telemetry**: Unified all analysis and environment phases (Environment & VSM Init, Parsing, Link Validation, IPC Teardown, Orphans, Snippets, Assets) under a single cohesive Rich `Progress` context, providing uniform determinate progress bars and explicit millisecond timing telemetry across all stages. -- **External URL Exclusion Pre-filtering & Baseline Debt UX**: Added deterministic prefix exclusion filtering in `_check_external_links()` and `LinkValidator.register()`, preventing unwanted HTTP requests for URLs matching `excluded_external_urls` or CLI `--exclude-url`. Refined baseline UX to display an encouraging notification upon massive technical debt resolution (> 50 issues resolved) without new findings. -- **Batched IPC Work Chunking in Hybrid Adaptive Engine**: Partitioned multiprocessing file tasks into dynamically sized chunks (`chunksize = max(4, N // (2 * cpu))`), drastically reducing inter-process serialization overhead and worker queue contention while preserving ADR-020 Parallel Fail-Fast security guarantees on security breach detection. -- **$O(1)$ Navigation Tree Memoization**: Pre-computed navigation paths as an immutable `frozenset[str]` during `MkDocsAdapter` initialization, eliminating over 350,000 redundant recursive dictionary walks and accelerating cross-check topological validation. -- **Single-Pass Fused Lexer & Pre-Masked Buffers**: Refactored `PolyglotExtractor` in `validator.py` to reuse pre-masked text buffers across HTML, reference definitions, and inline Markdown link extractors, eliminating redundant RE2 tokenization and cutting millions of intermediate string allocations. -- **Hybrid Adaptive Engine Multiprocessing Bugfix**: Fixed `workers: int | None = None` default in `scan_docs_references()`, added `RuleFinding.__reduce__()` for safe multiprocessing pickle serialization, and aggregated `consumed_global_patterns` across parallel worker reports into `config._global_tracker`. Restores 7x parallel speedup on workspaces with $\ge 50$ documents. -- **Single-Pass CLI Architecture**: Refactored `_collect_all_results()` to pass precomputed integrity reports directly to `validate_links_structured()`, eliminating redundant second-pass scans and restoring strict $O(N)$ execution. -- **RE2 Configuration Error Trapping**: Added comprehensive regex pattern validation with explicit user diagnostics detailing Google RE2 limitations (prohibiting lookaround assertions `(?=...)` and backreferences `\1`) in `CustomRuleConfig`, `PoliciesConfig`, and `ZenzicConfig`. +- **Mechanical Enforcement for the Breaking-Change Marker**: `scripts/check_breaking_change_marker.py` runs at pre-commit's `commit-msg` stage and refuses a commit that adds a new `BREAKING` entry to `CHANGELOG.md` without a `!` in its subject. `cli-contract-stability.md` listed that marker as the first of four requirements and enforced it by reading, which is how `e18e855` and `8245436` both shipped as `fix(security):` rather than `fix(security)!:`; both are pushed and unrepairable. The check keys on the **count of entries whose bold title carries the marker** rather than on lines added under `### Changed`/`### Removed`, because the latter false-positives on moving an existing entry between those sections — an operation this release actually performed. Counting bare occurrences of the word was the first attempt and was also wrong: the commit documenting this check tripped it, because the entry *describing* the marker names it in prose. A check whose own documentation it blocks has miscounted rather than caught something, and that case is now pinned in its self-test. Three limits are stated in the script's own docstring and on the contract page: it cannot repair a pushed commit, it only runs if the hook is installed (`just verify`'s git-hook check now requires `commit-msg` alongside `pre-commit` and `pre-push`, since a declared-but-uninstalled hook is silent), and `--no-verify` bypasses it. + +- **`--format gitlab-codequality` on `check all` — Native GitLab Code Quality Reports**: `check all` now emits GitLab's Code Quality report schema directly, so a `.gitlab-ci.yml` can declare the artifact under `artifacts.reports.codequality` and have findings appear inline on the merge request. Previously `--format` accepted only `text`/`json`/`sarif`/`github-annotations`, and Zenzic's `--format json` output is a category-keyed object rather than GitLab's required array-of-objects — so `docs/how-to/configure-ci-cd.md` documented uploading a plain artifact as a permanent workaround, which it no longer does. Each violation carries `description`, `check_name` (the finding code), a SHA-256 `fingerprint`, `severity`, and `location.path` + `location.lines.begin`. Zenzic's severities map explicitly onto GitLab's five-value enum (`security_breach`→`blocker`, `security_incident`→`critical`, `error`→`major`, `warning`→`minor`, `info`→`info`); a severity outside Zenzic's own set — reachable from a plugin rule — maps to `minor` rather than being passed through, because an unrecognised value makes the whole report unparseable and takes every other finding with it. The fingerprint deliberately excludes the line number so a violation is not reported as newly introduced when a paragraph is inserted above it; two identical findings in one file are distinguished by their order within it. For a credential finding the matched text contributes to the digest but is never emitted. When `governance.suppression_cap_fail_hard` aborts the run, the report is a single `blocker` violation named `SUPPRESSION_CAP_EXCEEDED` anchored to `.zenzic.toml` — an empty report would be displayed by GitLab as "no code quality issues", showing a clean merge request for a failed pipeline. Available on `check all` only: the per-aspect subcommands each see one slice of the findings, and uploading one of those as the job's report would silently shrink the merge request's view to that slice. +- **`zenzic doctor` — Repository-Convention Checks**: a new command that asks what `zenzic check` does not. Where `check` analyses documentation content, `doctor` verifies the repository's own conventions: that every architectural decision cited in code or prose has a record that exists (`adr-citations`), that the redirects file is structurally intact and has not been silently reshaped (`redirects`), and that the configuration loads (`config-schema`, reusing the existing `Z110`/`Z111` path rather than re-validating). Exits `1` on any finding, `0` when clean, with `--format json` for CI. Run against this repository with no configuration it reports **9** phantom ADR citations. +- **`zenzic adr new` — Decision-Record Scaffold**: allocates the next identifier and writes the five canonical sections with an SPDX header. The number is one past the **highest** identifier present, never the record count: the vault has real gaps, and reusing one would silently repoint every existing citation of that number at a different decision. Creating a second record with a number already in use is refused. Registering the record in the vault index stays a manual editorial step. +- **`[doctor]` Configuration Section**: `adr_vault_path`, `adr_citation_pattern`, `redirects_path`, `redirects_expected_blanks`, all defaulting to Zenzic's own real values so this repository needs no explicit configuration. **Public repository content only, by construction**: paths reaching into a gitignored internal directory are rejected at config load rather than merely defaulted away from, because a check reading gitignored content would pass for whoever holds those files locally and be unrunnable in CI or a fresh clone. An invalid `adr_citation_pattern` is a configuration error, not a scan-time crash. `zenzic init`'s scaffold now includes the section, commented out with its real defaults. + +- **`zenzic score --trend` — Recorded Score Series**: `zenzic score --save` now also appends one JSON object per run to `.zenzic-history.jsonl` in the repository root, and `--trend` reads it back — a one-line summary plus the last ten entries in text mode, or the full series and a summary object (`runs`, `first`, `last`, `min`, `max`, `delta`) under `--format json`. This answers the question a single score cannot: whether documentation quality is improving or degrading across runs. **Neither existing state file changes shape.** The series is deliberately not stored in `.zenzic-baseline.json`, whose schema is `additionalProperties: false` and whose job is finding-signature matching for suppression, not trend reporting; `.zenzic-score.json` is written exactly as before. The history file is append-only JSONL, capped at 500 entries with the oldest dropped first, so an interrupted run costs at most its own entry rather than corrupting the series. An absent history reports "No score history yet" and exits `0` — a fresh clone does not fail a pipeline step — and a failure to record an entry never fails the scoring run that produced it. + +- **`/llms.txt` and `/llms-ctx-full.txt` Generated at Build Time**: the published site now emits a Markdown link index of every page in the navigation, plus a full-content variant, following the [llms.txt proposal](https://www.answer.ai/posts/2024-09-03-llmstxt.html) (Jeremy Howard, Answer.AI, September 2024 — a proposal, not a ratified standard, and one that prescribes no processing behaviour). Both are build output only: derived from the nav on every build, never committed, so they cannot fall out of step with the documentation they describe. Naming follows the original proposal — the full variant is `llms-ctx-full.txt`; the widely circulated `llms-full.txt` appears nowhere in the spec. `llms.txt` and `llms-ctx-full.txt` join `robots.txt`, `_redirects`, `CNAME` and `sitemap.xml` in the scanner's site-root passthrough set, so a project keeping one in its docs directory does not have it analysed as documentation content. + +- **Editor Hover Now Explains Suppressions**: hovering a `` directive in any LSP-connected editor reports what it is actually doing — **Active** (suppresses a real finding), **Nothing to suppress** (dead, reported as `Z603`), **Redundant** (a `directory_policies` glob already covers this code for this file, and the hover names the matching pattern), **Has no effect** (a Tier-0 `Z2xx` security code, inviolable), or **Has no effect (ADR-093)** (a graph- or file-level code governable only through `.zenzic.toml`). Hovering a *live* diagnostic additionally states whether an inline comment could silence it at all, so the two inviolable families are visible before reaching for a directive that would only become dead weight. Previously a hover over a suppressed line returned nothing at all, and the only way to discover that a directive was dead or redundant was to run a scan and read the `Z603` output. +- **`SuppressionTracker.explain_suppression()` — Side-Effect-Free Suppression Query**: returns a `SuppressionVerdict(suppressed, source, pattern)` naming which mechanism decided and, for a policy match, which glob. `is_suppressed()` is unchanged in signature and return type — it now delegates its decision to `explain_suppression()` and then applies the state changes that verdict implies, so there is one decision tree rather than two that can drift. The split is required rather than cosmetic: `is_suppressed()` consumes the inline directive it matches and marks governance policies used, both of which feed `Z603` dead-suppression detection, so anything that merely describes suppression state — the hover above, a report — must not call it. `SuppressionVerdict` is truthy exactly when `suppressed` is. + +- **`scripts/check_redirects_structure.py` — Structural Tripwire for `docs/_redirects`**: validates that every non-comment line carries exactly three fields, that sources begin with `/`, that destinations begin with `/` or `http`, and that the status is always `301` — plus a pinned blank-line count, since the file's only legitimate blank lines are in its comment header. Wired into both `.pre-commit-config.yaml` (fires whenever the file is staged) and `.github/workflows/ci.yml` (fires on every push, independent of whether hooks are installed locally). Deliberately a detector and never a formatter: it does not edit the file. It exists because a published redirects file was once silently reshaped with no identifiable vector, and a tripwire catches that on the commit it happens regardless of cause. + +- **`zenzic fix --rename OLD NEW` — Third and Final Zensical-Studio-Inspired Feature, the CLI/Batch Counterpart to the LSP Rename Hook**: repairs inbound relative links across the docs tree after a file is renamed or moved, for scripted/CI/bulk `git mv` workflows (`git mv docs/old.md docs/new.md && zenzic fix --rename docs/old.md docs/new.md`). `OLD` does not need to still exist on disk. Reuses `RenameLinkMutation` unchanged (the same class the LSP's `workspace/willRenameFiles` handler uses) — no new resolution logic. Same `--dry-run`/`--apply` gate as the rest of `zenzic fix` (dry-run is the default, matching the existing command's own convention and this feature's own higher blast radius — it can rewrite files the user didn't directly touch, with no editor undo history backing it, unlike the LSP version). Same safety gates as the LSP feature: docs-root-relative (`/...`) and `@site/...` alias links are always left untouched; a file with an active inline suppression at the affected location is left untouched rather than overridden; every affected file is reported individually (never a single opaque batch result), so a partial failure never leaves the user guessing which files were actually touched. +- **`fixable` Field Added to `--format json`, `--format sarif`, and `zenzic inspect codes`**: derived from the same `CODE_DEFINITIONS` registry `zenzic explain ` already reads — no new registry, no duplicated data. `--format json`'s per-finding objects (`check links`/`check orphans`/`check snippets`/`check references`/`check assets --format json`) gain a `"fixable": true|false` key. `--format sarif`'s per-rule `properties` block gains `"fixable"` alongside the existing `"category"`/`"penalty"` — always `false` for plugin and custom (`ZZ-`) rules, since only Zenzic's own built-in codes have a wired `Mutation` class. `zenzic inspect codes` gains a `Fixable` column (`Yes`/`No`). `docs/reference/cli.md`'s JSON and SARIF example blocks updated to match. +- **`textDocument/willSaveWaitUntil` — Auto-Fix-on-Save for the LSP, Opt-In**: the Language Server now offers to auto-apply Zenzic's deterministic Quick Fixes when a Markdown/MDX document is saved, gated behind `self.auto_fix_on_save` (off by default; the `zenzic-vscode` client controls it via a new `zenzic.autoFixOnSave` setting). `textDocumentSync` changed from the bare `TextDocumentSyncKind` integer to the object form (`{"openClose": true, "change": 2, "willSaveWaitUntil": true}`) — required per the LSP spec to declare the capability; `vscode-languageclient` only auto-forwards `workspace.onWillSaveTextDocument` to the server when this is set. Reuses the exact same `Mutation` classes already used by manual Quick Fix and `zenzic fix` — no new fix logic, only a new trigger. **Safety gate**: if any occurrence of a fixable code is inline-suppressed anywhere in the file, that code is skipped entirely for the save (the `Mutation` classes have no per-occurrence location scoping except `DeadSuppressionMutation`, so partial suppression can't be respected surgically — skipping the whole code is the fail-safe choice over risking a fix against explicit user intent). A scan or mutation error also skips (with a `window/logMessage` warning) rather than guessing. `workspace/didChangeConfiguration` updates the flag live, no server restart needed. +- **CLI/LSP Manual Quick Fix Parity — `Z515`/`Z517`/`Z520` Now Reachable via `textDocument/codeAction`**: found while investigating the auto-fix-on-save trigger's integration points — 3 of the 6 `fixable=True` codes (`Z515` bare URLs, `Z517` heading punctuation, `Z520` malformed lists) were already wired into `zenzic fix` (the CLI) via their `Mutation` classes, but `textDocument/codeAction` had no corresponding branch, so the editor's manual Quick Fix action silently offered nothing for them. Added the 3 missing branches, reusing the same `BareUrlMutation`/`HeadingPunctuationMutation`/`MalformedListMutation` classes already trusted by the CLI. +- **`link` Field on `[[custom_rules]]`**: an optional rationale URL, shown alongside the message in the finding (`"{message} (see {link})"`). Regex-flavor rules only; omitted entirely when unset, so every existing rule without a `link` produces exactly its prior message, unchanged. +- **`workspace/willRenameFiles` — Auto-Repair Inbound Links on Rename, Opt-In**: the Language Server now offers to rewrite relative links pointing at a file's old path whenever it's renamed or moved, gated behind `self.auto_repair_links_on_rename` (off by default; the `zenzic-vscode` client controls it via a new `zenzic.autoRepairLinksOnRename` setting). **Phase 1 correction**: `resolve_href_target()` — assumed reusable end-to-end for this feature — only resolves an existing href to an absolute path (forward resolution); it has no reverse direction for constructing a new href after a rename. Added `RenameLinkMutation` (`mutator.py`) to compute and apply the mechanical relative-path replacement, while still reusing `resolve_href_target()` unchanged for the "does this link point at the renamed file" check — no new *resolution* logic, only new *fix-application* logic matching every other `Mutation` class's existing shape. Scope is bounded by the VSM's existing `incoming_links` reverse index (canonical URL → linking files), an O(1) lookup per rename rather than a workspace-wide scan. **Safety**: a docs-root-relative (`/...`) or `@site/...` alias href is always left untouched (ambiguous to reconstruct safely); a linking file excluded via `.zenzic.toml` is never rewritten; if one linking file's fix fails, the others are still applied independently rather than the whole rename's repair being abandoned. Scoped to Markdown/MDX file renames only — folder renames are not handled. +- **`ADR-012`, `ADR-078`, `ADR-084` — Drafted for Real, Closing 3 Phantom-Citation Gaps**: all three were cited across internal governance references and live source code (`config.py`, `_base.py`, `governance.py`, `suppressions.py`, `rules.py`) but had no corresponding record in `docs/developers/explanation/adr-vault/`. Drafted from real, verified material rather than removing the citations: **ADR-012 (Finding Taxonomy)** — the Z0xx-Z6xx numeric category axis, sourced from `codes.py`'s own registry docstring (the real primary source; an earlier pass had mismatched this citation against a blog post covering a different, orthogonal axis — tier ownership, not numeric category). **ADR-078 (BaseAdapter Abstract Runtime Contract)** — the v0.8.0 Protocol→ABC move, with the v0.25.0 `watched_config_files` "ADR-078-STRICT" addition recorded as an amendment section within the same document rather than a second ADR, since it tightens the identical contract rather than introducing a new decision. **ADR-084 (Centralized Core Governance)** — scoped specifically to the verified claim (`directory_policies`/`per_file_ignores` evaluated identically by CLI and LSP, confirmed live via `incremental.py:668-673` calling the same `governance.py` functions as `_check.py`/`_audit.py`/`_standalone.py`/`_lab.py`); a separate, narrower implementation detail (backtick/fence-skipping for suppression-comment counting in `suppressions.py`/`rules.py`/`scanner.py`) that had also cited "ADR-084" was decoupled and reworded as plain comments, since it was never actually this decision's subject. +- **`is_likely_placeholder` — Deterministic Documented-Example Detection on `Z201`/`Z204` Findings**: + - `SecurityFinding`/`Finding` gain a new `is_likely_placeholder: bool` field, set by a fixed, rule-based classifier (a marker-substring lookup — `"EXAMPLE"`, `"PLACEHOLDER"`, `"YOUR_API_KEY"`, etc. — plus an 8+ repeated-character-run check) applied at every credential/forbidden-term detection site. Deliberately **not** a confidence score: Tier-0 Invariant #1 (Determinism & Pure Functions) forbids probabilistic logic in the Core, so this is a fixed lookup, not a heuristic. Never suppresses or downgrades the finding — `Z201`/`Z204` remain non-suppressible, Exit 2 findings regardless of this flag; it is a display hint only. Surfaces as a `[LIKELY PLACEHOLDER]` tag next to the existing `SECURITY BREACH DETECTED`/`POLICY VIOLATION DETECTED` panel header in text output, and as an `is_likely_placeholder: true` SARIF result property when set (mirrors the existing `security-severity` property's pattern). +- **`--config PATH` — Explicit Config File Override on `check all`, `score`, `diff`, `audit`**: + - `ZenzicConfig.load()` gained a new `config_file` override parameter (priority 0, ahead of the normal `.zenzic.toml` → `pyproject.toml` → defaults discovery chain). When given, that exact file is loaded — it does not have to live under the repository root — and still layers `.zenzic.local.toml` on top like the two discovered paths. A missing or malformed override raises immediately rather than silently falling back to discovery. Threaded through as `--config PATH` on the four commands `zenzic-action` actually invokes (`check all`, `score`, `diff`, `audit`); the other CLI commands (`check links`, `fix`, `guard`, `clean`, etc.) do not yet have the flag — closing that gap is tracked separately. This closes a long-standing gap where `zenzic-action`'s `config-file` input was fully documented (in `action.yml` and its README) but silently did nothing, because core never had a corresponding flag to receive it. +- **Specification-Driven Development (SDD) Rule Suite**: + - `Z521` (`REQUIRED_TABLE_COLUMN`): Enforces mandatory table column headers declared under `[policies.required_table_columns]` (globally `*` or section-scoped). Penalty: 2.0 pts (Content). + - `Z522` (`TABLE_CELL_ENUM`): Restricts table cell values to predefined enum sets declared under `[policies.table_cell_enums]` with case-insensitive matching and precise data-row line reporting. Penalty: 2.0 pts (Content). + - `Z523` (`HEADING_ORDER_VIOLATION`): Enforces ordered document section templates declared in `[policies].required_heading_order`. Penalty: 2.0 pts (Content). + - `Z412` (`TRACEABILITY_BROKEN`): Enforces inbound graph link coverage from designated source namespaces to target specification documents declared in `[policies.traceability_targets]`. Penalty: 4.0 pts (Navigation). +- **GFM Table AST & Lossless Parser**: + - Extended AST in `zenzic.core.ast` with `TableNode`, `TableRow`, and `TableCell` dataclasses fully compliant with the multiprocessing Plugin Pickling Contract. + - Implemented $O(N)$ native GFM table parser in `zenzic.core.parser` with escaped pipe `\|` support, inline code span protection, and lossless byte-for-byte serialization. +- **Policy-as-Code Configuration Expansion**: + - Added `required_table_columns`, `table_cell_enums`, `required_heading_order`, and `traceability_targets` to `PoliciesConfig` in `zenzic.models.config`. +- **Smart Build Engine Disambiguation**: + - Implemented lightweight, allocation-free string inspection in `discover_engine()` (`src/zenzic/core/adapters/_factory.py`) to automatically identify Zensical projects configured via MkDocs YAML (`theme: zensical`) without YAML parser overhead or false positives in nav/comments. Backed by `tests/test_engine_discovery.py`. +- **DQS Mathematical Transparency & CLI `--breakdown`**: + - Added `--breakdown` flag to `zenzic score` displaying a fully exploded scoring ledger with category deductions, Gravity Cap calculations, and technical debt math. Showcased in root `README.md`. +- **Architectural Decision Records (ADR-089, ADR-093)**: + - Added [ADR 089](docs/developers/explanation/adr-vault/records/adr-089-ecosystem-wide-immutable-sha-pinning.md) defining *Ecosystem-Wide Immutable SHA Pinning* — formalizing a policy already actively enforced across `zenzic`, `zenzic-action`, and `zenzic-vscode` CI/pre-commit configuration, previously only cited informally (no corresponding vault entry existed). + - Added [ADR 093](docs/developers/explanation/adr-vault/records/adr-093-topological-suppression-non-inline.md) defining the *Topological Suppression Non-Inline Invariant & LSP UX Determinism*. +- **Tested Compatibility Matrix**: + - Added `docs/reference/compatibility.md`, the canonical reference for which MkDocs/Material for MkDocs/Zensical version Zenzic's adapters are verified against, the verification method, and the last-verified date per engine — registered in `mkdocs.yml` nav, linked from `README.md` and `docs/how-to/configure-adapter.md`. `docs/reference/engines.md`'s prior "Supported Engine Versions" table (accurate but undated, with no stated verification method) now points to this page instead of duplicating it. +- **Documentation & Rule Cards**: + - Added rule cards `docs/rules/Z521.md`, `docs/rules/Z522.md`, `docs/rules/Z523.md`, `docs/rules/Z412.md`, registered in `mkdocs.yml` and `docs/reference/finding-codes.md`. + - Added launch blog post `docs/blog/posts/2026-08-22-zenzic-v0310-specification-driven-development.md`. + - Added interactive lab scenarios `z521`, `z522`, `z523`, `z412` in `examples/` and registered them in `zenzic lab`. +- **`docs/developers/how-to/contribute/propose-a-custom-rule.md` — Documents the Previously-Undocumented `custom_rule_proposal.yml` GitHub Issue Template**: + - A full bidirectional audit of `.github/ISSUE_TEMPLATE/*` against `docs/developers/how-to/contribute/` found `custom_rule_proposal.yml` (propose a new Z-Code rule) had no corresponding documentation page or link anywhere in `docs/`, unlike `bug_report.yml` and `feature_request.yml`. Added a new page describing its real fields (flaw description, failing/passing Markdown examples, proposed Z-Code name), registered in nav and linked from `contribute/index.md`'s issue-creation card grid. The same audit found `good_first_issue.yml` (claim an easy task) similarly absent from `contribute/index.md` — added a one-line mention rather than a full page, since the template is self-explanatory and lightweight. `gate-bypass-postmortem.md` was confirmed intentionally undocumented in contributor-facing docs — it's an internal maintainer-only Break-Glass incident template, not a contribution path. `SECURITY.md` documented the private vulnerability-reporting channels (Security Advisories, email) but never mentioned that `security_vulnerability.yml` also exists as a public template for lower-severity, non-exploitable reports — added a short note pointing to it. +- **`docs/developers/explanation/adr-vault/records/adr-031-ssot-code-definitions-and-gate-paradox.md` — Retroactively Drafted, per the ADR-089 Precedent**: + - `ADR-031` had been a phantom citation for the whole session (5 occurrences across `codes.py`, `scorer.py` ×2 comments, this blog post, 2 example READMEs, `changelogs/v0.8.md` — no vault entry ever existed), but unlike the session's 4 other phantom-ADR instances, abundant real source material already existed to draft it for real rather than just removing the citation. Drafted from that scattered material, not invented: `CodeDefinition` (`codes.py`) as the Single Source of Truth for per-code severity/penalty/category, closing the "Gate Paradox" where 3 CI-blocking codes (`Z103`/`Z111`/`Z113`) carried 0 DQS penalty before v0.8.0 — a repository could fail the CI gate while `zenzic score` reported a perfect 100/100; and the Gravity Cap, which caps the total DQS at 70 whenever any scoring category's contribution reaches 0.00. Correction made mid-draft, not shipped wrong: initially conflated the flat-cost inline-suppression model (every suppression costs 1 pt, no free allowance) into this same ADR, since the same blog post narrates both as "two things v0.8.0 changed" — but the real source code cites that model as a *separate* ADR, `ADR-061` (`scorer.py` ×3, `api-json.md`), confirmed by direct grep before finalizing. Scoped this record to the Single Source of Truth and Gravity Cap only; `ADR-061` is logged as a 6th phantom-citation instance, not resolved here. Registered in `docs/developers/explanation/adr-vault/index.md` (Core Architecture Decisions table, numeric position between `ADR-022` and `ADR-075`) and the internal decision manifest. + +### Changed + +- **The report says when a setting came from your configuration rather than your command line.** Reading `STRICT MODE: Warnings have been promoted to errors` on an invocation that carried no `--strict` is indistinguishable from a bug; it was correct, and nothing said so. The telemetry line now ends with `… • from config: strict` when a verdict-deciding setting was taken from the file and the command line does not account for it. It names at most `strict` and `exit_zero` — the only settings with that shape that reach `check all` — and prints nothing at all for a project that configures neither, because a header that always says something teaches people to stop reading it. `zenzic config explain` remains the complete answer, with per-key provenance across the global and local layers. + +- **`--audit` now says that its score is not comparable with a normal run's.** The flag restores findings that declared exemptions had removed, so the DQS drops — on this repository, 96 to 59 — and nothing said the two numbers answer different questions. The audit-mode footer, the flag's help text and the CLI reference now state it. Restored findings already carried a `[POLICY_EXEMPTION]` label; that is unchanged. + +- **`fail_under`'s reference entry now says which commands consult it.** `zenzic score` gates on it; `zenzic diff` gates on the saved snapshot instead, at any absolute score; `zenzic check all` never reads it and has no `--fail-under`. Measured: `fail_under = 97` with a score of 96 gives exit 1 from `score` and exit 0, `Gate Passed`, from `check all`. The three commands answer three different questions and the divergence is deliberate, but it was findable only by reading the source. **CI is unaffected**: `zenzic-action` runs both commands and propagates the score command's exit code, so a workflow using the action already enforces the threshold; a pipeline calling `zenzic check all` alone does not. + +- **For plugin authors: `run_rule()` accepts the project's container vocabulary, and its signature is otherwise unchanged.** Without it — which is every call written before this release — your rule sees the default vocabulary: `!!!`, `???`, `=== "Tab"` and `:`, regardless of which extensions the project under test enables. That is correct for an isolated test against a literal string, and it means a rule can behave differently in a real scan. To reproduce production behaviour, pass `containers=container_pattern(adapter.get_enabled_extensions())`; the docstring carries the full snippet. `BaseRule.check(self, file_path, text)` is untouched, and no third-party rule needs changing. + +- **For adapter authors: `BaseAdapter` gains `get_enabled_extensions()`, and its default is deliberately not empty.** The core owns which construct an extension brings; the adapter owns which extensions the project enables. Unlike `get_output_dirs()`, where emptiness is the cheap answer, an empty vocabulary here classifies 1,112 lines of this repository's documentation and 1,068 of an external corpus as indented code. The base implementation therefore returns the four container-bearing extensions, and a custom adapter overrides it only when its engine reads a different configuration key. Documented in the adapter API reference and the implementation how-to. + +- **The generated `.zenzic.toml` says where to find the codes it does not list.** The file carries a key only for the gated codes — 23 of 71 — because only those have something to set, and it already explained the three activation kinds. It did not say where the other 48 are documented, so a reader could take the 23 keys for the whole catalogue. Five lines now state that the on-by-default codes take no configuration, that their absence is not an off switch, and link the finding-code reference. + +- **`just verify` runs the test suite under `pytest -n auto`.** Measured before adopting: 256 s serial against 129-150 s on eight cores, the coverage figure within the same three-line order noise two serial runs show once a test that read the xdist worker's `sys.argv` was pinned, and the performance assertions' worst margin narrowing from 1.74× to 1.45× under contention. CI's `just test-cov` stays serial: nothing was measured there. + +- **Suppression Debt Counts the Declared Exceptions in Use — `directory_policies` Now Cost a Point, and Dead Suppressions No Longer Do (BREAKING)**: a suppression is one declared decision to look away, and it costs one point of the DQS. Two things contradicted that. A `[governance.directory_policies]` pair — exactly such a declaration — cost nothing, so the broadest suppression was the only free one. And an inline directive or `per_file_ignores` entry that silenced nothing still cost a point, although it was also reported as dead (`Z603`, `Z620`). The count is now taken after the scan, from what the run used: an inline directive or `data-zenzic-ignore` attribute that silenced a finding, and a per-file or directory-policy pair that did. The Suppression Audit footer, the cap panel and the cap-exceeded JSON and SARIF payloads show directory policies beside inline and per-file (`statistics.directory_policies` is added to the schema). **What you will see**: a project that relies on directory policies loses one point per pair in use, and may exceed `suppression_cap` where it did not before — including through `zenzic-action`, whose `cap-exceeded` gate reads the count Zenzic reports, and whose `suppression-debt-pts` output changes with it. A project carrying dead suppressions gains the points back and keeps the `Z603`/`Z620` findings that name them. **What to do**: run `zenzic check all` and read the footer; remove what `Z620` reports as dead; set `suppression_cap` to the exceptions you actually defend and keep `fail_under` at or below `100 - suppression_cap`, the relation `zenzic init` writes into the generated configuration. **Documentation**: the configuration reference, the suppression policy, the scoring pages, the `zenzic init` templates and three blog posts described a directory policy as free; they now state the charge, and the default cap of 30 is documented as uncalibrated — it was the free allowance of the model that preceded flat-cost debt. The model is recorded as ADR 061 in the ADR Vault; the scorer and the JSON reference had cited that record since v0.8.0 without one existing. +- **A MkDocs Project's Declared Build Output Is No Longer Scanned by the Quality Checks — Findings on Generated Files Disappear**: with `docs_dir` covering a MkDocs project, `mkdocs build` left `site/` inside the scanned tree and every generated file was reported — `Z405` on `404.html`, source maps and webfonts, `Z402`/`Z410` on generated pages. The engine now asks the adapter for the directory the build engine declares — MkDocs' `site_dir`, `site` when unset — and removes that exact repository-relative path from the quality checks. **The credential scanner still reads it**: a secret written into build output still exits `2`, because a value taken from a file the scanned project writes must not be able to narrow the security tier. The match is on the path rather than the name, so a documentation section at `docs/site/` is still scanned, and a `site_dir` that is absolute, empty, `.` or outside the repository is ignored. Zensical, standalone and prebuilt projects declare no output directory and are unchanged. *What to do:* nothing, unless you excluded the build directory by hand — that entry is now redundant for a MkDocs project. +- **`zenzic score --breakdown` Grouped Findings by Code Number, Not by Scoring Category — `Z106` and `Z123` Were Printed Under STRUCTURAL**: the detailed breakdown decided a finding's section by reading the code's first digit whenever the scorer had not categorised it, so `Z106` (`CIRCULAR_LINK`) and `Z123` (`NON_HTTP_SCHEME`) — `Z1xx` codes that **no** bucket scores — were listed under **STRUCTURAL CATEGORY (Weight: 30%)** beside genuine point deductions. **The same run disagreed with itself**: the summary table above showed `structural: 0 issues`, because the scorer correctly excludes them, and `zenzic check all --format sarif` reported `"category": "uncategorized"` for the same code in the same scan. Both now say uncategorized, and such findings appear under **UNCATEGORIZED FINDINGS** with `(no DQS penalty)` — a section that existed, was correct, and had never once rendered because nothing ever reached it. **What changes for you**: if you parse `--breakdown` text, a zero-penalty uncategorised finding moves out of the weighted section it was never part of. No score changes — these codes carry 0.0 points and were never in any bucket's arithmetic; only where they are printed changes. `docs/reference/cli.md`'s worked example showed the defect and has been replaced with captured output. **Root cause, and why it recurred**: the grouping was one of thirteen places deciding something about a code from its numeric prefix. `CODE_DEFINITIONS` now exports `CATEGORY_DISPLAY_NAMES`, `category_display_name()` and `category_bucket_key()`, so a bucket has one source; the remaining twelve sites decide a different question (which band, or whether a code is fatal) and are recorded for separate treatment rather than swept blind. The regression test asserts the *association* between a code and its section: the previous test checked that `STRUCTURAL CATEGORY` and `Z106 (CIRCULAR_LINK)` each appeared somewhere in the output, which passed whether or not they appeared together. + +- **The Generated `.zenzic.toml`'s Activation Section Is Derived From the Code Registry**: the opt-in flags were hand-written in two template blocks, and they had already diverged — `enable_circular_link_check` was absent entirely, so `Z106` was documented in the configuration reference and invisible in the file the tool writes. The block is now generated from `CODE_DEFINITIONS`: every flag-gated code emits its flag, every data-gated code its required `[policies]` declaration, each preceded by the code and its description. **Grouped by code rather than by kind**, so a reader sees one check's identity and its activation together. The section opens by naming the three behaviours, because the third is the one a user cannot otherwise detect: an *inert* check is running and finding nothing until its data is declared, which reads identically to "no violations". Proven rather than asserted — adding a gated code to the registry changes the generated block, and `zenzic init` was verified by generating a project and parsing the result, not by reading a diff. That check earned itself: the first attempt emitted `enable_passive_voice_check` twice (once from a stale hand-written literal, once from the registry) and TOML rejects a duplicate key, so the generated file did not parse at all. + +- **Six Editorial-Policy Checks Are Now Opt-In — Baselined Findings For Them Will Disappear (ACTION REQUIRED)**: `Z511` (sentence length), `Z502` (short content), `Z517` (heading punctuation), `Z513` (duplicate heading), `Z411` (dead-end page) and `Z401` (missing directory index) no longer run unless enabled. **If your baseline or your CI expectations include any of these codes, they will vanish on upgrade — and if you want them, you must now ask.** Enable any of them under `[policies]`: `enable_sentence_length_check`, `enable_short_content_check`, `enable_heading_punctuation_check`, `enable_duplicate_heading_check`, `enable_dead_end_check`, `enable_directory_index_check`. Thresholds are unchanged when enabled — `max_sentence_length` and `placeholder_max_words` still mean what they meant. **The criterion**, applied to all 70 codes rather than to the three that prompted it: a correctness check identifies something *wrong* — a link that does not resolve, a snippet that does not parse, a credential in plain text — while a policy check identifies something that *differs from a preference*: a word count, a house style, a site shape. On a foreign corpus these six produced 42 of 117 findings, none of them defects. **Two codes were considered and deliberately left on**: `Z514` (generic alt text) is accessibility rather than style — alt text reading "image" is unusable with a screen reader — and `Z515` (bare URL) was decided by measuring renderers rather than asserting: MkDocs/Material, four python-markdown configurations and markdown-it all leave a bare URL as inert text, matching CommonMark where only `` is an autolink. GitHub linkifies it; the generator most Zenzic users build with does not. **A `per_file_ignores` or `directory_policies` entry naming one of the six is now dead configuration** and will be reported as `Z620`; Zenzic's own `.zenzic.toml` lost four such entries. +- **`zenzic init` Now Names Every Opt-In Check**: the generated `.zenzic.toml` listed one `enable_*` flag and omitted `enable_circular_link_check` entirely, so `Z106` was documented in the configuration reference and invisible in the file the tool writes — a check a user could not discover. All seven flags now appear, each with what it does and why it is off. Codes named in the generated configuration rise from 23 to 28 of 70. + +- **`Z505` Now Reports Untagged Code Blocks Inside Admonitions and Lists — Expect New Findings, and They Are Correct**: the previous fence pattern was anchored at column zero, so an untagged fence indented inside an admonition or a list item was never recognised as a fence at all. Counted by hand on `zensical/docs`: **31** genuinely untagged opening fences exist; the engine reported **9**, and now reports 30. **This is not a regression** — the findings were always true and were being hidden. Anyone whose documentation uses admonitions should expect the count to rise on the first run after upgrading, and should read the new findings as a backlog that was previously invisible rather than as something the upgrade broke. Add a language tag, or suppress deliberately. +- **A `per_file_ignores` Entry May Now Be Dead Configuration (`Z620`)**: because fences close correctly, content that was previously scanned as prose is now correctly inside a fence — so a suppression written to silence a finding in that content no longer suppresses anything, and `Z620` reports it as dead. This happened in Zenzic's own configuration: the entry silencing `Z501` on `docs/tutorials/examples/z5xx-content/z501-placeholder.md` was removed, because the placeholder words it covered live inside a fenced CLI transcript. If your `.zenzic.toml` suppresses a content code on a page whose examples are fenced, check whether that entry is still doing anything. + +- **BREAKING — A File in the Repository Could Downgrade a Path-Traversal Finding, and a Documentation Section Named After an OS Directory Now Needs a Declaration**: three sites resolved a traversal target and downgraded `Z203` (exit 3, non-suppressible) to `Z202` (exit 1) when the target existed on disk. Creating `docs/etc/passwd` with any contents turned every `/etc/passwd` link in the corpus into an exit 1 that `--exit-zero` then covered — a file acting as a suppression mechanism for a code documented as non-suppressible. The downgrade existed to spare a documentation section legitimately named `dev/` or `usr/`, and that case is still handled, by two different mechanisms depending on the link shape. **A relative hop is decided by arithmetic**: one hop up to a repository-level folder lands inside the repository, so it stays `Z202`, while `../../../../etc/passwd` leaves it and stays `Z203` — the same answer whether or not the target exists, and no filesystem call is made. **A site-absolute `/dev/...` maps inside the documentation root by construction**, so nothing separates it from a traversal target arithmetically; such a section must now be declared with `absolute_path_allowlist`. **Action required** if your documentation has a site-absolute section named after an OS root directory (`etc`, `root`, `var`, `proc`, `sys`, `usr`, `boot`, `dev`, `bin`, `sbin`, `windows`, `winnt`, `system32`, `programdata`) and you relied on it passing without configuration. `_classify_traversal_intent`'s Zero I/O claim, previously true of the classifier and false of both callers, is now true of the whole path. `docs/rules/Z203.md` states the new classification, including that the verdict never consults the filesystem. + +- **Security Tier No Longer Inherits the Quality Tier's Masking — `Z202`/`Z203`/`Z205` Now See Text They Were Blind To (ACTION REQUIRED BEFORE UPGRADING)**: **a corpus that passes today may fail after upgrading, and the new failures are non-suppressible.** Anyone whose CI gates on the exit code should scan with the new version before rolling it out. Masking exists to answer a quality question — *is this text content?* — so that a link written inside a comment, a math span or a code fence is not reported as broken. The security tier was reading the same masked text to answer a different question — *does this document contain a forbidden scheme or a traversal?* — and for that question the whole document is in scope. The result was that **`Cost is $5 - [c](javascript:alert(1)) - or $10.` exited `0`**, while the identical line without the two `$` exited `2`: two dollar signs on one line silenced a Tier-0 code that `codes.py` declares non-suppressible, in plain Markdown, with no MDX and no intent required. The same held for an HTML comment, an MDX comment, and any text following an unterminated code fence (which blanked every remaining line of the file), and it silenced path traversal as well as forbidden schemes (`Z202`/`Z203` exit `1`/`3` → `0`). The engine's own words describe what this was: *"the file was never looked at" is a suppression mechanism*. The security tier now extracts through its own pass (`PolyglotExtractor.extract_security_links`), which masks **only closed, well-formed code fences**. **Closed fences stay masked deliberately, and this is an accepted residual risk rather than an oversight**: a closed fence is the author's explicit, structural declaration that the content is an exhibit, and fenced text renders inert — it never becomes the clickable anchor that makes an unfenced `javascript:` URL a live vector. The three unmasked cases share the property the fence lacks: the author never declared an exhibit. A comment declares something about *rendering*, not about content; a math span declares nothing at all (two `$` on one line, which prose about prices produces by accident); an unterminated fence is an authoring error. The quality tier is unchanged — a commented-out broken link is still not reported. +- **Terminology Settled on "Documentation Quality Score" Across Every Live Surface**: + - The product used two expansions of DQS — "Documentation Quality Score" (the majority form) and "Document Quality Score". The canonical form is now **Documentation Quality Score** everywhere a reader or a tool can see it, including `zenzic-baseline.schema.json`, which is published at `zenzic.dev/schemas/` and whose `score` field description previously used the short form. + - Published, dated blog posts keep their original wording: a published article is a record of what was said then, not a live surface. No behaviour, field name, or JSON key changed — this is terminology only, and `DQS` remains the abbreviation everywhere. + +- **`Z301` (`DANGLING_REF`) Is Now a `--strict`-Gated Warning, Not a Hard Error — Action Required If Your CI Relied on It Failing**: + - A dangling reference-style link (`[text][label]` with no matching definition) previously **hard-failed a plain `zenzic check`**, exiting 1 with no `--strict` flag. It is now a warning: `zenzic check references` exits **0**, and only `zenzic check references --strict` exits **1**. Verified live on `examples/z301-dangling-ref/`: 2 warnings and exit 0 plain, exit 1 under `--strict`. + - **What to do**: if a pipeline depended on `Z301` failing a non-strict run, it now passes silently where it used to fail. Add `--strict` to that step to restore the previous gating, or promote the code explicitly in `.zenzic.toml`. No configuration change is needed if you already run `--strict`. + - **Why**: `codes.py`'s `CODE_DEFINITIONS` — the severity SSoT — has always classified `Z301` as a warning, but `scanner.py` hardcoded `is_warning=False` when constructing its `ReferenceFinding`. Two authoritative sources disagreed, and the hardcoded one won; `git blame` dates it to the repository's first commit, documented as deliberate in the docstring, so this was a genuine unreconciled conflict rather than a recent regression. `codes.py` is authoritative, so the override was removed. Sibling codes `Z302` and `Z303` never carried it and are unaffected. + +- **Mutation-Score Documentation Stated a Target as if It Were a Property**: five sites declared a `≥ 90%` mutation score for the credential scanner — an internal governance reference, `docs/developers/reference/credential-scanner-obligations.md`, `docs/developers/how-to/write-a-check.md`, `docs/developers/index.md` and `noxfile.py` — with no measured figure anywhere, because until 2026-09-02 no score had ever been computed. The first real run scored **56.5%** (231 mutants killed, 178 survived, 9 with no covering test). Every site now states target and measurement together and says plainly that the obligation is not currently met; the gap is tracked as its own work item rather than closed by rewording. Two adjacent factual errors were corrected in the same pass: the developer pages named `nox -s mutation` as the runner and `rules.py`/`credentials.py`/`reporter.py` as the targets, while the gate CI actually runs is `just mutation` over `credentials.py` alone — and both pages said mutation testing was deliberately not part of automated verification, which stopped being true when it was wired into `ci.yml`. + +- **`excluded_dirs`/`excluded_file_patterns` Documentation Named Only the Credential Half of the Security Tier**: both `docs/reference/configuration-reference.md` notes described the exemption as covering "the credential and forbidden-term scan (`Z201`/`Z204`/`Z205`)", which mislabelled `Z205` (a forbidden *scheme*, not a term) and omitted `Z202`/`Z203` (path traversal) entirely. Since the engine at the time genuinely only exempted `Z201`/`Z204`, the page was describing an intent the code did not implement. The code now covers the whole tier and both notes enumerate it accurately. + +- **BREAKING — CLI Usage Errors Now Exit 1, Not 2** — the Exit Code Contract reserves **exit 2** for a Credential Scanner Breach, "never suppressible". Click's default for a usage error — an unknown option, an unknown command, a missing subcommand — was **also 2**, so a typo'd flag and a live AWS key were indistinguishable by exit code and no CI gate could discriminate between them. The collision was not theoretical: it misled an adversarial audit of this very contract three separate times. Usage errors now exit **1** (the quality/error tier), leaving exit 2 exclusive to the security tier it is documented to mean. **Note for anyone scripting against the CLI**: a pipeline that treated exit 2 as "invalid invocation" must now treat exit 1 as that case — and should treat exit 2 as a credential breach, which is what it always meant. Implementation detail worth recording: Typer vendors its own `click` (`typer._click`), a different module object from the installed `click` package, so patching only the latter had no effect; both are set. + +- **`docs/explanation/the-zenzic-trinity.md` Reframed: the Trinity Is Three Concerns, Not Three Repositories**: the page described "three repositories that form a closed feedback loop" and gave documentation its own top-level section as the `zenzic-doc` repository — which is archived, its content having moved into this repository's own `docs/` tree, so the page linked a repository readers cannot use. It also predated the VS Code extension entirely, leaving a shipped, independently-released enforcement surface out of the model. The page is now organised around the three concerns its own opening sentence always named: **logic** and **intent** share the `zenzic` repository (versioned and reviewed in one diff, which is what makes the Mirror Law enforceable), while **enforcement** is explicitly multi-surface — `zenzic-action` in CI, `zenzic-vscode` at the keystroke, and `zenzic-mcp` for AI consumers, the last marked in-development rather than released. Each is a thin client over the identical engine, so a finding cannot mean one thing in the editor and another in CI. Retitled *Logic, Intent, and Enforcement*; the URL and all five existing anchors (`core-the-body`, `documentation-the-soul`, `action-the-arm`, `feedback-loop`, `architectural-awareness`) are unchanged, so existing deep links still resolve. `structural-integrity.md`'s "Further Reading" description of this page, which incorrectly credited it with defining the Three Pillars (they are defined in `developers/explanation/governance/index.md`), was corrected in the same pass. +- **ADR-075 (Radical Unawareness) Broadened From CI-Only to Every Consumer Class**: the vault record stated only "The Core completely ignores CI consumers," while the code has long enforced far more — `incremental.py`'s own module docstring cites ADR-075 by name for "zero knowledge of JSON-RPC, LSP, VS Code, or any transport layer," and the Tier-0 reference files already described the broader scope. The record now covers CI runners, editors and LSP clients, AI systems, and build frameworks as one invariant with one meaning, and names where each consumer-specific concern belongs instead (adapter layer, LSP server, Action wrapper). This documents a constraint already enforced; it grants no new permission and weakens nothing. +- **markdownlint Pre-Commit Hook No Longer Runs With `--fix`**: `.markdownlint.json` sets `"default": true`, so every rule a future markdownlint release adds is enabled the moment the pinned `rev` is bumped. Combined with `--fix`, such a bump would silently rewrite files across the repository on the next hook run rather than reporting anything — 76 `MD060` findings already appear under a newer version than the pinned v0.41.0. The hook is now report-only, so a version bump surfaces as a reviewable failure and each newly-enabled rule gets a deliberate disposition. Current state is clean without `--fix` (`pre-commit run markdownlint --all-files` passes). + +- **`NOTICE` Now Lists Runtime Dependencies' Own Transitive Dependencies, Not Just Direct Ones**: the file already included `Shellingham` labeled "transitive dependency of Typer," but was missing 10 more packages at the same tier — `colorama` (Typer's other transitive dependency, Windows-only), 4 transitive dependencies of Pydantic (`annotated-types`, `pydantic-core`, `typing-extensions`, `typing-inspection`), 2 of Rich (`markdown-it-py`, `Pygments`), and 4 of httpx (`anyio`, `certifi`, `httpcore`, `idna`). Each new entry's license/copyright was verified directly against that package's own repository, not assumed. Entries renumbered sequentially throughout; no license text or attribution was removed, only added. +- **`NOTICE` Now Covers Tailwind CSS**: `docs/assets/css/zenzic-tailwind.min.css` is a compiled, human-run Tailwind CLI build artifact committed to the repository and served directly to documentation site visitors, but was absent from `NOTICE` entirely. Added a new "Bundled Documentation Site Assets" section (entry 29) citing Tailwind Labs, Inc.'s MIT license — verified against the project's own published `LICENSE` file. The compiled CSS already carries its own inline `/*! tailwindcss ... | MIT License | ... */` banner; this entry consolidates that attribution alongside the rest of the project's third-party notices, matching the existing precedent for self-hosted fonts. +- **`mkdocs`/`mkdocs-material` Docs Dependencies Now Have an Upper-Bound Ceiling**: `pyproject.toml`'s `docs` extra changed from `mkdocs-material>=9.0.0`/`mkdocs>=1.5.0` to `mkdocs-material>=9.0.0,<10`/`mkdocs>=1.5.0,<2`. MkDocs 2.0 is a confirmed, upcoming ground-up rewrite with no plugin system, no theme-override compatibility, and no migration path — previously nothing in the dependency declaration prevented a future `uv lock` from silently resolving into it. `docs/reference/compatibility.md` updated to describe the real, now-enforced ceiling instead of the prior "known gap" disclosure. `uv.lock`'s currently-resolved versions (`mkdocs==1.6.1`, `mkdocs-material==9.7.7`) are unaffected. +- **`mdx-asset-rationale.md` Renamed to `svg-vs-native-asset-rationale.md`**: + - The page's content has been accurate since an earlier fix this session (a rationale for using native HTML/Mermaid over static SVG for in-page diagrams — no MDX, React, or Docusaurus relevance at all), but its filename and URL still said "mdx," left over from before that rewrite. Renamed to a filename that actually describes the content. `mkdocs.yml`'s nav entry and the one inbound cross-reference (`developers/reference/zenzic-style.md`) updated; `docs/_redirects` gained 2 new lines for the old bare canonical URL and had its 6 existing historical-variant lines (`/docs/...`, `/it/...` prefixes) retargeted to the new URL, rather than left pointing at a now-dead one. `mkdocs build --strict` clean. +- **Ecosystem Positioning Overhaul**: + - Updated tagline and core value proposition across `zenzic`, `zenzic-action`, and `zenzic-vscode` READMEs: *"Formatters handle syntax. Prose linters handle grammar. Zenzic protects the graph—and optionally enforces lightweight editorial policy without a separate tool."*, focusing on protecting documentation graphs from AI hallucinations. +- **Mirror Law Parity Protocol (10 Mandatory Targets)**: + - Formally expanded the Mirror Law protocol to 10 mandatory targets, elevating the `zenzic init` template (`templates.py`) and the VS Code IntelliSense JSON Schema (`zenzic.schema.json`) alongside core codes, scorer, scoring algorithm, scoring system, finding codes encyclopedia, rule cards, mkdocs nav, and lab fixtures. +- **CLI Init Configuration Template Remediation**: + - Synchronized `zenzic init` templates (`templates.py`) across `.zenzic.toml`, `.zenzic.local.toml`, and `pyproject.toml` with all 16 Policy-as-Code fields including the 4 new SDD policies, updated GitHub Action snippets to `pythonwoods/zenzic-action@v2`, purged obsolete `Z120-Z124` polyglot comments, and added topological rule codes (`Z410`, `Z411`, `Z412`, `Z620`) to default `directory_policies` examples. ### Removed -- **Legacy Custom Rule API v2**: Removed deprecated `BaseASTRule` stub from `src/zenzic/rules/base.py` as scheduled in the v0.30.0 debt eradication milestone. All custom rules must use Custom Rule SDK v3 (`zenzic.sdk.ZenzicRuleV3`). +- **`Z504` (`QUALITY_REGRESSION`) Removed — BREAKING**: the code was registered and had a rule card and a reference entry, and no code path could emit it — `zenzic diff` emits no codes. *What you will see:* `--only Z504` is rejected as an invalid finding code, and a suppression naming `Z504`, inline or in `per_file_ignores`, is reported as dead (`Z603`, `Z620`) because there is nothing for it to suppress. *What to do:* remove any reference to `Z504`. +- **`[project_metadata].obsolete_names` Removed — BREAKING**: the field had spent two minor versions as a deprecated alias that the loader copied into `[governance].brand_obsolescence` before every scan. **If your `.zenzic.toml` still sets `[project_metadata].obsolete_names`, move the list to `[governance].brand_obsolescence`** — the syntax is otherwise identical, and `obsolete_names_exclude_patterns` is unaffected and stays where it is (it remains a `[project_metadata]` field). Z601 detection is unchanged for anyone already using the replacement. **A configuration still setting the old key loads without error and contributes nothing to Z601** — the unknown-key warning in `ZenzicConfig._build_from_data` iterates root-level keys only and does not descend into a declared section, so neither this removal nor an ordinary typo inside `[project_metadata]`, `[governance]` or `[policies]` produces a diagnostic. The removal was chosen over carrying the alias a third minor because a `0.x` version promises no stability, and the alias cost was a dual-read spread across the model, the loader, the rule constructor and the tests — the same multi-surface divergence this release spent its time removing. `BrandObsolescenceRule` now takes the name list as an explicit constructor argument rather than reading it off `ProjectMetadata`, so there is one place the list comes from. + +- **Two Orphaned Brand Badge `.license` Sidecars and Their Two Dead Redirects**: `zenzic-badge-audit.svg` and `zenzic-badge-score.svg` were deleted from `docs/assets/brand/svg/` in an earlier documentation prune, but their `.license` sidecars and two `/static/assets/brand/svg/…` redirect rules were left behind — so both URLs issued a `301` into a `404`. Deleting the leftovers was chosen over restoring the SVGs: nothing in the repository references either badge. A full re-audit of `docs/_redirects` confirms zero remaining rules whose `/assets/` target does not exist on disk. + +- **All Blog Imagery Removed — 33 Embeds Across 25 Posts, 48 Asset Files, and 19 Now-Dead `docs/_redirects` Rules**: the blog is text-only pending a new visual approach. `docs/assets/images/blog/` (26 images plus their `.license` sidecars, including a pre-existing orphan `launch_v0270.jpg.license` whose `.jpg` never existed) and `docs/assets/images/terminal/` (6 in-body CLI captures) are deleted in full; `docs/assets/images/` now ships nothing, and `site/assets/images/` contains only `favicon.png`. **Two consequences for external consumers.** First, the 19 `/static/assets/images/blog/*` redirect rules whose targets no longer exist were removed rather than left in place — a 301 into a 404 is worse than a direct 404, since it costs an extra hop and reports as a live link to crawlers and link-checkers. Second, and more consequential: **any external copy of a Zenzic blog post that hotlinks `zenzic.dev/assets/images/blog/…` rather than hosting its own upload will now render a broken image.** Syndicated copies on third-party platforms typically re-host on upload, but this was not verifiable for every copy and should be treated as a live risk when auditing them. No article body depended on an image — the posts carrying real CLI output already carried it as verbatim text blocks alongside the capture, and the only two "shown above" phrases in the corpus were verified to refer to those code blocks, not to the removed figures. The capture tooling (`scripts/generate_blog_headers.sh` and its manifest) is retained as working infrastructure; note that anything it regenerates will correctly trip `Z405` (`ASSET_UNUSED`) until a post actually embeds it. +- **14 Unused Self-Hosted Font Files Removed (Inter, IBM Plex Mono, Barlow Condensed, JetBrains Mono)**: fresh re-verification (`mkdocs.yml`'s `theme.font: false` confirmed `fonts.css`'s manual `@font-face` rules are the site's only font-loading mechanism; a precise quoted-string grep for each family name across every CSS/HTML/Jinja template) confirmed all 14 files were genuinely dead weight — Inter, IBM Plex Mono, and Barlow Condensed had zero references anywhere; JetBrains Mono was named in 3 `font-family` stacks in `extra.css` but never loaded via `@font-face`, so the self-hosted files were never fetched regardless. Only Roboto/Roboto Mono (10 files) are actually wired up and serving. `NOTICE` entries for the 3 removed families deleted, remaining entries renumbered. `zenzic check all --quiet` and `mkdocs build --strict` both clean after removal, confirming no page depended on them. +- **4 Structural Doc Merges — Duplicated Content Consolidated to a Single Source**: + - `docs/how-to/configuration-strategy.md` (near-empty stub) merged into `docs/reference/configuration-reference.md`'s `.zenzic.local.toml` Local Sanctuary section, which gained its one piece of genuinely unique content — the additive-merge semantics of `forbidden_patterns`/`excluded_dirs`, live-verified against `config.py`'s `_apply_local_toml`. Deleted; `mkdocs.yml` nav entry removed; `docs/_redirects` retargeted (4 historical lines + 2 new bare-canonical lines) to `/reference/configuration-reference/#local-sanctuary`; the one inbound link (`configure-privacy-gate.md`) repointed. + - `docs/how-to/workflow-integration.md` merged into `docs/how-to/configure-ci-cd.md` as a new "Local Quality Gate" section (justfile/Makefile/standalone build-gate recipes, the cost-of-discovery table, and pre-launch/staging `ZENZIC_EXTRA_ARGS` handling) — both pages independently carried an Exit Code table/section that had already drifted out of sync once this session. Deleted; nav entry removed; `docs/_redirects` retargeted (4 historical lines + 2 new bare-canonical lines) to `/how-to/configure-ci-cd/#local-quality-gate`; `how-to/index.md`'s redundant "Pre-Commit & Git Hooks" card removed (its description never actually matched this page's real content — it described `configure-ci-cd.md`'s pre-commit-hook tab, which already existed on the merge target). + - `docs/explanation/github-action-internals.md`'s Exit Code Contract table removed in favor of a single-source pointer to `docs/reference/zenzic-action.md#exit-codes` (the canonical, more detailed version) — the two tables had already drifted independently once this session. + - `docs/explanation/core-mechanics.md`'s VSM and Credential Scanner sections trimmed to brief summaries pointing to `docs/explanation/architecture.md`'s more detailed, more current versions (full `Route`/`RouteMetadata` fields, F2-1/F4-1 hardening, DFA guarantee) — kept core-mechanics.md's own unique conceptual framing and its genuinely non-duplicated sections (Circular Links as Knowledge Graphs, Three-Pass Reference Pipeline, Smart Link Graph, Baseline Engine, Global Usage Tracker, Auto-Fix Engine) untouched. + - No code behavior changed — documentation structure and content only. `mkdocs build --strict` and `zenzic check all --quiet` clean after each merge. + +- **`Z000` (`UNSUPPORTED_ENGINE`) Removed — BREAKING**: + - `Z000` is removed entirely from `codes.py`'s `CODE_DEFINITIONS`/`CODE_NAMES`/`CODE_DESCRIPTIONS`/`FROZEN_CODES`, its rule card, `finding-codes.md`, `scoring-algorithm.md`, and mkdocs nav — same precedent as the earlier `Z113`/`Z114` removal (no stub, no deprecated-but-present marker). Full git history search (`git log --all -S'code="Z000"'`, `-S"UnsupportedEngine"`) confirmed `Z000` never had live emission logic at any point in this project's history — the commit that first registered it in `CODE_NAMES`/`CODE_DESCRIPTIONS`/`CODE_SARIF_LEVELS` (`a7aa0ef`, v0.7.1) says explicitly it was "already documented in docstring and finding-codes.mdx" and only completed catalog bookkeeping, never added a check. The scenario its docs described (an unsupported `engine` value in `.zenzic.toml`) has always been — and still is — handled by `Z001` (`CORE_CONFIG_STRUCTURE`) via Pydantic's `Literal[...]` field validation; live-verified unchanged before and after this removal. `docs/reference/engines.md` corrected in the same pass — it wrongly claimed an unsupported engine value emits `Z000`. The Z0xx category (previously "Migration & Compatibility," now containing only `Z001`) is renamed "Configuration Guard" to match its sole remaining member and the label `scoring-algorithm.md` already used for it. `docs/_redirects`: `Z000`'s URLs (historical `/docs/`-prefixed variants and the bare current path) now redirect to `/rules/Z001/` rather than the generic rules index, since `Z001` is the code that actually handles the scenario `Z000`'s docs described. Any external tooling, saved baseline, or custom rule configuration referencing `Z000` by name should be updated — this identifier no longer exists in the registry. +- **`--strict`/`-s` Removed from `zenzic score` and `zenzic diff` — BREAKING**: + - Both commands declared a `--strict`/`-s` option that was parsed but never read again in either function body — both always called the score engine with the config file's `strict` setting, never the CLI flag's value. Confirmed dead by live verification: `score`/`diff` JSON output was byte-identical with and without `--strict`, even against a fixture with a broken external link. Removed rather than wired up, since external HTTP link validation already runs unconditionally on both commands (`check_external=True`, hardcoded) with no opt-out — there was no natural, non-breaking behavior to wire the flag to. `docs/reference/cli.md` corrected in the same pass: it previously (and incorrectly) documented `--strict` on `score`/`diff` as gating external link validation — that was never true. Any script currently passing `--strict`/`-s` to `zenzic score` or `zenzic diff` will now get a CLI usage error (`No such option`). +- **`Z113` (`AUTHOR_KEY_COLLISION`) and `Z114` (`LARGE_PAGINATION_SET`) — BREAKING**: + - Both codes are removed entirely from `codes.py`'s `CODE_DEFINITIONS`/`CODE_NAMES` registry, their rule cards, `finding-codes.md`, `cli.md`, `scoring-algorithm.md`, `scoring-system.md`, and the `zenzic inspect capabilities` "Blog Integrity Guard" scanner listing. Neither code ever had live detection logic since introduction — confirmed by exhaustive grep across the engine (no emission site in `scanner.py`, `validator.py`, `rules.py`, or any adapter) — so `zenzic inspect capabilities` was presenting an always-active scanner that in fact never ran. Removed rather than retained as an unimplemented placeholder, consistent with this project's existing precedent for a fully-superseded code identity (`Z118` → `Z620`): no stub, no deprecated-but-present marker. `docs/rules/Z113.md` and `Z114.md` are deleted; `docs/_redirects` now sends both URLs (and their historical `/docs/`-prefixed crawl variants) to the `rules/` index rather than a specific successor page, since none exists. Any external tooling, saved baseline, or custom rule configuration referencing `Z113`/`Z114` by name should be updated — these identifiers no longer exist in the registry. +- **Documentation Site Pruning — 5 Pages Cut, ADR Records + Release Runbook Moved Out of Nav (Files Preserved)**: + - **Cut entirely** (deleted, nav entries removed, `docs/_redirects` retargeted to the nearest genuine content successor rather than left as dead 301s): `developers/explanation/governance/technical-debt.md` and `lpgp.md` (governance-as-public-doc content, redirects now point to the Governance section index); `developers/explanation/tailwind-mkdocs-bridge.md` (implementation-rationale-as-doc, redirects now point to the Developer Explanation index; its blog companion's dangling "see the full technical specification" pointer removed, the blog post's own body already stands on its own); `reference/brand-system.md` and `brand-kit.md` (redirects now point to the Reference index; 2 now-fully-orphaned badge SVGs, referenced only by the deleted `brand-kit.md`, deleted alongside them — confirmed via repo-wide grep that nothing else references them). `explanation/community-index.md`'s Brand Kit card and `reference/index.md`'s 2 dangling table rows removed in the same pass. + - **Moved out of public nav, files preserved** (versioned reference material, link-checked normally, readable via GitHub): all 20 individual ADR record pages and `developers/how-to/release-governance-protocol.md`. Applied via a new `[governance.directory_policies]` exemption for `Z402` (`ORPHAN_PAGE`) — a 0-DQS-debt "strategic exemption," not a suppression of a real defect, since nav-absence was never a defect for this category. Discovered mid-execution that `Z103` (`ORPHAN_LINK`, incoming-link nav-reachability) also needed the same treatment for every file that legitimately cites one of these pages — extended to the same directory/file set plus `adr-vault/index.md`, `governance/licensing.md`, `how-to/troubleshooting.md`, `developers/index.md`, `reference/cli-architecture.md`, `reference/compatibility.md`, and one blog post. + - Two dangerous factual claims on now-archived ADR pages corrected in the same pass: `adr-020-mirror-law.md` no longer describes a bilingual EN/IT documentation requirement (formally dropped by ADR-022; the real Mirror Law is 10 targets, not a second language); `adr-discovery.md` no longer claims `mkdocs.yml` is "deliberately excluded" from root markers — `find_repo_root()` has always included it and `zensical.toml` as fallback markers alongside `.git`/`.zenzic.toml`. + - `mkdocs.yml` nav labels shortened site-wide for needlessly verbose entries (redundant qualifiers like "(LSP)", "Developer" prefixes already implied by their section, "Index" suffixes on already-indexed subsections) — no URL or slug changed. + - No code behavior changed — documentation structure and content only. +- **`docs/developers/reference/adapter-examples.md` Merged Into the Z-Code Gallery (`docs/tutorials/examples/index.md`)**: + - The page described 5 named example projects (`broken-docs`, `i18n-standard`, `security_lab`, `standalone`, `plugin-scaffold-demo`) — live-confirmed via `ls examples/` that none of them exist anymore; the real fixture tree is entirely per-Z-code (`examples/zNNN-slug/`), already fully covered by the Z-Code Gallery's own "Quick-Run Pattern" (`zenzic lab `) and "Feature-to-Example Matrix." No unique content survived the comparison, so the page was deleted rather than rewritten. `mkdocs.yml`'s nav entry removed; `developers/index.md`'s "Example Projects" card retargeted to the Z-Code Gallery; `docs/_redirects` gained 2 new lines for the bare canonical URL (previously unredirected — the page existed) and had 6 existing historical-variant lines retargeted from the deleted page to `/tutorials/examples/` rather than left pointing at a 404. Whole-file `_redirects` syntax audit re-run (0 malformed lines, 0 duplicate sources) and a full repo-wide grep confirmed no other dangling reference to the deleted page remains. +- **`docs/developers/reference/supply-chain-assurance-profile.md` Cut — Governance-as-Public-Doc Content With a Confirmed Defect, Never Actually Executed From an Earlier Triage**: + - The page instructed running `scripts/enforce-radical-unawareness.sh` — confirmed via `find`/`test -f` that this script does not exist anywhere in the repo — and referenced a `zenzic-doc` repository name that doesn't match the real 4-repo ecosystem (`zenzic`, `zenzic-action`, `zenzic-vscode`, `zenzic-mcp`). This CUT was identified in an earlier triage but never executed pending explicit authorization; now executed. Deleted; `mkdocs.yml` nav entry removed; `developers/index.md`'s intro link and its "Supply-Chain Assurance" card both removed; `release-governance-protocol.md`'s cross-reference to the deleted page removed. `docs/_redirects`: 6 existing historical-variant lines plus 2 new bare-canonical-URL lines all retarget to `/developers/how-to/release-governance-protocol/` — the thematically closest surviving content (release/CI governance), still real and reachable even though itself nav-exempt via `directory_policies`, not a generic fallback. Whole-file `_redirects` syntax audit re-run (0 malformed lines, 0 duplicate sources) and a repo-wide grep confirmed no dangling reference remains. +- **`validate_same_page_anchors` Config Field Removed — Not a Breaking Config Change**: + - Confirmed dead (tracked internally): declared in `models/config.py` with `default=True` and a description claiming it disables same-page anchor validation, but read nowhere in `src/zenzic/core/` — `Z102` same-page anchor validation has always run unconditionally, for every file, regardless of this setting. Removed the field entirely rather than wiring it up, since the current, stricter-than-documented always-on behavior is the intended permanent behavior. Removed from `models/config.py` (the field itself, and its listing in `_validate_no_swallowed_root_keys`'s root-key set), `zenzic config explain`'s field listing (`_config_explain.py`), both `zenzic init` scaffold templates (`templates.py`), the root `.zenzic.toml`'s own commented example line, and all live doc references (`docs/rules/Z102.md`, `docs/reference/index.md`, `docs/reference/checks.md` — whose "Same-page anchor validation" tip previously described the same wrong disabled-by-default behavior, corrected in the same pass — and `docs/reference/configuration-reference.md`'s dedicated field entry and complete-example listing). **Confirmed empirically, not assumed**: `ZenzicConfig`'s `model_config` sets no `extra` policy (Pydantic v2 default, `extra="ignore"`), and `_build_from_data()` already has a generic unrecognized-top-level-key path (`config.py:869-884`) that logs a `".zenzic.toml: unknown key '...' will be ignored."` warning and silently drops the key — live-verified via a new test that a `.zenzic.toml` still setting `validate_same_page_anchors` loads successfully post-removal, with that exact warning, rather than raising a validation error. **Not a breaking change**: no deprecation-warning shim was added on top of that, since the loader's existing generic mechanism already covers this exact case. TDD evidence: 2 new tests in `test_config.py` (`test_validate_same_page_anchors_field_removed_from_model`, `test_validate_same_page_anchors_key_in_toml_is_ignored_with_warning`), both red (`AssertionError`) against the pre-removal code, both green after. +- **`docs/how-to/use-brand-system.md` and `docs/explanation/brand-philosophy.md` Cut — Brand-Identity Content Failing the Same Test That Already Removed `brand-system.md`/`brand-kit.md`**: + - Both pages read in full and classified as internal design-system/marketing material (a CSS-token consumption contract for the site's own components, an "A/B Palette Profile" cosmetic toggle, a lexicon/posture style guide, logo-symbolism prose, a palette-design-rationale essay) — zero operational content a third-party user or contributor would actually need. Real external comparables fetched before deciding: `eslint.org/branding/` and HashiCorp's product-logo brand page are both pure trademark/logo-usage references (naming convention, logo sizing, reference-only hex values) — no posture narrative, no symbolic-meaning essay, no design-token consumption guide; Prometheus, ruff, and ffmpeg have no dedicated brand page at all. Both pages deleted; `mkdocs.yml` nav entries removed; `how-to/index.md`'s "Brand Governance System" card removed (its own description — "Configure brand term dictionaries and eradicate obsolete product naming conventions" — didn't even match the real page content, a further confirmation the card had drifted from reality); `community-index.md`'s "Philosophy" card retargeted from `brand-philosophy.md` to `explanation/why-zenzic.md`, a real page that actually covers Zenzic's design philosophy and direction. `docs/_redirects`: 4 existing historical-variant lines for `use-brand-system` retargeted to `/how-to/add-badges/` — the real, already-existing, complete "add a build/score badge to your README" page, since `use-brand-system.md` never contained any badge-related content to begin with; 4 existing historical-variant lines for `brand-philosophy` retargeted to `/explanation/why-zenzic/`; 2 new bare-canonical-URL lines added for each deleted page. **Second-order consequence found and fixed in the same pass**: `use-brand-system.md`'s own text described its font/logo/favicon link list as existing specifically to keep those real, CSS/template-consumed theme assets out of `Z405` (`UNUSED_ASSET`) — deleting the page surfaced exactly the 30 `Z405` findings its own text predicted, live-confirmed via `zenzic check all --show-info` before the fix. Added `excluded_asset_dirs = ["overrides", "brand", "fonts"]` (preserving the pre-existing `"overrides"` default, which a bare list reassignment would otherwise have silently dropped) and `excluded_assets = ["favicon.ico"]` to the root `.zenzic.toml`, replacing the deleted page's incidental markdown-link-anchoring with the same real exclusion mechanism `configure-social-metadata.md` already documents for the same class of problem. `just check` (98/100, 0 new) and `mkdocs build --strict` both clean after the fix. Full `pytest tests/` suite unaffected (2118 passed). + +### Fixed + +- **The editor's site map kept its link graph across the rebuild.** `IncrementalAnalysisEngine.process_changes` rebuilds the site map and transfers it into the caller's instance with `clear()` + `update()`, which copies mapping items and no attribute of the wrapper. `incoming_links` and `undeclared_sources` were each carried across by hand, added one at a time after the same discovery; `outgoing_links` — the link graph — was the third and was not. Measured driving the real language server: the transferred map held **0** nodes where the rebuilt one held 10, so cycle detection ran over an empty graph on every full sync and `enable_circular_link_check` bought nothing. **No finding was lost**: `Z106` is `info`, and the engine drops `info` before any transport reads it, so neither the editor nor `zenzic-mcp` would have shown a cycle either way. What is fixed is work computed and discarded, and a trap for the day that severity changes. + +- **The file count now includes declared content roots, so adding one is visible.** `content_roots` is how a project declares a tree its `docs_dir` does not name — a Docusaurus `blog/`, its `versioned_docs/`. The trees were scanned and the telemetry line counted `docs_dir` alone, which made the setting unverifiable from the output: the how-to tells you a missed tree "does not appear in the file count", then gives an instruction whose success looks identical to a typo in it. Measured on `facebook/docusaurus` at `b52c2c1`: `94 pages` before declaring three content roots, **`1,196 pages`** after — 94 + 1,067 + 29 + 6, exactly. Locale trees were already counted; content roots are the third mechanism and were not. De-duplicated, because counting a tree twice would tell a reader the run covered more than it opened, which is the worse direction. + +- **A configuration file that will not parse no longer blanks the editor — including its credential findings.** `ZenzicConfig.load()` raises on malformed TOML, which is what `zenzic check` wants: there is nothing to run with. The language server loaded the configuration at **five** sites and let that exception escape at every one. Measured 2026-09-21 on a repository whose `pyproject.toml` was missing a bracket, with an `AKIA` credential in `docs/page.md`: the session published **zero** diagnostics. A security-tier finding, off the screen because of a typo in a file that is not the one being edited. The session now keeps the built-in defaults, reports the credential, and publishes `Z110` on the file that will not parse — so the author knows the run is not the one CI will make. The same holds when the file is broken *while* the editor is open: the hot-reload path is one of the five. + + **The test that was meant to cover this was green throughout.** `TestAConfigErrorDoesNotBlankTheWorkspace` built `IncrementalAnalysisEngine` directly and handed it a default `ZenzicConfig()`, so the configuration error never reached the code that blanks a workspace. It is renamed to what it actually covers, and the workspace claim now drives the server. + +- **The editor no longer goes dark when a declared `zensical` has no `zensical.toml`.** `mkdocs` and `zensical` are config-driven: their adapter raises when its configuration file is absent, which is what `zenzic check` wants — it exits `1` and names the file. The language server calls the same factory through `resolve_container_vocabulary`, *upstream of the analysis engine*, and inherited a failure it has no channel for. Measured on a repository declaring `engine = "zensical"` with no `zensical.toml`: the server answered `initialize` and then published **zero diagnostics** for a file the CLI flags, its only trace one `ZLS Error` line on stderr. The session now degrades to `standalone`, publishes the author's diagnostics, and reports the configuration error as `Z111` against `.zenzic.toml` — the same decision already made for a missing `docs_dir` and a missing route manifest, and the one `_resolve_docs_root` documents as the editor's rule. + + **The engine-level test for the manifest case could not have caught it.** It constructs `IncrementalAnalysisEngine` directly; the exception was raised while the *rule engine* was being built, before any analysis engine existed. The new tests drive the server. + + **The editor's `Z111` was printed twice, and that was older than this change.** `manifest_missing_error` carries the `[Z111]` marker in its message because the CLI shows the raw text and has nowhere else to name the code, and `RuleFinding` prepends the code as well — so the editor read `[Z111] [Z111] engine = ...`, on `HEAD` before any of this. The message keeps its marker for the CLI and the structured consumer drops it. The same diagnostic also carried `ZenzicError`'s context dict, which put an absolute repository path into a message attached to that repository's own configuration file. + +- **Prose inside an HTML block is scanned again when a blank line separates it from the tag.** CommonMark §4.6 ends a type-6 HTML block at the **first blank line**; the engine ended it at the matching close tag, so everything between the two was hidden from the sentence-length rule. Measured on this repository's own documentation: **139 of 141 HTML blocks** have that shape, and the fix makes **3,620 lines across 85 files** visible again with **zero** newly hidden. `pre`, `script`, `style` and `textarea` keep running to their closing tag — §4.6's own exception, because a blank line inside one means nothing. + +- **A tag named in backticks no longer hides the rest of the page.** Writing `` `
` `` in prose — which documentation about HTML does constantly — opened a block that never closed, and everything after it became invisible to `Z511`. Measured at 7 files and 533 lines here, and 3 files and 850 lines on an external corpus. **Present in the published v0.30.0**, verified on that release with its own control: the same document reports one `Z511` without the backticks and none with them. **If your pages document HTML, findings will appear** where nothing was reported. + +- **The block-tag list is now the specification's**: 36 of the 66 names §4.6 gives were absent, including `textarea`, so a `