Skip to content

feat: v0.31.0 Specification-Driven Development, Security Hardening & Ecosystem Governance - #233

Open
PythonWoods-Dev wants to merge 594 commits into
mainfrom
feat/v0.31.0-epic3-sdd
Open

PythonWoods-Dev wants to merge 594 commits into
mainfrom
feat/v0.31.0-epic3-sdd

Conversation

@PythonWoods-Dev

@PythonWoods-Dev PythonWoods-Dev commented Aug 23, 2026 •

Copy link
Copy Markdown
Owner

⚠️ Action required before upgrading

A documentation corpus that passes on the current release may fail after this one, and the
new failures cannot be suppressed. Anyone gating CI on the exit code should scan with this
build before rolling it out.

The security tier (Z202 / Z203 / Z205) previously read text that had already been
masked for the quality checks. Masking answers a quality question — is this text
content?
— so that a link inside a comment, a math span or a code fence is not reported as
broken. The security tier asks a different question — does this document contain a
forbidden scheme or a traversal?
— and for that question the whole document is in scope.

The consequence needed no MDX and no intent:

Cost is $5 - [c](javascript:alert(1)) - or $10.   ->  exited 0
[c](javascript:alert(1))                          ->  exited 2

Two dollar signs on one line silenced a code that is declared non-suppressible. The same
held for HTML and MDX comments and for everything following an unterminated code fence, and
it silenced path traversal as well as forbidden schemes.

What changes for you. Comments, inline math spans and unterminated-fence tails are now
in security scope. Closed, well-formed code fences are not — that exception is
deliberate, and it is also your remedy: if a page legitimately shows a forbidden scheme or a
traversal as an example, putting it inside a closed code fence keeps it out of security
scope. Fenced content is inert by construction, which is why the line is drawn there. See
docs/explanation/discovery.md ("Two Masks, Two Questions") for the rule and its accepted
residual risk.

A second behaviour change lands in the same release, and it also needs a scan before
rollout.
A site-absolute link whose first path segment names an OS system directory now
reaches Z203 — exit 3, non-suppressible — unless it is declared. The fourteen names are
bin, boot, dev, etc, proc, programdata, root, sbin, sys, system32,
usr, var, windows, winnt.

A repository with a real docs/etc/ section, linked as /etc/install, now needs:

# 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 change rather
than a side effect of it.
Three call sites previously resolved the path and downgraded the
finding when a file happened to be there, so creating docs/etc/passwd with any contents
turned every /etc/passwd link in the corpus from exit 3 into an exit 1 that --exit-zero
then covered — a file in the repository acting as a suppression mechanism for a code
declared non-suppressible. Relative links keep their zero-config behaviour: they are decided
by arithmetic against the repository root, with no filesystem call.

Both changes surface in one command, with file, line and cause:

zenzic check all

A second gate is fixed in the same commit. zenzic guard scan --staged returned exit
0 when it could not query the git index at all — a corrupt index, a non-repository working
directory, or a git missing from PATH — with output byte-identical to a clean run. A
commit carrying live secrets passed the pre-commit gate. A failed query now exits 1
(inconclusive), distinct from clean (0) and findings (2).


Summary

This PR has grown substantially past its original Epic 3 scope; this description reflects
its real, current diff.

Specification-Driven Development (Epic 3)

  • Native GFM Table AST parsing plus four new rules: Z521 (required table columns), Z522
    (table cell enum whitelisting), Z523 (heading order), Z412 (cross-namespace graph
    traceability) — with matching Policy-as-Code config surfaces (required_table_columns,
    table_cell_enums, required_heading_order, traceability_targets).
  • zenzic score --trend (append-only .zenzic-history.jsonl), zenzic doctor +
    zenzic adr new on a new [doctor] config surface, zenzic fix --rename OLD NEW, and a
    fixable field surfaced in json/sarif output and inspect codes.

Dual-track distribution & ADR-089

  • Formalized the three-track distribution hierarchy (pre-commit / project dependency /
    global-ephemeral) and extended ADR-089 immutable SHA pinning as an ecosystem-wide mandate
    across zenzic, zenzic-action, and zenzic-vscode.

Security hardening

  • Unified Z2xx severity and exit-code derivation behind one authority (code_severity(),
    _ALWAYS_EVALUATED_CODES), closing hardcoded-severity bugs across _check.py, rules.py,
    incremental.py, scanner.py, governance.py, suppressions.py and content.py — now
    guarded by a single comprehensive AST structural test superseding nine narrower ones.
  • Closed five real bypasses of the non-suppressible security tier: --only could silence
    Z2xx findings entirely; excluded_dirs/excluded_file_patterns silenced
    Z202/Z203/Z205 (only the credential half of the tier was covered); a leading ../ on
    an absolute href defeated traversal detection entirely; a relative ../ hop to a real repo
    file named like a system root misfired non-suppressible Z203 instead of Z202; and
    excluded_external_urls prefix matching was vulnerable to host spoofing (CWE-20).
  • zenzic audit now routes through the same exit-code authority as check and guard, so a
    credential breach can no longer report exit 1 there while check all correctly exits 2.
  • The security tier no longer inherits the quality tier's masking (see the banner above).
    Fixed at the single seam where all three codes are produced rather than at the fifteen
    extract* call sites. Verified by execution: four bypasses 0 → 2, traversal-via-math
    0 → 1, closed fence stays 0, and a commented-out broken link stays 0 so the quality
    tier is unmoved.
  • zenzic guard scan --staged no longer fails open. _staged_doc_files returned an empty
    list both when nothing was staged and when git could not be queried, so the caller could not
    tell them apart. It now returns (files, queried), mirroring _scan_file_for_secrets one
    function above it. Two existing tests asserted result == [] on both failure branches,
    pinning the defect as correct; they now assert the failure is distinguishable, with a
    positive control on the success path.
  • Plugin rule loading: _validate_plugin_code was checking attributes BaseRule never
    defines and was a complete no-op — it now validates the real rule_id, with
    core-distributed entry points correctly exempted from the third-party namespace check.

LSP hardening & capabilities

  • New opt-in capabilities: auto-fix on save (textDocument/willSaveWaitUntil), auto-repair of
    inbound links on rename (workspace/willRenameFiles), and hover-based suppression
    explanations.
  • Fixed four server-side robustness gaps: a config-file-change shortcut matching by filename
    alone with no location check; a redundant ghost-diagnostic-clearing pass that could erase a
    live security finding; a didChange full-sync message missing "text" silently blanking
    the buffer; and a crashed code-action mutation producing no signal.
  • One shared security-scan implementation now serves both the CLI and LSP paths.

Documentation

  • Full-corpus remediation across all four Diátaxis quadrants (reference/, how-to/,
    explanation/, developers/) plus the landing page and the Z-Code Gallery — hundreds of
    factual corrections (stale exit-code claims, fabricated examples, wrong Zensical/MkDocs
    capability claims, dead code references) verified against live execution rather than
    restated from memory.
  • Seven Foundations-series articles drafted, plus two launch articles (SDD, and deterministic
    tooling / pre-commit distribution). All remain draft: true pending a tagged release.

CI & tooling

  • Coverage measurement wired into just verify and made hard-blocking (fail_under=80).
  • SHA-pinned CI actions, integrity-manifest tooling for the internal workspace, and a
    git-hook-installation gate so an uninstalled pre-commit hook cannot silently pass a whole
    session.

Test plan

  • pytest tests/ — full suite passing
  • ruff check / ruff format --check / mypy — clean
  • zenzic check all --strict --no-header — DQS 98/100, gate passed
  • zenzic lab all — all gallery scenarios meet expectations
  • mkdocs build --strict — clean build
  • just verify — clean (pre-commit, pip-audit, coverage-enforced pytest, structural
    audit, docs build)

@PythonWoods-Dev PythonWoods-Dev changed the title v0.31.0: Specification-Driven Development, Dual-Track Distribution & Z205 Contract Fix feat: v0.31.0 Specification-Driven Development, Dual-Track Distribution & Z205 Contract Fix Aug 23, 2026
Comment thread tests/test_command_setup.py Dismissed
@PythonWoods-Dev PythonWoods-Dev changed the title feat: v0.31.0 Specification-Driven Development, Dual-Track Distribution & Z205 Contract Fix feat: v0.31.0 Specification-Driven Development, Security Hardening & Ecosystem Governance Sep 5, 2026
@PythonWoods-Dev
PythonWoods-Dev force-pushed the feat/v0.31.0-epic3-sdd branch 2 times, most recently from 318b228 to 65305b9 Compare September 6, 2026 09:09
PythonWoods-Dev and others added 22 commits September 10, 2026 15:01
Two Tier-0 gates reported clean without having looked.

Masking answers a quality question -- "is this text content?" -- so that a
link inside a comment, a math span or a 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?" For that
question the whole document is in scope.

The consequence needed no MDX and no intent: `Cost is $5 - [c](javascript:...)
- or $10.` exited 0, while the same line without the two dollar signs exited 2.
Comments, unterminated fences and math spans all silenced Z202/Z203/Z205 --
codes declared non-suppressible precisely so no document can silence them.

The security tier now extracts through its own pass, which masks only closed,
well-formed fences. That exception is deliberate and documented as an accepted
residual risk: a closed fence is an explicit declaration that the content is an
exhibit, fenced text never renders as a clickable anchor, and unmasking it
would make Zenzic's own Z203/Z205 rule pages unfixable. Wired at the single
seam where all three codes are produced rather than at the 15 extract call
sites.

`zenzic guard scan --staged` had the same shape: _staged_doc_files returned an
empty list both when nothing was staged and when git could not be queried at
all, so a corrupt index passed a commit carrying live secrets with output
byte-identical to a clean run. It now returns (files, queried), mirroring
_scan_file_for_secrets one function above it, and a failed query exits 1
(inconclusive) rather than 0. Two tests asserted `result == []` on both failure
branches, pinning the defect as correct; they now assert the failure is
distinguishable, with a positive control on the success path.

