Repository navigation
feat: v0.31.0 Specification-Driven Development, Security Hardening & Ecosystem Governance - #233
Open
PythonWoods-Dev wants to merge 594 commits into
Open
PythonWoods-Dev wants to merge 594 commits into
PythonWoods-Dev wants to merge 594 commits into
Conversation
PythonWoods-Dev
force-pushed
the
feat/v0.31.0-epic3-sdd
branch
from
August 25, 2026 16:51
adbc63e to
ced8f71
Compare
PythonWoods-Dev
force-pushed
the
feat/v0.31.0-epic3-sdd
branch
2 times, most recently
from
September 6, 2026 09:09
318b228 to
65305b9
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 beenmasked 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:
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 acceptedresidual 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 arebin,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: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/passwdwith any contentsturned every
/etc/passwdlink in the corpus from exit 3 into an exit 1 that--exit-zerothen 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:
A second gate is fixed in the same commit.
zenzic guard scan --stagedreturned exit0when it could not query the git index at all — a corrupt index, a non-repository workingdirectory, or a
gitmissing fromPATH— with output byte-identical to a clean run. Acommit 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)
Z521(required table columns),Z522(table cell enum whitelisting),
Z523(heading order),Z412(cross-namespace graphtraceability) — 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 newon a new[doctor]config surface,zenzic fix --rename OLD NEW, and afixablefield surfaced injson/sarifoutput andinspect codes.Dual-track distribution & ADR-089
global-ephemeral) and extended ADR-089 immutable SHA pinning as an ecosystem-wide mandate
across
zenzic,zenzic-action, andzenzic-vscode.Security hardening
code_severity(),_ALWAYS_EVALUATED_CODES), closing hardcoded-severity bugs across_check.py,rules.py,incremental.py,scanner.py,governance.py,suppressions.pyandcontent.py— nowguarded by a single comprehensive AST structural test superseding nine narrower ones.
--onlycould silenceZ2xx findings entirely;
excluded_dirs/excluded_file_patternssilencedZ202/Z203/Z205(only the credential half of the tier was covered); a leading../onan absolute href defeated traversal detection entirely; a relative
../hop to a real repofile named like a system root misfired non-suppressible
Z203instead ofZ202; andexcluded_external_urlsprefix matching was vulnerable to host spoofing (CWE-20).zenzic auditnow routes through the same exit-code authority ascheckandguard, so acredential breach can no longer report exit 1 there while
check allcorrectly exits 2.Fixed at the single seam where all three codes are produced rather than at the fifteen
extract*call sites. Verified by execution: four bypasses0 → 2, traversal-via-math0 → 1, closed fence stays0, and a commented-out broken link stays0so the qualitytier is unmoved.
zenzic guard scan --stagedno longer fails open._staged_doc_filesreturned an emptylist 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_secretsonefunction 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.
_validate_plugin_codewas checking attributesBaseRuleneverdefines and was a complete no-op — it now validates the real
rule_id, withcore-distributed entry points correctly exempted from the third-party namespace check.
LSP hardening & capabilities
textDocument/willSaveWaitUntil), auto-repair ofinbound links on rename (
workspace/willRenameFiles), and hover-based suppressionexplanations.
alone with no location check; a redundant ghost-diagnostic-clearing pass that could erase a
live security finding; a
didChangefull-sync message missing"text"silently blankingthe buffer; and a crashed code-action mutation producing no signal.
Documentation
reference/,how-to/,explanation/,developers/) plus the landing page and the Z-Code Gallery — hundreds offactual corrections (stale exit-code claims, fabricated examples, wrong Zensical/MkDocs
capability claims, dead code references) verified against live execution rather than
restated from memory.
tooling / pre-commit distribution). All remain
draft: truepending a tagged release.CI & tooling
just verifyand made hard-blocking (fail_under=80).git-hook-installation gate so an uninstalled pre-commit hook cannot silently pass a whole
session.
Test plan
pytest tests/— full suite passingruff check/ruff format --check/mypy— cleanzenzic check all --strict --no-header— DQS 98/100, gate passedzenzic lab all— all gallery scenarios meet expectationsmkdocs build --strict— clean buildjust verify— clean (pre-commit, pip-audit, coverage-enforced pytest, structuralaudit, docs build)