Tag-studio salvage assessment
Epic #3866. This is a read-only review of existing code, done to decide what the new engine can salvage. The engine is a Rust core compiled to WASM and to native code. It provides a normalized structure model, typed edit ops with an op log and undo, content-stream rewriting with a pixel-identity gate on save, lossless round-trip, and PDF/UA-1 validation.
The review covers engine-level and validation-level logic, algorithms, rules, fixtures and knowledge only. The UI of the existing editors is out of scope, apart from the pain points in the last section.
Nothing was built or run for this review. LOC figures are line counts. Test counts are
grep counts of def test_ / it( / test(.
Verdict key
| Verdict | Meaning |
|---|---|
| PORT | The algorithm is worth re-implementing in the Rust core. The existing tests become its spec. |
| REUSE-AS-IS | Keep it and call it (a server-side service), or keep the data (fixtures, rule catalogs). |
| REFERENCE | Knowledge only. Read it before designing the matching feature. |
| DISCARD | Nothing to carry forward. |
Summary table
| Component | Lang | Size / tests | Verdict |
|---|---|---|---|
apps/tag-editor engine (src/pdf, struct, edit, save, inject, audit) | TS | 7.3k LOC (about 4.1k engine), about 84 tests | PORT (best prior art) |
apps/tag-editor/src/sync (PDF.js geometry) | TS | small | REFERENCE (stays in JS next to the renderer) |
packages/tag-editor-ui | TSX | 6.2k LOC, about 143 tests | DISCARD UI; REFERENCE lib/triage.ts, lib/tableGrid.ts, lib/outline.ts |
apps/admin/src/components/tag-editor | TSX | about 3.2k LOC | DISCARD (a divergent copy of the above) |
packages/editor | TS | 8.8k LOC | DISCARD (HTML/DOM remediation, not PDF) |
apps/web /tags and /workspace editors | TSX | — | DISCARD (UI over flyer-tagger ops) |
services/flyer-tagger inject / promote / structure / structedit / decorative | Python (pikepdf, PyMuPDF) | 30k LOC, about 1,700 tests in 107 files | PORT selected algorithms |
services/flyer-tagger verify / tree_findings / conformance / tree_assess | Python | about 1.4k LOC | PORT rules; REUSE-AS-IS pixel_diff numbers and the veraPDF/qpdf calls |
services/flyer-tagger AI pipeline (analyze, textract, ocr_tag, fontsub, tounicode) | Python | — | REUSE-AS-IS server-side (phase 5 AI/bulk) |
services/verapdf + workers/api/src/services/verapdf-client.ts + pdf-ua-claim-gate.ts | Python/Java + TS | 128 + 235 + 125 LOC, 19 tests | REUSE-AS-IS |
tools/tag-lint | Python | 700 LOC, 27 tests | PORT (rules and the Form-XObject MCID walk) |
tools/pdfpolish repairs (structrepair, tagtree, inlinewrap, docmeta) | Python | 3.8k LOC, about 72 tests | PORT as quick-fix ops |
tools/pdfpolish/pdfpolish/verifygate.py | Python | — | REFERENCE (strict and bbox-scoped pixel modes) |
packages/pdf-enhance | Python | 1.2k LOC, 63 tests | REFERENCE (the invariant-diff idea only) |
tools/pdf-inventory/src/census (ua1_rules_catalog.json, clause-class-map.json) | JSON/Python | 106 rules | REUSE-AS-IS as the rule ID space |
workers/api/src/services/pdf-accessibility-scorer.ts | TS | 2.1k LOC, 93 tests | REFERENCE (check list and WCAG map); do not port the regex-over-bytes code |
workers/api/src/services/pdf-manual-risks-structure.ts | TS | 349 LOC | PORT as advisory rules |
tools/pdfdump/pdfdump.py | Python | 657 LOC, no tests | REFERENCE |
tools/tag-pdf | Node | 400 LOC | DISCARD (thin client of the tagging API) |
WCAG §5.2 conformance reqs (packages/shared/src/conformance-requirements.ts) | TS | 189 LOC, 5 reqs | REFERENCE; report layer only. Unmerged, on origin/feature/pdf-conformance-requirements |
tools/tag-corpus (#3856, PR #3862) | Python | 24 unit tests | REUSE-AS-IS (walker, spec format, record/replay, lint flags). Unmerged, on feature/tag-corpus-tooling |
docs/knowledge/print-accessibility | MD + rules.json | 117 PRINT-* rules | REFERENCE (print-side counterparts to tree rules) |
docs/admin/tag-tree-quality.md | MD | — | REFERENCE, required reading |
Component notes
apps/tag-editor: PORT
This is a client-side editor with its own PDF reader and writer. Its design already matches what the Rust core needs, so it is the closest prior art.
PDF layer (src/pdf/*, about 1.1k LOC). REFERENCE only. It is a hand-written
lexer, parser and xref reader that handles:
- classic xref tables, xref streams, hybrid files and object streams;
- a recovery scan (files opened this way are view-only).
It supports only two filters, Flate (with predictors) and ASCIIHex, and it refuses encrypted files. The Rust core should use lopdf or its own parser, and match this layer’s strict/recovery split.
Struct model (src/struct/model.ts, parse.ts). PORT.
- Elements live in an arena keyed by
NodeId. - A kid is a tagged union:
element | mcid{mcid,pageKey,stmKey?} | objr{objKey,pageKey?}. - Unknown keys are kept verbatim in
extra. /Aand/Care carried opaquely.- ClassMap, Namespaces and IDTree go into
rootExtraand are re-emitted untouched. This is the lossless-round-trip pattern to keep. parseKidshandles MCR (including/Stm), OBJR and inherited/Pg.- The walker detects cycles, caps depth and turns malformed kids into warnings.
Gaps the port must fix:
mappedRolefollows the RoleMap one hop only.- Form-XObject MCIDs are not verified.
- There is no attribute or ClassMap editing.
Edit ops (src/edit/commands.ts, history.ts). PORT the semantics. It is a
command pattern with apply/revert:
SetRoleSetTextProperty(T, Alt, ActualText, E, Lang)Move(refuses cycles)WrapUnwrap(refuses content directly under root)DeleteMergeSplit(structure-level only)Reclaim(orphan MCIDs)Composite(atomic, with partial-apply rollback)
Deleted subtrees stay in the arena, so undo keeps the original identities. This is the seed of the Gevell Tags op log. Known defects:
Deleteleaves content untagged instead of artifacting it (commands.ts:188).Reclaimappends at the end of the root, which puts reclaimed content in the wrong reading order.
Save (src/save/*, src/struct/build.ts). PORT all of it.
buildStructObjectsrewrites elements under their original object numbers, so IDTree and/Refstay valid.buildParentTreerebuilds the ParentTree from scratch.- It assigns missing
/StructParents. writeIncrementalUpdatewrites a true incremental update and matches the file’s xref style.
verifySavedFile is the model for Gevell Tags’ save gate. It refuses to hand back
the file unless every check passes:
- the original bytes are an untouched prefix of the output;
- the new xref is in effect;
- the file reparses strictly;
- every
/Pback-pointer is correct; - the ParentTree agrees with the tree for both MCIDs and OBJRs;
- every referenced MCID exists in content, including named
/Properties; - the page count is unchanged;
- page content is byte-identical.
The one flaw: the ParentTree is a single flat /Nums array. Tag-studio needs a
balanced number tree with /Kids for large documents.
Content (save/content-scan.ts, struct/orphans.ts, inject/*). PORT.
mcidsInContentis a BDC/MCID scan that skips inline images safely.findOrphanedMcidsdiffs content MCIDs against tree MCIDs.planMarkerInsertions/injectMarkers/removeInsertionsare insertion-only marking. Verification strips the insertions and must get back the original stream exactly. That is a cheap, render-free pixel-safety proof, and it is worth keeping as a first gate in front of the render diff.
The inject path has two limits: it works on fully unmarked documents only, and it groups at line level.
Audit (src/audit/rules.ts). PORT as the first live rules. There are 9 rules:
doc-langnonstandard-rolefigure-altheading-skipmultiple-h1table-structuretable-headerslist-structureempty-tag
Tests. The round-trip tests become engine tests. They cover:
- classic xref, xref-stream, object-stream and hybrid files;
- OBJR, MCR, RoleMap and IDTree;
- verifier tamper cases;
- inject and reclaim.
There is also a real-corpus no-op round-trip over TestFiles/.
services/flyer-tagger: PORT selected algorithms, REUSE the AI pipeline
This is an in-place tagger written in Python. It uses pikepdf for structure and
content-stream work (via parse_content_stream/unparse_content_stream; there is no
custom lexer) and PyMuPDF for inventory and rendering.
The pipeline runs in this order:
preflightinventoryanalyze(vision)inject(content rewrite)structure(tree and ParentTree)- post passes
verify- the
cli.evaluategate
PORT:
-
inject.py::Injector._rewriteis the proven wrap-untagged-content algorithm. It:- simulates the CTM (q/Q/cm), Tm/Tlm (BT/Tm/Td/TD/T*/’/“/TL) and the current font, including across nested forms;
- strips existing marks and renumbers MCIDs per page;
- snaps each show op’s device-space origin to an inventory line;
- buffers text objects until ET, then uses
flush_bt(one BDC outside BT when a single element owns the whole object) orflush_bt_mixed(marks inside BT/ET, merging consecutive same-element shows into one MCID); - wraps paths,
shand inline images as/Artifact, and turns imageDointo a Figure MCID or an Artifact; - artifacts whitespace-only text, decided font-first through ToUnicode (
_shows_only_whitespace).
It does not split a Tj/TJ at word boundaries. Tag-studio’s word-level split is new work.
-
promote.py:classify_form(empty / artifact / elem / mixed),split_form/build_slices(slices a mixed Form XObject at q..Q boundaries),expand_shared_forms(clones shared forms), andgs_is_risky. Forms are promoted instead of using/StmMCRs because Acrobat and GrackleGo mishandle/Stm. Keep that rule. -
structure.py:StructureContextand the ParentTree section ofbuild_structure. Keep these rules:- one key per page, shared by the page stream and every fallback form on it;
- MCIDs are page-global, so keys never collide (#2455/#2456);
- the page always gets
/StructParents, because pdf.js only reads that key; - OBJR keys are appended after stream keys so the number tree stays sorted.
Multi-page accumulation fixes #2155 (the multi-page tree rebuild).
-
decorative.py:_rewrite_wrapper,_promote,_demote. These are the only existing artifact ⇄ un-artifact ops. They swap the single BDC/BMC operator at an index and patch the ParentTree slot: null on demote,max+1MCID with null padding on promote. This is the template for Gevell Tags’artifact/unartifactops. -
structedit.py:set_roleand_heading_outline_ok, the whole-tree PDF/UA 7.4.2 rule against skipped heading levels;restructure_table,_validate_restructure_grid(exact tiling, spans in bounds, each source cell used once, valid Scope) and_apply_cell_attrs;_has_marked_content, used to refuse dropping a cell that has content;reorder_children;- label decoding:
_parse_tounicode,_font_cmap,_decode_glyphs,_join_mark_texts.
Keep the #2485 codespace fix: simple fonts use 1-byte codes even when they declare a 2-byte codespace.
-
table_headers.py:compute_headers,apply_headers_ids,build_id_tree. These build/ID,/Headersand the IDTree for complex tables. -
textdecode.pyandgeometry.Mat. -
altedit.set_alt, together with the meaningful-alt floor (#2836).
REUSE-AS-IS (server side):
verify.pixel_diff. It renders both files with PyMuPDF at 150 dpi, uses a per-channel tolerance of 2, and gates atPIXEL_THRESHOLD = 99.95% per page (cli.py:97). Use the same numbers for the Rust gate so results stay comparable.verapdf_checkandqpdf_check.- The AI pipeline:
analyze,textract_ocr,ocr_tag+glyphless_font,fontsub,tounicode.add_missing_tounicode,cidgidmap, and thelinksdescribe-fetch.
REFERENCE:
formfields.py,links.py,otherannots.py(OBJR creation, Form/Link/Annot nesting,_interleave_annotations);table_semantics.py,cell_grid.py,table_grid.py(see the span-counting bug intag-tree-quality.md);source_table_prior.py, which is the “narrowest thing, never downgrade, fail closed” pattern;heading_shape.py,heading_rank.py,repeat_text.py,list_labels.py,formula.py,outlines.py,recolor.py;sourceview.pyandsourcediff.py.
DISCARD:
deploy/*, the positional-identity op transport (#2378), full-file rewrites on every edit,checkpoint_file, anddevcost.
tools/tag-lint: PORT
This is a rules-as-data linter (#2524, phase 1) with 10 rules:
list-l-direct-childli-missing-lbllbody-bullet-in-textfigure-missing-altempty-tagwhitespace-only-tagheading-skipduplicate-adjacent-tagdecorative-path-in-textredundant-wrapper
Port these pieces:
walk.py: the MCID-level walk that recurses into Form XObjects the way Acrobat does, with per-MCID glyph and path counts.- The
Findingshape:rule_id,severity,matterhorn,wcag,path. engine.py: runs each rule in isolation, so one broken rule cannot take down the lint.
Fix the Matterhorn IDs while porting. empty-tag is labelled 01-004, which is actually
“tagged content inside an artifact”, and 01-005 is a loose fit. Nothing else in the repo
consumes tag-lint yet.
tools/pdfpolish: PORT the repairs as quick-fix ops
repairs/structrepair.py: RoleMap (Matterhorn 02), ParentTree, ParentTreeNextKey, and MCID orphan reconciliation.repairs/tagtree.py: removes empty elements and wrapper elements while keeping the ParentTree slots consistent.repairs/inlinewrap.py: wraps ILSEs (Link, Figure, Span, Quote) that sit directly under a grouping root. It re-parents the element in place and changes only its/P, which means no ParentTree edit is needed. This is a clean worked example of a structure op that needs no content change.repairs/docmeta.py: MarkInfo, Title, DisplayDocTitle,/Lang,/Tabs /S.repairs/ported.py: OC config/Nameand CIDToGIDMap.
verifygate.py is a REFERENCE. It has two pixel modes: strict identity, and
“changed pixels only inside the targeted bboxes”. The second is what recolor-type edits
will need later.
The files in output/ and reports/ include real university documents. Check their
provenance before using any of them as fixtures (see “Never commit a customer PDF” in
tag-tree-quality.md).
Question 1: Do we already have a PDF/UA-1 / Matterhorn checker?
We have no native checker. We do have a real veraPDF deployment.
What exists:
services/verapdf/server.pyis a Flask wrapper aroundverapdf/cli, called asPOST /validate?flavour=ua1. It runs as a sidecar on the EC2 anchor (VERAPDF_URL=http://verapdf:5002).workers/api/src/services/verapdf-client.tsparses the veraPDF JSON into{clause, testNumber, passed, failedChecks}.pdf-ua-claim-gate.tsstripspdfuaid:partunless veraPDF confirms the file passes (fail-closed).- flyer-tagger bundles the veraPDF jars and calls them from
verify.verapdf_check. pdfpolish and the census tooling call veraPDF too. tools/pdf-inventory/src/census/ua1_rules_catalog.jsonlists all 106 veraPDF UA-1 rules.clause-class-map.jsonassigns each one to class A (structural, 101) or class B (needs authored text, 5).docs/admin/products/pdf/matterhorn-automation-plan.mdmaps all 31 Matterhorn checkpoints and 136 failure conditions (87 machine, 47 human, 2 with no test) to veraPDF clauses.
What the home-grown checks cover. They are quality and heuristic layers: tag-lint,
tree_findings, the apps/tag-editor audit rules, pdf-manual-risks-structure, and the
regex scorer. Between them they touch about 10 checkpoints (01, 02, 06, 07, 11, 13, 14,
15, 16, 28, part of 09) and none of them exhaustively. Nothing native covers:
- checkpoint 10/31 (fonts, ToUnicode and embedding: the largest deterministic cluster);
- checkpoints 19, 20, 21, 25, 26 and 30;
- the 7.2 nesting-legality rules, which are 41 of the 106 veraPDF rules.
Recommendation: use both a ported checker and veraPDF, each at its own layer.
-
Live validator: port into Rust, running against the in-memory model. Use the veraPDF UA-1 clause IDs from
ua1_rules_catalog.jsonas the rule ID space, so live findings and veraPDF findings line up. Start with the rules that are cheap on a tree:- 7.1: standard types or RoleMap resolution with cycle check, content not tagged, Suspects, MarkInfo, DisplayDocTitle, Lang;
- 7.2: the nesting table (the biggest win);
- 7.3: Figure Alt;
- 7.4: headings;
- 7.5: tables;
- 7.9: notes;
- 7.18: annotations,
/Tabs,/StructParent; - 5:
pdfuaidin the XMP.
The “no false passes” requirement in #3866 means the live validator must label each rule as checked / not checked by this engine. It must never show green on the strength of a rule it did not run.
-
Authoritative verdict: keep calling veraPDF server-side on save and export, and route the result through
pdf-ua-claim-gate.ts. Leave the byte-level rules (7.21 fonts, CMaps, embedding) to veraPDF. Do not reimplement veraPDF in WASM, and do not ship the regex scorer as a validator.
Question 2: What content-stream rewriting has proved pixel-identical in production?
- flyer-tagger
inject.py+promote.py. This is production preserve-lane tagging on Lambda and the host lane. It only ever wraps painting ops: it inserts BDC/BMC/EMC, strips old marks, and slices or clones Form XObjects. Every output is gated bypixel_diff(150 dpi, tolerance 2, ≥ 99.95% per page), qpdf and veraPDF. About 27 test files assertpixel_diff, includingtest_beacom_e2e,test_multipage,test_structeditandtest_decorative. - flyer-tagger
decorative._rewrite_wrapper. This is the production artifact ⇄ Figure swap behind the /tags editor. It replaces one operator and is pixel-safe by construction. It is re-verified after every edit withorphaned_mcids == 0plus qpdf. - apps/tag-editor
inject/content-marker.ts. It is insertion-only, and verifies by stripping the insertions and comparing with the original byte for byte. That works as a proof without rendering, but it only applies to fully unmarked files, and its live usage is small. packages/tag-editor-uirecolor andborder_recolor. These change paint on purpose, so they are not pixel-identical. REFERENCE only.
Not proven anywhere: splitting a show op at word boundaries, which needs TJ-array splitting with a Td re-anchor and glyph-width advance. Also not proven: un-artifacting arbitrary text (anything beyond the image wrapper swap). Both are new engine work and must go through the render-diff gate from the start.
Question 3: Which fixtures and tests should become engine round-trip tests?
No-op round-trip (open, save with no edits, byte-prefix-identical, reparse, ParentTree consistent, render-identical):
docs/standards/*.pdf: 14 tagged spec PDFs. Several are Acrobat PDFMaker for Word exports (PDF-Declarations.pdf,PDF20_AN001-BPC.pdf), andTagged-PDF-Best-Practice-Guide.pdfis Word + axesWord. These are the best real-world, redistributable set, including the array-valued/StructTreeRoot /Kshape that burned #3472.tools/pdf-inventory/src/census/fixtures/pdfs/: 6 files, including a PDFMaker form, a Publisher file and a scan.tools/contrast-sweep/fixtures/{complex,form}.pdf.TheAccessible-Contact-Form*.pdfin the repo root: tagged AcroForms, one of them UA-2.workers/api/src/__tests__/fixtures/image-of-text/: 10 parser-robustness edge cases (degenerate CTM, unbalanced form stream) andlink-annots/weasyprint-inline-formatted-links.pdf.services/flyer-tagger/fixtures/:beacom-flyer.pdf(Canva) withbeacom-analysis.json, which gives deterministic replay;beacom-flattened.pdf(OCR lane); andMathematics Review.pdf(pdfTeX, formulas).- The R2 corpus:
RCL2DEV:accessible-dev-assets/TestFiles(the target of the apps/tag-editor corpus test) andtag-corpus/docs/*once #3862 lands.
Op-level spec tests to port as Rust tests:
- apps/tag-editor:
roundtrip.test.ts(classic, xref-stream, ObjStm, hybrid, OBJR, MCR, RoleMap, IDTree),commands.test.ts,advanced.test.ts,reclaim.test.ts,inject.test.ts, the verifier tamper cases, andfixtures.ts/fixtures-advanced.ts, which are in-code PDF builders. - flyer-tagger:
test_structedit,test_decorative(the demote → promote round trip),test_multipage,test_page_level_tagging,test_whitespace_ops,test_tounicode,test_textdecode,test_nested_paths, and the synthetic builders intests/conftest.py(text_pdf,image_pdf,form_xobject_pdf). - tag-lint: the hand-built tree tests, as the validator spec.
- pdfpolish: the
structrepair,tagtreeandinlinewraptests, as quick-fix specs. tools/tag-corpus/tests/pdfbuild.py(on the PR branch): synthetic PDFs built the way Word builds them.
Question 4: Which semantic tag-tree rules belong in the live validator?
These are the rules veraPDF cannot see, from #3856 (tools/tag-corpus lint.py /
invariants.py), flyer_tagger/tree_findings.py, tools/tag-lint, apps/tag-editor
rules.ts and pdf-manual-risks-structure.ts. They should run live against the
in-memory model with their own ID prefix, as findings, never gate failures, following
the tag-tree-quality.md rule that “a clean conformance gate is a floor, not a verdict”.
| Rule | Source(s) |
|---|---|
no_lang (document or part) | tag-corpus lint, tag-editor doc-lang |
| Figure or Formula without Alt | tag-corpus, tag-lint, tag-editor, formula.formula_alt_problem |
Heading level skip, first heading not H1, h1_flood / multiple H1, empty heading | tag-corpus, tag-lint, tag-editor |
Heading that is really a paragraph (heading_shape decision table) | tree_findings.headings_that_are_paragraphs |
Running header, footer or page number tagged as content, or tagged as H/Title (margin_text_is_artifact, not_in_role) | tag-corpus invariants, pdf-manual-risks-structure |
mcid_referenced_twice / layered duplicate text (no_duplicate_text), duplicate-adjacent-tag | tag-corpus, tag-lint |
untagged_text / tree text coverage below floor (0.80), orphaned MCIDs | tag-corpus, tree_assess, verify.orphaned_mcids, struct/orphans.ts |
undecodable_text (no usable ToUnicode) | tag-corpus lint |
empty-tag / whitespace-only tag / empty_elements (ignoring Word’s empty BDC EMC runs) | tag-lint, tag-corpus |
element_spans_columns (per-page gutters) | tag-corpus invariants |
| Reading-order jumps at column boundaries | tag-corpus reading_order |
| List structure: L > LI > Lbl/LBody, missing Lbl, bullet glyph in LBody, adjacent lists that should merge, sublist beside its item rather than inside LBody | tag-lint, tag-editor, tree_findings |
Table: TR/TH/TD parentage, table_without_th, missing row headers (#3771), table_mostly_empty, empty_table | tag-editor, tag-corpus, tree_findings |
| Link with no content (OBJR only) | tree_findings._check_empty_links, verify.link_coverage |
| Title diverges from cover | tree_findings.title_divergence |
| Inline element (Link, Figure, Span) directly under a grouping element | pdfpolish inlinewrap |
| Redundant wrapper, non-standard role without a RoleMap entry | tag-lint, tag-editor |
The work of reviewing the corpus specs belongs to #3856. Tag-studio should consume the same rule module, as #3856 plans: one rule set used for editor highlights, the server-side gate and the review-risk report.
Existing-editor pain points (do not repeat)
- Positional identity. The /tags editor addresses nodes by flat index or child path. Every edit means a refetch, collapse state is lost, and conflicts and table 404s follow a reshuffle. Tag-studio needs stable node IDs that survive edits and saves.
- A server round-trip and full rewrite on every edit. Each click triggers a Lambda download, a pikepdf edit, a full save, and a qpdf/veraPDF reverify. There is no undo on the server editors.
- A narrow op set. Re-roling is limited to H1–H6 and P. Reordering is refused for containers that hold content directly. There is no wrap, unwrap, split, merge, general attribute editing, or Lang/ActualText editing.
- Two tree views with separate state. The flat
walk_elementsview and the nestedwalk_treeview are deliberately kept apart “so they don’t fight”. - Delete leaves content untagged, not artifacted, and Reclaim appends orphaned content at the end of the root, in the wrong reading order.
- Content-changing ops reload the session. In apps/tag-editor, inject and recolor invalidate the NodeIds held by undo, so they are refused while edits are unsaved. Content edits must be first-class ops in the log.
- Weak highlights. Canvas highlights come from heuristic PDF.js text geometry (height × 1.2). Multi-page elements get a null bbox. MCIDs inside Form XObjects are not highlighted.
- Performance. Every edit bumps a React version and re-renders the whole editor, and the tree is not virtualized.
- Flyer-only scope. The /tags and /workspace editors require a delivered flyer job, and generic upload is still open (#2377, #2379). The UI also exists as two divergent copies (apps/admin and packages).
- Validation split across four places (the editor rules, tag-lint,
tree_findings, veraPDF), each with its own IDs, and none of them saying what it did not check.