Verified by execution: four bypasses 0 -> 2, traversal-via-math 0 -> 1, closed
fence stays 0, commented-out dead link stays 0 (quality tier unmoved). Guard:
corrupt index 0 -> 1, non-repo 0 -> 1, empty index stays 0, healthy index with
staged secrets still 2. Full suite green; zenzic passes its own repo at 0.

Signed-off-by: PythonWoods <ops.pythonwoods@gmail.com>
Signed-off-by: PythonWoods <dev@pythonwoods.dev>
test_no_undeclared_imports.py imported tomllib unconditionally. tomllib is
stdlib only from 3.11, and this project declares requires-python = ">=3.10",
so the module failed to import on the lowest supported version -- a collection
error, not a test failure, so the whole file was skipped rather than reported.

Uses the same guard the codebase already applies in four places
(_governance.py, _env.py, _standalone.py, _config_explain.py), falling back to
tomli, which is already declared in pyproject.toml under
`python_version < '3.11'`.

The irony is worth recording: this is the test that exists because an import
worked on the development machine and failed elsewhere.

Both branches verified rather than assumed -- tomli is present here and parses
pyproject.toml to the same 8 dependencies as tomllib.

Signed-off-by: PythonWoods <ops.pythonwoods@gmail.com>
Signed-off-by: PythonWoods <dev@pythonwoods.dev>
Four wrong quotations on the page a new user reads first, each corrected
against a real run rather than against the source string — which is how three
of them got there.

The workspace-error block quoted a superseded message naming two of the four
root markers, with no ancestor clause and no closing instruction. It now shows
what the command prints, with all four markers scanner.py really checks.

The prose above it named the same two markers; it now names four.

The zenzic init block quoted output that exists nowhere in the code. It now
shows the real closing block. The confirmation panel is described rather than
transcribed, because transcribing box-drawing into a fenced block is how a
quote drifts next time.

The Z101 example — introduced by the page's own claim that "the finding is
exact" — showed a message the engine has never emitted.

One correction the recording pass had wrong: the row said the ERROR: prefix
was absent from the source string and therefore wrong in the doc. It is absent
from the string and present in the output, because the CLI's error handler adds
it. Running the command settled it; reading the string would not have.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
Each of these pages teaches a finding code by showing its output, so a wrong
block teaches a wrong rule. All four now carry what the fixture actually
prints, captured by running the command the page tells the reader to run.

z101-broken-links showed [Z104] with a column component for a scenario that
emits [Z101] with the Virtual Site Map wording and no column. Its own sibling
page already stated the correct code, so the corpus contradicted itself.

z301-dangling-ref rendered a warning as an error: an error count, and a
FAILED / Exit code 1 is mandatory footer that cannot occur for a
warning-severity code. It also omitted the Z411 the fixture emits. Its stated
exit code was 1; the real one is 0, and --strict is what makes it 1.

z112-stale-allowlist carried four drifts in one line, and a fifth the sweep
had not recorded: the allowlist entry is /legacy/unused/path/, not
/legacy/path/.

z109-external-link-broken quoted a suffix that appears nowhere in the source,
on a line prefix the external-check path cannot produce.

That last page also told the reader to run a command that cannot produce its
own finding: external URLs are fetched only under --strict, so the documented
invocation exits 0 and reports nothing. The fixture's own .zenzic.toml header
promised the same impossible result. Both now name --strict, and both record
that the failure arrives as Z101 rather than Z109, because external-link
errors are consolidated at report time.

Two findings logged rather than fixed here: the external-link location is
printed as an absolute filesystem path, and eight further example pages still
transcribe output glyphs as ASCII.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
…ever emits

The adapter list, on two pages, named three installed adapters where the real
message names five. Both pages now also say the list is reached through
--engine: an unknown engine written into .zenzic.toml is rejected earlier, by
config validation, and never reaches this message at all.

cli.md's SARIF examples described Z104 as "File not found" and Z201 as
"Credential detected". Both fields carry the canonical code description —
confirmed by generating real SARIF, where the sibling api-json.md block was
already correct.

finding-codes.md showed a Z104 message that does not exist, together with a
"Did you mean:" suggestion line no finding has ever emitted. The only such
string in the codebase is the engine-adapter hint.

checks.md listed fourteen literal placeholder_patterns defaults; there are
four, they are word-anchored regexes rather than substrings, and ten of the
fourteen match nothing. Verified by running each: todo and WIP fire, todos,
wipe, "coming soon" and stub do not.

advanced-features.md quoted a Z103 message with a remediation clause the
engine never emits, in two places.

add-custom-rules.md showed output contradicting the TOML printed directly
above it — and the column too, which the sweep had not recorded.

The .zenzic.local.toml scaffold documented a [core] section that does not
exist and omitted two that do. A reader who uncommented docs_dir under [core]
would have set a key nothing reads. Its real placeholder token is left out
deliberately: pasting it into a page trips the credential scanner, which is
the correct behaviour and not something to suppress.

Logged rather than fixed: that error writes to stdout while stderr receives
the exit code as a message.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
The reporter prefixes every finding with [Zxxx]. Three construction sites also
put the code inside the message, so a user saw [Z601]  [Z601] Obsolete… and a
missing-asset message ending in ) [Z404]. Cosmetic, and still what gets pasted
into an issue.

Reproduced by running the fixtures before changing anything, and confirmed the
same way afterwards. The sweep that found this recorded two sites; the third,
in the Zensical adapter, uses the same shape as the MkDocs one and surfaced
only when the new guard was widened past finding-constructor calls to the
adapters' issue tuples.

The guard walks every module's AST rather than pinning three messages, so a
fourth site fails at introduction. It carries its own planted-violation
control, because a structural test that silently stops matching reports
success in the same words as a clean tree.

Two surfaces depicted the duplication and are corrected with it: the homepage
terminal partial — caught automatically, which is the parity test doing
exactly its job — and the Z601 example page, regenerated from a real run.

Also in this batch, all documentation: engines.md promised a migration banner
whose string exists nowhere in the codebase, and now says the bridge activates
silently and names the command that does report the active configuration; and
two blog posts quoted output no code path has produced. git log -S settles
that second pair as fabrications rather than staleness — the Virtual Site Map
wording predates the post by four months, and the parenthesised limit message
has never existed in src/ — so they are corrected rather than given the
historical-snapshot note the corpus uses for genuinely dated content.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
…erdict

A `>` inside a quoted attribute truncated the tag. Both HTML and MDX permit
one, but the attribute region was a bare negated-`>` class, so
<a title="a > b" href="javascript:alert(1)"> stopped matching inside title,
href was never parsed, and the run ended at exit 1 with a suppressible
finding where the contract owes exit 2. A wrong finding at a lower tier is
worse than none: the scanner looks like it engaged.

Enumerated before changing anything: ten sites in src/ use that construction.
Two feed the link and security tiers and are fixed; the other eight mask or
detect structure and cannot mis-tier a finding. The two now share one regex
fragment instead of two hand-kept copies, because these same two gates had
already diverged once. A tag with unbalanced quotes keeps its old behaviour
through an explicit fallback — a browser reads that as having no href, so
dropping the tag would trade a mis-tiered finding for a missing one.

The <link> divergence was that second drift, still live: the quality tier
gated on a|img|link and the security tier on a|img, so a broken <link> exited
1 while a javascript: one exited 0. Both now share one constant. The security
tier was widened rather than the quality tier narrowed, since narrowing would
delete a check that works. The JSX <Link> component stays out of scope.

The traversal downgrade is the one with a real cost. Three sites resolved the
target and downgraded exit 3 to exit 1 when the file existed, so creating
docs/etc/passwd with any contents suppressed a code documented as
non-suppressible, and --exit-zero covered what was left. The false positive
that downgrade existed for is still handled: a relative hop is decided by
arithmetic against the repository root, giving the same answer whether or not
the target exists. A site-absolute /dev/... maps inside the docs root by
construction and has no arithmetic answer, so it must now be declared through
absolute_path_allowlist.

That last part is a behaviour change, and it reverses a deliberate earlier
decision whose test suite called the allowlist a workaround. Those tests are
rewritten to the new contract rather than deleted, keeping their coverage of
all fifteen system-root names and gaining the half they were missing: that the
verdict no longer moves when the target is planted.

The classifier's Zero I/O claim was true of the function and false of both
callers. Neither calls the filesystem now, so it is true of the path.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
Z203's card said a link reaches Z203 when it "escapes the repository
entirely, or targets a path that does not exist". The second clause described
the filesystem-keyed downgrade that has just been removed, so the card now
documented a rule the engine no longer follows.

It now states what the engine does: classification never consults the
filesystem, so the same link gets the same verdict whether or not anything
exists at the target; a relative hop that stays inside the repository is
Z202 and one that leaves it is Z203; and a site-absolute link naming an OS
system directory reaches Z203 unless declared through
absolute_path_allowlist, because it maps inside docs_dir by construction and
no arithmetic separates it from a real section.

discovery.md's MDX section listed <a> and <img> as the tags that participate
in link, asset and scheme checks. It is <a>, <img> and <link> now.

The v0.17.0 post says the extractor parses <a> and <img>. Checked and left
alone: that was true of v0.17.0, and <link> reached the security tier only
today, so it is a record rather than an error.

Also here: the fourth advisory draft, gated behind the tag because its fix is
on this branch and in no released version, with its affected range
established per tag rather than interpolated between endpoints.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
A reader scanning for "does this affect me" met two separate fix entries and
no signal that together they change the upgrade profile. The notice now sits
at the top of the release section, in both this CHANGELOG and the extension's,
and names one command that surfaces both.

