Skip to content

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.

sh
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.

  1. Quote the Schematron context and test in a comment above the rule, as the existing rules do.
  2. 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.
  3. Port the assert exactly: ctx.normSpace() only where the Schematron uses normalize-space(), ctx.dec() and Decimal for numbers, and the country variant of ctx.country the rule's own let uses.
  4. Add table tests in packages/validator/test/rules/<family>/ with baseInvoice(), patch(), fail(id, { at }) and pass(id). rules-have-tests.test.ts fails 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.

ts
// 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/official runs 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 in known-test-issues.ts.
  • Coverage gate. coverage-gate.test.ts compares the implemented rules, in the validator and in the parser's tables, with every active id of the pinned Schematron.
  • Schematron oracle. tools/oracle runs 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 with validateXml(). Every difference must be listed, with its reason and link, in known-oracle-differences.ts. conformance.yml runs it with Java in CI (pnpm test:oracle locally, with Java 17+), and a nightly mutation suite adds 2,000 mutated documents. The per-rule results in tools/oracle/parity-summary.json feed the rule index columns; when the parity test says the summary is stale, run pnpm test:oracle -u and pnpm codegen, or, without Java, copy parity-summary.json from the conformance job's parity-report artifact and run pnpm codegen.
  • Coverage. Every file of packages/*/src is covered at 100 %. pnpm test:coverage runs the projects vitest.coverage.config.ts selects (the library packages, scripts and the oracle tooling's unit tests), and pnpm coverage:check fails 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 --project filters, 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.

sh
pnpm docs:dev      # local site with hot reload
pnpm docs:build    # API reference (typedoc) and site, failing on warnings and dead links

MIT licensed. Specification artefacts belong to OpenPEPPOL, CEN and OASIS.