Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,9 @@ jobs:
# and use a stable anchor for Windows/macOS.
os: [ubuntu-latest, windows-latest]
python-version: ["3.10", "3.14"]
# include:
# - os: windows-latest
# python-version: "3.12"
# - os: macos-latest
# python-version: "3.12"
exclude:
- os: windows-latest
python-version: "3.14"

steps:
- name: Checkout Repository
Expand Down
23 changes: 10 additions & 13 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
# SPDX-FileCopyrightText: 2026 PythonWoods <dev@pythonwoods.dev>
# SPDX-License-Identifier: Apache-2.0

name: Zenzic Docs CodeQL
name: Zenzic Core CodeQL

on:
push:
branches:
- main
branches: [ main ]
paths:
- 'src/**'
- 'scripts/**'
- 'tests/**'
- '.github/workflows/codeql.yml'
pull_request:
branches:
- main
branches: [ main ]
paths:
- 'src/**'
- 'scripts/**'
- 'tests/**'
- '.github/workflows/codeql.yml'
schedule:
- cron: '24 3 * * 1'
Expand All @@ -34,20 +32,19 @@ jobs:
strategy:
fail-fast: false
matrix:
language:
- javascript-typescript
language: [ python ]

steps:
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@v4

- name: Initialize CodeQL
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}

- name: Autobuild
uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3
uses: github/codeql-action/autobuild@v3

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3
uses: github/codeql-action/analyze@v3
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ Versions follow [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- **Suppression Code Actions (`LSP-FEAT-003`)**: The LSP server now dynamically generates "Suppress this finding" Code Actions for all suppressible diagnostics. Clicking the action automatically injects the correct `<!-- zenzic:ignore:ZXXX -->` comment above the offending line. This feature is strictly disabled for `NON_SUPPRESSIBLE_CODES` (Z2xx Security findings) to enforce the security gate.

### Fixed

- **LSP State Hygiene (`LSP-FIX-015`)**: Eradicated "ghost diagnostics" by ensuring the LSP server explicitly sends an empty diagnostics array (`[]`) to VS Code when a file is deleted, clearing the PROBLEMS panel.
- **Code Action Routing (`LSP-FIX-016`)**: Fixed a routing bug in the LSP server that prevented Quick Fixes for `Z108` (Empty Link Text) and `Z505` (Untagged Code Block) from appearing in the editor.
- **Z603 Parity (`LSP-FIX-015`)**: Ensured HTML comment suppressions (`<!-- zenzic:ignore -->`) are correctly evaluated for "dead" status in the LSP engine.

## [0.26.2] - 2026-07-28

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions docs/editor/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ If you use a custom virtual environment or isolated installation, configure `zen
|---|---|---|---|
| `zenzic.executablePath` | `string` | `"zenzic"` | Absolute path or binary name for the Zenzic executable. |

## Inline Diagnostics & Code Actions

The extension exposes real-time LSP diagnostics directly in the PROBLEMS panel and editor margin.

Zenzic provides automated Quick Fixes for specific structural and content findings (e.g., injecting placeholder text for empty links `Z108`, adding language tags to code blocks `Z505`, and removing dead suppressions `Z603`).

In addition, Zenzic offers automated "Suppress this finding" Code Actions (`<!-- zenzic:ignore:ZXXX -->`) for all suppressible diagnostics. Hovering over a finding allows you to insert an inline suppression directive on the line above with a single click. To enforce security governance, suppression Code Actions are intentionally disabled for Security findings (`Z2xx`), which must be remediated at the source.

## Domain Boundaries & Supported Files

To uphold **Domain-Aware Discovery** and **Radical Unawareness**:
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -595,8 +595,8 @@ Zenzic is read-only by default. Auto-fixing is an explicit, opt-in operation pro

Currently, `zenzic fix` supports auto-fixing:

- **Z108 (EMPTY_LINK_TEXT):** Converts a structural accessibility error into a content debt warning (`Z501`), injecting the `[MISSING LINK LABEL]` keyword. You must subsequently resolve these placeholders.
- **Z121 (MISSING_OR_EMPTY_HREF):** Converts a structural HTML integrity error into an HTML hygiene warning (`Z122`) by injecting `href="#"` (safe self-reference).
- **Z108 (EMPTY_LINK_TEXT):** Injects a placeholder label for empty link text.
- **Z505 (UNTAGGED_CODE_BLOCK):** Injects default `text` language specifier for untagged fenced code blocks.
- **Z603 (DEAD_SUPPRESSION):** Cleanly extracts dead/unused inline suppression comments (`<!-- zenzic:ignore: Zxxx -->`) and `data-zenzic-ignore` HTML attributes without corrupting the surrounding text.

`zenzic clean assets` respects `excluded_assets`, `excluded_dirs`, and
Expand Down
7 changes: 5 additions & 2 deletions docs/reference/finding-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ An HTML `<a>` tag contains unknown or malformed attributes.

### Z121: MISSING_HREF {#z121}

**Severity:** `error` · **Penalty:** −8.0 pts (Structural) · **Exit:** 1 · **Suppressible:** Yes · **Fixable:** Yes · [↗ Gallery](../tutorials/examples/z1xx-links/z121-missing-href.md)
**Severity:** `error` · **Penalty:** −8.0 pts (Structural) · **Exit:** 1 · **Suppressible:** Yes · [↗ Gallery](../tutorials/examples/z1xx-links/z121-missing-href.md)

An HTML `<a>` tag is missing the required `href` attribute.

Expand Down Expand Up @@ -341,6 +341,9 @@ An HTML link has an opaque context or relies on inline scripts for navigation.

## Z2xx — Security (credential scanner)

!!! danger "🔒 NON-SUPPRESSIBLE SECURITY GOVERNANCE"
All `Z2xx` Security findings are strictly **Non-Suppressible** across the Zenzic engine and Language Server Protocol (LSP). `<!-- zenzic:ignore: Z2xx -->` directives are silently rejected, and the editor will **never** offer inline suppression Code Actions for security findings. Security breaches must be resolved at the source.

### Z201: CREDENTIAL_SECRET {#z201}

!!! danger "🔒 INVIOLABLE — Cannot be suppressed | Exit 2 | DQS collapses to 0/100"
Expand Down Expand Up @@ -589,7 +592,7 @@ The Snippet Guard identified a syntax error in a fenced code block marked with a

### Z505: UNTAGGED_CODE_BLOCK {#z505}

**Severity:** `warning` · **Penalty:** −1.0 pt (Content) · **Exit:** 1 · **Suppressible:** Yes · [↗ Gallery](../tutorials/examples/z5xx-content/z505-untagged-code-block.md)
**Severity:** `warning` · **Penalty:** −1.0 pt (Content) · **Exit:** 1 · **Suppressible:** Yes · **Fixable:** Yes · [↗ Gallery](../tutorials/examples/z5xx-content/z505-untagged-code-block.md)

A fenced code block has no language specifier. Syntax highlighters, the Snippet Guard (Z503), and screen readers cannot process it. Some engine-specific metadata (e.g. `` ```python title="file.py" showLineNumbers ``) is fully supported and never flagged.