It carries the fourteen system-root names in full, and says plainly that the
traversal verdict no longer depends on whether the target exists — that is the
change, not a side effect of it. The two individual entries stay where they
are; the notice is what a reader meets first.

Fourteen, not fifteen: read from _SYSTEM_ROOT_DIRS rather than from the
docstring I wrote earlier, which said fifteen and was wrong. That docstring
and the priority-table row that inherited the number are corrected.

The notice's own example tripped the security scanner while I was writing it —
the change working on the text describing it. Fenced, which is the remedy the
notice itself documents.

configuration-reference's absolute_path_allowlist entry described a
convenience that exempts a link from Z105. It is now also the required
declaration for a site-absolute section named after an OS directory, and the
entry says so. Its stale-entry code was wrong there too: STALE_ALLOWLIST_ENTRY
is Z112, not Z110, per codes.py.

Verified accurate and left alone: docs/rules/Z203.md, updated in the previous
directive and already stating both the allowlist requirement and that
classification never consults the filesystem. There is no upgrade or migration
guide page to update — migrate-engines.md is about moving between
documentation engines, not between Zenzic versions.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
The security-breach block is shared by every Tier-0 code. Z204 already had its
own branch; Z201 and Z205 shared the remaining one, so a javascript: link was
printed under "Credential:", obfuscated, with the advice to rotate it and purge
it from repository history. There is no credential, nothing to rotate and no
history to purge — the fix is to change the link.

Z205 now prints "Link:" with the URL in full. Masking exists to keep a secret
out of a log, and a scheme is not a secret; obfuscating it hid the one thing
the reader has to find and edit. Z201 is untouched: still masked, still the
rotation advice, pinned by a control in the same test file so a branch that
renamed every label could not pass.

This mattered more than the wording because the security-tier masking change
in this release makes Z205 fire for many corpora for the first time, so the
wrong remedy arrived at the moment a reader was least equipped to judge it.

Also here, the CHANGELOG move the contract-stability page requires: both
BREAKING entries now sit under ### Changed rather than ### Fixed. Verified as a
pure move rather than by reading the diff — zero byte delta, identical
sorted-line multiset, each entry present verbatim exactly once and in one
section only, mkdocs build --strict clean.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
Fourteen documented commands named subcommands and options the CLI has never
had, across 35 sites. Twenty were `check content` and `check credentials` —
inventions; the real set is links, orphans, snippets, references, assets,
placeholders, all — and sixteen sat in the examples/ fixtures a newcomer opens
first, twelve inside .zenzic.toml headers.

Each replacement was chosen by running it, not by pattern. z501 and z502 mean
placeholders, z503 means snippets, z505 and z510-z517 mean references, z201
means references. Every fixture was re-run to confirm it still demonstrates
the finding it exists for, and the exit codes its header asserts were
corrected to the measured ones: z516 really is exit 1, the rest exit 0.

Four more were inline in fixture prose, invisible to a line-anchored
extractor and found by grep afterwards. That is now a stated limit of the gate
rather than a surprise for the next reader.

The rest: a path read as a subcommand, `lab all` for `lab --all`, `fix --only`
on a command with no per-code flag, three global options placed after their
subcommand, --fail-under, and --format sarif --output in README.md, the
most-read file in the ecosystem.

One of the 35 is not fixed and is logged instead. examples/z201-credentials
exists to contain a credential, was last committed before the guard hook
existed, and staging it now blocks at exit 2. guard scan honours no exemption
— verified, exit 2 with and without excluded_file_patterns — so the only way
past is --no-verify, which skips every hook rather than this one finding. That
needs a decision about a Tier-0 gate, not a workaround here.

The gate is the half that matters. A full Diátaxis audit had read all 299 docs
pages and found none of this, because it executed nothing.
_check-documented-commands extracts every zenzic invocation across 514
surfaces in four repositories and runs each in a throwaway project: 109
commands, ~40s, wired into _local-gates so it blocks.

It self-tests every run — a planted broken command must be found, a valid one
must not be flagged, a mutating command must be skipped by an exact-token
list, and the prose filter must still admit `zenzic check .` while rejecting a
sentence. It reports how many commands it executed, so a run that extracts
nothing looks different from one that finds nothing wrong. Skipped
repositories are named.

The exact-token list is deliberate: filtering on the substring --force once
excluded --force-color and hid one of three real defects from the instrument
built to find it.

The operating obligation is written down together with its limits, so the
gate is not mistaken for full coverage. The method-boundary half of it was
folded into an existing obligation as a corollary rather than standing up a
separate one, because a rule that bundles two duties gets cited for one and
forgotten for the other.

No CHANGELOG entry: every defect named something the CLI never had, so no
behaviour changed and nothing user-visible exists to record.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
examples/z201-credentials/ carries an AWS example key on purpose -- it is the
fixture the Z201 tutorial cites. The Secret Guard therefore detected it and
blocked every commit that touched it, which left one inline command in that
file uncorrectable. That is the scanner working, not failing, so the fix is
not to make the scanner quieter.

Two options were tested and both declined. Honouring excluded_file_patterns
in the security tier would put a general bypass in the engine behind a narrow
default; the tier is never suppressible by any mechanism, which is what
iter_security_scan_sources exists to guarantee. Moving the fixture outside the
scanned corpus was already true and is not the issue: zenzic check all exits 0
and never reports it, because examples/ sits outside docs_root. The guard is
deliberately wider than the corpus, and narrowing it to match would drop 203
staged-scannable Markdown files, including README.md, CONTRIBUTING.md,
SECURITY.md, .github/, scripts/ and changelogs/ -- where a leaked credential
matters most.

So the exemption lives in .pre-commit-config.yaml, where a diff shows it. The
hook now runs with pass_filenames: true and an anchored exclude instead of
--staged with pass_filenames: false. That distinction is the substance of the
change: with pass_filenames: false the hook re-derived the staged list from
git itself, so the exclusion was decorative -- the fixture was rescanned
whenever any other Markdown file was staged alongside it, the one case nobody
tests.

Verified by execution. Fixture staged alone: skipped. Fixture staged together
with docs/index.md: passed, with the docs file still scanned. Positive
control, without which none of the above is verified: a real-shaped AKIA key
staged in docs/ still fails at exit 2 with the breach block printed.

The exclusion is prevented from widening mechanically rather than by comment.
The new tests pin the exclusion string exactly, require pass_filenames: true
and the absence of --staged, and assert the exemption never reached
_GUARD_HOOK_BLOCK -- the template zenzic guard init writes into a user's
config, which would otherwise ship this repository's blind spot to every
project that runs it. Each assertion was shown to fail before it passed:
widening the anchor to ^examples/ fails, and reverting the hook to --staged
with pass_filenames: false fails.

No user-facing change, so no CHANGELOG entry: guard scan --staged and the
shipped hook template are both untouched. This repository's own hook now
differs from the template it ships, deliberately, because this repository has
a fixture users do not.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
…ed it

zenzic check credentials is not a subcommand and never has been; the real set
is links orphans snippets references assets placeholders all. This was the
thirty-fifth and last of the broken documented commands, and the only one left
uncorrected -- the Secret Guard blocked every commit touching this file,
because the file exists to hold a credential.

Chosen by execution rather than by pattern, like the other thirty-four:
running zenzic check references inside examples/z201-credentials/ exits 2 and
reports the Z201, which is what the sentence promises a reader who runs it.
The neighbouring .zenzic.toml header already documented that same command and
that same exit code, so the file was internally inconsistent as well as wrong.

This commit is also the end-to-end proof of the hook change one commit back:
staging this file alone leaves the guard reporting Skipped rather than exit 2.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
…opping

The previous commit's exclusion named one fixture. Running the full gate found
a second -- tests/sandboxes/hero_specimen/docs/secrets.md -- and the way it
surfaced is the more important half of this change.

just verify runs pre-commit over all files. Under the old wiring the hook was
entry: zenzic guard scan --staged with pass_filenames: false, so it ignored the
file list pre-commit handed it and re-derived its own from the git index. In an
--all-files run nothing is staged, so the gate scanned nothing and reported
success. It had been passing without looking, in the same words it would use
after a real scan. Passing filenames turned it into a genuine repository-wide
scan, and the second fixture appeared immediately.

The exemption is now two anchored paths rather than a widened one. That these
two are the complete set was established by execution, not assumed: running the
guard over all 502 tracked Markdown/MDX files flags exactly these and nothing
else.

The test was updated deliberately, which is what its own failure message asks
for when a second fixture genuinely needs exempting -- add another anchored
path, never loosen the existing one. It still pins the string exactly, so a
future widening fails.

Verified after the change. All-files run: passed. Both fixtures staged: skipped.
Positive control, unchanged and still the thing that makes the rest mean
anything: a real-shaped AKIA key staged in docs/ still fails at exit 2.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
Audit (windows-latest, 3.10) failed three assertions in this file. None of the
three defects it named were real. The subprocess env was hand-built as
{"PATH": ..., "NO_COLOR": "1", "COLUMNS": "200"}, which drops SystemRoot; without
it Windows Python cannot reach the CryptoAPI and dies before running anything:

  Fatal Python error: _Py_HashRandomization_Init: failed to get random numbers
  to initialize Python

Each assertion then searched that fatal message for its expected substring and
failed with a plausible, wrong diagnosis -- "the scheme finding did not fire at
all", "the offending URL was obfuscated", "the credential label was lost". A
test whose failure message names the wrong defect is worse than one that simply
errors, so the helper now says in its docstring what the enumerated env costs.

Fixed by inheriting os.environ and overriding only NO_COLOR and COLUMNS, which
is all these tests ever needed.

Swept for the same shape rather than fixing only the instance CI happened to
catch: three files in the repository pass env= at all, and the other two already
spread os.environ. This was the only occurrence.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
Same defect as 6bf234b, in the files that commit did not reach. With text=True
and no explicit codec, Windows decodes the CLI's UTF-8 box-drawing output with
cp1252, the decode fails, stdout comes back None, and the assertion dies on
"TypeError: can only concatenate str (not NoneType) to str" -- a message that
names nothing about the behaviour under test.

