Contributing
How the library is put together, for when you want to change it: generated code, adding a rule, the conformance tests and these docs. Setup, commit conventions and releases are in CONTRIBUTING.md.
Generated code
The model types, the runtime schema, the code lists, the rule tables and the reference pages of this site are generated from the pinned specification artefacts by scripts/codegen/. Never edit them by hand: every such file starts with an AUTO-GENERATED header.
pnpm spec:fetch # download the pinned artefacts into spec/vendor
pnpm codegen # regenerate
pnpm codegen:check # fail when regeneration changes a committed file (CI runs this)Change the generator, run pnpm codegen, and commit the generator with its output. A pre-commit hook rejects generated files staged without a generator or spec/spec.lock.json change.
Adding a rule
Rules live in packages/validator/src/rules/<family>/, in files named after the Schematron context they port. A rule id appears exactly once.
- Quote the Schematron context and test in a comment above the rule, as the existing rules do.
- Write it with
rule(id, { at | context, when?, test }). Severity, message, family and business terms come from the generated spec table, so an id that is not in the pinned release throws when the module loads. - Port the assert exactly:
ctx.normSpace()only where the Schematron usesnormalize-space(),ctx.dec()andDecimalfor numbers, and the country variant ofctx.countrythe rule's ownletuses. - Add table tests in
packages/validator/test/rules/<family>/withbaseInvoice(),patch(),fail(id, { at })andpass(id).rules-have-tests.test.tsfails for a rule without a test, and the official test sets run against every rule automatically.
Document kind belongs in context, not in when
A Schematron rule whose context exists in one document only, such as ubl-creditnote:CreditNote or cac:InvoiceLine, selects nothing in the other one. Port that into context: return no hits for the other kind.
// Schematron: <rule context="ubl-creditnote:CreditNote">: the rule has no context in an invoice.
const creditNotesOnly = rule.custom('EX-R-003', {
severity: 'fatal',
message: 'A credit note must reference the invoice it credits (BG-3).',
context: (view) => (view.root.kind === 'CreditNote' ? [{ node: view.root, path: [] }] : []),
test: (creditNote) => (creditNote.billingReference ?? []).length > 0,
});Keep when for preconditions outside the context, such as "the customer is Danish" or "the parser already reported this id". A ruleset override may replace a rule's when, and it must never be able to run an invoice-only rule on credit notes; counts.skippedByGate also stays a count of real preconditions.
Conformance
- Official test sets.
packages/validator/test/officialruns every<test>of the Peppol (unit-UBL-*) and CEN test sets against the rules of their configuration and checks the ids they expect. Upstream mistakes are listed with a link inknown-test-issues.ts. - Coverage gate.
coverage-gate.test.tscompares the implemented rules, in the validator and in the parser's tables, with every active id of the pinned Schematron. - Schematron oracle.
tools/oracleruns the pinned UBL 2.1 XSDs and the official Schematron over the example documents, every test set fragment, the fixtures and probe documents, and compares each document's failed asserts and verdict withvalidateXml(). Every difference must be listed, with its reason and link, inknown-oracle-differences.ts.conformance.ymlruns it with Java in CI (pnpm test:oraclelocally, with Java 17+), and a nightly mutation suite adds 2,000 mutated documents. The per-rule results intools/oracle/parity-summary.jsonfeed the rule index columns; when the parity test says the summary is stale, runpnpm test:oracle -uandpnpm codegen, or, without Java, copyparity-summary.jsonfrom theconformancejob'sparity-reportartifact and runpnpm codegen. - Coverage. Every file of
packages/*/srcis covered at 100 %.pnpm test:coverageruns the projectsvitest.coverage.config.tsselects (the library packages,scriptsand the oracle tooling's unit tests), andpnpm coverage:checkfails when the report it wrote is empty, so the thresholds cannot pass with no file to check. Select coverage projects in that config, never with--projectfilters, which empty the report.
These docs
Pages are in docs/, and every code sample is a module in docs/snippets/<page>/, included with <<< @/snippets/<page>/<file>.ts#region. pnpm test:docs type-checks and runs every snippet, checks that exported invoices and credit notes validate and that every include resolves, and asserts the values the snippets state in comments (docs/test/claims.test.ts).
A reader copies the regions a page shows, so docs/test/typecheck.test.ts also compiles each shown region with only what the page showed before it:
- the imports of its module that sit outside every region, which the page takes as given;
- the regions of the same module shown earlier on the page;
helper*regions of other modules shown earlier on the page.
Anything else a region uses must be inside a shown region. Only pnpm test:docs catches a region that depends on code the page never shows: pnpm typecheck compiles whole modules and passes.
pnpm docs:dev # local site with hot reload
pnpm docs:build # API reference (typedoc) and site, failing on warnings and dead links