Format follows Keep a Changelog; this
project doesn't have a release yet, so everything below is grouped under
[Unreleased].
- An authorized-use notice now ships with the tool and is surfaced
everywhere it matters:
formwatch legalprints it,docs/legal.mdcarries the long form,NOTICEand the README cover it, and the HTML report and community dashboard carry a footer.test/monitorprint a one-line reminder when the notice hasn't been acknowledged, and--submit(a real POST to a live system) now refuses to run until acknowledged via--accept-terms,accept_terms: truein config, orFORMWATCH_ACCEPT_TERMS=1. - New privacy and network controls:
--no-screenshots(omit captured page content, which can contain personal data),--proxyand--insecurefor corporate proxies/internal test hosts,--no-sandboxfor containers, and per-host request pacing (--per-host-delay-ms) plus an append-only, screenshot-free JSON audit log (--audit-log).
pruneno longer opens or parses any run file. Same anti-pattern asall_known_formsbelow, in the one command whose entire job is handling a lot of accumulated history:history::pruneread and fully deserialized every run's JSON just to sort byrun.timestamp, whichsave_runalready writes into the filename. Retention decisions are now made from filenames alone —prunenever touches a run's content at all, so a deployment that's been runningmonitornightly for months prunes exactly as fast as one just getting started.all_known_formsno longer parses every historical run to find the newest one.history::all_known_forms— behindformwatch report,serve's/metrics//api/forms, andbaseline --write— picked each form's newest run by fully parsing every run file in that form's directory and keeping the max timestamp, discarding everything else. Sincesave_runalready encodes the timestamp in the filename ({ts}.json/{ts}-{suffix}.json), the winner can be picked by comparing filenames and only that one file needs to be read and parsed — a form directory with months of accumulated history now costs the same as one with a single run. Also fixed a latent robustness gap this exposed: a single corrupted/truncated old run file used to failall_known_forms(and thereforereport/serve/baseline) for that entire form; it's now simply never read.report::buildalso had its own copy of theprevious_runfind-predecessor snippet — missed in the fix below — now uses the shared helper too.- History loaded once per invocation, not three times per form.
print_run(the CLI diff/flakiness display),notify::regressions_from_history(--webhook-url), andissues::events_from_history(--github-issues-repo) each independently calledhistory::load_runsfor the same URL — a full directory scan plus a parse of every historical run, screenshots included. With both integrations enabled (the README's own documented full CI setup), that was 3 full history reloads per form, growing with however much history has accumulated.main::load_prior_by_urlnow loads each URL's history once per invocation and shares it;notify::regressions_from_runsandissues::events_from_runs(renamed, since they no longer touch disk themselves) take that pre-loaded map instead. Also collapsed theprior.iter().rev().find(|r| r.timestamp < run.timestamp)"find the predecessor" snippet — copy-pasted identically in all three places — into onehistory::previous_runhelper. - LLM client reuse across a whole
monitorrun.--llmsemantic checks used to build a brand-new HTTP client (LlmClient::new) for every single form, discarding its connection pool immediately after — so a registry of N forms paid N separate DNS/TCP/TLS handshakes to the same provider host (openai.com, anthropic.com, ...) even though every form talks to the identical endpoint. The client is now built once pertest/monitorinvocation and passed by reference into every concurrent form task (runner::run_one_with's newllm_clientparameter), so the connection pool is actually shared. Client-build failure (rare) is now logged once instead of once per form, and still degrades every form's LLM checks to aWarn, never a dropped run.
-
GitHub Issues auto-tracking (
--github-issues-repo owner/repo,github_issues.*config,FORMWATCH_GITHUB_ISSUES_REPO/FORMWATCH_GITHUB_ISSUES_TOKEN): a check going to Fail opens a tracking issue, a check recovering to Pass comments and closes it, and Warn never touches issue lifecycle either way. Reuses the samehistory::diffprimitive as--webhook-urlregression notifications rather than a second notion of "what changed." A stable HTML-comment marker (form URL + check name) embedded in the issue body is used to find the same issue again, so repeatedmonitorruns never duplicate it. The token falls back throughgithub_issues.token→GITHUB_TOKEN(set automatically in GitHub Actions) →GH_TOKEN(theghCLI's convention) → a skip-with-warning if none is found, and the integration is never fatal to the run itself. Live testing against a real repository surfaced a genuine bug unit tests couldn't have caught: GitHub's issue-search index is eventually consistent, so a lookup moments after creating an issue can miss it and create a duplicate; fixed with a bounded retry-with-backoff before falling back to create. -
A reusable GitHub Action (
action.yml): any repository can adduses: voidstackloop/formwatch@vXto its own CI and runtestormonitorwith no Rust toolchain — it downloads the matching prebuilt release binary for the runner's OS (Linux/macOS x86_64+ARM64/Windows), reflects formwatch's own exit code, and always writes a full JSON report (exposed as thereport-jsonoutput) regardless of that outcome.scripts/test-action-locally.shexercises the action's own download/extract/invoke logic against a locally-built binary, so changes toaction.ymldon't need a real release to verify. -
A ninth check, Duplicate field names: flags form controls that share a
nameattribute with something other than a legitimate radio/checkbox group. Standard form encoding keeps only one value (or silently merges them) for a repeated key, so two different pieces of information submitted under one name means one vanishes with no client-side signal at all — not an accessibility issue (axe-core has no concept of submission semantics) and not covered by any other check. Always aFail: unlike Bot protection, this is a real defect in the form itself. -
A tenth check, Required-indicator mismatch: flags a field whose own label visually promises it's required (a
*, or the word "required") but isn't actually markedrequiredoraria-required="true". The visual promise and the real enforcement silently disagree — native validation and screen readers both treat the field as optional — so a user who skips it can submit incomplete data with no error at all. Not what axe-core's label rule checks (that's about a label existing, not about a required-looking one being honored), and not covered by any other check. Always aFail: the field's own label contradicts its own enforcement. -
An eleventh check, Input type mismatch: guesses a field's semantic purpose from its label the same way Autofill hints does, but checks the
typeattribute instead ofautocomplete— a field labeled like an email address or phone number left astype="text"(or untyped) gets no native format validation, and the wrong virtual keyboard on mobile (a full alphabetic layout instead of an email-optimized or numeric-telephone one). Deliberately scoped to email/phone only: both have a well-supported dedicatedtypeand an unambiguous label pattern, unlike e.g. a ZIP code, wheretype="number"would actively be the wrong call (it strips a leading zero and adds spinner arrows). A heuristic guess from label wording, like Autofill hints, so a mismatch is aWarn, not aFail. -
Optional LLM semantic checks (
--llm/llm:config): two provider-agnostic checks — "Error wording (LLM)" and "Instructions (LLM)" — that score the clarity of validation error messages and instructions/required-document guidance, addressing the heuristic wording limitation called out in the README. Providers are OpenAI (and any OpenAI-compatible endpoint via--llm-base-url), Anthropic, and a no-networkmock. The feature is off by default, never fatal (missing key / provider error / timeout / unparseable reply degrade to a singleWarn), redacts obvious PII, truncates input, and caches verdicts on disk. A low score is aWarnunless--llm-failis set;--llm-thresholdsets the pass bar. -
LLM resilience and safety hardening: transient provider failures (HTTP 429/5xx, network) are retried with exponential backoff honoring
Retry-After; the two semantic checks run concurrently; page text is wrapped as untrusted data with delimiter neutralization and an explicit instruction not to follow embedded directions (prompt-injection resistance); provider token usage is logged at debug level; andformwatch doctor --probe-llmverifies provider connectivity and credentials. -
Baseline / allow-list of accepted findings (
--baseline FILE,baseline:config,FORMWATCH_BASELINE, andformwatch baseline [--write]): a finding is suppressed only when a baseline entry for the same URL and check is at least as severe as the current status, so a baselinedWARNnever absorbs aFAIL. Baseline-aware behavior coverstest/monitorexit codes, a plain-text[BASELINED]annotation, and regression notifications; stale entries (improved or disappeared) are reported. This lets CI fail on new problems instead of failing on day one and being ignored. -
Deterministic sharding (
monitor --shard INDEX/TOTAL,shard:config,FORMWATCH_SHARD): checks only a stable slice of the configured forms, so a large registry can be split across several CI jobs or runners with no coordination — the union of all shards is the whole set and each form lands in exactly one shard every run. -
Baseline-aware JUnit and SARIF: with
--baseline, accepted findings render as<skipped>in JUnit (so an allow-listedFAILdoesn't fail CI) and asbaselineState: "unchanged"with a SARIFsuppressionsentry, so code-scanning UIs treat them as pre-existing rather than new. -
testandmonitoraccept--junit/--sarif(with--out), so a single CI job can run the checks and emit results in one step. -
report --prometheusrenders Prometheus text-exposition metrics (formwatch_forms,formwatch_checks_failing,formwatch_checks_warning,formwatch_check_last_run_timestamp_seconds, and per-checkformwatch_check_status) for a scrape endpoint or a node_exporter textfile-collector directory. -
A read-only service mode (
formwatch serve --addr HOST:PORT, configserve_addr, orFORMWATCH_SERVE_ADDR): serves/healthz,/readyz,/metrics(Prometheus), and/api/forms(JSON) over the accumulated history, binding to127.0.0.1:8080by default and running until Ctrl-C. Deliberately read-only — no endpoint accepts a URL, triggers a run, or submits anything, so exposing it can't make formwatch touch a third-party site. -
History retention:
formwatch prune (--keep-last N | --keep-days D) [--dry-run](defaults viakeep_last/keep_daysconfig orFORMWATCH_KEEP_LAST/FORMWATCH_KEEP_DAYS) trims append-only history per form, touching only*.jsonrun files. -
Streak/trend reporting: the report now computes each check's unbroken run of its current status. The plain-text report adds a
↳ FAIL for the last N run(s), since <date>line for chronic non-Pass checks, and the HTML report adds a streak badge plus a compact coloured trend strip of recent runs. -
The HTML report gained a client-side toolbar: search by name/URL, filter by status (failing / warnings / flaky / passing), sort by recency, severity, or name, and a live "shown/total" count — all self-contained, no external assets.
-
A bundled demo site (
formwatch demo, pages underdemo-sites/) with 35 deliberately varied forms covering every built-in check and known edge case: a clean baseline; missing labels / low contrast / missing alt / no landmarks; small tap targets and horizontal overflow; validation variants (vague errors, aria-required-only, no required fields, a required checkbox); document-upload variants; autofill; lost input vs. explained session loss; multi-step wizards including slow, keyup-gated, and icon-only-next variants; open, nested, and closed shadow roots; multiple forms on a page; RTL Arabic; reCAPTCHA, hCaptcha, Turnstile, and a full-page Cloudflare challenge; and the LLM-wording demos. Pages are compiled into the binary, socargo installgets them; nothing third-party is contacted. -
scripts/benchmark.sh+ BENCHMARKS.md: a reproducible benchmark over the demo site (9 forms / 72 checks, median 4.4s at--wait 1), with methodology and caveats. -
Structured diagnostics via
tracing, written to stderr so machine-readable stdout is never corrupted:-v/-vv/--quietand--log-format json. -
Configuration file and environment-variable support (
src/config.rs): aformwatch.yml(or--config PATH, or the user config dir) plusFORMWATCH_*variables, with CLI > env > file > default precedence. -
New machine-readable report formats:
report --junit(JUnit XML for CI dashboards;FAILbecomes<failure>,WARNbecomes<skipped>) andreport --sarif(SARIF 2.1.0 for code-scanning UIs). -
Regression webhooks:
--webhook-urlontest/monitorposts to a Slack incoming webhook or a generic JSON endpoint when a check regresses toFAIL(notify.on: alwaysto notify every run). -
formwatch completions <shell>emits shell completions; a man page lives inman/formwatch.1. -
Every persisted/JSON run now carries a
schema_version(history::SCHEMA_VERSION), so downstream consumers can detect a breaking shape change instead of silently misreading newer data. -
A typed
formwatch::error::Error(thiserror) alongside the existinganyhowcode, so embedders can match on failure cause; new modules are written against it andanyhow::Errorinterops via one variant. -
Container and supply-chain packaging: a multi-stage
Dockerfile,deny.toml(cargo-deny policy), a Homebrew formula template, a Dependabot config, and release checksums, a CycloneDX SBOM, and build provenance attestation in the release workflow. -
CI now also runs an MSRV check,
cargo deny, coverage (cargo-llvm-cov), and CodeQL. -
formwatch doctor [--json]: checks the local environment (Chrome availability, writable history/audit directories, config, legal acknowledgement) without running a form, and exits non-zero if something a run depends on is broken. -
formwatch notify-test [--webhook-url URL]: sends one synthetic regression so a webhook can be verified before relying on it. -
--fail-on fail|warn(alsofail_onin config andFORMWATCH_FAIL_ON): choose whether onlyFAIL, or alsoWARN, makes the process exit non-zero. Default is unchanged (fail). -
A
SECURITY.mdvulnerability-disclosure policy. -
formwatch init,formwatch test,formwatch monitor,formwatch report(plain-text,--html,--json) — the whole CLI. -
Seven built-in checks: submission flow (including multi-step wizard walking), accessibility (via vendored axe-core), mobile usability, validation errors, required documents, autofill hints, input persistence.
-
--checks-dir: run custom*.jschecks through the samepage.evaluate()mechanism the accessibility check uses, no forking required. -
--history-dir,--json,--wait,--submit,--headful,--name. -
A community registry/dashboard layer (
community-forms/,.github/workflows/monitor.yml,index.html) — scaffolded and locally verified, not yet deployed. -
CI (
fmt,clippy -D warnings,test, a build-only check on macOS/Windows,cargo audit) and a release build matrix (Linux/macOS/Windows binaries on a version tag). -
Full rustdoc coverage of the public API, enforced going forward via
#![warn(missing_docs)]. -
Every check that comes back Fail or Warn (including a custom check) now carries a full-page screenshot of the moment it finished, embedded in
--html/--jsonoutput — a report reader can see what the check actually saw without re-running formwatch against a possibly- already-changed page. A Pass carries no screenshot: it needs no evidence, and capturing one for every check on every run would just bloat history for no benefit. -
Flakiness detection:
formwatch report(plain-text and--html) now flags a check whose status has flip-flopped across its entire recorded history (2+ transitions), not just the single immediately- previous rundiffcompares against. A check that changed once and stayed changed is a genuine regression or fix, not flakiness — only the unstable, back-and-forth case gets the "Flaky" badge. -
monitor --delay-ms MS: paces how fast new forms start (default 0 — unchanged behavior), so a largeforms.ymlagainst real third-party sites can spread its load out instead of firing up toMAX_CONCURRENT_FORMSrequests at once. -
Page loads now retry up to 3 times with backoff (500ms, then 1s) before reporting "Page load: Fail" — a transient DNS blip or dropped connection against a real site no longer gets mistaken for the form itself being down.
-
An eighth check, Bot protection: detects reCAPTCHA, hCaptcha, Cloudflare Turnstile, or a generic "verify you're human" interstitial. Never a Fail — none of this is evidence the form itself is broken, a real person sails through a CAPTCHA fine — but without it, a page stuck behind a challenge just looked like "No
<form>element found," a real finding with the wrong explanation.
- Validation reported a false
FAILfor a correctly-described field whosearia-describedby(oraria-errormessage) listed more than one id: the check resolved the whole space-separated list with a singlegetElementById, which returns null for a list. Each id is now resolved, and either attribute counts as a described field. - LLM verdict parsing clamped an out-of-range score up to a passing 5,
contradicting the module's own contract that an out-of-range score is a
parse failure and letting a model-injected
{"score": 99}force aPass. It is now rejected (the check degrades toWarn), and verdict extraction is brace-balanced, so prose containing{}no longer discards a valid verdict. - LLM retries now apply to 429/5xx responses whose body isn't JSON (routine for gateway/proxy errors): the status is checked before the body is parsed, instead of the failed parse turning a retryable status into a permanent failure.
- LLM
Retry-Afteris honored up to its documented 60s cap instead of being silently re-capped to 8s.LlmOptions' hand-writtenDebugredacts the API key; the verdict cache key includes the resolved base URL (so switching--llm-base-urlno longer serves the previous backend's verdicts); and the Anthropic response parser finds the firsttextcontent block rather than assuming the first block is text. serve's/readyzreported ready for an existing but read-only history directory —create_dir_allreturnsOkfor any existing directory — so it now probes writability, and the server bounds concurrent connections and caps header-read time.formwatch baseline --write(Baseline::from_runs) recorded the first status seen for a repeated URL instead of the newest.formwatch doctorprobed the audit log's parent directory rather than an existing (possibly read-only) log file, so it could report writable while every append failed.--per-host-delay-mscollapsed every IPv6 literal onto one pacing bucket:host_ofsplit[::1]:8080on:and returned"[".--check-timeout-secs(via the wizard timeout) and the Chrome-open retry backoff could panic on overflow; both saturate now.- Webhook auto-detection treated any URL containing
hooks.slack.com(even in a query string or a look-alike host) as a Slack webhook; it now matches the host exactly. report --prometheus'sformwatch_forms_totalwas a gauge carrying the reserved counter_totalsuffix, whichpromtoolrejects; renamed toformwatch_forms.- JUnit XML output also drops the XML-1.0-forbidden U+FFFE/U+FFFF characters, and history loads same-second runs in a stable filename order so "the previous run" can't vary between invocations.
- The declared MSRV was wrong: the code (edition-2024 let-chains) and
several transitive dependencies (
home,icu_*need 1.88;idna_adapterneeds 1.86) require Rust 1.88, not 1.85, so the MSRV CI job failed.rust-version, the CI MSRV toolchain, and the Docker builder are now 1.88. - Mobile usability never reported horizontal overflow: the check compared
document.documentElement.scrollWidthagainstwindow.innerWidth, but under mobile emulationinnerWidthalready reflects the overflowed width, so the comparison was never true even on a page far wider than a phone. It now compares against the layout viewport (documentElement.clientWidth). Found while building the demo site, and now covered by a fixture + regression test. - The validation check counted the
<form>element itself as an unlabeled invalid field: the invalid-field query used a bare:invalidscoped at the form, andform:invalidmatches whenever any control inside is invalid. A form whose actual invalid field had a properaria-describedbywas therefore failed. Found by building the demo site; fixed to queryinput:invalid, select:invalid, textarea:invalid, [aria-invalid=true], with a regression fixture and test. - A truly cold Chrome cache failed to populate: the concurrent-safe
download created the cache's parent directory but never the private
temp directory the fetcher writes its archive into, so the very first
run on a machine with no system Chrome — or an empty cache — died with
a bare
Failed to create archive file: No such file or directory. Found while exercising the release test suite in a fresh environment; the temp directory is now created before the download. - All emitted errors/diagnostics now go through the logging pipeline, so
--log-format jsonproduces valid newline-delimited JSON on stderr instead of being interleaved with plain-texteprintln!warnings.
Found via code review and two independent review agents during development; every fix below was verified with a fixture reproducing the bug, not just reasoned about:
- Date fields were filled with a hardcoded past date, tripping a form's
own
min/maxconstraints for no real reason. - A session-timeout detector matched bare "timed out" anywhere on the page, false-positiving on unrelated copy like "connection timed out".
- Forcing one required field invalid used
.value = '', a silent no-op for checkboxes/radios — a required checkbox as the first field never actually got tested. - With
--submit, every check after the real submission silently ran against whatever page the site's confirmation landed on, not the form. - Any single check erroring (or, later, hanging) lost every other check's result for that form instead of being isolated.
- An unreachable page (DNS failure, connection refused, blocked port) wasn't detected — Chrome's own error interstitial was silently treated as a real page, and the full check suite ran against it.
- URLs differing only by a hyphen vs. underscore collapsed to the same history directory, silently merging two different forms' history.
- Two runs completing within the same wall-clock second could clobber each other's history file.
- The plain-text
formwatch reportnever showed "changed since previous run", unlike--html. monitor.yml's exit-code contract meant it would never actually publish results, since finding a real Fail is the normal case, not a workflow failure.- Every check-engine selector was unscoped, so a page with more than one
<form>(e.g. a header search box) could be filled, validated, or — with--submit— actually clicked on the wrong form entirely. - Fields marked only
aria-requiredmade validation checking a silent no-op that always reported a false Pass. - Icon-only Next/Submit buttons (aria-label, no text) were invisible to the button classifier.
- A quote in an element's
id(legal HTML) crashed a hand-built CSS selector instead of just not matching. check_input_persistencehad the same unscoped-selector bug as above, missed in the first pass — a false negative, not just a false positive: it could report "input retained" while the real form's input was actually lost.monitorexited 0 on a missing or broken YAML config, contradicting its documented CI-safety contract.formwatch init subdir/file.ymlfailed ifsubdir/didn't exist yet.- An unmaintained
async-stdruntime was compiled in for no reason — a transitive default feature of an unrelated dependency. - No timeout existed anywhere in the check engine; a hung page or custom check would block a run (and, with concurrency, a whole concurrency slot) forever.
- Two formwatch processes downloading Chrome-for-Testing at the same time on a machine with a cold cache could corrupt each other's extraction ("corrupt deflate stream"). Each caller now downloads into its own private temp directory and atomically renames it into place; whichever caller loses that race discards its own copy instead of colliding with the winner's.
- A form (or field, or label) rendered inside an open shadow root —
common in modern government-site design systems built on web
components — was completely invisible to every check, which reported
"No
<form>element found" as if the page had no form at all. Every selector-based lookup now pierces open shadow roots. Closed shadow roots and cross-origin<iframe>s remain genuinely out of reach (real platform limits, not gaps), and are documented as such. - Every
browser::launch()shared a single fixed Chrome profile directory by default (a chromiumoxide default, not something formwatch ever set). Harmless launching one at a time, but two concurrent launches collide on Chrome's own SingletonLock for that shared profile and one fails outright — invisible in local runs, but hit reliably the first timecargo testran on GitHub Actions' faster, more-parallel runner. Each launch now gets its own unique profile directory. formwatch report --out some/new/dir/file(plain--jsonor--html) failed with a bare "No such file or directory" if that directory didn't exist yet — the same bug already fixed once forformwatch init subdir/file.yml, in a command that fix never reached. Found by the community-monitor workflow's own first real run, writing to aresults/directory nothing had created yet.fixtures/input-vanishes-form.html's test was flaky on a loaded CI runner: itssetTimeoutcounted from page load, not from when formwatch actually got around to writing its marker value, so a slower machine could clear the field before the marker was ever set — silently defeating the whole scenario instead of testing it. Now waits for the field's owninputevent (which formwatch's own marker-set dispatches) before scheduling the clear.
- Wizard-step button recognition now covers English, Spanish, French, German, Portuguese, Arabic, and Chinese wording (not full i18n, but meaningfully wider than English-only), and the word list was de-duplicated into one source of truth used everywhere.
- Field filling now dispatches
keydown/keyupin addition toinput/change, so a Next button gated on typing (not just value changes) is recognized correctly. - The fixed 700ms wait after clicking a wizard's Next button was replaced with polling — faster for the common case (most transitions are near-instant) and more tolerant of a genuinely slower one.
monitorchecks up to 4 forms concurrently instead of one at a time.