This is the second Windows failure in this file in two runs. The first was a
hand-built subprocess env; fixing it only exposed this one underneath, because
both come from the same habit of specifying a subprocess call by enumeration
and letting the platform fill the rest in.

Swept instead of spot-fixed: every subprocess.run in tests/ that passed
text=True without an explicit encoding now sets encoding="utf-8" and
errors="replace", and a re-sweep reports none remaining. The comment at each
site says what the locale codec costs, so the next author does not rediscover
it from a CI log.

Twenty-six further occurrences live in local-only maintenance scripts that run
on this Linux machine and never on a Windows runner. They are recorded rather
than changed -- the class is closed where it can actually fire.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
…ally caused

The quiet summary is the one line a pre-commit user reads. It printed
"SECURITY CRITICAL: N secret(s) detected -- rotate immediately. Exit 2." for
the whole Tier-0 set, and three things about that were wrong.

A forbidden scheme and a forbidden term are not secrets and nothing about them
is rotated, so the reader was sent hunting for a credential that did not exist.
This is the same mislabel already corrected in the breach block, on the surface
that fix did not reach -- one more instance of a correction applied where the
defect was reported rather than everywhere it lives.

"Exit 2." was a literal. It printed unchanged on runs that exit 3, so a CI
author reading the output to learn the contract learned it wrong.

And the line counted by severity, which _evaluate_security_exit's own docstring
warns against: severity is stamped by whichever subsystem builds the finding,
and incremental.py emits Z203 with severity "error". A system-directory
traversal therefore produced no security summary at all on a run that exited 3
because of it.

Each exit-forcing code now carries its own noun and remedy in
SECURITY_SUMMARY_TERMS, the count keys on the code, and the exit code comes
from security_exit_code -- the same authority _evaluate_security_exit now calls
instead of doing the arithmetic itself. A message that restates a contract is a
second copy free to drift; this one had.

Verified by execution on all three paths, each in an isolated corpus:

  credential       -> exit 2  "1 credential detected. Rotate it immediately."
  forbidden scheme -> exit 2  "1 forbidden scheme detected. Remove the
                               offending URL. Nothing needs rotating."
  traversal        -> exit 3  "1 path traversal detected. The path targets an
                               OS system directory. Repoint it inside the docs
                               root."

Positive control: a clean document prints no security line and exits 0, so the
three above are not an instrument that always fires.

A structural test asserts SECURITY_SUMMARY_TERMS covers the exit-forcing code
sets exactly, so a new Tier-0 code cannot force an exit without being
described. docs/reference/cli.md carried both defects too -- the exit-2 row
named only credentials, and the admonition framed the contract on severity and
advised rotation for all three codes; it now states the per-code remedy and
warns that a gate testing for equality with 2 reads a traversal as a pass.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
<Callout text="see [p](../../../../etc/passwd) for details" /> exited 3 with a
non-suppressible Z203, and the same prop carrying javascript: exited 2, on text
that is not a link at all. MDX does not parse Markdown link syntax inside a prop
string -- it reaches the page as literal text, producing no anchor and no href.

The mask existed and was wired into one path of three. rules.py's Z101 helper
had it; extract_security_links and extract_all_links did not. The traversal
check reads from extract_all_links, which is why masking only the security view
left the CLI at exit 3 while the unit test passed -- the fix was verified at the
wrong altitude before it was verified at the right one.

Masking on the security path needs a reason beyond convenience, since that tier
deliberately sees more: a comment is still content. The reason is the test a
closed fence already passes -- a prop string renders nothing. And the mask is
safe because of what it does not match: _JSX_ATTR_VALUE_RE requires [...](...)
in the value, so <Callout to="javascript:alert(1)" />, the case a component
genuinely could render as an anchor, is left untouched and still reaches the
tier.

The severity had been rising, not falling: removing the filesystem-dependent
traversal downgrade correctly stopped a planted file from lowering a verdict,
which moved this false positive from Z202/exit 1 to Z203/exit 3.

Verified both directions: three false findings 3/2/1 -> 0; real <a href>
traversal still 3, real scheme still 2, plain Markdown traversal still 3,
math-span and comment payloads still 2, genuine broken link still 1.

Signed-off-by: PythonWoods <ops.pythonwoods@gmail.com>
Signed-off-by: PythonWoods <dev@pythonwoods.dev>
`{ /* … */ }` is legal MDX -- the braces are an expression container, the
whitespace is free -- and Prettier emits that form. Every place that recognises
an MDX comment required the braces adjacent to the markers, so in a formatted
file a commented-out link was reported broken (Z101, exit 1) while the identical
`{/* … */}` spelling was correctly silent.

Four sites, not the three the pattern grep found: validator.py, credentials.py
and scanner.py each hold a copy, and scanner.py also has a line-oriented opener
that tracks multi-line comment state -- a spaced `{ /*` would have left it
mis-tracking the rest of the block. credentials.py's fast-path guard tested for
the literal `{/*` before stripping and is widened too.

The security tier is deliberately unmoved: a forbidden scheme inside a comment,
in any spelling, is still reported. A comment declares something about
rendering, not about content.

The credential half of the original report was wrong. It claimed a spaced
comment defeats ZRT-007 rejoining so Z201 does not fire; both spellings do fire,
and a negative control shows why that proved nothing -- a token interrupted by
random text also reports, so the detector was substring-matching a valid prefix
on one side rather than rejoining. The quality direction was then measured with
a control that does discriminate.

Signed-off-by: PythonWoods <ops.pythonwoods@gmail.com>
Signed-off-by: PythonWoods <dev@pythonwoods.dev>
Every example page carries an "Expected output" block. Comparing each against
its own fixture at a fixed width showed 55 of them stale, and the differences
were never only the glyphs the row described: z107 documented a warning where an
error fires, z103 documented Z101 where Z103 fires, message text had drifted,
column components appeared that the tool does not emit, summary counts were
wrong, and the separator character had changed.

The row said twelve pages with ASCII glyphs. It was nineteen with glyphs and
fifty-five stale overall. Substituting glyphs uniformly -- the obvious shortcut
-- would have left every other defect in place and made the pages look verified.

Each block is now the fixture's real capture; only the per-run throughput figure
is pinned, since it varies by machine.

Two pages then failed the gate, and that is worth recording rather than
suppressing quietly: z501-placeholder and z403-missing-alt quote output that
necessarily contains the payload their own rule detects, so printing the truth
made each page trip itself. The previous sanitised text avoided that by accident
rather than by design. Both get a per_file_ignores entry, the same instrument
and the same reasoning as the Z205 rule page -- the demonstration is the content,
and deleting it to satisfy the gate would delete the page's reason to exist.

mkdocs build --strict clean, full suite green, zenzic passes its own repo at 0,
and every documented Exit code line now matches its fixture's real exit.

Signed-off-by: PythonWoods <ops.pythonwoods@gmail.com>
Signed-off-by: PythonWoods <dev@pythonwoods.dev>
`zenzic check all --engine hugo` printed the unknown name and the list of
installed adapters to stdout, while stderr received `ERROR: 1`. A caller
redirecting stderr to a log -- which is what CI does -- kept the number and
discarded the half that says what went wrong.

A sweep for the same shape found one sibling: `--plugin requires a non-empty
name`. Both now use stderr_console.

The test carries a positive control asserting a successful run still writes its
report to stdout, so the fix cannot pass by moving everything to stderr.

Signed-off-by: PythonWoods <ops.pythonwoods@gmail.com>
Signed-off-by: PythonWoods <dev@pythonwoods.dev>
Every other test runs under captured output, where isatty() is False and
get_terminal_size() returns its fallback. Code branching on those answers
produces identical output in its broken and correct states, so the assertion
passes either way -- which is how color_system=None disabled colour for every
interactive user from v0.8.0 with a fully green suite.

All four sites turn out to behave correctly under a real terminal. The defect
was never the behaviour; it was that nothing could have told us.

Two harnesses, because the sites need different things. One gives the child a
pty on stdin while stdout and stderr stay separate pipes: `script` cannot be
used there because it merges both streams into the pty, and the whole point of
the language-server test is which stream a message landed on. The other makes
stdout the pty at a caller-chosen width, because get_terminal_size interrogates
the stdout descriptor. COLUMNS is stripped from the child environment -- it
short-circuits get_terminal_size before it ever asks the terminal, so a width
assertion would measure the environment and pass whatever the code did.

Each test carries a control that can observe the wrong branch, verified by
mutating the source and watching the test fail:

  hint written to stdout instead of stderr        -> fails
  term_cols hardcoded to 120                      -> fails
  supports_color forced False                     -> fails
  color_system=None (the original v0.8.0 defect)  -> fails

The width test needed rewriting before it could do that. Measuring the rendered
width passes in both states, because Rich already crops to the console width --
so the first version of this assertion was itself vacuous, in exactly the way
the file was written to prevent. What the reporter's own arithmetic changes is
whether the excerpt fits on its caret row: truncated against the real width it
does; truncated against a hardcoded 120 in a 61-column terminal it wraps, the
caret row is left empty and the source spills onto continuation lines, so the
caret no longer points at the column it names. That is the defect the code's own
comment cites, and it is observable.

The marker token in the source line is ZXQV rather than x, because x also occurs
in the footer's "Exit code" and the spill detector matched it.

Signed-off-by: PythonWoods <gianluca.catalano@gmail.com>
…er fields have

