Appearance
How the linter works
This is a map of one lint run through packages/core/src, then of the CLI around it, what a plugin can import and how the Smart Forms plugin in packages/plugin-smart-forms/src is built, for contributors. Terms follow GLOSSARY.md; the behaviour each step implements is specified in core-plan.md, plan-amendments.md and cli.md.
The pipeline
lint() in packages/core/src/lint.ts runs one lint run and returns a CompletedReport, or throws a LintFailure for a run that cannot complete (section 9).
The diagram shows the order of the calls; each node names the section that describes it. The sections follow the source directories, so resolveRunPaths, which runs after the rule set is resolved, is described with the config.
The CLI calls lintWithRules(), which is lint() returning the run's resolved rules as well, and writes the report with the reporter --format selects (section 9).
1. Config (config/)
load.ts:loadConfiguses--configwhen given; otherwise it looks in the working directory only forsdc-questionnaire-lint.config.jsonorsdc-questionnaire-lint.config.mjs, and fails if both exist. It returnsnullwhen no config is used.validate.ts:validateConfigchecks the config's shape. Plugins can be listed only in an.mjsconfig. AhostVariablesentry must be a name (hostVariableProblem, which a plugin's hook results are checked with too), listed once, that FHIRPath, FHIR or SDC does not define.paths.ts:resolveRunPathsresolvesfiles,source.fshand the defaults against the config's directory, or the working directory when there is no config. It runs afterresolveRuleSet.types.ts: the config's types,LintConfig, anddefineConfig.
A config that cannot be loaded or is invalid fails the run with CONFIG_INVALID.
2. Rule set (plugins/)
resolveRuleSet in rule-set.ts builds the run's rule set from:
- the built-in
sdcplugin (sdcPlugininbuiltin.ts); - the reserved
lintnamespace (lintPlugininbuiltin.ts), used only forlint/stale-suppression; - the plugins listed in the config.
It does this in four steps, all before any input is read, so lint and explain fail the same way:
validatePluginchecks each plugin's shape against the plugin contract, whose types are incontract.ts. A rule'smeta.docsUrl, when present, must be an absolute URL (plan-amendments.md, Z). It also rejects a reserved or duplicate namespace.- It resolves the config's compatibility target (
compat) against each plugin's compatibility matrix (LintPlugin.compat)."latest", the default, takes the newest verified version of every component. An explicit object must name exact verified versions of the required components. Core treats versions as opaque strings. The result becomes the report'srun.compat; each rule gets the versions of its own namespace as itscompat, frozen. - It gives each rule its effective rule setting: the configured one, or the rule's default severity. It then rejects unknown rule IDs in
rulesandsuppress, and suggests a similar name when one exists (closestNameinsuggest.ts). A compat error is therefore reported before an unknown rule ID. - It keeps, in config order, each listed plugin that has a
hostVariableshook, with the same frozen compat its rules get (hostVariableHooks).
3. Inputs (inputs/)
loadInputs in load-inputs.ts runs these steps and fails with NO_QUESTIONNAIRES when none are found:
select.ts:selectInputEntriespicks the paths. CLI paths make a targeted run; otherwise the config'sfiles, or the default directory, make a full run. These are precedence levels, not fallbacks: a list that fails to match never lets a later one run.expand.ts:expandEntriesexpands globs and directories. A path that does not exist fails withINPUT_NOT_FOUND, and a pattern or directory with no JSON file withINPUT_PATTERN_UNMATCHED. A missing or empty default directory is left toNO_QUESTIONNAIRES.paths.ts:canonicalizeInputFilesdeduplicates files by their real path.read.ts:readInputsparses each file intoQuestionnaireDocuments, standalone or from inside a Bundle. Every resource of a Bundle is kept as alocalResourcesentry. JSON that is neither a Questionnaire nor a Bundle is skipped; a file that is not valid JSON fails withINPUT_INVALID_JSON.
A file or directory that cannot be read fails with INPUT_UNREADABLE.
Two lookups follow:
findFshSourcesinrun/sources.tsrecords which FSH directories exist, for the summary and for source mapping (section 7).findSushiVersioninrun/sushi-version.tsfinds the installed SUSHI as Node finds a package:node_modules/fsh-sushi/package.jsonin the run's base, then in each directory above it. Its version becomes the report'srun.sushi.version, which is left out when none is found.
4. Expression discovery and analysis (analysis/)
This is the heart of the linter. Every document is analysed before any rule runs, so a hook's crash wins over a rule's.
First, suppliedHostVariables in run/host-variables.ts calls each plugin hook with the document and the plugin's compat, whether or not the plugin's rules are on. A throw, a promise or a result that is not an array of names fails the run with PLUGIN_CRASH, naming the plugin and the file; the names FHIRPath, FHIR or SDC define are dropped. The config's hostVariables, followed by the hooks' names, are the document's host variables.
Then:
expression-site.tsdefines the expression-site model rules receive:QuestionnaireDocument,ExpressionSite,CataloguedLocation,QueryFragment. It is public API.extension-catalog.ts:extensionCatalogis data, not code. It lists the SDC extensions the linter understands, such as variable, calculatedExpression, enableWhenExpression, initialExpression, itemPopulationContext, targetConstraint, and cqf-expression on primitive elements (_titleon the root;_text,_prefixand_answerValueSeton items). For each one it records where the extension may sit, its sub-extension for a complex extension, and its use cases as the SDC expressions table lists them (rendering, population or extraction). Use cases do not decide which names an expression can reference. Supporting a new location means adding an entry here, not changingexpression-sites.ts.expression-sites.ts:findExpressionSiteswalks a Questionnaire's JSON depth-first and returns a flat list of frozenExpressionSites in document order.- A site at a catalogued location is catalogued.
- Any other object with a string
languageand eitherexpressionorreference, including in a contained resource, is uncatalogued; only its syntax and function names can be checked when text is present. A reference-only Expression remains unchecked and may prevent an unused-variable finding. - An x-fhir-query site with text is directly followed by one fragment site per closed
{{…}}fragment: carrierfragment, languagetext/fhirpath, with offsets into the query. - A catalogued Expression with a
namehasdeclaresset: it declares that name for other expressions.
declarations.ts:findDeclarationslists a document's declarations in document order: the declaring sites (variable,itemPopulationContextand other named expressions),launchContextnames on the root, andextractAllocateIdnames on the root and items. Each records its name, kind, pointer, declaring item (none for the root), use cases and, when it is an Expression, its site. Contained resources declare nothing; sourceQueries results are host variables, not declarations.available-variables.ts:availableVariablesanswers, for each catalogued site, which names its expressions can reference:- the declarations on its element, its ancestor items and the root, the innermost of a name winning;
- the built-in names FHIRPath, FHIR and SDC define (
builtinNames, plus thevs-andext-prefixes); - the names the root's
assembleContextlists, which the Questionnaire that includes the module supplies (findAssembleContextNamesindeclarations.ts; they are not declarations); - the host variables.
An extraction site sees only extractAllocateId names and named extraction contexts among the declarations; any other site sees every declaration except extractAllocateId names, whatever the declaring expression's use cases. An uncatalogued site gets none, because name analysis cannot run there. At a declaring site, and in the fragments of its query, the declaration's own name resolves to the next binding outward, and to the declaration itself only when nothing else supplies the name (core-rules.md, D25).
dependencies.ts:findDependenciesrecords, for each declaration, the declarations its expression references, resolved at the expression's site or at each fragment of its query, and whether that list is known: the expression parses and no reference follows adefineVariablewith a computed name. A reference that may be bound locally is left out.uses.ts:findUsesrecords, for each declaration, whether the expressions of others reference it:referencedwhen a reference resolves to it;unreferencedwhen none does and every expression that could see it was analysed;blockedwhen only expressions with a syntax error were not analysed;unknownotherwise. An expression that could see it but was not analysed is CQL or another language, an Expression with only a reference, FHIRPath that does not parse, an uncatalogued expression that names it, a reference after adefineVariablewith a computed name, aquestionnaire-constraint, or asubQuestionnaire.
A reference from a declaration's own expression is not a use of it, and a duplicate on the same element shares the uses of the other.
linkids.ts:findLinkIdslists the linkIds of a Questionnaire's items at any depth, not those of contained resources.findLinkIdReferencesAtgives the linkId references of each FHIRPath site that parses, with what the site establishes as items of this form: at a catalogued site,%questionnaire,%resource,%contextand the expression's input, plus%qitemat an item site. Nothing is established at an uncatalogued site or in a modular Questionnaire, one that includes asubQuestionnaireor is a module (it hasassembleContext, or anassemble-expectationthat allows a child), because the assembled form has other items.document-analysis.ts:analyseDocumentcomputes core's internal analysis of a document once per run, from the document, its sites and its host variables. It holds:- its declarations;
- the variable references of each FHIRPath site that parses;
- the available variables at each site (
variablesAt); - the dependencies of each declaration, and their uses;
- the Questionnaire's linkIds, and the linkId references of each FHIRPath site that parses;
- found on first use, whether each such site reads its input (
readsInput).
lint.tskeeps it on each analysed document. It is not exported. A plugin rule receives only its references,variablesAt, declarations, dependencies, uses andreadsInput, throughRuleContext. They are frozen, because the suppression assessment reuses the same analysis after the rules run.
The analysis helpers:
fhirpath.tsis the only place fhirpath.js is used. Its exports:findFhirPathSyntaxProblemparses an expression and turns a parser error into offsets.findFunctionCallsextracts function calls.findVariableReferenceslists%namereferences: each reference gives the name fhirpath.js looks up and its range as written. A name thatdefineVariablebinds for the later steps of its chain is left out, and a reference after adefineVariablewith a computed name is marked incomplete. A reference is conditional where fhirpath.js 4.10.1 may skip evaluating it: in aniifargument after the criterion, acoalesceargument after the first, an argument evaluated once per item of the function's target (where,select,exists,all,repeat, the first ofaggregate,sort's selectors), below one of these, and in any argument of a call whose name has an escape sequence. Operator operands,combineandunionarguments, andtrace's anddefineVariable's values are always evaluated (smart-forms-rules.md, API stage D).findLinkIdReferencesfinds linkId references:where(linkId = …), its input followed throughitem,answer,repeat,whereandselectfrom the roots the site establishes. The literals of established references are listed, and the reference is uncertain when its input is not established or its linkId is computed. A selection that flows through item steps intoexists(),empty()or acount()comparison is an absence probe and is left out.readsInputtells whether an expression reads its input. It follows the focus as fhirpath.js 4.10.1 sets it: an argument typedExpr, andsort's, is evaluated against the function's target;coalesce's against its target with the enclosing$this; every other argument and operand against the enclosing$this, or else the input. A term on the input other thanlinkId, a function on it other thaniif,select,today,nowandtimeOfDay, and the input itself used as a value, including through%context, read it.
x-fhir-query.tsscans x-fhir-query strings for fragments and shape problems.fhirpath-function-catalog.tsis generated from fhirpath.js'sinvocationTablebypackages/core/scripts/generate-functions.ts; never edit it by hand.
5. Rules (rules/, run by run/rules.ts)
runRules calls each rule that is not off once per Questionnaire. A rule's check(ctx) is synchronous. ctx provides:
document, andsites, frozen, as is each site;declarations, the analysis's own frozen array;referencesAt(site),variablesAt(site)andreadsInput(site): the analysis's references, available variables and input reads at a site;dependenciesOf(declaration)anduseOf(declaration): its dependencies and uses;compat: the versions selected for the rule's namespace;{}for a plugin without compat;report().
What a plugin can import gives the types and the meaning of undefined for each lookup. Each Questionnaire finding carries its rule's docsUrl: the value read when the plugin was validated, kept in ResolvedRule.docsUrl. An sdc rule also receives the document's analysis as a second argument (CoreRule in plugins/core-rule.ts); a plugin cannot register an sdc rule.
rules/ holds one module per core rule; the rules library documents each, and adding-a-rule.md is the guide to writing one. rules/describe.ts names the element a finding is on, by the item's text and linkId, and places a finding about a declaration at its Expression or its extension.
The runner validates every finding. A bad site, pointer or range, a throw, or a returned promise fails the run with RULE_CRASH, naming the rule and the file; built-in and plugin rules are treated alike. It then turns each finding into a report Finding with its JSON location, an expression excerpt, its related locations and a fingerprint. A finding in a fragment is shown within its whole query. Every finding starts with sourceLocation unmapped as source-unavailable. runRules also records each finding's document and site as a FindingOrigin in a side table keyed by the finding object, which the source mapper reads; applySuppressions passes the findings it keeps on unchanged, so the keys still match. Related locations stay unmapped.
6. Suppressions (run/suppressions.ts, run/fingerprint.ts)
Without a config, there are no suppressions and nothing is assessed.
applySuppressionsmatches eachsuppressentry to exactly one expression by its selectors:file(required),linkId,extensionUrl,name,resourceandpointer.- Only sites whose carrier is
expressionare candidates. A fragment shares its query's pointer, so suppressing the query also hides the findings in its fragments. - An entry that matches several expressions fails the run with
CONFIG_INVALID. - An entry whose
extensionUrlis launchContext or extractAllocateId instead matches one name on one element among the declarations, and hides the rule's findings at every declaration of that name there. - An entry hides the findings of its rule whose file and JSON pointer are its target's.
- Only sites whose carrier is
fingerprint.ts:siteKeysgives each site a stable key based on its item's linkId and its catalogued location, plus its name when it declares one, rather than array indexes, so keys survive reordering.declarationKeysdoes the same for a launchContext or extractAllocateId by its item, kind and name.questionnaireFingerprintandconfigFingerprintbuild the findings' fingerprints from them; a Questionnaire finding's also has the text of its range, so two findings of one rule in one expression differ.distinctFingerprintskeeps fingerprints unique within a report: a repeat is hashed again with its occurrence number, per document inrunRulesand across stale suppressions inapplySuppressions.- Every entry ends up applied, stale or not assessed:
- not assessed when its file is outside the selection, or a targeted run cannot resolve it;
- stale when a full run confirms its file no longer exists, or when no expression, or no declaration, in its file matches its selectors;
- stale when its target exists, it hides nothing, its rule is on and the rule could have completed its check there. The
completesChecktable inplugins/builtin.tsdecides this from the site and the document's analysis, andcompletesDeclarationCheckfor a declaration that has no site. A rule missing from it, such as a plugin rule, leaves such an entry not assessed.
- Stale suppressions are reported as
lint/stale-suppressionwarnings at the config entry. With that rule off, staleness is not assessed.lint/findings cannot be suppressed.
7. Source mapping (source/)
mapSourceLocations in map-findings.ts gives each finding left after suppressions its FSH location, or the reason it has none (core-plan §7.1 and §7.2, and plan-amendments.md). It reads FSH only when a Questionnaire finding remains and FSH directories exist, and it never fails the run: an exception while mapping a finding leaves it not-found. It changes nothing but sourceLocation, so fingerprints and counts are the same with or without FSH.
For each finding, locateExpression in locate.ts:
- finds the FSH Instance behind the finding's resource (
definitions.ts): for the resource at the file's root, through the SUSHI index (sushi-index.ts) when its entry for the input file is current; otherwise, and always for a Bundle entry or a contained resource, by searching the FSH directories for the one Instance with the resource's id and type; - lists the Instance's expression literals with their anchors (
expression-literals.ts): the linkId of their item, their extension and their Expression's name, found through the full path of every rule (fsh-paths.ts); - maps the finding's expression text:
- text that occurs once in the resource's JSON and once in the Instance maps as
exactwhen the literal's anchors are the expression's; - repeated text maps as
anchoredwhen the anchors single out one literal and one expression, except in a definition with aninsertrule, where it is alwaysambiguous; - otherwise the finding stays unmapped as
ambiguous,multilineornot-found;
- text that occurs once in the resource's JSON and once in the Instance maps as
- turns the reported range into lines and columns (
positions.ts), after reading the literal again from the file and checking that it and the range's source decode to the reported text (fsh-string.ts).
The FSH modules mirror SUSHI 3.20.1, so they read FSH as SUSHI does: fsh-document.ts splits a file into definitions and rules with SUSHI's longest-match tokens, fsh-paths.ts applies indentation context and soft indices as SUSHI's importer and resolveSoftIndexing do, and fsh-string.ts decodes strings as SUSHI's unescapeQuotedString does. fsh-files.ts reads and parses each FSH file of a run at most once.
8. Report (report/)
types.tsdefines the public report contract, which report-format.md specifies and lint-report.schema.json matches.lint.tsfills the report'srunmetadata:tool,plugins(the name and version ofsdcand each listed plugin, sorted),compat,selection(full or targeted) and, when found,sushi.summary.ts:buildSummarycounts the findings by severity; the rest of the summary comes from the earlier stages as it is.expression-coverage.ts:summarizeExpressionCoveragecounts the analysed and unchecked expressions for the summary. A site is analysed when it is catalogued, has text and is FHIRPath or x-fhir-query, unless an enabled rule applies there but could not complete.incompleteSitesinplugins/builtin.tslists those from itsleavesUncheckedtable:- a site where
sdc/undefined-variablemeets a reference after adefineVariablewith a computed name; - a declaration from which
sdc/dependency-cyclereaches a declaration with such a reference; - a variable whose use
sdc/unused-declarationcannot establish (unknown); - a site where
sdc/unresolved-linkidmeets a linkId reference whose target or linkId is uncertain.
A declaration that does not parse or is in another language is not followed, so it leaves nothing unchecked; a variable that only an expression with a syntax error could reference (
blocked) is not unchecked either.- a site where
sort.ts:compareFindingsorders findings; every reporter uses it. It compares strings withcompareStringsincompare.ts, which is locale-independent, so the order is the same on every machine.locations.ts:primaryLocationsays where a finding or related location is (its FSH range, its compiled JSON site and the reason it is unmapped, or its config entry), andfindingDetailLinesgives thelocation:,note:,related:andhelp:lines. The text, GitHub and SARIF reporters share them.json.ts,text.ts,github.tsandsarif.tsare the four reporters:formatJsonReport,formatTextReport,formatGithubReportwithformatGithubSummary, andformatSarifReport, which also describes the run's rules.github.tsandsarif.tsalso render a failed run, forrenderFailureOutput.
9. CLI and failures (cli/, failure.ts)
cli.md is the contract; this is where it lives in the code.
main.tsis the binary. It turns on the stdout guard for the whole process, then setsprocess.exitCodefromrunCli.args.ts:parseCliArgsparses argv. A usage error fails withUSAGE_ERROR; oncelint --formatis recognised, the failure document goes to stdout in that format too.run.ts:runClirunslintorexplain, or writes the help (help.ts) or the version. Forlint, it callslintWithRules()and writes the report with the reporter--formatselects. With--format github, it also passesformatGithubSummarytoappendStepSummaryinstep-summary.ts, which appends it to the job summary thatGITHUB_STEP_SUMMARYnames. It exits 0, 1 when the report has errors, or 2 on a failure (cli.md).output-guard.ts:guardStdoutsendsconsole.log,info,debugandprocess.stdout.writeto stderr while config and plugin code run, so only the report reaches stdout.explain.ts:runExplainloads the config and resolves the rule set aslintdoes, reads no input, and lists the rules or describes one (cli.md).
failure.ts holds LintFailure and its FailureCodes (report-format.md). Each stage throws its own: CONFIG_INVALID (sections 1, 2 and 6), the input codes (section 3), PLUGIN_CRASH (section 4) and RULE_CRASH (section 5). asLintFailure turns anything else thrown into INTERNAL_ERROR. Every failure exits 2 and reports no findings. renderFailureOutput writes it in the selected format: a failure document on stdout in json, github and sarif, and a job-summary section in github; the error and its help go to stderr in every format.
What a plugin can import
A plugin imports from the @aehrc/sdc-questionnaire-lint package only; everything under packages/core/src that index.ts does not export is internal. Besides lint, LintFailure, defineConfig and the options, report, failure and config types, index.ts exports the following. The name analysis is published in stages, each with the Smart Forms slice that first needs it (smart-forms-rules.md, S5).
- The plugin contract:
LintPlugin,LintRule,RuleContext,RuleFinding,RuleRelatedLocation,RuleDocs,PluginCompatandHostVariablesInput(plugins/contract.ts). Besides its rules, a plugin can supply host variables throughLintPlugin.hostVariables({ document, compat }): synchronous and pure, it only adds names, which resolve withorigin: 'host'where no declaration or assembleContext name wins (smart-forms-rules.md, S6). - The expression-site model:
QuestionnaireDocument,ExpressionSite,CataloguedLocation,SiteItemand the other types inanalysis/expression-site.ts. There is no item-tree API: a plugin reads an item by resolvingsite.item.pointeragainstdocument.resource. - The language lookups (API stage A):
definedLanguages: the six codes FHIR R4 and R5 define, frozen. A language outside them issdc/language-not-in-value-set's.languagesAllowedAt(location): the languages SDC allows at a catalogued location, orundefinedwhere it allows any language. A language outside the list issdc/language-not-allowed's, or the FHIR Validator's: the list also rejectsapplication/x-fhir-queryon enableWhenExpression, by the SDC invariantsdc-inv1.isQueryLanguage(language): whether a language is either x-fhir-query code.
- The types of core's name analysis (API stages B, B2, C and D), which
RuleContext'sreferencesAt,variablesAt,declarations,dependenciesOfanduseOfgive:VariableReference: one%namereference, with itsname, its range as written (startOffset,endOffset),complete, which is false after adefineVariablewith a computed name, andconditional, which is true where fhirpath.js may skip evaluating it (API stage D).AvailableVariables: thevariablesavailable at a site, andresolve(name), theVariablea reference gets: itsname, itsorigin(declaration,builtin,assembledorhost) and, for a declaration, theDeclaration.DeclarationandDeclarationKind: a declaration's name, kind, pointer, item, use cases and declaring site.Dependencies: what a declaration's expression references:on, the declarations its references resolve to, each once, in the order of their first reference;analysed, whether its references were found; andknown, whetheronis all of them, which a reference after adefineVariablewith a computed name prevents.DeclarationUse: whether the expressions of others reference a declaration:referenced,unreferenced(sdc/unused-declaration's condition), or, when an expression that could reference it was not analysed,blocked(only expressions with a syntax error) orunknown.
definerOf(name)andBuiltinDefiner(API stage C): the specification that defines a name,FHIRPath,FHIRorSDC, orundefined.sdc/reserved-namereports a declaration by it, so a plugin rule stays silent on such a name without a copy of core's list.
referencesAt(site) is undefined when the site has no FHIRPath analysis: its language is not text/fhirpath, it has no text, or the text does not parse. It is [] when the text parses and references nothing. variablesAt(site) is undefined at an uncatalogued site. readsInput(site) is whether the expression's value can differ between two inputs that share their linkId, such as two repetitions of a group: some term is evaluated with the input as its focus, other than to read its linkId, or the expression uses %context. It is syntactic, not proof that a value differs, and undefined where referencesAt is. The three are undefined for an object that is not one of ctx.sites, and dependenciesOf and useOf for one that is not one of ctx.declarations. A rule treats undefined as "cannot check", never as "no references". The list of built-in names is not exported: a rule reads origin, or definerOf.
ctx.declarations holds every declaration in the document, in document order, frozen: the array core's own rules use. Each Variable.declaration that variablesAt gives is one of its elements, and each declaration's site is one of ctx.sites. The item of a launchContext or extractAllocateId declaration is a different object from site.item for the same item, so a rule compares items by pointer.
A plugin rule that stays silent where a core rule reports the same root cause checks the condition with these lookups, never whether that rule ran.
Inside the Smart Forms plugin
packages/plugin-smart-forms/src builds on these exports only (smart-forms-rules.md):
index.ts: the plugin, its default export: its meta, its nine rules, its compatibility matrix (compat: the verified renderer, populate and template-extract versions; renderer and populate required) and thehostVariableshook. This is what core validates.facts/: the runtime facts, each with its evidence and the upstream issues that report it as a Smart Forms bug. Renderer and populate facts are keyed by component and version; template-extract facts by its version alone. The rules,eligibility.ts,population.tsandstored-names.tsconsult them; the JSON readers know no facts.eligibility.ts:requireCompat(ctx), the selected renderer and populate versions, which each rule reads first, andtemplateExtractVersion(ctx);readBy(ctx, site), a site's readers;storesVariable, whether a component stores a variable's name.
document.ts: reading the Questionnaire JSON behind a site;urls.ts: the extension URLs and the language codes.launch-contexts.ts,source-queries.tsandtemplates.ts: the readers of root launchContexts, root sourceQueries and the templates template extraction uses.population.ts: the population model, built for one document and compat: whether population runs, the sourceQueries it binds, what it binds for each declaration and when, what binds a name, and whether it reads an itemPopulationContext.stored-names.ts:storedName, the names each component stores, over the population model and the renderer's variables.host-variables.ts: thehostVariableshook, an adapter over the population model's sourceQueries.rules/: one module per rule, pluslanguage-sites.ts, the site predicates the language rules share.
Tests
Rules are tested with lint cases, which the private packages/lint-cases package compiles with SUSHI and lints; Smart Forms runtime facts have reproduction tests against the pinned packages. AGENTS.md describes the layout, and adding-a-rule.md how to test a rule.