diff --git a/.bumpversion.toml b/.bumpversion.toml index b47639a..b109fb0 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 [tool.bumpversion] -current_version = "0.10.2" +current_version = "0.10.3" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)((?Pa|b|rc)(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}{pre_l}{pre_n}", diff --git a/.github/ISSUE_TEMPLATE/security_vulnerability.yml b/.github/ISSUE_TEMPLATE/security_vulnerability.yml index f3b9a15..f347f72 100644 --- a/.github/ISSUE_TEMPLATE/security_vulnerability.yml +++ b/.github/ISSUE_TEMPLATE/security_vulnerability.yml @@ -29,7 +29,7 @@ body: attributes: label: Zenzic version description: Output of `zenzic --version` - placeholder: "0.10.2" + placeholder: "0.10.3" validations: required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b7a4274..47eb80c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -56,6 +56,16 @@ must satisfy all that apply. --- +## Enterprise governance compliance + +- [ ] This PR addresses an approved Issue #___ and complies with the **Issue-First Policy**. +- [ ] Every commit in this PR is **cryptographically signed** (GPG/SSH/S/MIME) and shows as "Verified" on GitHub. +- [ ] Every commit has a valid **Developer Certificate of Origin (DCO)** sign-off (`Signed-off-by:` via `git commit -s`). +- [ ] I have verified and can architecturally justify every single line of code proposed in this PR (**No AI Slop**). +- [ ] All commit messages comply with the **Conventional Commits** specification. + +--- + ## Quality gates - [ ] `just verify` passes end-to-end (pre-commit + coverage ≥ 80% + `zenzic check all --strict` self-dogfood). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88fcd5d..af1f1fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,13 +42,13 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install just uses: taiki-e/install-action@ea85faa6acd705ad6d40586db99f1a70b09c2929 # just - name: Setup uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml new file mode 100644 index 0000000..8bbc586 --- /dev/null +++ b/.github/workflows/compliance.yml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2026 PythonWoods +# SPDX-License-Identifier: Apache-2.0 + +name: Zenzic Core Compliance + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + pr-title: + name: Lint PR Title + runs-on: ubuntu-latest + steps: + - name: Validate PR Title + uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + dco: + name: Check DCO + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Verify Signed-off-by + run: | + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + + echo "Checking commits between $BASE_SHA and $HEAD_SHA" + + # Check each commit in the range + git log --no-merges --format='%H' "$BASE_SHA..$HEAD_SHA" | while read -r commit_sha; do + commit_msg=$(git log -1 --format='%B' "$commit_sha") + if ! echo "$commit_msg" | grep -q "^Signed-off-by:"; then + echo "::error::Commit $commit_sha is missing 'Signed-off-by:' sign-off." + exit 1 + fi + done + echo "All commits have valid DCO sign-offs." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a03db1a..2928738 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,12 +19,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - name: Setup uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index d1d1ea7..36aa5d5 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Generate SBOM (Syft — spdx-json) uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 diff --git a/.github/workflows/security-posture.yml b/.github/workflows/security-posture.yml index 9398757..d0de89b 100644 --- a/.github/workflows/security-posture.yml +++ b/.github/workflows/security-posture.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Check for SECURITY.md run: | diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 33ae468..5609923 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -7,7 +7,7 @@ # # repos: # - repo: https://github.com/PythonWoods/zenzic -# rev: v0.10.2 +# rev: v0.10.3 # hooks: # - id: zenzic-verify # quality gate — corrisponde a `just verify` lato zenzic # - id: zenzic-guard # fast staged-file credential scan diff --git a/CHANGELOG.md b/CHANGELOG.md index d43d0bd..5e7e06b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ No changes yet. --- +## [0.10.3] - 2026-06-08 + +### Fixed + +- **Core Engine (AST Parser):** Fixed Z104 false positives by correctly ignoring footnote definitions (e.g., `[^1]:`) in the AST reference builder. +- **Core Engine (AST Parser):** Fixed Z102 false positives by stripping markdown attribute lists (e.g., `{...}`) from headings before slugification and adding native support for explicit block-level and footnote anchors. +- **Core Engine (Snippet Validator):** Fixed Z503 false positives on MkDocs configurations and custom tags by registering PyYAML custom tags (e.g., `!!python/*`) and unregistered custom tags (e.g., `!ENV`) in the `PermissiveSafeLoader`. + +--- + ## [0.10.2] - 2026-06-07 ### Fixed diff --git a/CITATION.cff b/CITATION.cff index ed56b3d..ac19dc1 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -15,8 +15,8 @@ abstract: >- performs deterministic static analysis using a two-pass reference pipeline and a RE2-backed credential scanner, with zero subprocess calls and full SARIF 2.1.0 support for CI/CD integration. -version: 0.10.2 -date-released: 2026-06-07 +version: 0.10.3 +date-released: 2026-06-08 url: "https://zenzic.dev" repository-code: "https://github.com/PythonWoods/zenzic" repository-artifact: "https://pypi.org/project/zenzic/" diff --git a/CONTRIBUTING.it.md b/CONTRIBUTING.it.md index 1ec28fd..1048f00 100644 --- a/CONTRIBUTING.it.md +++ b/CONTRIBUTING.it.md @@ -57,6 +57,18 @@ model. --- +## Policy di Governance Enterprise e Contributo + +Per garantire la sicurezza, l'integrità architetturale e la conformità legale di Zenzic, tutti i contributi devono aderire alle seguenti linee guida di Governance Enterprise: + +1. **Issue-First Policy (Prima le Issue)**: Nessuna Pull Request sarà presa in carico, revisionata o discussa se non preceduta da una Issue corrispondente discussa e approvata dai maintainer. Collega sempre l'Issue approvata nella descrizione della tua PR. +2. **Firma Crittografica Obbligatoria**: Tutti i commit devono essere firmati crittograficamente tramite chiavi GPG, SSH o S/MIME (mostrati come "Verified" su GitHub). I commit non firmati verranno respinti automaticamente dal gate di merge. +3. **Clausola "No AI Slop"**: Applichiamo una policy severa contro il codice generato da intelligenza artificiale non verificato. I contributor devono comprendere appieno, saper spiegare e giustificare dal punto di vista architetturale ogni singola riga di codice proposta nella PR. La proposta di codice non compreso porterà al rifiuto immediato del contributo. +4. **Developer Certificate of Origin (DCO)**: Tutti i commit devono includere la riga `Signed-off-by:` (usando `git commit -s`) per certificare la conformità con la DCO. +5. **Conventional Commits**: I messaggi di commit devono seguire rigorosamente la specifica Conventional Commits (es. `feat: add block anchor support (#123)`). + +--- + ## Prerequisiti | Requisito | Versione | Note | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index becd5dd..ef563d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,18 @@ the live code registry and tier ownership model. --- +## Enterprise Governance & Contribution Policy + +To maintain the security, architectural integrity, and legal compliance of Zenzic, all contributions must adhere to the following Enterprise Governance guidelines: + +1. **Issue-First Policy**: No Pull Request will be reviewed, merged, or discussed unless it is preceded by a corresponding Issue that has been formally discussed and approved by the maintainers. Always link the approved Issue in your PR description. +2. **Mandatory Cryptographic Commit Signatures**: Every commit must be cryptographically signed using GPG, SSH, or S/MIME keypairs (appearing as "Verified" on GitHub). Unsigned commits will be rejected by the merge gates. +3. **No AI Slop Clause**: We enforce a strict policy against unverified AI-generated code. Contributors must fully understand, explain, and architecturally justify every single line of code proposed in a PR. Proposing code that you cannot explain will lead to immediate rejection of the contribution. +4. **Developer Certificate of Origin (DCO)**: All commits must include a `Signed-off-by:` line (using `git commit -s`) to certify compliance with the DCO. +5. **Conventional Commits**: Commit messages must strictly follow the Conventional Commits specification (e.g., `feat: add block anchor support (#123)`). + +--- + ## Prerequisites | Requirement | Version | Notes | diff --git a/RELEASE.md b/RELEASE.md index f6f1eec..d5fde92 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,9 +8,9 @@ | Field | Value | | :------- | :--------- | -| Version | v0.10.2 | +| Version | v0.10.3 | | Codename | Magnetite | -| Date | 2026-06-07 | +| Date | 2026-06-08 | | Status | Stable | ## Release Checklist @@ -21,7 +21,7 @@ Before tagging, every item must be green: - [ ] `zenzic lab all` — all 20 scenarios exit with expected code - [ ] `zenzic score --stamp` committed — badge in README.md and README.it.md reflects current score - [ ] `zenzic check all .` — zero findings in the repo root -- [ ] `pyproject.toml` version matches the tag (`0.10.2`) +- [ ] `pyproject.toml` version matches the tag (`0.10.3`) - [ ] `CITATION.cff` version and date updated - [ ] `CHANGELOG.md` — `[Unreleased]` section moved to the new version heading - [ ] Update SECURITY.md support table (Add new release, demote previous to Critical/EOL). @@ -54,11 +54,11 @@ git checkout main git pull origin main # 3. Tag the main branch and push -git tag v0.10.2 +git tag v0.10.3 git push origin main --tags ``` -- [ ] Create GitHub Release from the tag, using the `## v0.10.2` CHANGELOG section as the release body. +- [ ] Create GitHub Release from the tag, using the `## v0.10.3` CHANGELOG section as the release body. ## Changelog Reference diff --git a/pyproject.toml b/pyproject.toml index a3fed40..8bba29b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "hatchling.build" [project] name = "zenzic" -version = "0.10.2" +version = "0.10.3" description = "Engineering-grade, engine-agnostic static analyzer and credential scanner for Markdown documentation" readme = "README.md" requires-python = ">=3.10" diff --git a/src/zenzic/__init__.py b/src/zenzic/__init__.py index a604986..23e70d4 100644 --- a/src/zenzic/__init__.py +++ b/src/zenzic/__init__.py @@ -2,5 +2,5 @@ # SPDX-License-Identifier: Apache-2.0 """Zenzic — engine-agnostic static analyzer and credential scanner for Markdown documentation.""" -__version__ = "0.10.2" +__version__ = "0.10.3" __version_name__ = "Basalt" # Release codename stored separately from the package version. diff --git a/src/zenzic/cli/_standalone.py b/src/zenzic/cli/_standalone.py index d4494ca..94d2a9e 100644 --- a/src/zenzic/cli/_standalone.py +++ b/src/zenzic/cli/_standalone.py @@ -1270,7 +1270,7 @@ def _scaffold_plugin(repo_root: Path, plugin_name: str, force: bool) -> None: description = "Custom Zenzic plugin rule package" readme = "README.md" requires-python = ">=3.11" -dependencies = ["zenzic>=0.10.2"] +dependencies = ["zenzic>=0.10.3"] [project.entry-points."zenzic.rules"] {project_slug} = "{module_name}.rules:{class_name}" diff --git a/src/zenzic/core/validator.py b/src/zenzic/core/validator.py index 012b51c..f992c40 100644 --- a/src/zenzic/core/validator.py +++ b/src/zenzic/core/validator.py @@ -82,7 +82,18 @@ class _PermissiveSafeLoader(yaml.SafeLoader): """SafeLoader that silently ignores unknown YAML tags (e.g. MkDocs !ENV).""" -_PermissiveSafeLoader.add_multi_constructor("", lambda loader, tag_suffix, node: None) # type: ignore[no-untyped-call] +def _construct_undefined(loader: yaml.SafeLoader, tag_suffix: str, node: yaml.Node) -> Any: + if isinstance(node, yaml.ScalarNode): + return loader.construct_scalar(node) + elif isinstance(node, yaml.SequenceNode): + return loader.construct_sequence(node) + elif isinstance(node, yaml.MappingNode): + return loader.construct_mapping(node) + return None + + +_PermissiveSafeLoader.add_multi_constructor("!", _construct_undefined) # type: ignore[no-untyped-call] +_PermissiveSafeLoader.add_multi_constructor("tag:yaml.org,2002:python/", _construct_undefined) # type: ignore[no-untyped-call] # ─── Regexes ────────────────────────────────────────────────────────────────── @@ -112,6 +123,8 @@ class LinkInfo(NamedTuple): # Matches MkDocs Material explicit anchor attribute: ``{ #custom-id }`` _EXPLICIT_ANCHOR_RE = re.compile(r"\{[^}]*#([\w-]+)[^}]*\}") +_ATTR_LIST_RE = re.compile(r"\s+\{[^}]*\}$") +_FN_DEF_RE = re.compile(r"^ {0,3}\[\^([^\]]+)\]:") # Matches HTML tags to strip from heading text before slugification. _HTML_TAG_RE = re.compile(r"<[^>]+>") @@ -436,10 +449,11 @@ def slug_heading(heading: str) -> str: """ import unicodedata - explicit = _EXPLICIT_ANCHOR_RE.search(heading) + heading_clean = _ATTR_LIST_RE.sub("", heading).strip() + explicit = _EXPLICIT_ANCHOR_RE.search(heading_clean) if explicit: return explicit.group(1).lower() - slug = _HTML_TAG_RE.sub("", heading).strip() + slug = _HTML_TAG_RE.sub("", heading_clean).strip() # Decompose accented characters and drop combining marks so that e.g. # "Integrità" → "integrita" (matching MkDocs toc extension behaviour). # Lowercase AFTER NFKD so that mathematical/styled Unicode codepoints @@ -453,10 +467,10 @@ def slug_heading(heading: str) -> str: def anchors_in_file(content: str) -> set[str]: - """Return anchor slugs for every ATX heading in *content*. + """Return anchor slugs for every ATX heading and custom/footnote anchor in *content*. - Recognises MkDocs Material explicit anchors (``{ #id }``) and strips HTML - tags from heading text before slugification. + Recognises MkDocs Material explicit anchors (``{ #id }``), block-level custom ID + anchors, footnote targets, and strips HTML tags from heading text before slugification. Args: content: Raw markdown content (no I/O). @@ -464,7 +478,31 @@ def anchors_in_file(content: str) -> set[str]: Returns: Set of lowercase anchor slugs, e.g. ``{'introduction', 'quick-start'}``. """ - return {slug_heading(m.group(1)) for m in _HEADING_RE.finditer(content)} + anchors: set[str] = set() + # 1. Extract heading slugs + for m in _HEADING_RE.finditer(content): + anchors.add(slug_heading(m.group(1))) + + # 2. Extract block-level explicit anchors & footnote anchors (skipping code blocks) + in_block = False + for line in content.splitlines(): + stripped = line.strip() + if not in_block: + if stripped.startswith("```") or stripped.startswith("~~~"): + in_block = True + continue + # Search for explicit inline/block anchors { #id } + for m in _EXPLICIT_ANCHOR_RE.finditer(line): + anchors.add(m.group(1).lower()) + # Search for footnote definitions [^label]: + fn_match = _FN_DEF_RE.match(line) + if fn_match: + label = fn_match.group(1).strip() + anchors.add(f"fn:{label}") + else: + if stripped.startswith("```") or stripped.startswith("~~~"): + in_block = False + return anchors # ─── Reference link pure helpers (S4-4) ────────────────────────────────────── @@ -493,7 +531,10 @@ def _build_ref_map(text: str) -> dict[str, str]: continue m = _REF_DEF_RE.match(line) if m: - norm_id = m.group(1).lower().strip() + label = m.group(1) + if label.startswith("^"): + continue + norm_id = label.lower().strip() if norm_id not in ref_map: # first-definition-wins ref_map[norm_id] = m.group(2) else: @@ -1514,7 +1555,7 @@ def check_snippet_content( elif lang in ("yaml", "yml"): try: - list(yaml.safe_load_all(snippet)) + list(yaml.load_all(snippet, Loader=_PermissiveSafeLoader)) except yaml.YAMLError as exc: mark = getattr(exc, "problem_mark", None) offset = (mark.line + 1) if mark is not None else 1 diff --git a/src/zenzic/models/references.py b/src/zenzic/models/references.py index 76496f9..4eb0b88 100644 --- a/src/zenzic/models/references.py +++ b/src/zenzic/models/references.py @@ -59,6 +59,13 @@ class ReferenceMap: used_ids: set[str] = field(default_factory=set) duplicate_ids: set[str] = field(default_factory=set) + def _normalize(self, ref_id: str) -> str: + """Normalize reference ID per CommonMark §4.7: lowercase, stripped, internal whitespace collapsed.""" + if not isinstance(ref_id, str): + return str(ref_id) + # Collapse consecutive internal whitespace (including newlines) to a single space + return " ".join(ref_id.split()).lower() + def add_definition(self, ref_id: str, url: str, line_no: int) -> bool: """Register a reference-link definition (first-wins per CommonMark §4.7). @@ -71,7 +78,7 @@ def add_definition(self, ref_id: str, url: str, line_no: int) -> bool: ``True`` if the definition was accepted (first occurrence), ``False`` if it was a duplicate (already registered, ignored). """ - key = ref_id.lower().strip() + key = self._normalize(ref_id) if key in self.definitions: self.duplicate_ids.add(key) return False # duplicate ignored — first wins (CommonMark §4.7) @@ -87,7 +94,7 @@ def resolve(self, ref_id: str) -> str | None: Returns: The target URL string, or ``None`` if the ID has no definition. """ - key = ref_id.lower().strip() + key = self._normalize(ref_id) if key in self.definitions: self.used_ids.add(key) return self.definitions[key][0] @@ -95,20 +102,20 @@ def resolve(self, ref_id: str) -> str | None: def get_definition_line(self, ref_id: str) -> int | None: """Return the source line number for a definition, or ``None``.""" - key = ref_id.lower().strip() + key = self._normalize(ref_id) entry = self.definitions.get(key) return entry[1] if entry is not None else None def __getitem__(self, ref_id: str) -> str: """Case-insensitive item access — returns URL only. Raises ``KeyError``.""" - key = ref_id.lower().strip() + key = self._normalize(ref_id) return self.definitions[key][0] def __contains__(self, ref_id: object) -> bool: """Case-insensitive membership test.""" if not isinstance(ref_id, str): return False - return ref_id.lower().strip() in self.definitions + return self._normalize(ref_id) in self.definitions @property def orphan_definitions(self) -> set[str]: diff --git a/tests/test_validator.py b/tests/test_validator.py index db4c4a0..572da3e 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -113,6 +113,8 @@ class TestSlugHeading: ("foo-bar", "foo-bar"), (" foo bar ", "foo-bar"), ("API Reference (v2)", "api-reference-v2"), + ('Install with uv { data-toc-label="with uv" }', "install-with-uv"), + ("Caret, Mark & Tilde { #caret-mark-tilde }", "caret-mark-tilde"), ("", ""), ], ) @@ -143,6 +145,19 @@ def test_no_headings(self) -> None: def test_heading_with_special_chars(self) -> None: assert "api-reference-v2" in anchors_in_file("## API Reference (v2)\n") + def test_explicit_anchors_and_footnotes(self) -> None: + content = ( + "# Heading\n" + "This is a paragraph with an anchor { #custom-id }.\n" + " \n" + '{ #feedback style="color: red" }\n' + "[^1]: This is a footnote definition.\n" + "```\n" + "Ignore this { #ignored-inside-code-block }\n" + "```\n" + ) + assert anchors_in_file(content) == {"heading", "custom-id", "feedback", "fn:1"} + # ─── Internal link validation ───────────────────────────────────────────────── @@ -1204,6 +1219,18 @@ def test_validate_snippets_yaml_valid(tmp_path: Path) -> None: assert validate_snippets(docs_root, mgr, config=config) == [] +def test_validate_snippets_yaml_custom_tags(tmp_path: Path) -> None: + docs = tmp_path / "docs" + docs.mkdir() + (docs / "page.md").write_text( + "```yaml\nkey: !ENV [VAR, default]\nanother: !custom {a: b}\n```\n" + ) + config = ZenzicConfig(snippet_min_lines=1) + docs_root = tmp_path / config.docs_dir + mgr = make_mgr(config, repo_root=tmp_path) + assert validate_snippets(docs_root, mgr, config=config) == [] + + def test_validate_snippets_yaml_invalid(tmp_path: Path) -> None: docs = tmp_path / "docs" docs.mkdir() diff --git a/uv.lock b/uv.lock index 9d6b243..58d8304 100644 --- a/uv.lock +++ b/uv.lock @@ -2163,7 +2163,7 @@ wheels = [ [[package]] name = "zenzic" -version = "0.10.2" +version = "0.10.3" source = { editable = "." } dependencies = [ { name = "google-re2" },