`CodeDefinition.activation`/`activation_key` declare which codes are opt-in and
nothing applied that declaration. `templates.py` and `explain` read the fields to
enumerate; each check then wrote its own `if config.policies.<key>`. Of the other
`CodeDefinition` fields, `severity` has the central `code_severity()` accessor and
a test, and `fixable` has `test_fixable_code_wiring_structural.py`. `activation`
had neither, and drifted -- Z503 reported in the editor while `zenzic check`
suppressed it, which is how `docs/rules/Z111.md` shipped a `.zenzic.toml` example
declaring `docs_dir` twice.

Behavioural rather than static, deliberately. An AST sweep for the gating `if`
would have passed on the defect: the key *was* present in `validator.py`. What was
absent was a gate on a second call path, and only running both paths can see that.

Three assertions. The fixture map must cover exactly the registry's opt-in set, so
a tenth code cannot be added without a page that triggers it. The CLI must report
all nine with the flags on, so the gate assertion is not passing over an
uninteresting corpus. And neither `zenzic check` nor `IncrementalAnalysisEngine`
may emit any of them with the flags off.

The engine side reads raw findings, unfiltered. The parity harness drops the
topology family and the info tier because it compares two shapes; this asks a
different question -- did the engine compute what it was told not to -- and a
finding one transport discards is still a gate that did not hold for the other,
which is the surface `zenzic-mcp` consumes.

Negative control run on both architectures rather than one. Removing the gate from
`check_snippet_content` turns it red naming Z503 on the engine path; replacing
Z502's construction-time `if` with `if True` turns it red naming Z502 on the CLI
path. Both reverted, green again.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…ering what it rejects

`[build_context] base_url` was declared, written uncommented into every
generated config, and read by nothing. The reference page told a reader "the
adapter uses this value instead of attempting static extraction from the build
tool's config file" -- no adapter read it, and no adapter performs that
extraction either, so a project served under /docs/ set it and got silence.

Two findings land on the same link and that is the trap. `[Ref](/docs/ref/page/)`
on a site based at /docs/ reports Z105 *and* Z101, because the VSM is keyed
/ref/page/. `absolute_path_allowlist = ["/docs/"]` already cleared Z105 alone --
measured -- so wiring base_url to the allowlist and stopping would have silenced
the cosmetic half and left every such link broken, which looks like a working
feature and is not one.

So both consumers read one authority. `get_absolute_url_prefixes()` becomes
concrete on BaseAdapter, the factory normalises base_url into it once, and the
three overrides that each returned [] are gone. Z105 allowlists that list and
_to_canonical_url re-bases against the same list; they cannot disagree about
where the site starts. Re-basing is safe against traversal by construction: the
result stays absolute and root-relative, and the docs_root escape check guards
the relative branch, which this does not enter.

`"/"` reads as no prefix, and that is a decision rather than an oversight: every
config this tool has generated carried an uncommented `base_url = "/"` written
while nothing read the field, and honouring it would re-base every absolute link
in every existing project the moment the field started working. Recorded at the
normalisation, in the reference page and in the CHANGELOG, because in six months
it would read as a bug.

prebuilt refuses it instead of ignoring it. Its routes come from
.zenzic-vsm.json, which already carries the prefix the build produced. And init
does not offer the setting when it writes a prebuilt config -- following
docs_dir_line's existing conditional pattern, because a template that offers
what the project's own state rejects is the defect closed in
suppression_cap_scope the same day, not a separate one.

Also closed rather than recorded, because measuring the cost took longer than
the fix: `--engine` held a literal {"mkdocs", "zensical", "standalone"} while
this same command's help text and the `# Supported:` comment it writes were both
derived from list_adapter_engines(). prebuilt and vsm were advertised in two
places and refused in the third. The set is derived now.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
`tomllib` is stdlib from 3.11. The tested matrix still carries 3.10, where
the backport is the same parser under another name, and the rest of the suite
already guards the import. This module did not, so it passed here on 3.14 and
broke both 3.10 legs -- ubuntu and windows -- with ModuleNotFoundError at
collection time, taking the whole run down rather than one test.

Verified on the interpreter that broke rather than on the one that did not:
the full suite runs green under `uv run --python 3.10 --isolated`, which is
the check that was missing when the module was written.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
… score panel is dark again

Six `// MARKER` labels lived in home.html as separators between the includes.
Reordering the sections moved the includes and left the labels where they were.
Measured in the built page rather than the source: two labelled the wrong
section -- `// DIAGNOSTIC_OUTPUT` sat above the engine panels and
`// ADAPTER_SURFACE` above the governance one -- two labelled nothing at all and
floated back-to-back above the footer, and three sections had no label.

Each marker now lives inside the section it names, as that section's eyebrow. A
label that travels with the thing it labels cannot come apart from it, and the
names match the partials' own. Verified in the rebuilt page: six markers, each
followed immediately by its section, zero orphans; the hero carries none by
design.

The gap around them is not a defect and is left alone. Measured on the rendered
page by scanning for runs of uniform background: within a section the rhythm is
60-97px, and section transitions are 193-264px -- the 193 and 197 are exactly
two stacked `md:py-24` paddings, and the larger two are the same 192 plus the
typographic slack of a paragraph's last line. It reads as a void because the
markers used to float in the middle of it. The only lever available without
regenerating the Tailwind bundle would be dropping `md:py-24` entirely, since it
is the one compiled `md:` vertical-padding utility, and that is a design
decision rather than a repair.

The Quality Score panel was the one light surface on a dark page.
`dark:bg-[var(--zz-surface-0)]/30` compiled to nothing: Tailwind cannot apply an
opacity modifier to an arbitrary var() colour, and `zz-surface-0` appears
nowhere in the built bundle, so `bg-zinc-50/50` was the only background that
existed -- in both themes. Fixed to `dark:bg-zinc-900/20`, the form all fifteen
sibling panels use, because the token spelling without the modifier compiles
only if someone regenerates the bundle, which is a human-operated step with no
pinned version in this repo.

The quickstart's two action buttons carried `items-start` and no
`justify-center`, so they sat left of the terminal panel above them, aligned to
neither its edge nor the column. Centred like the hero's pair.

Every class used was checked against the compiled bundle before being applied.
That check caught a first attempt that moved the sections' top padding onto the
markers: `pt-24`, `pb-16`, `pb-24` and `pb-10` are not in the bundle, so it
would have removed padding and added none.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…l becomes the real one

Censused every fence in docs/ and the syndication drafts: 1266 fences, 90
carrying box-drawing characters. The premise that these are hand-drawn
decoration does not survive running the tool.

63 are verbatim CLI output. The gutter bar in them separates line number from
source, the same shape `git diff` and a compiler's error context use, and the
caret underline works by aligning under a token. Verified by execution rather
than by reading: `zenzic check all` on the z501 fixture prints those
characters, so stripping them would make the documentation a false
reproduction of the tool and break the alignment that carries the meaning.

A further handful are Rich tables and panels, introduced in prose as what the
command prints -- `scoring-system` carries `title="Terminal"` -- and the rest
are arrow diagrams, file trees and comparison tables whose meaning *is* the
drawing. `auto-fix-philosophy` is a three-stage flowchart connected by arrows,
not a frame around a sentence; an admonition would destroy the sequence.

Exactly one was a frame drawn around our own prose: install.md's three
distribution tracks, introduced as "Zenzic recommends three distinct
distribution tracks". It is a Markdown table now, word for word, and it renders
as one -- checked in the built page, not in the diff.

install.md's Zenzic Tip panel stays framed, because the product prints it
framed: the string lives at cli/_shared.py:180 as a Rich Panel title. But the
reproduction was hand-typed and its right border did not line up, so it was
wrong in the way this directive's verification step asks about. Replaced with
the captured output of a real run at a fixed width. Asserted word-for-word
before writing: no word of the message is lost.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…ellings join the other thirteen

The style contract was measured from the twelve existing diagrams rather than
assumed: all twelve are `flowchart TD` or `LR`, all twelve carry the same five
classDef lines byte for byte, and across 102 edges there is exactly one arrow
form. The three conversions copy that contract verbatim, so the palette gate
passes on its own terms and the result reads as the same visual language.

Converted: the remediation lifecycle on auto-fix-philosophy, the action's
invocation chain on github-action-internals, the gate logic on zenzic-action.
Content survives word for word; the diff on each shows one removed line per
node. Rendered and inspected, not just gate-checked -- a first attempt drew two
edges per transition because the node declaration and the labelled edge were
written on the same line, which the screenshot showed and the gate could not.

Not converted, and this is a finding rather than an omission:
advanced-features' docs listing is a directory tree, which Mermaid represents
worse than a tree does. Same shape as the box-frame census, where 63 fences
turned out to carry the gutter bar and caret underline the tool really prints --
verified by running it -- and stripping them would have made the documentation
a false reproduction.

zenzic audit and zenzic config explain built docs_root without `.resolve()`
while thirteen other sites used it. `docs_dir` accepts `../docs` and
`docs/../docs` -- checked against the schema -- so the two spellings can
disagree once anything compares them. No divergence is demonstrable today, so
it is closed as a latent inconsistency and the comment says exactly that rather
than claiming a defect.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…declares

The bundle is 44 KB of committed CSS whose builder was recorded nowhere, so
`npx @tailwindcss/cli` would have resolved to whatever was current that day and
produced a different artifact. The version was not guessed: the bundle names it
in its own first comment, `tailwindcss v4.3.1`.

package.json pins @tailwindcss/cli at 4.3.1 exactly, with no range, and carries
the build as `npm run build:css`. package-lock.json pins the other 72 packages
with integrity hashes, and `tailwindcss` in it resolves to 4.3.1 -- so the pin
is verified against the artifact, not only against the manifest.

The build stays human-operated and CI installs nothing from here; the Python
package ships no JavaScript. What changes is that the step is now reproducible.

