diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c62cae5..71c7dae4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 192cf255..79ef4e47 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,22 +1,20 @@ # SPDX-FileCopyrightText: 2026 PythonWoods # 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' @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ca245cd5..4e6fe679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 (``) are correctly evaluated for "dead" status in the LSP engine. + ## [0.26.2] - 2026-07-28 ### Fixed diff --git a/docs/editor/vscode.md b/docs/editor/vscode.md index 44c0e44c..0c3e4f79 100644 --- a/docs/editor/vscode.md +++ b/docs/editor/vscode.md @@ -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 (``) 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**: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ef883895..c3e631a2 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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 (``) and `data-zenzic-ignore` HTML attributes without corrupting the surrounding text. `zenzic clean assets` respects `excluded_assets`, `excluded_dirs`, and diff --git a/docs/reference/finding-codes.md b/docs/reference/finding-codes.md index d8583620..98071d08 100644 --- a/docs/reference/finding-codes.md +++ b/docs/reference/finding-codes.md @@ -307,7 +307,7 @@ An HTML `` 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 `` tag is missing the required `href` attribute. @@ -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). `` 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" @@ -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. diff --git a/src/zenzic/cli/_fix.py b/src/zenzic/cli/_fix.py index c317eac2..4c4f98ce 100644 --- a/src/zenzic/cli/_fix.py +++ b/src/zenzic/cli/_fix.py @@ -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 @@ -96,7 +96,7 @@ def fix( mutator = Mutator( [ EmptyLinkTextMutation(), - HtmlMissingHrefMutation(), + UntaggedCodeBlockMutation(), DeadSuppressionMutation(dead_lines), ] ) diff --git a/src/zenzic/core/codes.py b/src/zenzic/core/codes.py index 2f1bf0b6..a4b37d37 100644 --- a/src/zenzic/core/codes.py +++ b/src/zenzic/core/codes.py @@ -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( @@ -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 @@ -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) diff --git a/src/zenzic/core/mutator.py b/src/zenzic/core/mutator.py index e17f92cb..b5d42753 100644 --- a/src/zenzic/core/mutator.py +++ b/src/zenzic/core/mutator.py @@ -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[`~]{3,})(?P.*)$") + + class Mutation(Protocol): """Protocol for AST mutations.""" @@ -37,7 +41,7 @@ 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 @@ -45,7 +49,7 @@ def apply(self, node: Node) -> bool: 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: @@ -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.""" @@ -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 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"" - 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.""" diff --git a/src/zenzic/lsp/server.py b/src/zenzic/lsp/server.py index 8228f078..74352c59 100644 --- a/src/zenzic/lsp/server.py +++ b/src/zenzic/lsp/server.py @@ -373,6 +373,23 @@ def _handle_file_changes(self, changes: list[dict[str, Any]]) -> None: elif change_type == 3: # Deleted if self.engine is not None: self.engine.remove_file_cache(file_path) + # State Hygiene (LSP-FIX-015): evict the deleted URI from all + # in-memory caches so it is never re-scheduled for analysis. + self.documents.documents.pop(uri, None) + self.dirty_documents.pop(uri, None) + if self.overlay is not None: + self.overlay.remove(uri) + # LSP contract: an empty diagnostics array clears stale entries + # from the editor's PROBLEMS panel immediately. Without this, + # VS Code retains ghost diagnostics until the next full scan. + self.send_message( + { + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": {"uri": uri, "diagnostics": []}, + } + ) + continue # Deleted files must NOT be re-added to dirty_documents self.dirty_documents[uri] = 0.0 @@ -654,13 +671,13 @@ def _handle_code_action(self, params: dict[str, Any], msg_id: int | str | None) import re - from zenzic.core.codes import CODE_DEFINITIONS + from zenzic.core.codes import CODE_DEFINITIONS, NON_SUPPRESSIBLE_CODES from zenzic.core.mutator import ( DeadSuppressionMutation, EmptyLinkTextMutation, - HtmlMissingHrefMutation, Mutation, Mutator, + UntaggedCodeBlockMutation, ) from zenzic.core.parser import parse, serialize @@ -668,65 +685,84 @@ def _handle_code_action(self, params: dict[str, Any], msg_id: int | str | None) for diag in diagnostics: raw_code = diag.get("code") - code = str(raw_code) if raw_code is not None else "" - if not code and "message" in diag: + diag_code = str(raw_code) if raw_code is not None else "" + if not diag_code and "message" in diag: m = re.search(r"\[(Z\d{3})\]", str(diag["message"])) if m: - code = m.group(1) - - defn = CODE_DEFINITIONS.get(code) - if not defn or not getattr(defn, "fixable", False): - continue - - mutations: list[Mutation] = [] - title_desc = "" - - if code == "Z121": - mutations.append(HtmlMissingHrefMutation()) - title_desc = 'Inject placeholder href="#"' - elif code == "Z603": - line_no = diag.get("range", {}).get("start", {}).get("line", 0) + 1 - mutations.append(DeadSuppressionMutation({line_no})) - title_desc = "Remove dead inline suppression" - elif code == "Z108": - mutations.append(EmptyLinkTextMutation()) - title_desc = "Inject placeholder link text" - else: - continue + diag_code = m.group(1) + + defn = CODE_DEFINITIONS.get(diag_code) + if defn and getattr(defn, "fixable", False): + mutations: list[Mutation] = [] + title = "" + + if diag_code == "Z108": + mutations.append(EmptyLinkTextMutation()) + title = "Fix Z108: Inject placeholder link text ('TODO')" + elif diag_code == "Z505": + mutations.append(UntaggedCodeBlockMutation()) + title = "Fix Z505: Inject language specifier ('text')" + elif diag_code == "Z603": + line_no = diag.get("range", {}).get("start", {}).get("line", 0) + 1 + mutations.append(DeadSuppressionMutation({line_no})) + title = "Fix Z603: Remove dead inline suppression" + + if mutations: + try: + ast = parse(content) + mutator = Mutator(mutations) + new_ast, changed = mutator.mutate(ast) + except Exception: + changed = False + + if changed: + new_content = serialize(new_ast) + lines = content.splitlines(keepends=True) + total_lines = max(0, len(lines) - 1) + last_line_len = len(lines[-1]) if lines else 0 + + full_range = { + "start": {"line": 0, "character": 0}, + "end": {"line": total_lines, "character": last_line_len}, + } - try: - ast = parse(content) - mutator = Mutator(mutations) - new_ast, changed = mutator.mutate(ast) - except Exception: - changed = False - - if changed: - new_content = serialize(new_ast) - lines = content.splitlines(keepends=True) - total_lines = max(0, len(lines) - 1) - last_line_len = len(lines[-1]) if lines else 0 - - full_range = { - "start": {"line": 0, "character": 0}, - "end": {"line": total_lines, "character": last_line_len}, - } + action = { + "title": title, + "kind": "quickfix", + "diagnostics": [diag], + "edit": { + "changes": { + uri: [ + { + "range": full_range, + "newText": new_content, + } + ] + } + }, + } + code_actions.append(action) - action = { - "title": f"Fix {code}: {title_desc}", + if diag_code and diag_code not in NON_SUPPRESSIBLE_CODES: + insert_line = max(0, diag.get("range", {}).get("start", {}).get("line", 0)) + suppress_action = { + "title": f"Suppress {diag_code} for this line", "kind": "quickfix", "diagnostics": [diag], "edit": { "changes": { uri: [ { - "range": full_range, - "newText": new_content, + "range": { + "start": {"line": insert_line, "character": 0}, + "end": {"line": insert_line, "character": 0}, + }, + "newText": f"\n", } ] } }, } - code_actions.append(action) + code_actions.append(suppress_action) self.send_response(msg_id, result=code_actions) diff --git a/tests/test_custom_rules.py b/tests/test_custom_rules.py index 57b46756..8ac02a57 100644 --- a/tests/test_custom_rules.py +++ b/tests/test_custom_rules.py @@ -177,24 +177,31 @@ def visit_html_node(self, node, file_path): assert "AWESOME-101" in rule_ids -def test_autofix_z121_and_z603(tmp_path: Path) -> None: - """Test autofixes for missing/empty href (Z121) and dead suppression (Z603).""" - from zenzic.core.mutator import DeadSuppressionMutation, HtmlMissingHrefMutation, Mutator +def test_autofix_z505_z108_z603(tmp_path: Path) -> None: + """Test autofixes for untagged code blocks (Z505), empty link text (Z108), and dead suppression (Z603).""" + from zenzic.core.mutator import ( + DeadSuppressionMutation, + EmptyLinkTextMutation, + Mutator, + UntaggedCodeBlockMutation, + ) from zenzic.core.parser import parse, serialize - # 1. Z121 Auto-Fix tests - z121_inputs = [ - 'test', - 'test', - 'test', - ] - mutator_z121 = Mutator([HtmlMissingHrefMutation()]) - for inp in z121_inputs: - ast = parse(inp) - new_ast, changed = mutator_z121.mutate(ast) - assert changed - res = serialize(new_ast) - assert 'href="#"' in res + # 1. Z505 Auto-Fix tests + z505_input = "```\nprint('hello')\n```\n" + mutator_z505 = Mutator([UntaggedCodeBlockMutation()]) + ast505 = parse(z505_input) + new_ast505, changed505 = mutator_z505.mutate(ast505) + assert changed505 + assert serialize(new_ast505) == "```text\nprint('hello')\n```\n" + + # 2. Z108 Auto-Fix tests + z108_input = "[](https://example.com)\n" + mutator_z108 = Mutator([EmptyLinkTextMutation()]) + ast108 = parse(z108_input) + new_ast108, changed108 = mutator_z108.mutate(ast108) + assert changed108 + assert serialize(new_ast108) == "[TODO](https://example.com)\n" # 2. Z603 Auto-Fix tests (Dead suppression) text_with_dead = ( diff --git a/tests/test_fix.py b/tests/test_fix.py index 2b3afac9..8fa3df44 100644 --- a/tests/test_fix.py +++ b/tests/test_fix.py @@ -79,7 +79,7 @@ def test_formatted_empty_link_validation_and_mutation() -> None: assert changed, f"Expected mutator to change: {text}" serialized = serialize(new_ast) - assert serialized == "[MISSING LINK LABEL](url)", f"Got: {serialized}" + assert serialized == "[TODO](url)", f"Got: {serialized}" def test_polyglot_extractor_comment_masking() -> None: diff --git a/tests/test_lsp.py b/tests/test_lsp.py index a4d736e3..22432a03 100644 --- a/tests/test_lsp.py +++ b/tests/test_lsp.py @@ -748,14 +748,14 @@ def test_lsp_drops_out_of_bounds_markdown_did_open(tmp_path) -> None: assert in_uri in server.dirty_documents -def test_lsp_code_action_z121(tmp_path) -> None: - """Verify textDocument/codeAction returns valid CodeAction WorkspaceEdit for Z121 fix.""" +def test_lsp_code_action_z505(tmp_path) -> None: + """Verify textDocument/codeAction returns valid CodeAction WorkspaceEdit for Z505 fix.""" server = LanguageServer() out_stream = io.BytesIO() server.stdout = out_stream doc_uri = (tmp_path / "docs" / "index.md").as_uri() - doc_text = "Link without href\n" + doc_text = "```\ncode\n```\n" # Open document server.handle_message( @@ -769,7 +769,7 @@ def test_lsp_code_action_z121(tmp_path) -> None: out_stream.seek(0) out_stream.truncate(0) - # Request code action for Z121 diagnostic + # Request code action for Z505 diagnostic server.handle_message( { "jsonrpc": "2.0", @@ -779,18 +779,18 @@ def test_lsp_code_action_z121(tmp_path) -> None: "textDocument": {"uri": doc_uri}, "range": { "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 24}, + "end": {"line": 0, "character": 3}, }, "context": { "diagnostics": [ { "range": { "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 24}, + "end": {"line": 0, "character": 3}, }, - "code": "Z121", + "code": "Z505", "source": "Zenzic", - "message": "[Z121] Missing or empty href attribute", + "message": "[Z505] Fenced code block has no language specifier", } ] }, @@ -806,18 +806,86 @@ def test_lsp_code_action_z121(tmp_path) -> None: assert response["id"] == 100 actions = response["result"] - assert len(actions) == 1 - action = actions[0] - assert action["title"] == 'Fix Z121: Inject placeholder href="#"' + assert len(actions) == 2 + action = [a for a in actions if a["title"].startswith("Fix Z505")][0] + assert action["title"] == "Fix Z505: Inject language specifier ('text')" assert action["kind"] == "quickfix" assert doc_uri in action["edit"]["changes"] edits = action["edit"]["changes"][doc_uri] assert len(edits) == 1 - assert '' in edits[0]["newText"] + assert "```text" in edits[0]["newText"] + + +def test_lsp_code_action_z108(tmp_path) -> None: + """Verify textDocument/codeAction returns valid CodeAction WorkspaceEdit for Z108 fix.""" + server = LanguageServer() + out_stream = io.BytesIO() + server.stdout = out_stream + + doc_uri = (tmp_path / "docs" / "index.md").as_uri() + doc_text = "[](https://example.com)\n" + + # Open document + server.handle_message( + { + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": {"textDocument": {"uri": doc_uri, "text": doc_text}}, + } + ) + + out_stream.seek(0) + out_stream.truncate(0) + + # Request code action for Z108 diagnostic + server.handle_message( + { + "jsonrpc": "2.0", + "id": 101, + "method": "textDocument/codeAction", + "params": { + "textDocument": {"uri": doc_uri}, + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 23}, + }, + "context": { + "diagnostics": [ + { + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 23}, + }, + "code": "Z108", + "source": "Zenzic", + "message": "[Z108] Link text is empty or contains only whitespace", + } + ] + }, + }, + } + ) + + out_stream.seek(0) + raw_output = out_stream.read().decode("utf-8") + assert "Content-Length:" in raw_output + body_str = raw_output.split("\r\n\r\n")[1] + response = json.loads(body_str) + + assert response["id"] == 101 + actions = response["result"] + assert len(actions) == 2 + action = [a for a in actions if a["title"].startswith("Fix Z108")][0] + assert action["title"] == "Fix Z108: Inject placeholder link text ('TODO')" + assert action["kind"] == "quickfix" + assert doc_uri in action["edit"]["changes"] + edits = action["edit"]["changes"][doc_uri] + assert len(edits) == 1 + assert "[TODO](https://example.com)" in edits[0]["newText"] def test_lsp_code_action_unfixable(tmp_path) -> None: - """Verify textDocument/codeAction returns empty list for unfixable diagnostics.""" + """Verify textDocument/codeAction returns empty list for unfixable & non-suppressible diagnostics.""" server = LanguageServer() out_stream = io.BytesIO() server.stdout = out_stream @@ -834,7 +902,7 @@ def test_lsp_code_action_unfixable(tmp_path) -> None: out_stream.seek(0) out_stream.truncate(0) - # Z120 is unfixable (fixable=False) + # Z201 is non-suppressible and has no quick fix server.handle_message( { "jsonrpc": "2.0", @@ -850,9 +918,9 @@ def test_lsp_code_action_unfixable(tmp_path) -> None: "start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 9}, }, - "code": "Z120", + "code": "Z201", "source": "Zenzic", - "message": "[Z120] Relative link error", + "message": "[Z201] Security breach", } ] }, @@ -1305,3 +1373,264 @@ def test_lsp_adapter_watched_config_files_hot_reload(tmp_path) -> None: diags_after = results_after.get(index_uri, []) z103_after = [d for d in diags_after if d.code == "Z103"] assert len(z103_after) == 0, "Z103 should be cleared after hot-reloading mkdocs.yml nav" + + +def test_file_deletion_clears_ghost_diagnostics(tmp_path: "Path") -> None: # noqa: F821 + """LSP-FIX-015 Fix 1 — Deleting a file must clear its diagnostics from the PROBLEMS panel. + + When a file is deleted (workspace/didChangeWatchedFiles type=3), the LSP must + send a ``textDocument/publishDiagnostics`` with an empty diagnostics array ``[]`` + so that VS Code clears stale (ghost) entries from the PROBLEMS panel immediately. + """ + + docs = tmp_path / "docs" + docs.mkdir() + # Create a file with a Z107 circular anchor so there will be prior diagnostics + doc_path = docs / "ghost.md" + doc_path.write_text("[self](#self)\n", encoding="utf-8") + (tmp_path / "mkdocs.yml").write_text( + f"site_name: T\ndocs_dir: {docs}\nnav:\n - Page: ghost.md\n", + encoding="utf-8", + ) + + def _encode(msg: dict) -> bytes: + body = json.dumps(msg, separators=(",", ":")).encode("utf-8") + return f"Content-Length: {len(body)}\r\n\r\n".encode() + body + + def _parse_frames(raw: bytes) -> list[dict]: + """Parse all Content-Length-framed JSON-RPC messages from a byte stream.""" + msgs = [] + offset = 0 + while offset < len(raw): + # Find the double CRLF that terminates the header block + header_end = raw.find(b"\r\n\r\n", offset) + if header_end == -1: + break + header = raw[offset:header_end].decode("ascii", errors="ignore") + content_length = 0 + for line in header.splitlines(): + if line.lower().startswith("content-length:"): + content_length = int(line.split(":", 1)[1].strip()) + break + body_start = header_end + 4 + body_end = body_start + content_length + if body_end > len(raw): + break + try: + msgs.append(json.loads(raw[body_start:body_end])) + except json.JSONDecodeError: + pass + offset = body_end + return msgs + + doc_uri = doc_path.as_uri() + root_uri = tmp_path.as_uri() + + in_stream = io.BytesIO() + # 1. initialize — establishes the repo root and config + in_stream.write( + _encode( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"rootUri": root_uri, "capabilities": {}}, + } + ) + ) + # 2. initialized — triggers _build_vsm_sync() so self.vsm is available + in_stream.write(_encode({"jsonrpc": "2.0", "method": "initialized", "params": {}})) + # 3. didOpen — opens the file so its diagnostics are emitted + in_stream.write( + _encode( + { + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": {"textDocument": {"uri": doc_uri, "text": "[self](#self)\n"}}, + } + ) + ) + # 4. didChangeWatchedFiles type=3 — simulates file deletion + in_stream.write( + _encode( + { + "jsonrpc": "2.0", + "method": "workspace/didChangeWatchedFiles", + "params": {"changes": [{"uri": doc_uri, "type": 3}]}, + } + ) + ) + in_stream.write(_encode({"jsonrpc": "2.0", "method": "exit", "params": {}})) + in_stream.seek(0) + + out_stream = io.BytesIO() + server = LanguageServer(stdin=in_stream, stdout=out_stream) + server.serve() + + out_stream.seek(0) + raw = out_stream.read() + frames = _parse_frames(raw) + + empty_diags_found = any( + frame.get("method") == "textDocument/publishDiagnostics" + and frame.get("params", {}).get("uri") == doc_uri + and frame.get("params", {}).get("diagnostics") == [] + for frame in frames + ) + + assert empty_diags_found, ( + "Expected a textDocument/publishDiagnostics with diagnostics=[] " + "after the file was deleted, but none was found. " + "Ghost diagnostics will remain in the VS Code PROBLEMS panel.\n" + f"Frames received: {[f.get('method') for f in frames]}" + ) + + +def test_lsp_code_action_suppression(tmp_path) -> None: + """Verify textDocument/codeAction generates Inline Suppression CodeActions (LSP-FEAT-003). + + 1. Suppressible code (Z101): returns 'Suppress Z101 for this line'. + 2. Non-suppressible code (Z201): returns no suppression action. + 3. Fixable + Suppressible code (Z108): returns both Quick Fix and Suppression action. + """ + server = LanguageServer() + out_stream = io.BytesIO() + server.stdout = out_stream + + doc_uri = (tmp_path / "docs" / "index.md").as_uri() + doc_text = ( + "[](https://example.com)\n[Broken link](missing.md)\nAWS_SECRET_KEY=AKIAIOSFODNN7EXAMPLE\n" + ) + + # Open document + server.handle_message( + { + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": {"textDocument": {"uri": doc_uri, "text": doc_text}}, + } + ) + + # 1. Test Z101 (Suppressible only) + out_stream.seek(0) + out_stream.truncate(0) + + server.handle_message( + { + "jsonrpc": "2.0", + "id": 201, + "method": "textDocument/codeAction", + "params": { + "textDocument": {"uri": doc_uri}, + "range": { + "start": {"line": 1, "character": 0}, + "end": {"line": 1, "character": 24}, + }, + "context": { + "diagnostics": [ + { + "range": { + "start": {"line": 1, "character": 0}, + "end": {"line": 1, "character": 24}, + }, + "code": "Z101", + "source": "Zenzic", + "message": "[Z101] Target file missing.md does not exist", + } + ] + }, + }, + } + ) + + out_stream.seek(0) + raw = out_stream.read().decode("utf-8") + resp = json.loads(raw.split("\r\n\r\n")[1]) + actions = resp["result"] + assert len(actions) == 1 + assert actions[0]["title"] == "Suppress Z101 for this line" + assert actions[0]["kind"] == "quickfix" + edit = actions[0]["edit"]["changes"][doc_uri][0] + assert edit["newText"] == "\n" + assert edit["range"] == { + "start": {"line": 1, "character": 0}, + "end": {"line": 1, "character": 0}, + } + + # 2. Test Z201 (Non-suppressible security gate) + out_stream.seek(0) + out_stream.truncate(0) + + server.handle_message( + { + "jsonrpc": "2.0", + "id": 202, + "method": "textDocument/codeAction", + "params": { + "textDocument": {"uri": doc_uri}, + "range": { + "start": {"line": 2, "character": 0}, + "end": {"line": 2, "character": 40}, + }, + "context": { + "diagnostics": [ + { + "range": { + "start": {"line": 2, "character": 0}, + "end": {"line": 2, "character": 40}, + }, + "code": "Z201", + "source": "Zenzic", + "message": "[Z201] Hardcoded credential secret detected", + } + ] + }, + }, + } + ) + + out_stream.seek(0) + raw = out_stream.read().decode("utf-8") + resp = json.loads(raw.split("\r\n\r\n")[1]) + assert resp["result"] == [], "Z201 Security findings must NOT offer suppression Code Actions" + + # 3. Test Z108 (Fixable + Suppressible) + out_stream.seek(0) + out_stream.truncate(0) + + server.handle_message( + { + "jsonrpc": "2.0", + "id": 203, + "method": "textDocument/codeAction", + "params": { + "textDocument": {"uri": doc_uri}, + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 23}, + }, + "context": { + "diagnostics": [ + { + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 23}, + }, + "code": "Z108", + "source": "Zenzic", + "message": "[Z108] Link text is empty", + } + ] + }, + }, + } + ) + + out_stream.seek(0) + raw = out_stream.read().decode("utf-8") + resp = json.loads(raw.split("\r\n\r\n")[1]) + actions = resp["result"] + assert len(actions) == 2 + titles = [a["title"] for a in actions] + assert "Fix Z108: Inject placeholder link text ('TODO')" in titles + assert "Suppress Z108 for this line" in titles diff --git a/tests/test_redteam_remediation.py b/tests/test_redteam_remediation.py index fbeaf17e..e2bc3a3a 100644 --- a/tests/test_redteam_remediation.py +++ b/tests/test_redteam_remediation.py @@ -670,3 +670,44 @@ def test_total_length_preserved(self) -> None: raw = "A" * n result = _obfuscate_secret(raw) assert len(result) == n, f"len mismatch for n={n}: {result!r}" + + +# ─── CORE-FEAT-001: Remediation Expansion Verification ─────────────────────── + + +class TestRemediationExpansion: + """CORE-FEAT-001: Verification of Atomic Mutator refinement and remediation expansion.""" + + def test_z121_is_not_fixable(self) -> None: + """Z121 (missing href) requires human context and must NOT be marked fixable.""" + from zenzic.core.codes import CODE_DEFINITIONS + + assert not CODE_DEFINITIONS["Z121"].fixable + + def test_z505_is_fixable_and_injects_text(self) -> None: + """Z505 (untagged code block) is fixable and injects 'text' specifier.""" + from zenzic.core.codes import CODE_DEFINITIONS + from zenzic.core.mutator import Mutator, UntaggedCodeBlockMutation + from zenzic.core.parser import parse, serialize + + assert CODE_DEFINITIONS["Z505"].fixable + + untagged = "```\ndef foo():\n pass\n```\n" + ast = parse(untagged) + new_ast, changed = Mutator([UntaggedCodeBlockMutation()]).mutate(ast) + assert changed + assert serialize(new_ast) == "```text\ndef foo():\n pass\n```\n" + + def test_z108_is_fixable_and_injects_todo(self) -> None: + """Z108 (empty link text) is fixable and injects 'TODO' placeholder.""" + from zenzic.core.codes import CODE_DEFINITIONS + from zenzic.core.mutator import EmptyLinkTextMutation, Mutator + from zenzic.core.parser import parse, serialize + + assert CODE_DEFINITIONS["Z108"].fixable + + empty_link = "[](https://zenzic.dev)\n" + ast = parse(empty_link) + new_ast, changed = Mutator([EmptyLinkTextMutation()]).mutate(ast) + assert changed + assert serialize(new_ast) == "[TODO](https://zenzic.dev)\n"