Appearance
Core rules plan: the nine remaining sdc/ rules
Builds the core rules from core-plan.md §4.1 that do not exist yet. Reviewed on 2026-10-08. The agreed corrections are part of this plan, and its overrides of core-plan are recorded in plan-amendments.md. Decision IDs D1–D25 are stable because other docs cite them.
Already built: sdc/parse-error, sdc/fragment-parse-error, sdc/query-syntax, sdc/unknown-function, sdc/uncatalogued-expression.
How to work on a slice
- Pick the next slice in the slice table whose status is
todoand whose dependencies are done. - Read the slice section, every decision it cites (Dn), and the GLOSSARY terms it uses. Use the Reference section only when a case is not covered by a decision.
- If the slice has an open item, ask the user to settle it before the PR merges.
- Complete the checklist for every slice PR, then set the slice's status to
done.
Slices at a glance
One PR per slice, in this order. The exception is slice 4, which is three PRs.
| Slice | Delivers | Default severity | Depends on | Status |
|---|---|---|---|---|
| 0 | Local corpus and the command that compares its findings | – | – | done |
| 1 | sdc/language-not-in-value-set | warning | 0 | done |
sdc/language-not-allowed | error | 0 | done | |
| 2 | sdc/external-expression | info | 0 | done |
| 3 | Declarations, sdc/duplicate-declaration, suppressions that target declarations | error | 0 | done |
| 4a | Variable references (no findings) | – | – | done |
| 4b | Available variables and the hostVariables config key | – | 3 | done |
sdc/reserved-name | error | 0, 3 | done | |
| 4c | sdc/undefined-variable | error | 0, 3, 4a, 4b | done |
| 5 | sdc/shadowed-declaration | info | 4c | done |
sdc/dependency-cycle | warning (D21) | 4c | done | |
| 6 | sdc/unused-declaration | warning | 4c | done |
| 7 | sdc/unresolved-linkid | warning (D6) | 0 | done |
These severities are the core-plan defaults, with the amendments applied. The changes from §4.1's error defaults are sdc/dependency-cycle (D21) and sdc/unresolved-linkid (D6, D7).
Checklist for every slice PR
- The rule has
meta.docs, written with theexplaintemplate. - The rule has focused tests. Reuse the reporter tests; a rule does not need its own text and JSON snapshots. Keep targeted integration fixtures for fragment offsets, related locations, declaration targets and suppressions. Include patterns the corpus lacks.
- Completion is assessed for each of the rule's applicable targets (see Shared analysis).
- The corpus has been run locally, and the PR description lists the findings that were added or removed, by rule ID, count and JSON Pointer, without expression excerpts from forms whose terms restrict redistribution (D14). A rule that reports at error merges only with zero false-positive errors on the corpus.
npm run checkpasses.
Principles
- Core checks the SDC specification. Where SDC versions differ, follow STU4 and the current build. Where Smart Forms behaves differently from the specification, the Smart Forms plugin reports it, not core (Plugin follow-ups).
- The linter serves any IG. Each decision must hold for any IG, not just one, and each rule is checked against forms from several sources (slice 0).
- If specification statements conflict, core reports no error. Record the conflict as an open item; runtime behaviour does not settle it.
- Messages use author terms. For example, a message says a name "is not available on this item".
- A clean corpus run is evidence about the forms it contains, not proof of zero false positives on other forms (D7).
Shared analysis and completion evidence
This work cuts across slices 3, 4, 6 and 7. Add each piece in the slice that first needs it.
One internal analysis per document (D15, D18):
- Its inputs are explicit: the Questionnaire document, its existing expression sites and the configured
hostVariables. Keep declaration discovery, parsed references and name resolution inside this internal module. Avoid a global cache keyed only by the document: it hides the host configuration and finds sites again with different object identities. lint.tskeeps the analysis for each document.runRulesreceives it explicitly and passes it to core checks through an internal argument. External plugin checks keep the current publicRuleContext.- Do not publish
Declaration,Variable,AvailableVariablesor variable references until a concrete plugin rule needs them. Keep the publicScope/scopeAtstub, which stays documented as unavailable. Lifted by Smart Forms P2 (S5).
Completion evidence is recorded for each rule and target, including the reason an applicable check could not run:
- Coverage and suppression assessment read the same evidence, but each answers a different question. A site is unchecked if an applicable, enabled check did not complete. A suppression is stale only if its own rule completed at its target.
- An uncertain linkId check must not block assessment of a completed
sdc/unknown-functioncheck on the same site. - Keep the distinctions in report-format.md:
- A site with no applicable linkId reference is not an unchecked limitation.
- A rule that is configured off does not reduce coverage.
- An established syntax error that blocks dependent checks gets the report's invalid-expression treatment.
- Do not add a public plugin framework for completion.
Slices
0. Corpus
Implements D14.
corpus/is listed in.gitignore, and nothing under it is committed. Copy these forms into it, and record each file's origin, commit or version, and licence terms in a localcorpus/SOURCES.md:- the 27 standalone Questionnaires at Smart Forms commit
7e64a22f, including its GP CCMP snapshot and the ATSI form; - the current GP CCMP build: the SUSHI output of
../gpccmp-fhir-igat a recorded commit; - the SDC IG STU4 example Questionnaires.
- the 27 standalone Questionnaires at Smart Forms commit
- Add a local command, which is not part of
npm run check. It lintscorpus/with the built-in rules, compares the findings with a snapshot kept incorpus/, and prints the findings that were added or removed. Whencorpus/is missing, it exits with a message.
Done when the command runs and every current finding has been reviewed and is a true positive. More forms can be added later; that work does not block the other slices.
1. Language rules
Decisions: D3, D10, D11.
sdc/language-not-in-value-set(warning) reports an Expression whose language is not one of the six R4 and R5 codes. It checks every site that has a language, whether catalogued or not.sdc/language-not-allowed(error):- Only two catalog entries get the optional restriction to FHIRPath or CQL: enableWhenExpression and answerOptionsToggleExpression. Entries without a restriction need no language list.
- Skip
application/x-fhir-queryon enableWhenExpression, because its Validator invariant already rejects it. - This rule checks
text/x-fhir-queryon enableWhenExpression, either query language on answerOptionsToggleExpression, and any other language these two locations do not allow.
- Add one shared predicate that recognises both
application/x-fhir-queryandtext/x-fhir-query. Use it in dispatch, fragment discovery, the query checks and coverage. Reports show the language code as written. - The help message never suggests replacing an R5 query code just because it is the R5 spelling. At a location that does not allow queries, it never suggests the R4 query code as a fix: the R4 query code is not allowed there either.
- Tests: pair the query tests with fragment tests for both query codes. Once slice 4 lands, add a test for an undefined name inside an R5 query fragment.
Done when both rules report as specified, and every query check and fragment check handles both query codes.
2. sdc/external-expression
- Info: the Expression has a
referencebut noexpressiontext, so its content is not checked. - Resolving and analysing the referenced Library content is out of scope.
3. Declarations and sdc/duplicate-declaration
Decisions: D9, D12, D15, D16, D17, D20, D22, D23.
Declarations go in a new file, analysis/declarations.ts:
findDeclarations(document, sites)returnsDeclaration { name, kind, pointer, item?, useCases, site? }in document order.- There are five kinds:
variableitemPopulationContextnamedExpression: any catalogued expression that has aname(D9)launchContextallocateId: thevalueStringof eachsdc-questionnaire-extractAllocateIdextension on the root or an item; its use case is extraction (D17)
- The results of sourceQueries are host variables, not declarations (D16).
- launchContext names come from the
namesub-extension of the rootsdc-questionnaire-launchContextextension:- Read
valueCoding.code. AcceptvalueIdtoo, for legacy ballot compatibility; Smart Forms readsvalueId ?? valueCoding.code. - A launchContext is not an Expression, so it has no site. The declaration's pointer is the extension.
- Its use cases are rendering and population (D12).
- Read
- The owning element is the site's item. When the site has no item, it is the root of the site's resource.
- Ancestors come from item pointer prefixes. Compare with a trailing
/, so that/item/1is not treated as a prefix of/item/10.SiteItemgets no parent chain. - Contained resources declare nothing, because their sites are uncatalogued.
Declarationand the declaration list stay in the shared analysis. Do not export them fromindex.tsor add them toRuleContext(D15).
Site and catalog changes:
- Align the use cases in the extension catalog with the SDC table (D20). Update
expression-sites.test.ts, theExpressionUseCasecomment and the catalog description in architecture.md. - The walker sets
declareson each site ascatalogued && carrier === 'expression' && name !== undefined. This replaces the per-entry catalog flag, which then has no users; remove it. - Update the ExpressionSite comments, and the descriptions of declarations in architecture.md and the glossary. Keep the public Scope terminology (D15), and add allocateId to the glossary's Declaration entry.
baseKeyincludes the name only whensite.declaresis true. Named expressions that become declarations can therefore change existing finding fingerprints. Test and document this one-time change; do not promise that fingerprints stay the same across it.
sdc/duplicate-declaration (error):
- It reports two declarations of any kinds with the same name on one element. The finding points at the later declaration and gives the other as a related location.
RuleFindinggainsrelatedLocations, which maps to the report's existingrelatedLocationsfield. This changes the plugin contract.- A declaration whose name collides with a reserved built-in name needs its own check, separate from a duplicate between two user declarations. A forbidden declaration must not quietly take the place of the built-in. That check is
sdc/reserved-name, built in slice 4b (D23).
Suppressions that target declarations (D22) must exist before any finding is reported on a launchContext or allocateId pointer:
A suppression entry whose
extensionUrlis launchContext or extractAllocateId selects declarations, not Expression sites. For example, to suppress two launchContexts namedpatienton the root:json{ "rule": "sdc/duplicate-declaration", "file": "…", "extensionUrl": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext", "name": "patient", "reason": "…" }Record completion evidence for these rules by element and name, so that the stale-suppression check works.
Update cli.md, the selector, the assessor and fingerprints. Represent these declarations directly; do not invent Expression sites for extensions that are not Expressions.
Test pairs where neither declaration has an Expression site, and mixed pairs where one has.
Tests: root, item and nested declarations; every kind; launchContext with each value type; contained resources; Bundle entries.
4. Available variables, and sdc/undefined-variable
There are three PRs. 4a adds no findings, so it can land before the corpus exists. 4b adds sdc/reserved-name (D23). 4c needs the corpus.
4a. Variable references
Decisions: D18.
- Add
findVariableReferences(text)toanalysis/fhirpath.ts, besidefindFunctionCalls. It walks theExternalConstantTermnodes and returns{ name, start, end }, with offsets into the text. - Each spelling reduces to the name fhirpath.js looks up:
%aisa.%`a b`isa b, taken verbatim with no escape decoding.%'a'is the string's value, with escapes decoded.
- The range covers the written
%, any whitespace or comments after it, and the name's delimiters:- Convert the term's start and the end of the identifier token to offsets.
- For a name written as a string literal, scan the text, skipping comments and respecting quoted escapes.
ExternalConstantTerm.lengthdoes not give the end (see fhirpath.js). A text search can also match the quoted name inside a comment.
defineVariable('y', …)binds%yonly in the later steps of its own chain:- Both
defineVariable('y', %y).select(%y)anddefineVariable('y', 1).select(%y) | %ythrow whenyis not otherwise defined, so do not exclude every%yin the expression. - Track the supported chain exactly, or mark the affected references incomplete. References with computed names are incomplete too.
- Parsing successfully does not by itself mean name resolution completed.
- Both
- An expression that does not parse has no references.
- Compute the references once per document, in the shared analysis, and share them between the core rules and completion assessment. Design a public contract only when a plugin rule needs it (
flat-variable-context,named-expression-not-referenceable). - Tests:
- each spelling, and keyword names;
- whitespace and comments after
%, and a comment that contains the quoted name; - ranges across lines, including CRLF line endings;
- references in both query codes;
- a reference in a
defineVariableinitializer and in a separate branch; - nested and parenthesised terms.
4b. Available variables
Decisions: D1, D8, D12, D13, D15, D19.
Add a new file, analysis/available-variables.ts. The available variables are built once per document in the shared analysis, using the configured hostVariables. The sketch below is internal; it changes neither RuleContext nor the package exports. A site alone determines its available variables, so the resolver takes no use-case parameter:
ts
interface Variable {
name: string;
origin: 'declaration' | 'builtin' | 'host';
declaration?: Declaration;
}
interface AvailableVariables {
variables: readonly Variable[];
resolve(name: string): Variable | undefined;
}
// Internal document analysis; completion is recorded per rule and target separately.
variablesAt(site: ExpressionSite): AvailableVariables;resolve() applies the innermost-wins rule and the vs- and ext- prefixes, so each rule gets them without reimplementing them.
What a site can reference:
- Declarations on the site's element, in any order (D8), then on each ancestor item, then on the root. The innermost declaration of a name wins, because SDC says the descendant declaration overrides.
- At an extraction site: only extractAllocateId names and named extraction contexts.
- At any other site: every other kind of declaration, whatever the use cases of the declaring expression (D12, D13).
- The built-in names in the table below. These are available at extraction sites too (D19).
- The names in
hostVariables(D1), at every site, extraction sites included.
| Built-in name | Available at | Defined by |
|---|---|---|
resource, rootResource | every site | FHIR |
context, ucum | every site | FHIRPath |
questionnaire | every site | SDC |
qitem | sites on an item | SDC |
sct, loinc, and the vs- and ext- prefixes | every site | FHIR |
terminologies | base FHIR contexts | FHIR R4/R5 terminology API |
factory, server | R5 base FHIR contexts; draft APIs | FHIR R5; fhirpath.js supplies factory itself |
- FHIR R5 defines
%server, so it exists. Its methods are not yet supported:sdc/unknown-functionexempts calls only onfactoryandterminologies. Coordinate with that rule before treating%server's methods as supported. - A fragment can reference whatever its containing expression can. It shares the query's item and use cases, so it is an extraction site only when its query is one.
- At an uncatalogued site, name analysis cannot run. Record that in the completion evidence. An empty variable list does not mean the analysis ran.
sdc/reserved-name (error, D23): reports a declaration of a name that FHIRPath, FHIR or SDC reserves. Specify it in this slice, from the built-in names in the table above.
Config: add hostVariables, a non-empty array of unique, non-empty names written without %. Check uniqueness explicitly, because the validation used for files does not. Update LintConfig, knownKeys, the config tests and the CLI configuration documentation.
Tests:
- Core-rule test support uses the shared analysis. The existing
scopeAtfake used by the plugin-interface tests moves to one shared place. - A table-driven resolution test covers:
- ancestor overrides;
- host names;
- fragments;
- contained resources;
- extraction sites (D19);
- names declared by expressions with other use cases (D13), including a reference from rendering to a named initialExpression, and references from descendant initialExpressions to a candidateExpression.
Docs: describe the internal pipeline in docs/architecture.md, and keep the public Scope/scopeAt contract as it is. Rename the glossary terms when that contract is redesigned for a plugin.
4c. sdc/undefined-variable
Decisions: D2, D13, D19, D24.
- Error: a reference does not resolve at its site.
- Example message:
%weigt is not available on item "BMI" (bmi). - Example help:
a variable with a similar name is available: %weight.
- Example message:
- Suggest a name with
closestName, choosing only from names available at the site.weigtis one edit away fromweight, so it gets a suggestion; the transpositionweigthdoes not. - The message describes the name only. It does not say the reference will be evaluated, or that a calculation will be left empty.
- Completion: the site is catalogued, its FHIRPath parses, and local-name handling completed for the affected references. Read the internal per-rule outcome. A successful parse, or a literal
defineVariablename alone, does not prove completion. examples/health-checkuses%patientwithout a launchContext. Add a launchContext so the example does not start reporting an error, and updateexamples.test.tsand the example's README.- This slice has no pass for unused host variables, and no stub rule, fingerprint or report-schema handling for them (D2).
- Add the test slice 1 deferred: an undefined name inside a
text/x-fhir-queryfragment. - The names a module lists in assembleContext are available throughout it (D24).
Done when the corpus run shows zero false-positive errors.
5. sdc/shadowed-declaration and sdc/dependency-cycle
Decisions: D13, D19, D21, D25. Open item: the scope of the launchContext name restriction.
The review of b46699a records two open P2 follow-ups: cycle edges retained from partially parsed queries, and stale suppressions reported when declaration analysis is unavailable.
sdc/shadowed-declaration(info) reports a descendant that declares a name an ancestor or the root already declares. The related location is the ancestor's declaration.- The info default is a deliberate change from SDC's SHOULD-level warning; plan-amendments.md already records it.
- Descendant shadowing of a launchContext name is reported like any other shadowing until the open item is resolved. It gets no additional error.
sdc/dependency-cycle(warning):- Find the cyclic strongly connected components, including self-edges. Report each component once, at its first declaration in document order, with the others as related locations. Do not enumerate every simple cycle.
- Resolve edges with the availability model (D13, D19). In a declaration's own expression, its own name means the next binding outward (D25).
- The message states the dependency and its possible consequence:
Declarations %a and %b reference each other; updates may not reach a stable value.Use singular wording for a self-edge. - The help says:
Break the dependency cycle or verify that these expressions reach stable values in the form filler.A stable cycle can be deliberate; skipping a conditional branch is not the only way to avoid failure. - The graph establishes a syntactic cycle, not failure to initialize or converge. Keep warning severity even when the cycle has no conditional construct. Any future core error requires a separately specified, proven failure class with explicit runtime assumptions (D21).
- Tests cover self-edges, components with more than two declarations, and cycles through lazy branches and unconditional expressions. They verify the factual message and warning severity; the rule does not evaluate expressions or simulate a form filler.
6. sdc/unused-declaration
Decisions: D4, D5.
- Warning: no reference resolves to a
variabledeclaration, and every plausible consumer was analysed. - Use resolution, not name matching: a reference to a shadowing declaration is not a use of the declaration it shadows.
- Report nothing when a plausible consumer is CQL, an Expression that has only a reference, an excluded whole-string carrier, or an unsupported case of local variables. A first, conservative version can skip the unused check for the whole document instead of building analysis across languages.
- Tests: an itemPopulationContext that is useful although nothing references it, and variables used only by opaque consumers.
- Generic discovery includes uncatalogued reference-only Expressions with a language. These opaque consumers block the check and leave suppressions not assessed. The own-expression exclusion uses the shared element/name identity for duplicates, including query fragments and incomplete local-name references, so either duplicate's own references are excluded from their shared uses.
- As built: the check is per variable, not per document. A plausible consumer is an expression on the variable's element or a descendant item that could see it (D19), other than the variable's own expression: a reference from its own expression is not a use. A
subQuestionnaireis a plausible consumer too, because its module may reference the name once assembled; the SDC modular examples rely on it. An uncatalogued FHIRPath expression with inline text is a consumer only of the names it mentions. The templateExtractvalueStringcarriers are not consumers, because extraction cannot see variables (D19); for the same reason a reference to a variable from an extraction site is not a use, so it gets bothsdc/undefined-variableand this warning. Declarations of one name on one element share their uses, assdc/duplicate-declarationalready reports them. A variable that only expressions with a syntax error could reference is not reported and its suppressions are not assessed, but it is not counted as unchecked: the syntax error is established.
7. sdc/unresolved-linkid
Decisions: D6.
- Warning: a literal linkId in a recognised pattern that demonstrably targets this Questionnaire or its response names no item anywhere in this Questionnaire.
- Example message:
No item in this Questionnaire has linkId 'typo'. - This is initially a preview check. A project can configure it as an error under an explicit authoring policy requiring literal linkIds to name existing items. An empty selection alone does not establish an expression error (D6).
- Example message:
- Start with a bounded, documented family of item selectors followed by
.where(linkId = '…'). Include explicit%questionnaireand item-site%qitemtargets, and%resourceand valid%contexttargets for the response. Define the exact supported patterns at the start of the slice, using the corpus and the selector facts.- Questionnaire selectors include
.itemand.repeat(item); response selectors include.item,.repeat(item)and.repeat(item | answer.item). %contextis the response root at root sites and the corresponding response item(s) at item sites. An implicititemorrepeat(item)is supported only when its current input is established; nested functions can change that input.%qitemis available only at item sites. Do not infer an item target from a root-site use.
- Questionnaire selectors include
- Check global item membership first. A linkId that exists elsewhere in this Questionnaire gets no unresolved-linkid finding even if the written selector searches the wrong depth or child path. Path correctness and general alias/provenance analysis are deferred.
- Prove that the selected items belong to this form.
descendants()can cross into contained resources, so its root alone does not establish the target. Leave such a reference incomplete unless its item provenance is established. - Exclude recognised deliberate absence probes, such as selectors consumed by
.empty(),.exists()or acount()comparison, from findings. Treat these exclusions conservatively without attempting general intent inference. Runtime absence of an unanswered response item is not a missing Questionnaire linkId. - A linkId reference with an uncertain target produces no finding and leaves this check incomplete. Examples are
%previousResponse, computed linkIds and unsupported aliases. - A site is not unchecked just because it matches none of the patterns. Pass the per-rule completion evidence to coverage and suppression assessment.
- Tests cover both target kinds,
%qitemand root/item%context, nested groups and answer children, a known linkId searched at the wrong depth, contained resources, foreign responses, deliberate absence probes, and configured warning/error severity. - As built, the supported patterns are these:
- A linkId reference is
where(linkId = …)whose only argument is an=with the memberlinkIdon either side. Other comparisons, such aslinkId != '…',linkId ~ '…', or a comparison joined to another byandoror, are not references, and a site that has only those is not unchecked. - At a catalogued site,
%questionnaire,%resource,%contextand the expression's input are items of this form, and so is%qitemat an item site. The input is followed throughitem,answer,repeat(…)whose argument stays in the form,where(…),select(…)and parentheses and unions; the arguments ofwhere,select,repeat,exists,allandiifget the input as theirs. Anything else, such as another variable,%previousResponse,descendants(),first()or the argument of any other function, is not established. - A reference whose input is not established, or whose linkId is not a string literal, is uncertain: it gets no finding, and its site is unchecked and its suppressions are not assessed.
- A selection consumed by
exists(),empty()or acount()that is compared, directly or through furtheritemandwheresteps, is an absence probe; it is never a finding and never makes the site unchecked. Arepeat(…)continues it when its argument does, asrepeat(item)andrepeat(item | answer.item)do, and so does a selector inside the argument..answerends it, sowhere(linkId = 'a').answer.value.exists()andwhere(linkId = 'a').repeat(answer.item).exists()are checked. - Nothing is established in a modular Questionnaire: one that includes a
subQuestionnaire, or is a module, withassembleContextor anassemble-expectationcode that allows a child. Its assembled form has items this Questionnaire lacks. - The help names a similar existing linkId when there is one, within the edit distance
sdc/undefined-variableuses.
- A linkId reference is
Decisions
Each decision is listed under its topic and keeps its original number.
Names supplied by the host
- D1. Host variables. A host variable is a name an expression references that the form does not declare, and that FHIRPath, FHIR and SDC do not define, but that the application running the form supplies at runtime (see the glossary). Examples are the
additionalContextentries Smart Forms passes, and%PrePopQuery, the name Smart Forms gives a sourceQueries result (D16). A project lists its host variables by exact name in a new config key,hostVariables. They are available at every site in every use case. Why: a form built for one host can use such a name in many expressions, and one suppression per expression does not scale. Because only exact names are accepted, typos in other names are still reported. Core reads a list and learns no host's conventions. - D2. Defer
lint/unused-host-variable; keep the explicithostVariableslist.Why: a warning for unused entries needs a pass across documents and config-reporting machinery, with no evidence yet that it is needed. Adding the exact typo an expression already uses would get past that warning anyway. - D16. sourceQueries result names are host variables, not declarations. Smart Forms names these results
%<contained Bundle id>, or%sourceQuery<index>for a reference that is not contained. Core does not read sourceQueries, and these names take part in no declaration check; a project lists them inhostVariables. A plugin hook that lets the Smart Forms plugin supply them automatically is a plugin follow-up. Amended by S6 in smart-forms-rules.md: a listed Smart Forms plugin supplies them through thehostVariableshook (P4); without the plugin, a project still lists them in config. Why: SDC defines sourceQueries for StructureMap population and gives the results no FHIRPath name; the names are a Smart Forms convention. Declaring them in core would invent SDC errors, such as a duplicate-declaration error for a variable that shares the Bundle's id. It would also accept%PrePopQueryfor engines that do not supply it. A plugin cannot yet tell core about a name, so the config list fills the gap until it can.
Languages
- D3.
sdc/language-not-allowedskips restrictions the FHIR Validator already enforces.Why: core-plan §1.2. A project that needs them runs the Validator in CI. - D10.
sdc/language-not-allowedenforces only the restrictions in the SDC expressions table, minus those the Validator enforces. contextExpression counts as allowing any language.Why: the table and the contextExpression definition contradict each other, and core reports no error on a contradiction. - D11.
sdc/language-not-in-value-setaccepts the R4 and R5 codes.Why: a Questionnaire does not state its FHIR version, and an IG can target R4 or R5. How Smart Forms handles R5's query code belongs to the Smart Forms plugin: it ignores it at filtering locations (smart-forms/unsupported-expression-language) and evaluates it as FHIRPath at targetConstraint, answerOptionsToggleExpression, initialExpression and itemPopulationContext (smart-forms/expression-evaluated-as-fhirpath).
Declarations and availability
D8. A variable can reference a variable declared later on the same element, and core does not report it.Why: STU4 says "tools cannot rely on this ordering". Smart Forms evaluates in order, which is a plugin follow-up.
D9. Any expression with a
nameat a catalogued location declares that name, not onlyvariableanditemPopulationContext.Why: the SDC FHIRPath supplements say "All expressions that specify a 'name' can be accessed by other expressions appearing on the same item or any descendant item". Without this, valid forms would get falsesdc/undefined-variableerrors.D12. launchContext names are available at every site except extraction sites (D19).Why: the population guidance allows launch-context variables in any expression, and the $extract guidance excludes launchContext. These statements are broader and more direct than the recommendation to use external data mainly in initialExpression and calculatedExpression.
D13. A name is available at every non-extraction site on its declaring element and that element's descendants, whatever the use cases of the declaring expression. Use cases restrict availability only for extraction (D19). Why: SDC states availability by element (see Visibility). The table's use cases say which SDC feature uses an extension, not when a name exists. Whether a runtime computes the value in time is the plugin's concern.
D17. An
extractAllocateIdname is a declaration that is available in extraction only.sdc/unused-declarationdoes not report it (D4). Why: SDC $extract lists these names as available during extraction; without them, every use would get a false error.D19. Extraction sites. An extraction site is a site whose only use case is extraction. It can reference the extractAllocateId names and named extraction contexts on its element and its ancestors, the built-in names and the host variables (D1). Every other site can reference every other declaration on its element and its ancestors. After D20, no catalogued site has extraction together with another use case. So
sdc/undefined-variablereports a reference that does not resolve at its site, without combining results across use cases. Definition-based and template-based extraction follow the same rule. Why: the explicit $extract statements (see Extraction) override the table'salllabel, which names the features that use an extension. FHIRPath and FHIR define their built-in names for every context; where SDC's "only" list disagrees, core reports no error.D20. The use cases in the extension catalog follow the SDC expressions table.
behavioris rendering;populationis population, including continuous population while the form is edited;allis rendering and population; the $extract extensions are extraction. As a result, initialExpression gains rendering; candidateExpression, answerExpression and contextExpression become population; and variable loses extraction. cqf-expression is not in the table, so it stays rendering. Why: core checks the specification. The current entries follow Smart Forms' evaluators, which belong in the plugin's compatibility matrix. Nothing reads use cases yet, so only the catalog, its tests and the glossary change.D24. assembleContext names are supplied names, not declarations. Each
valueStringof ansdc-questionnaire-assembleContextextension on a Questionnaire's root is available at every catalogued site of that Questionnaire, extraction sites included, after the built-in names and before the host variables. It is not a declaration, sosdc/duplicate-declaration,sdc/reserved-name, suppressions that target declarations (D22) and fingerprints do not apply to it. Decided on 2026-10-08, during slice 4c. Why: SDC modular forms require a module to list every name the including Questionnaire must supply in assembleContext, "to allow validating these subQuestionnaires independent of their inclusion in a parent", so any other undeclared name in a module is still an error. The extension states a requirement on the parent and is dropped by $assemble; SDC's own module example redeclares such a name on an item, which a root declaration would make a shadowing case. The parent may supply the name as a variable, which extraction cannot see, or in a form it can, so core reports no error at an extraction site (D19). Checks of the names themselves, such as a reserved or unused assembleContext name, are left to a later rule.D25. A declaration's own name in its expression means the next binding outward. In the expression of a declaration, and in the
{{…}}fragments of its query, a reference to the declaration's own name resolves as if that declaration were absent: to the declaration of the name on the nearest ancestor item or the root, or else to a built-in, assembleContext or host name. A later duplicate on the same element is not a binding outward. This applies to every rule that resolves names, so the site alone still determines the result. Only when nothing else supplies the name does it resolve to the declaration itself, whichsdc/dependency-cyclereports as a self-reference. Any other name resolves as usual, the innermost declaration winning. Decided on 2026-10-08, during slice 5. Why: SDC's own module example,sdc-modular-contact, declareslinkIdPrefix = %linkIdPrefix + 'name.'on an item, meaning the prefix the including Questionnaire supplies through assembleContext. Strict innermost-wins resolution would make that a self-referencing cycle. A reference such asself = %self * 0, with nothing else of the name, still has no other binding to mean.
Rule behaviour
- D4.
sdc/unused-declarationreports onlyvariable. It excludes itemPopulationContext, launchContext, allocateId and other named expressions. Why: an itemPopulationContext can create repeating groups without any reference to its name, and the other kinds also have purposes that need no reference to their names. - D5. A variable is unused only when no reference in the Questionnaire resolves to it, and every plausible consumer was analysed. References inside
{{…}}fragments count. A reference in any supported use case counts as a use. Why: when a consumer is not supported, finding no reference in it does not prove the variable is unused. - D6.
sdc/unresolved-linkiddefaults to warning and checks bounded patterns targeting this Questionnaire or its response. Initially check whether a literal names any item in the Questionnaire; defer path correctness and general alias analysis. Exclude recognised deliberate absence probes conservatively. A project may configure error severity under an explicit policy requiring existing item names. A reference whose target is uncertain, such as%previousResponse, produces no finding and leaves the applicable check incomplete; its site is counted inexpressions.unchecked. Why: Questionnaire targets are part of core-plan §4.1. A known target and an absent literal do not prove author error: a reusable expression can intentionally test for absence. A broad traversal can also reach another resource's items. Warning severity and bounded target recognition preserve useful checks without claiming runtime failure, and an author cannot act on an "unchecked" finding. - D7. New rules keep the default severities in core-plan §4.1, with the amendments applied. The changes from error to warning are
sdc/dependency-cycle(D21) andsdc/unresolved-linkid(D6). Why: a reviewed corpus is evidence about precision, but it does not prove zero false positives on forms it does not contain. - D21.
sdc/dependency-cycledefaults to warning, not error. Report syntactic cyclic components with factual dependency wording and possible failure to stabilize. Absence of conditional constructs is not sufficient evidence for an error. Any future core error requires a separately specified, proven failure class with explicit initialization and scheduling assumptions. Build no symbolic evaluator. This overrides the error severity in core-plan §4.1. Why: SDC allows iterative updates that reach stable values.iif(false, %self, 1)returns[1], and unconditional cycles can stabilize, oscillate or grow under supplied bindings. Missing initial bindings can instead fail before iteration starts. The cycle probes establish engine results under a simulated scheduler, not a form filler's initialization behavior. A reference graph alone cannot distinguish these outcomes. - D22. Suppressions can target declarations. A suppression can target a declaration that has no Expression site, by element and name, using the existing keys:
extensionUrl: the launchContext or extractAllocateId extension;name;linkIdfor an item, or nolinkIdfor the root. Usepointerfor an item that has no linkId. The suppression hides the rule's findings for that name on that element. It is stale when the name is no longer declared there, or when the rule completed there with no finding. Why: a duplicate launchContext or allocateId has no Expression site, so the current suppressions cannot reach it. The error would then fail CI, and the only way out would be to turn the rule off. An element plus a name survives reordering and needs no new config keys.
- D23. A declaration of a reserved name is reported by
sdc/reserved-name, at error, built in slice 4b. Decided on 2026-10-08, settling the open item that blocked slice 3.sdc/duplicate-declarationcompares only declarations in the form. Why: redeclaring a name FHIRPath, FHIR or SDC reserves is a different mistake from declaring a name twice, with a different fix. The check needs the built-in name list that slice 4b defines for available variables.
Process and architecture
- D14. The corpus is local. A corpus of real forms is copied into the gitignored
corpus/directory and never committed. A local command lints it and compares the findings with a local snapshot. The PR author runs it and reports the changes in findings in the PR, by rule ID, count and JSON Pointer, without expression excerpts from forms whose terms restrict redistribution. Why: every rule change is checked against real forms before it merges, and tests need no network. Each source's own terms allow this local use: Smart Forms is Apache-2.0, ATSI 0.4.0 is CC BY-SA 4.0, the SDC IG examples are CC0-1.0, and GP CCMP permits evaluation and local testing only, with no redistribution. Some forms state no terms. Leaving the corpus uncommitted keeps every redistribution term untouched. CI cannot run the corpus. - D15. Declarations, references and the resolution of available variables stay internal until a plugin rule needs them. New analysis uses the term available variables. The public
Scope/scopeAtreplacement and the matching glossary rename are deferred. Lifted by Smart Forms P2 (S5). Why: one shared internal module gives the core rules consistent facts, without expanding or renaming the plugin interface too early. - D18. Variable references stay internal to core, and
RuleContextdoes not expose them yet. Lifted by Smart Forms P2 (S5). Why: no plugin rule needs them yet, and adding them to the contract later breaks nothing.
Open items
| Item | Blocks |
|---|---|
| Clarify whether the broad restriction on launchContext names overrides the general permission for descendants to shadow a name (Visibility). | Any error for descendant shadowing of a launchContext name in slice 5. |
Reconcile the older CQL shorthand in the expressions table with STU4's explicit guidance on text/cql-identifier and text/cql-expression. Until then, behave conservatively: sdc/language-not-allowed does not reject those codes just because they are newer spellings. | Nothing; slice 1 behaves conservatively. |
Choose the linter's own licence (MIT or Apache-2.0) before the repository goes public or a package is published. There is no LICENSE file and no license field in the package. | Publishing only. |
Plugin follow-ups (not in this plan)
- A plugin hook for supplied names. With it, the Smart Forms plugin supplies its own host variables: the sourceQueries result names it reads from the form (
%<Bundle id>,%sourceQuery<index>). Core's resolution then accepts names fromhostVariablesand from plugins alike, still without knowing any host's conventions (D1, D16). Accepted as S6 and planned in the Smart Forms plugin's P4. TheadditionalContextnames an application passes are not supplied by the plugin: they belong to that application, so they stay in confighostVariables(D1). - An eagerly evaluated reference to a later variable can fail in Smart Forms when there is no earlier binding that was supplied or fetched (D8). Tell this apart from lazy branches, and from x-fhir-query contexts that are fetched first. Done in the Smart Forms plugin's P7:
smart-forms/forward-variable-reference, with core'sVariableReference.conditionalfor the lazy positions. - Smart Forms ignores
text/x-fhir-query(D11) at filtering locations, andapplication/x-fhir-queryeverywhere exceptvariable; it evaluates either as FHIRPath at targetConstraint, answerOptionsToggleExpression, initialExpression and itemPopulationContext. Done in the Smart Forms plugin's P1:smart-forms/unsupported-expression-languageandsmart-forms/expression-evaluated-as-fhirpath. - sdc-populate skips item variables when the Questionnaire root has no extensions.
- Template-extract evaluates no Questionnaire
variables, as SDC $extract specifies (D19). This is not a compatibility gap. - The renderer sees itemPopulationContext names only through populate's
additionalContext. A reference that SDC allows (D13) can therefore be empty when the form is rendered without populate. This belongs insmart-forms/named-expression-not-referenceable. - The renderer's answerExpression fallback supplies only the first parsed launch or query name, so a second name the form supplies can fail. Built-in names can still resolve.
- Several renderer and population paths use
{}for%context; root and item variable evaluation are exceptions. Match the evaluator responsible, rather than rejecting every use of%context.
Out of scope
- Whole-string FHIRPath carriers:
questionnaire-constraintand the templateExtractvalueStringextensions. - The content of
sourceQueriesBundles, and the Library content behindExpression.reference. - CQL analysis (core-plan §1.3).
Reference: specification and runtime facts
These facts were checked on 2026-10-08 against:
- SDC STU3 and STU4, and the SDC current build;
- the R4 and R5 core specifications;
- the FHIR extensions pack 5.3.0;
- Smart Forms commit
7e64a22f32129b36660392738b8541e8876d931d(renderer 1.4.0, populate 4.7.1, template-extract 1.0.15).
Where specification statements conflict, the conflict is listed as an open item instead of being settled from runtime behaviour.
Visibility
Source: SDC STU4 expressions.
- A variable "is available for use in expressions within the same item and any descendant items", so variables declared on the root are visible everywhere.
- Order on one element. STU4 says tools cannot rely on the order of declarations, and that a reference still unresolved after all in-scope extensions are processed SHOULD be an error. The same release also says "subsequent expressions", and its population page evaluates root variables in order of appearance. The source is not consistent with itself; D8 follows the explicit guidance that tools make no assumption about order.
- "It is an error if the same variable name is assigned a value more than once in a single node."
- "If the same variable is assigned on both an element and a descendant element, a warning SHOULD be generated, however it is not an error. The descendant declaration SHALL be treated as overriding the higher-level declaration."
- launchContext sits on the Questionnaire root. Its names behave "as if the value had been set in a variable".
- Names reserved by FHIRPath, FHIR and SDC must not be redeclared; this is a separate matter from duplicate declarations on one element. It is still open whether launchContext also prohibits shadowing by descendants anywhere in the Questionnaire: the broad wording about launch names and the explicit permission for descendants to override have to be reconciled.
- The table's use cases are not availability rules. The table says "Use Cases: Indicates which of the SDC use-cases make use of the extension", and the use cases also select the expression-language feature code. initialExpression is
all, and its value is set "upon the question first becoming enabled" as well as when the response is created. candidateExpression, answerExpression and contextExpression arepopulation, and candidateExpression is "only invoked when the user is entering/editing the answer". The catalog follows the table (D20); availability follows D13 and D19.
Languages
| Location | Allowed | What the Validator enforces |
|---|---|---|
| enableWhenExpression | FHIRPath or CQL | Rejects application/x-fhir-query (sdc-inv1, language!='application/x-fhir-query'). R5's text/x-fhir-query passes it. |
answerOptionsToggleExpression (expression) | FHIRPath or CQL | Nothing |
targetConstraint (expression) | Any | Nothing in R4: only the R5 extensions pack 5.3.0 fixes the language to text/fhirpath |
| contextExpression | Contradictory: the table says any language, the definition says a FHIR query | Nothing |
| Every other catalogued location | Any | Nothing |
The language codes have an extensible binding:
- R4:
text/cql,text/fhirpath,application/x-fhir-query. - R5:
text/cql,text/fhirpath,text/x-fhir-query,text/cql-identifier,text/cql-expression.
R5 replaces the R4 query code, so together they make six codes (D11).
launchContext
Sources: the definition and the codes.
- The codes are
patient,encounter,location,user,studyandclinical. - STU3 and STU4 use
valueCodingwith an extensible binding, which allows agreed custom names. GP CCMP declaresgpccmppractitionerrole. valueIdappeared only in the 2.7.0 ballot; STU3 and STU4 do not permit it.
Extraction
- The expressions page says the other extensions "are not available during extraction". It says $extract FHIRPath expressions "only have access to"
%resource,%context,%questionnaire,%qitemand extractAllocateId names "from parent items". - The extraction guidance says extraction has no access to "the launchContext, initialExpression, or variable extensions". It says a named template context "will be made available to any child property extraction expressions".
- extractAllocateId names apply on their own node and its descendants, not across sibling groups.
- The table labels variable and initialExpression
allbecause of the features that use them; the explicit statements above decide availability (D19).
Literal linkId selectors
These follow from the published structure (FHIRPath N1, R4 QuestionnaireResponse); SDC specifies no linter algorithm. Slice 7 first checks global item membership only; the depth and child-path differences below matter for the deferred path check.
Receiver before .where(linkId = '…') | Candidate items |
|---|---|
%questionnaire.item | Direct root children only. |
%questionnaire.repeat(item) | All Questionnaire items below the root. |
%qitem.item at an item site | Direct children of that Questionnaire item. |
%qitem.repeat(item) at an item site | Its descendant items, excluding %qitem itself. |
%resource.item | Direct response children, which correspond to root Questionnaire items. |
%context.item | Root response children at root sites; child response items at item sites. |
%resource.repeat(item) | Only items reachable through item, not those nested under answers. |
%resource.repeat(item | answer.item) | Every response item, through both child paths. |
descendants() on a proven resource root | Every descendant, including items of contained resources, so the root alone does not prove the items belong to this form. |
- A response item may be absent when unanswered; a reference to it returns an empty collection, not an error.
%qitemis valid only at item sites. An explicit%contextis the response root only at root sites.
fhirpath.js 4.10.1
These facts were verified by running fhirpath.js on 2026-10-08.
- Grammar. The grammar is
externalConstant: '%' ( identifier | STRING ).identifieralso acceptsas,contains,in,is,asc,descandsort. The pinned parser rejects the backtick spelling ofsort. The AST isTermExpression > ExternalConstantTerm > ExternalConstant > Identifier, except for%'…', which has noIdentifier. - Spellings and ranges.
%`a`looks up the backtick text verbatim, and%'a'decodes string escapes.ExternalConstantTerm.lengthexcludes%and backticks, but includes the quotes of a name written as a string literal.- Whitespace and comments can separate
%from the name, so the term's start plus its length is not a reliable range for what was written.
- Lookup order. fhirpath.js looks in the caller's variables, then the built-in names, then names from
defineVariable.- A name that is not found throws when evaluated.
iif(false, %nope, 1)returns[1]. Functions given an empty input, such asall,repeat,selectandwhere, may also skip evaluating their argument.- Boolean operators give no such guarantee:
false and %nopethrows. - A name defined with a null value gives
[].
- Built-in names.
- Always supplied:
ucum,context(the evaluation root) andfactory. terminologiesonly when aterminologyUrlis given.fhirServerUrl, which is non-standard, only when a server URL is given.resource,rootResource,sct,loinc,vs-*andext-*throw unless the caller passes them.
- Always supplied:
defineVariable('y', …)makes%yavailable only to later steps in its own chain, and throws ifyis already a variable.- Cycles under a sequential scheduler. These were simulated with supplied initial bindings, which says nothing about how a form filler initializes declarations:
%b * 0without abbinding fails as an undefined name.a = %b * 0,b = %a * 0froma = 7,b = 3both become 0 and stabilize; from empty bindings they stay empty.self = %self * 0from 7 becomes 0.a = 1 - %b,b = %afrom zero oscillates between (1, 1) and (0, 0).a = %b + 1,b = %a + 1from zero grows without bound.
- Selectors. Root
.item.where(linkId = 'weight')misses a nested item that.repeat(item)finds. Onlyrepeat(item | answer.item)finds a response item nested under an answer.descendants().where(linkId = 'foreign')finds an item of a contained Questionnaire. A missing-item selection followed by.empty()gives[true].%previousResponseresolves against the response supplied for it, not this form.
Smart Forms runtime
These facts matter for the plugin, not for core.
- Variables share one flat namespace: one value for each name for the whole form. Root variables are evaluated in array order; item variables in post-order of items, with integer-like linkIds first, and in the renderer once for each repetition, each overwriting the name (Smart Forms rules plan, P5).
- Forward references. Applied in the Smart Forms plugin's P7 (
smart-forms/forward-variable-reference).- An eagerly evaluated forward reference fails when the name is not in the supplied context.
- Population fetches x-fhir-query contexts before FHIRPath variables, so a reference to one of those names is not necessarily a failing forward reference.
- Renderer reevaluation can keep an earlier value.
- A lazy branch may never access the reference at all.
- Failures on undefined names are caught. Renderer calculations can keep a previous value. Population can return OperationOutcome warnings, which the app can display. So a failure does not always leave an empty field with no visible feedback.
- No component supplies
%questionnaireor%qitem. - Built-in names by component.
- Renderer and populate:
%resource,%rootResource,%factory,%terminologies. Populate also supplies%fhirServerUrlwhen a source server is set. - Template-extract:
%resource,%rootResource,%factoryand allocateId names only. It evaluates no Questionnairevariables.
- Renderer and populate:
- Evaluation contexts. Renderer root variables are evaluated against the QuestionnaireResponse. Item variables are evaluated against the response item, or
{}when it is absent. Several other renderer and population paths evaluate against{}. See the renderer evaluators. - Launch resources can arrive through
additionalContext. The answerExpression fallback also reads patient, user and encounter, and resolved launch references, directly. It supplies only the first%namein the expression. See the fallback evaluator. - sourceQueries. The result names are
%<id>for a#idreference, chosen before the reference is resolved, or%sourceQuery<i>for any other reference, whereicounts every root sourceQueries extension with avalueReference. A#idname gets a value only from the first contained Bundle with that id, and only when it has anentryelement. SDC defines sourceQueries for StructureMap population, not as FHIRPath bindings; the naming is a runtime convention, handled as host variables (D16), which the Smart Forms plugin supplies (P4). - x-fhir-query variables. Populate evaluates only
text/fhirpathvariables. It fetchesapplication/x-fhir-queryvariables, on the root and on items, as context parameters named after the variable. The renderer sets everyapplication/x-fhir-queryvariable name to[]in advance, so referencing one never throws there. Atext/x-fhir-queryvariable binds nothing in either. - x-fhir-query embeddings start with
{{%launchName.…}}. Populate evaluates the rest of the embedding against the launch resource, without the form's variable map and with no model: only%context,%ucum,%factory,%terminologyUrland%asyncresolve in it, and%fhirServerUrlwhen a source server is configured. Populate builds an unescaped regular expression from each embedding and applies it to every query in the form. Reproduced;smart-forms/unsupported-query-embeddingchecks both (Smart Forms rules plan, P3). See the embedding evaluator.
GP CCMP at commit a243b68
- The form uses launch contexts, Questionnaire variables and itemPopulationContext names.
- The PractitionerRole declaration is inside a commented-out FSH block, so it is not an unused declaration in the compiled form.
- PostalAddressArray is declared by an itemPopulationContext and referenced by an enableWhenExpression on the same item. SDC makes the name available there, so core reports nothing (D13).
- Count references in the generated JSON, not in raw FSH comments. Counts that have not been verified do not justify D19.