The first lockfile attempt failed with EALLOWREMOTE. Diagnosed rather than
worked around: `allow-remote = "none"` is npm 12's own built-in default -- it
appears under `; "default" config from default values` and no .npmrc on this
machine sets it -- and it is not a proxy, a registry or a sandbox restriction,
since `npm view` reached the registry in the same session. One of Tailwind's
optional platform packages is fetched as a remote tarball, which that default
refuses. Resolved with `--allow-remote=all` on that single invocation, so the
machine's hardening default is untouched; the README says so for whoever runs
it next.

The README also now states the failure mode this pin exists for: a class absent
from the bundle compiles to nothing, silently. It has happened twice --
`dark:bg-[var(--zz-surface-0)]/30`, and a padding change that would have removed
padding and added none.

Also in this commit: the pre-commit boundary diagram becomes Mermaid in the
canonical post, and the two syndicated copies were regenerated by
derive_syndication.py rather than hand-edited. Verified at the source first,
because a Mermaid fence that a platform does not render would show readers raw
source: Forem special-cases `mermaid` in its Redcarpet renderer
(app/lib/redcarpet/render/html_rouge.rb emits `<pre data-lang="mermaid">`) and
renders it client-side, so dev.to shows a diagram. Hashnode's parser remains
unverified, as recorded.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…ver to bypass

`core/reporter.py`'s `_obfuscate_secret` has said since 2026-04-04 that it is
"the only place where raw secret material is allowed to be formatted for human
consumption" and "must never be bypassed". `cli/_guard.py` bypassed it from
2026-08-14 with its own `_mask_secret` -- four months later, inside a squashed
release commit that states no reason for not reusing the existing one, with a
generic CWE docstring and no test at all. Accidental duplication, determined
from the history rather than assumed: the exclusivity rule was already written
when the second implementation was added.

Consolidated onto the authority, which is also the tested half -- it is pinned
by the mutation-killing tests down to `_obfuscate_secret("X") == "*"`.

But consolidating naively would have moved a security property the wrong way.
`_obfuscate_secret` pads with one asterisk per hidden character, so its output
is exactly as long as the secret; `_mask_secret` did not do that above eight
characters. Adopting the authority verbatim would have made `guard scan` start
encoding the secret's length where it had not. So the authority now takes
`preserve_length`, which selects the rendering and never the disclosure: both
forms reveal the same four characters at each end and nothing else.

The default is unchanged and stays pinned. A functional reason for preserving
length was looked for and not found -- the gutter caret is sized from
`len(match_text)` against the source line, not from this string, which prints on
its own "Credential:" line with no alignment constraint. Absence of a found
reason is not proof of absence, so the mutation-tested behaviour was left alone
rather than redesigned on a hunch.

`guard scan` takes `preserve_length=False`, which is strictly stronger than what
it had: `_mask_secret` still revealed the length of any secret of eight
characters or fewer by returning that many asterisks.

Also here, the same shape found by the same question:

`cli/_shared.py` gated the "declared prebuilt without a manifest" check on the
literal `("prebuilt", "vsm")`. Register a third adapter reading the manifest and
the check skips it in silence. It now derives from the registry --
`manifest_driven_engines()` -- and a structural test plants exactly that
regression: a third `PrebuiltVSMAdapter` subclass registered at runtime must
appear in the derived set, which a literal cannot do.

Deriving cost 35 ms a call, because `list_adapter_engines()` reaches
`entry_points()`, and this gate sits on the incremental path. That pushed
`test_engine_latency_benchmark` from under 50 ms to 89.6 ms -- caught by the
repository's own latency invariant rather than shipped. Cached on a fingerprint
of the built-in registry, so the planted-regression test still invalidates it:
0.0003 ms a call now.

And `cli/_inspect.py` called `load_config_with_diagnostics()` the "sole
construction site" for Z110/Z111. `incremental.py:240` builds Z111 directly,
added 2026-09-19 for the docs_dir widening disclosure; the comment was simply
not updated. Both sites are on the editor path, so the conclusion it supports --
that neither code can fire from any CLI command -- is unchanged. The comment now
names both.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…asses had no rule

tailwind-input.css carried `@tailwind components; @tailwind utilities;` while
package.json pins the v4.3.1 CLI. Under v4 those directives are not an error --
they simply generate almost nothing. Building them produced 93 rules where the
committed bundle had 340.

Migrated to `@import` plus `@source`, naming both trees that hold markup: docs/
carries `class=` in 85 files against 10 in overrides/, so scanning only overrides
would have missed most of it.

Result, measured with a matcher validated in both directions (`md:py-24` found,
an invented class not found): 32 classes written in the templates had no rule in
the committed bundle and now have one; zero regressions. The diagnosis in the
batch file holds -- this was never decay, it was on-demand generation that had
not been re-run since 2026-07-04 while the templates kept changing.

Two regressions were introduced and caught before committing, both by looking
rather than by the diff:

The dark variant. v4 defines variants in CSS, and nothing in this repository
carries one: tailwind.config.js was created once in e88cdbd and has never had a
`darkMode` key. Yet the committed bundle wires 114 selectors to
[data-md-color-scheme=slate] -- so it was built from an input this repository
does not contain, which is why pinning the CLI version alone did not make the
artifact reproducible. Without an explicit @custom-variant the rebuild fell back
to prefers-color-scheme, and every Tailwind surface would have followed the
reader's operating system instead of the site's own toggle. Declared explicitly;
102 selectors wired, zero on prefers-color-scheme.

The cascade. The first attempt wrapped the imports in `@layer theme, components,
utilities`, which is the shape v4's documentation shows. Layered styles lose to
unlayered ones, extra.css uses no layers, so every utility began losing to it --
visible as hero buttons rendering near-invisible text on a white pill, because
dark:text-zinc-950 lost to extra.css's link colour. The committed bundle has zero
`@layer utilities`; the imports are now unlayered to match. The class-level diff
was clean through both of these: only the screenshot showed them.

Preflight stays disabled, verified in the output rather than assumed.

The regenerated file carries no SPDX header. The old one did, added by hand and
recorded nowhere -- the manual post-step that made the artifact irreproducible.
reuse lint passes without it, because REUSE.toml's docs/assets/** block covers
the path. The README now says not to re-add it.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…, and the landing's spacing measured at the DOM

scripts/check_generated_provenance.py runs the declared build command and
compares the result to what is committed. It exists because two things shipped
in docs/assets/css/zenzic-tailwind.min.css that no gate could see: a hand-added
SPDX header the CLI does not emit, and an input this repository does not contain
-- the bundle wired 114 selectors to a dark-mode attribute tailwind.config.js has
never configured. reuse lint passed throughout, because REUSE.toml covers that
path with an unrelated exclusion: a compliance gate standing where a provenance
gate was missing.

Scoped before building rather than assumed. Four committed artifacts declare
themselves generated; two already have a mechanism -- uv.lock is checked by
`uv sync --frozen` in four workflows, .zenzic-score.json by `zenzic score
--check-stamp` in the justfile and CI. Two had none: the CSS bundle and
package-lock.json, which nothing verifies because CI has no Node by design. Two
is not a registry, so this is two checks rather than a framework.

Determinism established before relying on diff-equality, per the directive's own
condition: two consecutive builds are byte-identical. The only permitted
difference is the trailing newline the end-of-file-fixer adds after generation,
which the check normalises.

Proven in both directions. Planting the historical defect -- an SPDX header added
by hand -- is caught and named; desynchronising package.json from its lockfile is
caught and named; after reverting, both pass and the files are byte-identical to
where they started. It then caught real drift within the hour: editing the
partials made the committed bundle stale, and the gate said so before the commit.

It uses subprocess deliberately. Zero Subprocess (ADR-002) binds the Core
runtime -- "Core runtime operates entirely inside Python" -- not project tooling,
and four scripts already shell out. A provenance gate that did not actually run
the build command would verify nothing.

The landing page's vertical spacing, measured at the DOM rather than by scanning
pixels:

An earlier report of mine claimed ~72px of unexplained excess in two sections.
That figure was wrong, and the instrument was the reason: a pixel scan counts
runs of uniform background, so it charged the whitespace *inside* the last text
block -- descenders, line-box slack -- to the gap between sections. Measured
through getBoundingClientRect and getComputedStyle instead: every section's
computed margins are 0, the distance from one section's box to the next marker
is exactly the declared padding for all six, and the lowest visible descendant of
every section reaches its padding edge with 0px to spare. There is one inline
style in the whole landing page and it is the deliberate -64px pull under the
header; no <style> block, no !important.

So there was nothing to find, and the spacing is now simply smaller because it
can be: rebuilding the bundle is possible since the toolchain is pinned, which it
was not when this was last looked at. Sections carry the bottom padding and the
marker carries the top, so the gap is one value rather than a sum -- 40px each
side, measured identical across all six.

Also: the quickstart terminal was the only box on the page with negative
horizontal margins, which is why it sat 64px left of its own heading; removed.
The markers now share the content container, so marker, eyebrow, title, prose and
box all start on one column.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
… its second paragraph lived twice

The h1 declares text-3xl sm:text-4xl md:text-5xl lg:text-6xl and rendered 40px at
a 1424px viewport, where lg:text-6xl asks for 75px. Everything that should make
it work does: the class is on the element, `min-width:64rem` matches, and
`--text-6xl` resolves to 3.75rem on :root. What wins was never found --
document.styleSheets exposed 70 rules against the bundle's ~540, so that
instrument is unreliable here and the trail stops at the symptom rather than
pretending to a cause. `.md-typeset h1` was ruled out properly: the landing page
is not inside .md-typeset, confirmed with Element.matches, so Material's rule
never applied and the specificity story I first reported was wrong.

extra.css now states the size explicitly, fluid rather than four breakpoint
copies -- restating Tailwind's scale in a second file is the duplication this
codebase keeps finding. Verified by pixel diff of the rendered page rather than
by the probe, which turned out to be reading a stale document: the same probe
reported no change after a rule that demonstrably moved 710x405 pixels.