Expand Down
4 changes: 2 additions & 2 deletions src/zenzic/cli/_fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def fix(
exclusion_mgr = _build_exclusion_manager(config, repo_root, docs_root)
files = list(iter_markdown_sources(search_dir, config, exclusion_mgr))

from zenzic.core.mutator import DeadSuppressionMutation, HtmlMissingHrefMutation
from zenzic.core.mutator import DeadSuppressionMutation, UntaggedCodeBlockMutation
from zenzic.core.scanner import _scan_single_file

modified_count = 0
Expand All @@ -96,7 +96,7 @@ def fix(
mutator = Mutator(
[
EmptyLinkTextMutation(),
HtmlMissingHrefMutation(),
UntaggedCodeBlockMutation(),
DeadSuppressionMutation(dead_lines),
]
)
Expand Down
6 changes: 3 additions & 3 deletions src/zenzic/core/codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ class ZenzicExitCode:
"Z105": CodeDefinition("error", 2.0, "structural"), # ABSOLUTE_PATH
"Z106": CodeDefinition("note", 0.0, None), # CIRCULAR_LINK — informational
"Z107": CodeDefinition("error", 1.0, "structural"), # CIRCULAR_ANCHOR
"Z108": CodeDefinition("error", 1.0, "structural"), # EMPTY_LINK_TEXT
"Z108": CodeDefinition("error", 1.0, "structural", fixable=True), # EMPTY_LINK_TEXT
"Z109": CodeDefinition("error", 3.0, "structural"), # EXTERNAL_LINK_BROKEN
"Z110": CodeDefinition("warning", 1.0, "structural"), # STALE_ALLOWLIST_ENTRY
"Z111": CodeDefinition(
Expand All @@ -203,7 +203,7 @@ class ZenzicExitCode:
# Z120/Z122 are warnings; Z121/Z124 are errors (exit 1); Z123 is informational.
# All Z12x codes are suppressible via data-zenzic-ignore (-1.0 pts DQS each).
"Z120": CodeDefinition("warning", 1.0, "html_hygiene"), # UNKNOWN_HTML_ATTRIBUTE
"Z121": CodeDefinition("error", 1.0, "structural", fixable=True), # MISSING_OR_EMPTY_HREF
"Z121": CodeDefinition("error", 1.0, "structural"), # MISSING_OR_EMPTY_HREF
"Z122": CodeDefinition("warning", 1.0, "html_hygiene"), # JUMP_LINK_DETECTED
"Z123": CodeDefinition("note", 0.0, None), # NON_HTTP_SCHEME — informational
"Z124": CodeDefinition("error", 1.0, "structural"), # OPAQUE_HTML_CONTEXT
Expand Down Expand Up @@ -233,7 +233,7 @@ class ZenzicExitCode:
"Z502": CodeDefinition("warning", 1.0, "content"), # SHORT_CONTENT
"Z503": CodeDefinition("warning", 10.0, "content"), # SNIPPET_ERROR
"Z504": CodeDefinition("warning", 0.0, None), # QUALITY_REGRESSION — governance gate
"Z505": CodeDefinition("warning", 1.0, "content"), # UNTAGGED_CODE_BLOCK
"Z505": CodeDefinition("warning", 1.0, "content", fixable=True), # UNTAGGED_CODE_BLOCK
"Z506": CodeDefinition("error", 5.0, "content"), # MALFORMED_FRONTMATTER
# ── Z6xx — Governance ─────────────────────────────────────────────────────
"Z601": CodeDefinition("warning", 2.0, "brand"), # BRAND_OBSOLESCENCE (escalates exponentially)
Expand Down
127 changes: 61 additions & 66 deletions src/zenzic/core/mutator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@
import copy
from typing import Protocol

from zenzic.core import regex
from zenzic.core.ast import CodeSpanNode, LinkNode, Node, TextNode


_FENCE_OPEN_RE = regex.compile(r"^(?P<fence>[`~]{3,})(?P<info>.*)$")


class Mutation(Protocol):
"""Protocol for AST mutations."""

Expand Down Expand Up @@ -37,15 +41,15 @@ def _has_text_content(node: Node) -> bool:


class EmptyLinkTextMutation:
"""Z108 Auto-Fix: Injects placeholder text into empty links."""
"""Z108 Auto-Fix: Injects placeholder 'TODO' text into empty links."""

def apply(self, node: Node) -> bool:
mutated = False
if isinstance(node, LinkNode):
is_empty = not any(_has_text_content(child) for child in node.children)

if is_empty:
node.children = [TextNode(text="MISSING LINK LABEL")]
node.children = [TextNode(text="TODO")]
mutated = True

for child in node.children:
Expand All @@ -55,6 +59,61 @@ def apply(self, node: Node) -> bool:
return mutated


class UntaggedCodeBlockMutation:
"""Z505 Auto-Fix: Injects 'text' language specifier into untagged fenced code blocks."""

def apply(self, node: Node) -> bool:
from zenzic.core.ast import Document
from zenzic.core.parser import parse, serialize

if isinstance(node, Document):
text = serialize(node)
lines = text.splitlines(keepends=True)
new_lines = []
mutated = False
inside = False
open_char = ""
open_count = 0

for line in lines:
line_clean = line.rstrip("\r\n")
m = _FENCE_OPEN_RE.match(line_clean)
if not inside:
if m:
fence = m.group("fence")
info = m.group("info").strip()
has_tag = bool(info)
inside = True
open_char = fence[0]
open_count = len(fence)
if not has_tag:
rest = line[len(fence) :].lstrip(" \t")
line = f"{fence}text{rest}"
mutated = True
else:
if m:
fence = m.group("fence")
info = m.group("info").strip()
if fence[0] == open_char and len(fence) >= open_count and not info:
inside = False
open_char = ""
open_count = 0

new_lines.append(line)

if mutated:
new_doc = parse("".join(new_lines))
node.children = new_doc.children
return True
return False

mutated = False
for child in node.children:
if self.apply(child):
mutated = True
return mutated


class Mutator:
"""Engine that applies a list of Mutations to an AST."""

Expand All @@ -74,70 +133,6 @@ def mutate(self, ast: Node) -> tuple[Node, bool]:
return new_ast, changed


def fix_missing_or_empty_href(attrs: str, tag: str) -> tuple[str, bool]:
if tag != "a":
return attrs, False
from zenzic.core.validator import _RE_POLY_ATTR

attrs_dict = {}
for m in _RE_POLY_ATTR.finditer(attrs):
key = m.group("key").lower()
val = m.group("val")
if val is not None:
if (val.startswith('"') and val.endswith('"')) or (
val.startswith("'") and val.endswith("'")
):
val = val[1:-1]
attrs_dict[key] = (val, m.start(), m.end())

if "href" not in attrs_dict:
new_attrs = attrs.rstrip() + ' href="#"'
return new_attrs, True

val, start, end = attrs_dict["href"]
if val is None or val.strip() == "":
prefix = attrs[:start]
suffix = attrs[end:]
new_attrs = prefix + 'href="#"' + suffix
return new_attrs, True

return attrs, False


class HtmlMissingHrefMutation:
"""Z121 Auto-Fix: Injects href="#" into missing or empty <a> tag href attributes."""

def apply(self, node: Node) -> bool:
mutated = False
from zenzic.core.ast import TextNode

if isinstance(node, TextNode):
from zenzic.core.validator import _RE_POLY_TAG

text = node.text
new_text = ""
last_idx = 0
for m in _RE_POLY_TAG.finditer(text):
tag = m.group(1).lower()
attrs_str = m.group("attrs")

if tag == "a":
new_attrs, changed = fix_missing_or_empty_href(attrs_str, tag)
if changed:
new_text += text[last_idx : m.start()] + f"<a {new_attrs.strip()}>"
last_idx = m.end()
mutated = True

if mutated:
new_text += text[last_idx:]
node.text = new_text

for child in node.children:
if self.apply(child):
mutated = True
return mutated


class DeadSuppressionMutation:
"""Z603 Auto-Fix: Removes dead inline suppression comments and attributes."""

Expand Down
Loading
Loading