Six h2 elements have the same conflict, 40px against the 45px their md:text-4xl
asks for. Deliberately not touched: seven instances is a pattern, and it belongs
to an audit of which Material and extra.css rules the Tailwind bundle now
supersedes. Checked first whether it reaches beyond the landing page -- zero
elements in docs/ carry Tailwind sizing classes on h1-h6 or p, in markdown or in
raw HTML -- so the conflict is structural to the one hybrid page and the scope of
any fix is that page.

The hero's second paragraph is gone, and the measurement is why. Every technical
specific in it lives in a section built to carry it: MkDocs, Zensical, Astro,
Docusaurus and the route manifest are all in engine_surface; "broken links" and
"credential" are in failure_topology and execution_layer. The same content in two
places on one page is the content form of the code duplication this session has
been closing.

Reference heroes, fetched rather than recalled: astro.build, tailwindcss.com and
squidfunk.github.io/mkdocs-material each carry ONE sentence under the headline.
This hero carried seven lines across three blocks; it now carries three.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…n headings

`extra.css` carried `.zz-tailwind-root .text-3xl, .zz-tailwind-root
.md\:text-4xl { font-size: 2rem !important }`, and its own comment says what it
was for: "Aggressive Typography Override for Hero Metrics -- force smaller metric
numbers". Those two utility classes are carried by seven other elements. With
`!important` it beat every responsive size they declare.

Two numbers identified it beyond doubt: 2rem at the 20px root is 40px, and
`line-height: 1.2` gives a 48px line box -- exactly the font size and the
line-to-line step measured on the rendered page. So `lg:text-6xl` on the hero
title was inert, and so was `md:text-4xl` on six section headings.

Scoped to `.tabular-nums`, which only the four metric numbers carry.

Getting there took three wrong answers, and they are worth recording because each
was a bad instrument rather than a bad theory. `.md-typeset h1` was blamed first:
ruled out with `Element.matches`, the landing page is not inside `.md-typeset`.
Then a pixel diff was read as proof the fix worked; it was showing a margin
change. Then `document.styleSheets` was walked to find the winning rule and
returned nothing, because it exposed 70 rules against the bundle's ~540. What
found it was grepping the *served* CSS for `font-size` with `!important`.

The replacement rule also did nothing at first, and that is its own finding:
`clamp(2rem, 1.1rem + 3.2vw, 3.75rem)` minifies to `1.1rem+3.2vw`, CSS requires
whitespace around `+` inside a math expression, so the declaration was dropped in
silence. The comment in extra.css now says not to use those operators there.

Then the title was too large, which a reader spotted before any measurement
did: at the 3.75rem the utility asks for it ran to FOUR lines. Measured against
the references at the same 1424px viewport, by line-to-line step from their
rendered pages: astro.build 54px title over 27px body, two lines;
tailwindcss.com 96-109px over a much shorter headline, two lines. Ours was 67px
over 34px -- a ratio of 1.95 against Astro's 2.00, so the ratio was never the
problem. The line count was: 58 characters do not fit two lines at 64px. The
ceiling is now 2.25rem and the title sits on two lines.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…verywhere

Two causes, one commit, because they touch the same three files and
splitting them by hunk would leave an intermediate commit that does not
pass its own tests.

**The breaking change.** The factory raised for `zensical`, substituted
silently for `mkdocs`, and `prebuilt` was caught by a separate check only
three commands called. Three answers to one condition. It now raises `Z111`
for any declared engine whose configuration is absent; `standalone` and
`auto` are untouched, because nothing was declared and falling back is the
answer they asked for. `zensical` already raised but reported itself as the
generic `Z001` against a `Z111` reference page -- `config_error_z111` moves
to Core so the adapters can reach it, and `cli/_shared._z111` delegates
rather than staying a second copy.

Measured across ten commands and three engines, before and after: eight now
answer identically. `inspect capabilities` builds no adapter and `lab` runs
on the shipped example corpus -- out of class, with the reason.

`engine.substituted` and `engine.reason` go with it. With no substitution
left they could only ever be false, and the reference page taught a CI gate
on them that could only ever pass. Verified before removing: absent from the
previous tag, never deployed to the site, absent from the three satellite
repositories. The changelog entry that announced them is rewritten rather
than retracted, since a release changelog should not describe a field added
and removed in one release.

**The editor stopped going dark, twice.** `LanguageServer` did work before
`IncrementalAnalysisEngine` exists, and let every failure in it escape. A
declared `zensical` with no `zensical.toml` raised from
`resolve_container_vocabulary` and published zero diagnostics; a
`pyproject.toml` missing one bracket raised from `ZenzicConfig.load()` at
five separate sites and did the same, credential findings included.
`_resolve_containers`, `_ensure_adapter` and `_load_config` are the three
construction points, hoisted; `incremental.py` reports the configuration
error as `Z110` or `Z111` and the session keeps analysing.

Both were invisible because the tests for them built the analysis engine
directly, below the layer that failed. The editor tests now drive the
server, and the CLI/LSP parity harness resolves its container vocabulary
and tab style the way the server does rather than passing `None`.

Forty-six tests broke on the breaking change and every one was triaged:
forty-two fixtures that declared an engine they never provided, three whose
subject was the deleted notice, one contract consequence. No regressions.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
`docs/how-to/configure-adapter.md` uses the telemetry count as the signal
that a second content tree is being missed -- "it does not appear in the
file count and produces no findings either way" -- and then tells the
reader to declare it with `content_roots`. Declaring it made the tree
produce findings and left the count unchanged, so a working setting and a
typo in it produced identical output.

`_count_docs_assets` walked `docs_root` and, since locale support landed,
the adapter's locale source roots. `content_roots` is the third mechanism
by which a run reads a tree `docs_dir` does not name, and it was not
walked. Fixed beside the locale branch, de-duplicated against what is
already counted: an overcount is the worse direction, because it tells a
reader the run covered more than it opened.

Verified at scale on `facebook/docusaurus` at `b52c2c1`, cloned rather
than assumed: `94 pages` before declaring three content roots and
`1,196 pages` after -- 94 + 1,067 + 29 + 6 exactly.

The same measurement closed Batch 20's last open claim. Its file said
"i18n and versioning break the manifest in silence -- documented with a
danger block"; i18n was documented and versioning appeared nowhere in
`docs/` at all, against 90 occurrences of `i18n` as the instrument's
control. Docusaurus's own site publishes 1,067 sources from
`versioned_docs/` beside 94 from `docs/`, so `docs_dir` alone reads 8% of
it and reports success over the rest. The how-to now says so, including
that wanting a frozen version checked is a real decision rather than an
oversight.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
`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` are each
carried across by hand, added one at a time after the same discovery, and
the function carries a comment about the trap twice. `outgoing_links` was
the third and was missing.

Measured driving the real server: 0 nodes transferred against 10 rebuilt,
so `_find_cycles_iterative` ran over an empty graph on every full sync and
`enable_circular_link_check` bought nothing.

No finding was lost, and the report says so rather than overclaiming:
`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 this fixes is work computed and discarded, and a trap for the day that
severity changes. The test asserts the attribute rather than the finding,
because a test on `Z106` would be green over an empty graph.

Beside it, Batch 25's audit: seven engine constructions in tests claiming
editor behaviour, each measured against the server. Six were faithful and
are annotated as engine-level; two were renamed for saying "the editor"
while never starting one, and a server-driven companion now covers the path
they only simulate.

The seventh was not. `test_the_editor_widens_and_says_so` handed the engine
`project.resolve()` with the comment "what the server falls back to" -- the
widening was the behaviour under test and the test supplied its result. A
planted mutant removing the fallback left the suite green while the server
published `Z111` and nothing else, the author's own document never
analysed. Split in two, and the new test fails against that mutant.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
The disclosure gate caught three citations of a numbered rule from the
private control plane in public test docstrings. The rules live where they
live; a public file states what they say instead of naming them.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
The core reproduces two slug functions rather than importing them -- it has
to read a project it is not installed beside -- and the versions they were
characterised against lived only in source comments. `compatibility.md`
answers "which generator versions were tested"; this answers a different
question and gets its own section rather than a second matrix.

The three satellite READMEs now point at that page beside the adapter link.
They point rather than copy: a second table that diverges from the first is
the shape this cycle has closed repeatedly.

`CONTRIBUTING.md` had a thorough recipe for engine versions and said nothing
about the reproductions. There is no `just` recipe to add: the parity test
imports the real functions and compares against whatever the lock resolves,
so a dependency bump either passes or fails with the input it disagreed on.
That is written down, along with what the comparison does not cover.

And `Z102`'s rule card now says what the anchor was predicted against. A user
meeting a slug divergence is looking at `explain Z102`, which points at the
card; the card said nothing, and `compatibility.md` is not somewhere they
would think to look. On `mkdocs` and `zensical` the slug is compared against
the library itself; on `prebuilt` it is a prediction, because `prebuilt`
means a generator Zenzic does not model.

Three of Batch 22's premises did not survive measurement and are corrected
in its file: the bump recipe exists, the parity test covers two replicas of
two rather than one of three, and `.zenzic-core-ref` holding a branch name is
the documented intent -- a deleted ref fails that check loudly rather than
falling back.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
`README.md` and `CONTRIBUTING.md` are first-party documentation and nothing
checked their links. Measured in both directions: a deliberately broken
external link in `docs/_probe_external.md` fails `zenzic check all`
(91/100, gate failed); the identical link in `README.md` passes it
(100/100). The engine can be targeted at a root file -- `check all
README.md` reports `./README.md - 1 file` -- and its external pass still
does not reach it.

Two options were declined with their reason, both in the script's
docstring. Extending `check_built_site_links.py` would redefine a claim
that is accurate: a README is neither built-site nor internal.
`content_roots = ["."]` was measured at 21 security breaches across 10
files and 4 security incidents, because the repository root holds test
sandboxes with planted secrets and the private control plane -- which is
why `docs_dir = "."` is commented out here and active in `zenzic-action`,
whose root holds neither.

It covers all four repositories, because a gate that reads one reports
"clean" in the same words as one that reads everything, and the sweep now
checks the other three out. It runs there rather than in the merge gate for
the reason that workflow already carries: nine github.com timeouts once
left `#233` unmergeable, so external checking is triaged on a schedule.

`main` is clean: 34 external links across 7 root documents, all resolve.
The two this branch reports are pages that land with their links at merge.

The instrument was wrong once before it was right. It reported
`zenzic-vscode`'s Marketplace install link broken; the URL built from that
extension's own `package.json` is the same URL and returns 200. The
Marketplace answers HEAD with 404 on a published extension, and the retry
covered 403 and 405 only. Any 4xx is retried now, that host is a self-test
case, and a redirect counter that could never fire -- `urlopen` follows
redirects, so 300-399 never arrives -- was made true instead of removed: 25
of the 34 resolve only via one.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
Every internal link that reached its page through this project's own
`docs/_redirects` now names the page. Two of them, and neither was fixed by
following the redirect:

- The README's "Policy-as-Code Guide" resolved to
  `#local-sanctuary`, which is about machine-local overrides. The section it
  means is `#policies-settings`. The redirect was not adding a hop; it was
  landing on the wrong part of the right page.
- `CONTRIBUTING.md` promised that "the visual identity and color palette
  reference live at" a page deleted in `3e3fc99` as identity content outside
  a technical tool's public documentation. Its redirect lands on the
  reference index, which says nothing about a palette. Repointing it would
  have kept a false promise, so the promise is withdrawn and the note says
  where the assets actually are.

Three example URLs in the `Z108` and `Z614` cards drop `/docs` too. One of
them is labelled GOOD, and a card teaching a redirect is a small defect in
the thing the card is for.

Externally, `opensource.org/licenses/X` moved to `/license/X`; the
destination was verified 200 before the three links were updated. Two
external redirects are left deliberately, each with the reason in the
report: a GitHub advisory form that redirects to *login*, which is
auth-gating rather than a move, and `modelcontextprotocol.io`, whose
destination carries a date in the path and will move again.

`docs/_redirects` is untouched. It now has no live internal consumer, which
is the state it expects: its own header says it is built from "100% of GSC
crawled URLs", so it exists for external ones.

The root-document checker gained two corrections it needed to be trusted.
Only `404`/`410` fail: `opensource.org` answers `403` to every programmatic
client, browser User-Agent included, and a gate that stays red on something
no edit can fix is one people learn to skip. And the redirect classification
was behind the early return, so one broken link hid the dependency picture
for every other link in the sweep.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
.zenzic-vsm.json had no schema page: the how-to's example showed two of
the three fields the engine reads. The new reference documents all three
with their defaults, and the four behaviours measured by running the
product -- Z111 and exit 1 with no manifest, Z115 for an unlisted source,
an unmatched key, and an unknown field accepted in silence.

slug is documented as accepted and inert: the reader passes it on and the
site map is built without it. A version field added today does nothing,
which is stated rather than implied, because Batch 23 asks for a versioned
schema and that needs a reader, not a page.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
Zenzic derives a page's 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 was a Z102 on a link the browser serves. The engine
cannot predict better because it cannot know which renderer to predict:
get_enabled_extensions is overridden by the mkdocs adapter alone.

So .zenzic-vsm.json may carry an `anchors` array per entry, and where it
does the engine stops predicting for that source. Declared anchors replace
the predicted set rather than extending it -- merging would let a wrong
prediction keep passing fragments the site does not serve, which would make
the field a suppression mechanism wearing a schema's name. Absent keeps the
previous behaviour, so existing manifests need no change.

Both paths honour it: the scanner's cache and the incremental engine the
editor drives, which builds its own cache on a path the scanner never walks.

Z102 now says when it is predicting -- a renderer divergence and a typo read
identically before. The note appears only where anchors were in fact
predicted, and there is a test in each direction.

Unlike `slug`, which is read and dropped, `anchors` had a consumer before it
had a producer: Route.anchors is what Z102 tests against.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
The comment read "to_lsp_dict() is the ONLY serialization site in the
codebase". Workspace edits build their own LSP range objects two places
below it. The mechanism is real -- a diagnostic becomes a dict in exactly
one place, and nothing converts one with asdict or __dict__ -- but the
sentence claimed the whole codebase.

A claim stated wider than its evidence sends the next reader to verify a
scope nobody was defending.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
The comment audit's remaining claims were classified by executing the
path each one describes. Thirty-three held. Five did not:

- incremental.py said one branch could raise Z203; three emit it.
- rules.py named SECURITY_TIER_CODES as what the exit computation matches
  against; it matches SECURITY_INCIDENT_CODES and SECURITY_BREACH_CODES,
  both subsets of the tier, which also holds Z202. The conclusion held,
  the mechanism did not.
- server.py called the hover the only way to see whether an ignore comment
  is doing anything; Z603 DEAD_SUPPRESSION reports it from the CLI.
- vsm.py called build_vsm the only I/O entry point two lines below "Pure
  data, no I/O". The module makes zero filesystem calls.
- main.py said the usage-error exit remap is set at module scope so the
  console entry point, the test runner and zenzic-mcp agree. It is applied
  inside cli_main(), so those three are exactly the callers that do not
  get it. Comment corrected; moving the remap changes exit codes for
  library callers and is a separate decision.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…y the pin

package-lock.json was never committed: an active .gitignore rule excluded
it since the pin was introduced in cdf2c05, so npm install always resolved
whatever was current on the day rather than the pinned 4.3.1. Removed the
ignore rule and committed the lockfile; the pin now verifies against a
tracked artifact instead of trusting package.json alone.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…ot just present

Path.is_file() cannot tell a committed lockfile from one sitting ignored
in the working tree -- exactly how package-lock.json stayed untracked for
a week while this check reported clean on the machine that generated it.
Added a git ls-files check ahead of the existing pin-agreement check;
verified red in a disposable clone with the lockfile untracked, green
with it tracked.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
… index

git ls-files (used since the previous fix) is satisfied by a file that is
staged but not yet committed -- the same false-green shape the check was
built to close, one layer further out: just verify runs before a commit,
so a staged-only lockfile at that point still reports clean while a clone
would not have it. Switched to git cat-file -e HEAD:package-lock.json,
which only a real commit satisfies. Verified in a disposable clone by
soft-resetting to the commit before the lockfile existed, leaving it
staged: the old check would have passed this silently, the new one fails
it correctly.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
main.py's comment claimed the usage-error-exit-1 remap needed to move to
module scope for zenzic-mcp and other library callers to agree on it.
Neither premise held: zenzic-mcp imports only zenzic.core.*, and the only
direct app callers anywhere are this repository's own tests via
CliRunner(app), which never reach cli_main() and get Click's native exit
2 today -- accepted, since those tests choose the narrower Click-native
contract on purpose. The comment now states this measured history instead
of the stale claim, including why an earlier module-scope version was
replaced (it leaked into unrelated Click applications in the same
interpreter). One sentence added to the CLI reference's exit-codes section
stating the same boundary for external readers.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
pip-audit reported 11 known vulnerabilities and stopped `just verify` at
step 3: urllib3 2.7.0 (PYSEC-2026-4175/4176/4177, fixed in 2.8.0) and
virtualenv 21.7.4 (PYSEC-2026-4011/4012/4013/4014, fixed in 21.7.11 to
21.7.13; 21.7.13 closes all four). Each advisory has a single range, read
from its live OSV record on 2026-10-13.

Both are transitive. urllib3 arrives through requests, from
mkdocs-material and mkdocs-rss-plugin in the docs extra and from pip-audit
in the dev and release groups; virtualenv through nox (dev extra, dev and
release groups) and pre-commit (dev and lint groups). Neither is in the
built wheel's Requires-Dist, so the lock reaches no user of the package.
virtualenv 21.7.13 requires python-discovery>=1.6, so python-discovery
moves 1.5.2 -> 1.6.2; nothing else in the lock changes.

just verify without the verdict cache: 3821 passed, pip-audit clean.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…eives

anyio reaches zenzic through httpx (`httpx>=0.27,<1.0`), which does not
bound it, so the published 0.30.0 wheel lets an environment keep an anyio
below 4.14.2. Before that version anyio encodes a TLS host name with IDNA
2003 (GHSA-82r6-8w77-94w6, critical), and the external-link check reaches
that code: `_check_external_links` opens `httpx.AsyncClient`, and httpcore's
AnyIOBackend calls `anyio.connect_tcp` and `TLSStream.wrap`.

httpx encodes the host with the idna package (IDNA 2008) before httpcore
sees it, which is the advisory's own workaround. The new test proves it on
the real path, without network: for https://straße.de/ anyio.connect_tcp
receives xn--strae-oqa.de, not strasse.de (IDNA 2003) nor the raw name.
With httpx's encode_host replaced by the identity inside a test, it fails.

The floor `anyio>=4.14.2` joins the runtime dependencies; the lock already
held 4.14.2, so only the requirement is added. CHANGELOG entry under
Changed. ZENZIC_VERDICT_CACHE=0 just verify: 3822 passed, pip-audit clean.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
…dependency

CHANGELOG.md declares the Keep a Changelog 1.1.0 format, whose Security
section is where a fix for a vulnerability belongs; the anyio entry moves
there from Changed, placed after Fixed as that format orders it.

NOTICE listed anyio only as a transitive dependency of httpx. Since
8c49aa8 it is also a direct one, with the floor anyio>=4.14.2; the entry
now says both. reuse lint: 1076/1076, compliant.

ZENZIC_VERDICT_CACHE=0 just verify: 3822 passed, pip-audit clean.

Signed-off-by: PythonWoods <dev@pythonwoods.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants