Appearance
Smart Forms rules plan: the smart-forms/ plugin
Builds the Smart Forms compatibility plugin from core-plan.md §2.1, §2.3, §4.2, §4.3 and §5, and the plugin follow-ups of the core rules plan. Researched on 2026-10-09 against Smart Forms commit 7e64a22f (renderer 1.4.0, sdc-populate 4.7.1, sdc-template-extract 1.0.15). Revised the same day after a review: its runtime claims were checked against the source, and the population claims were reproduced with the published @aehrc/sdc-populate@4.7.1.
Status: in progress. S1, S3, S4, S7 and S11 were accepted on 2026-10-09, S2, S5, S9 and S10 with P1, and S13 with P2, the same day, and S6 for P4 on 2026-10-12, and S14 for P6, and S12 and S17 for P7, on 2026-10-13; P5's design questions were settled on 2026-10-13 (P5), two of P6's the same day (P6), six of P7's (P7), and four of P8's (P8); S8, S15 and S16 were accepted, and S18 and S19 added, on 2026-10-14, when the open items were triaged (open-items review); S18 was withdrawn the same day, and seven of P12's design questions were settled (P12). Every other decision is accepted. A slice merges once every decision it cites is accepted; record each accepted override of core-plan in plan-amendments.md. Decision IDs are stable once other docs cite them.
P0 to P8 and H are done: config compat is resolved against the plugin's verified versions, the report names them in run.compat, the plugin has the two language rules, questionnaire-variable-unavailable, unsupported-query-embedding, variable-name-collision, repeating-group-variable, named-expression-not-referenceable, forward-variable-reference and unsupported-template-extract-context, plugin rules receive core's references, each marked conditional where fhirpath.js may skip it, available variables, declarations, dependencies, uses and input reads, the plugin supplies the sourceQueries result names population binds through core's hostVariables hook, and population facts, the embedding, sourceQueries, variable storage and evaluation order facts included, are reproduced against @aehrc/sdc-populate@4.7.1, and the template extraction facts against @aehrc/sdc-template-extract@1.0.15. P12, planned for the first release in the open-items review, is done too: core's public contract names no Smart Forms component, and findings carry their rule's docsUrl (S19). Left: the optional renderer harness, P9. P10 and P11 were dropped the same day: the launchContext order is a Smart Forms bug, reported upstream, and SDC allows a templateExtractValue only as valueString.
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 (Sn, and Dn in core-rules.md), the GLOSSARY terms it uses, and the runtime facts it relies on.
- Re-check every runtime fact the slice relies on against the pinned source before writing the rule, and reproduce it where a harness exists (S10). Several facts in the first draft of this plan were wrong until reproduced.
- If a decision the slice cites is not accepted, or the slice has an open item, ask the user to settle it before the PR merges. An unresolved decision blocks only the first slice that depends on it.
- 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.
| Slice | Delivers | Default severity | Depends on | Status |
|---|---|---|---|---|
| P0 | Plugin skeleton, compat config, run.compat, ctx.compat, plugin test helper, corpus with the plugin | – | – | done |
| P1 | API stage A (language lookups), the site-eligibility helper, smart-forms/unsupported-expression-language | error | P0 | done |
smart-forms/expression-evaluated-as-fhirpath | error | P0 | done | |
| P2 | API stage B (referencesAt, variablesAt), populate reproduction harness, smart-forms/questionnaire-variable-unavailable | error | P1 | done |
| P3 | smart-forms/unsupported-query-embedding | error | P2 | done |
| H | ctx.declarations (API stage B2), frozen ctx.sites; fixes in P2's silence and P3's launch names | – | P3 | done |
| P4 | Supplied-names hook, PLUGIN_CRASH, built-in names rejected in config hostVariables; the plugin supplies sourceQueries result names | – | P0 | done |
| P5 | API stage C (dependenciesOf, useOf, readsInput, definerOf), item linkIds for pointer locations, smart-forms/variable-name-collision | error | H, P4 | done |
smart-forms/repeating-group-variable | warning | P5 | done | |
| P6 | smart-forms/named-expression-not-referenceable | error | H, P4, P5 | done |
| P7 | API stage D (conditional flag on references), the sort(…) crash fix, smart-forms/forward-variable-reference (new, S12) | warning | P5, P6 | done |
| P8 | Template-extract reproduction harness, smart-forms/unsupported-template-extract-context | error | P0 | done |
| P9 | Renderer reproduction harness; upgrades renderer facts from source-only | – | P1 | optional |
| P10 | A rule for a launchContext whose sub-extensions are not in name, type order (S16) | error | P5 | dropped |
| P11 | smart-forms/unsupported-template-extract-value (S18) | error | P8 | dropped |
| P12 | Generic compat versions in the report, no RuleContext.options, the resolved compat in explain, docsUrl on findings (S19) | – | – | done |
P1 is the tracer bullet: it exercises the plugin package, the compat resolution, the report and the corpus run, and needs only small exports from core. P2 is the first slice that publishes analysis.
Severities are the core-plan §4.2 defaults. The research confirmed each one; S12 adds the only new rule. Four rule IDs differ from core-plan §4.2 (S17).
Checklist for every slice PR
- The rule has
meta.docs, written with theexplaintemplate, andexplain smart-forms/<rule>works with the plugin listed. - The rule has focused tests built from synthetic minimal forms. GP CCMP and other restricted forms are never committed (D14).
- Every runtime fact the rule depends on is recorded with its evidence (S4): a reproduction test against the pinned npm package where a harness exists, otherwise
source-onlywith a permalink (S10). - The rule reports only at sites some selected component evaluates (the eligibility helper).
- The rule stays silent where a core rule reports the same root cause (S9), with a test for each such case. The silence never depends on whether that core rule is enabled.
- Every message states only a consequence the evidence shows. Messages use author terms;
helpstates the fix (linter-design-guidelines.md, C3). - The corpus has been run locally with the plugin (P0), and the PR description lists the findings that were added or removed, by rule ID, count and JSON Pointer, without expression excerpts from restricted forms (D14). A rule that reports at error merges only with zero false-positive errors on the corpus. The corpus counts in this plan are expectations to investigate, not targets to reproduce.
npm run checkpasses.
Principles
- The plugin reports where Smart Forms differs from SDC; core reports SDC. A plugin rule never re-reports an SDC violation that a core rule or the FHIR Validator already catches (S9).
- A finding states a demonstrated runtime consequence in the pinned components: a value that is never set, an expression that is ignored, a query sent with the wrong URL. If the consequence cannot be shown from source or a reproduction, the rule does not report it, and a message never claims more than was shown.
- The supported integration is population, then rendering (S1): rules assume population succeeded and its context reached the renderer. Template extraction is checked only when it is selected in
compat(S3). - Version facts are data beside the rules (S4). They are small typed structures with evidence links, not a universal entry schema; a runtime change can still require a rule change.
- Publish core analysis when a slice needs it, together with what it needs (S5, D15, D18).
Architecture
The plugin is an ordinary npm package that a project's .mjs config imports and lists in plugins. Core owns the run: it loads the config, validates the plugin and resolves compat, finds the expression sites, analyses each document and calls the plugin's rules. The plugin owns only Smart Forms knowledge: its versions, its runtime facts, the names it supplies and its rules. Everything in the plugin box is new; a slice in parentheses marks a piece this plan adds, in core or the plugin. Dotted arrows show how the Smart Forms versions and supplied names flow through a run. The core pipeline itself is described in architecture.md.
How the plugin is embedded in a run
- Config time.
resolveRuleSetchecks the plugin's shape, registers its rules undersmart-forms/, and resolvescompatagainst the plugin's verified versions (S3). An invalidcompatfailslintandexplainwithCONFIG_INVALIDbefore any input is read. - Analysis time. For each Questionnaire, core calls the plugin's
hostVariables({ document, compat })before finding its expression sites, whether or not any of the plugin's rules is enabled, sosdc/undefined-variableaccepts the sourceQueries names Smart Forms supplies (S6). Core drops names FHIRPath, FHIR or SDC define. A throw, a promise or an invalid result fails the run withPLUGIN_CRASH. - Rule time.
runRulescalls each enabled rule'scheck(ctx)once per Questionnaire, synchronously. The rule reads the context, consults its facts, and reports. Core validates each finding, attaches the rule ID, severity, location and fingerprint, and applies suppressions as for any rule (S8). - Report time.
run.compatnames the exact versions the run checked against.
What a rule sees and does
A rule never reads Smart Forms code at run time and never evaluates an expression: it reasons from core's analysis and the plugin's recorded facts. undefined from referencesAt or variablesAt means the analysis is unavailable, and the rule reports nothing there.
Where the facts come from
Each fact names its evidence (S4, S10). When a harness exists, the evidence is a reproduction test that runs the pinned package; otherwise it is a permalink, and the rule's message claims only what the source shows. Verifying a new Smart Forms version means adding it to the plugin's verified versions, re-running the reproductions, and updating the facts and, where behaviour changed, the rules.
Shared plugin infrastructure
Public API stages (S5)
RuleContext today gives a plugin document, the flat frozen sites and declarations, compat, referencesAt and variablesAt, and report() with relatedLocations. Everything else is internal to core (DocumentAnalysis, plugins/core-rule.ts). Each stage is plumbing in run/rules.ts from an analysis core already computes once per document, plus exported readonly types in index.ts and a section in architecture.md.
| Stage | Slice | Adds to RuleContext | Exported types and functions |
|---|---|---|---|
| 0 | P0 | compat: Readonly<Record<string, string>>, the resolved versions for the rule's own namespace | – |
| A | P1 | – | The query-language predicate, the SDC allowed-languages lookup and the six value-set codes (isQueryLanguage, languagesAllowedAt, definedLanguages) |
| B | P2 | referencesAt(site), variablesAt(site); replaces the scopeAt stub and Scope | VariableReference { name, startOffset, endOffset, complete }; AvailableVariables with resolve(name) giving { name, origin, declaration? }; Variable; Declaration { name, kind, pointer, item?, useCases, site? } |
| B2 | H | declarations: every declaration in the document, in document order, frozen; identical to the objects variablesAt resolves to | – |
| C | P5 | dependenciesOf(declaration), useOf(declaration), readsInput(site) | Dependencies, DeclarationUse; definerOf and BuiltinDefiner |
| D | P7 | – | VariableReference.conditional |
- Empty versus unavailable.
referencesAtreturnsundefinedwhen the site has no FHIRPath analysis (a non-FHIRPath site, or text that does not parse) and[]when it parses and references nothing.variablesAtreturnsundefinedfor an uncatalogued site. Both returnundefinedfor an object that is not one ofctx.sites. Document both; a rule treatsundefinedas "cannot check", never as "no references". - Stage B is a breaking contract change (
scopeAtgoes) and lifts D15 and D18. Core and the plugin stay at 0.1.0, and the peer range at^0.1.0, before the first release: core is unreleased, so 0.1.0 is the version that ships stage B, and a core withoutreferencesAtfails the plugin's rule withRULE_CRASH. The GLOSSARY rename D15 deferred is done. readsInput(site)is in core because core owns the parser; the plugin takes nofhirpathdependency. Its definition is in P5. It returnsboolean | undefined:undefinedfor a site with no FHIRPath analysis or not inctx.sites, likereferencesAt.definerOf(name)is exported so a plugin rule can stay silent wheresdc/reserved-namereports (S9) without copying core's list of built-in names.- The arrays a rule receives,
sitesanddeclarations, are frozen, not only their elements (H). - P7 adds a
conditionalflag toVariableReference(stage D): fhirpath.js may skip evaluating the reference, because it is in aniifbranch, a latercoalesceargument or an argument evaluated per item of its target. The positions are listed in P7. - Not planned: a completion hook, rule options, an item-tree API, inline config. A plugin reads an item by resolving
site.item.pointeragainstdocument.resource(stripdocument.resourcePointerfirst); put that helper in the plugin.
Site eligibility
A private plugin helper, readBy(ctx, site) in src/eligibility.ts, returns the selected components that read a site, each with how it handles the language there, or none. It answers whatever the site's language: unsupported-expression-language reports exactly the sites a component reads but does not evaluate. It is built in P1 and used by every rule that reports at an expression site (P1, P2, P6). It accounts for each component's prerequisites (src/facts/prerequisites.ts), not only the location. A prerequisite never depends on the language at the site itself, though it may depend on the language of other extensions on the element:
- an itemPopulationContext is read only on an item with child items, and only the first of its URL on the item, with a
nameand expression text. Populate keeps one per name: a later one of the same name anywhere in the form replaces it, and it is never evaluated (readPopulationExpressions.ts:56-63); - only the first initialExpression on an item is read. Populate evaluates it once for an item outside a repeating group with an itemPopulationContext. Below one, only a direct child of a repeating group is evaluated, once per repetition, and only when a direct child's initialExpression names a context and the group is reached; a nested repeating group is reached only when it has an itemPopulationContext of its own (
constructResponse.ts:225-238,574-713); - a targetConstraint is built only with
key(valueId),severity(valueCodeerrororwarning),human(valueString) and anexpressionsub-extension; the firstexpressionsub-extension with avalueExpressionis used. The renderer keeps one per key, reading root extensions and then each item's before its child items': a later built one with the same key replaces it, and it is never evaluated (extractTargetConstraint.ts:55-90); - an answerOptionsToggleExpression is read only with an
optionsub-extension that hasvalueCoding,valueStringorvalueInteger; the firstexpressionsub-extension with avalueExpressionis used; - an answerExpression is read only as a fallback when answerValueSet produced no codings, so it is evaluated, but not always;
- the renderer reads an initialExpression, an enableWhenExpression and an answerExpression only when no earlier extension of the same URL on the element has the language
text/fhirpath: each getter takes the first one with that language (getExpressionsFromItem.ts:29-60,178-191). A secondtext/fhirpathone is read by nothing, and atext/fhirpathinitialExpression after a CQL one is read by the renderer only. calculatedExpression and cqf-expression need no such prerequisite: the renderer reads everytext/fhirpathone; - candidateExpression, contextExpression, itemExtractionContext, the root
_titlecqf-expression and a root itemPopulationContext are read by nothing, and neither are uncatalogued and fragment sites.
readBy answers per component: a site population does not read, such as a second initialExpression, can still be read by the renderer, which reads the first text/fhirpath initialExpression to repopulate a form. A rule that cares about one component filters on component or on the handling.
A rule whose ctx.compat names no renderer or populate version throws, which a conforming core never causes: resolveRuleSet always selects every required component. A selected version with no facts throws too: it is a plugin bug.
launchContexts
A private plugin helper, launchContextsOf(document) in src/launch-contexts.ts, is the plugin's only reader of a launchContext's sub-extensions. It returns every launchContext in the root extension array, of any shape, in array order, each with its pointer, its index and four readings:
byUrl: the firstnamesub-extension's name and the firsttypesub-extension'svalueCode, found by URL;byPosition: the first sub-extension's name and the second'svalueCode, whatever their URLs;input: whether population reads it as an input (population-needs-input): anamesub-extension with avalueId || valueCoding.codeand atypesub-extension with avalueCode, found by URL;boundName: the name population binds the launch resource under (launch-context-named-by-position): an input with a name and a type by position.
A name is the valueId, else the valueCoding.code, and an empty one is none, so an empty valueId beside a code makes population run but binds nothing (reproduced). The reader knows no facts: a rule checks the fact for the component and version before it uses a reading. Only the root extension array is read, as populate and the renderer read it, so a launchContext on an item or in the root modifierExtension is not one, though core declares it, and one core does not declare can be. The population model uses boundName and input, named-expression-not-referenceable's answerExpression heads use boundName, and unsupported-query-embedding uses byUrl (#2161). test/launch-contexts.test.ts checks each shape, and checks input and boundName against populate 4.7.1.
Population
A private plugin module, population({ document, compat, declarations? }) in src/population.ts, models what Smart Forms population binds for one document and the selected populate version, under the supported integration (S1). It takes a rule's ctx or the hostVariables hook's input, which has no declarations. Everything is computed on first use. It answers:
runs: whether population runs (population-needs-input);sourceQueries: the root sourceQueries whose results population binds (P4);hostVariablesinsrc/host-variables.tsmaps them to names;bindingOf(declaration): the name population stores the declaration's value under, orundefined, and when it binds it (at): aPosition([step, key, index],population-evaluation-order; in step 1 the key is a sub-step: launch resources, contained#idBundles, fetched sourceQueries, then query variables, a#idmoving to the fetched ones when no launch resource is bound,contained-fetched-without-launch),'never'when it keeps the name but never evaluates the declaration (an itemPopulationContext a later one of its name replaces, anapplication/x-fhir-queryvariable without text, an item variable on the linkId__proto__, a declaration outside the root's or an item'sextensionarray), or'unknown'when a missing or duplicate linkId makes the order unreadable. It does not depend onruns: under S1 the form is populated, and a missing input is a gap in the form. The caller decides what'unknown'means for it;bindersOf(name): everything population binds a name with, sourceQueries results and the declarations it was given, earliest first, so the last one with aPositionwins;'never'and'unknown'last;readsContext(site): whether population reads an itemPopulationContext, or why not (contextReadReasoninsrc/facts/prerequisites.ts, built on the prerequisitesreadBychecks).
It hides the step order, the key order, linkId reliability, which declarations are in the form, the populate half of the stored names (P5), the subjectType check that population-needs-input and requires-patient-subject both describe, and the gating of each behaviour on the populate facts, so no rule asks variableFactsAt('populate', …) a binding question. storedName in src/stored-names.ts is a facade: population's names from bindingOf, the renderer's from the variables it evaluates (storesVariable in src/eligibility.ts). The rules keep their own logic: forward-variable-reference its consumer walk, cycle check, S9 silences, messages and help; named-expression-not-referenceable its answerExpression heads and the components whose names reach a site (S14); variable-name-collision its first-only targets.
test/population.test.ts covers the interface on JSON forms, with declarations from core's internal name analysis (analyseDeclarations in test/support/sites.ts), and runs the pinned populate beside the model for a table of small forms: runs, which names population binds, which binder of a shared name wins, step 1 included, and the order of every pair of declarations of one form. These tests are not fact evidence.
Completion and suppressions
Plugin rules do not take part in completion evidence: completesCheck and leavesUnchecked in plugins/builtin.ts are built-in tables, and core-rules.md (Shared analysis) forbids a public completion framework. The consequences (S8):
- A suppression of a plugin finding is applied when it hides a finding, and stale when its file, expression or declaration is gone, like any suppression.
- When its target exists and the plugin reported nothing there, the suppression is not assessed: rule-completion evidence cannot establish staleness for a plugin rule.
- A plugin rule never makes a site unchecked.
Document this in cli.md and the plugin README.
Test harness
- A plugin test helper writes a temporary
.mjsconfig that imports the plugin's builtdistby absolute file URL, and callslint({ cwd, configPath })in-process. This already works (packages/core/test/lint.test.ts,test/support/tmp.ts). Do not add an inlineconfigoption toLintOptionsfor tests: it would need its own answers for the base directory offiles,source.fshand suppression paths, the config path shown in the report, and the.mjs-only rule for plugins. - CLI tests reuse the inline-plugin
.mjspattern inpackages/core/test/cli/lint.test.ts. - Both Vitest projects use one shared global setup that builds core at most once per run; the plugin's then builds the plugin (P0).
- Add a plugin docs test like core's
docs.test.ts, which covers only built-in rules.
Slices
P0. Plugin skeleton and compat
Decisions: S1, S3, S4, S7, S11.
P0 resolves the compatibility target; it adds no Smart Forms findings and runs no compatibility checks. run.compat names the selected versions that enabled plugin rules run against, and its presence does not imply any coverage.
Core:
- Add
compattoknownKeysandLintConfig(config/validate.ts,config/types.ts). Shape:compat?: { [namespace]: "latest" | { [component]: string } }.validateConfigchecks the shape only. validatePluginchecks a plugin'scompat:verifiedis a non-empty object whose lists are non-empty, unique, non-empty strings, none of them"latest";requiredholds unique keys ofverified. Failures point at/plugins/<i>/compat/….resolveRuleSetchecks the meaning, solintandexplainfail the same way before inputs are read. Each isCONFIG_INVALIDwith a pointer:/compat/<ns>names no listed plugin. Help: list the plugin inplugins.- The plugin declares no compat.
- A component is unknown, or a required component is missing.
- A version is not verified. Help lists the verified versions; never fall back to the nearest one (core-plan §2.3).
- A listed plugin with compat and no
compatentry gets"latest"(C1). "latest"selects every component, each at the last, newest, entry of its verified list in the installed plugin, not npm's latest (S3). npm already has renderer 1.5.0, populate 4.7.2 and template-extract 1.0.16; they stay unverified until a PR verifies them.lint.tsfillsrun.compatfrom the resolved versions, always exact. Maketemplate-extractoptional in the report schema (smartFormsVersions),SmartFormsVersionsand report-format.md;schemaVersionstays 1 before the first release.report/text.tsprints the Smart Forms line without template-extract when it is not selected.ctx.compat(stage 0): a frozen copy of the resolved versions for the rule's own namespace;{}forsdcand for a plugin without compat.
Plugin contract addition: LintPlugin.compat?: { verified: Readonly<Record<string, readonly string[]>>; required: readonly string[] }. The components are the keys of verified, each list oldest to newest; required lists those an explicit object must name. Core reads only this.
Plugin:
- Default export
{ meta: { name: 'smart-forms', version }, rules: {}, compat }, with the version from apackage.jsonJSON import. - Components:
rendererandpopulate(required),template-extract(optional). Verified: renderer 1.4.0, populate 4.7.1, template-extract 1.0.15. - No runtime facts yet: each arrives with its first consumer (S4). Remove
compatfromfilesinpackage.json; nothing ships outsidedist. - Peer range stays
^0.1.0: core is unreleased, so 0.1.0 is the version that shipscompat. From P1, a rule that receives no version for a required component throws rather than check nothing, which a conforming core never causes: it selects every component incompat.required(contract.ts). - Tests build through one shared global setup that builds core at most once per Vitest run, then the plugin, so the plugin's tests also run alone from a clean checkout.
Corpus: npm run corpus -- --smart-forms builds both packages, lints with the committed packages/plugin-smart-forms/scripts/corpus.config.mjs (it imports ../dist/index.js, with compat: 'latest'), prints the Smart Forms versions from run.compat, and compares with a separate snapshot, corpus/findings.smart-forms.json. The snapshot keeps findings of every namespace, without messages; --smart-forms --update writes only that snapshot. Core slices keep their snapshot and stay independent of the plugin build.
Docs: cli.md (the compat key, its default and errors), report-format.md (the compat CONFIG_INVALID cases, optional template-extract), architecture.md (compat resolution and ctx.compat), GLOSSARY ("Compatibility matrix", "Component"), plan-amendments.md (S1, S3, S4, S7, S11 and the merge gate).
Tests:
- Resolution:
latestresolves all three; no entry resolves likelatest; with two versions per component,latesttakes the last; an object with renderer and populate resolves without template-extract; an unknown version, an unknown component, a missing required component, a per-component"latest"and a namespace with no plugin each fail with their pointer;explainfails on the same config errors. - Plugin validation: each invariant of
compatabove. - Runner: a synthetic plugin rule receives its own namespace's versions as a frozen copy; a plugin without compat receives
{}; with two compat plugins, neither sees the other's versions. - Report: text and JSON show the versions, with and without template-extract.
Done when npm run check passes, npm run corpus -- --smart-forms prints Compat smart-forms: renderer 1.4.0, populate 4.7.1, template-extract 1.0.15. (a Smart Forms line until P12), and its findings are identical to the core-only run's.
P1. Language rules
Decisions: S1, S2, S5, S9, S10, D3, D10, D11. Uses the language facts. The corrections below were found in the source while implementing it.
Build stage A and the eligibility helper. Both rules check sites that are carrier === 'expression', catalogued, not fragment sites, with a language and with expression text, that readBy returns a component for. Both name the element as core does: item "Text" (linkId), item linkId, item "Text", an item that has no linkId, or the Questionnaire root.
smart-forms/unsupported-expression-language (error). A component ignores the expression because of its language. Locations: root and item variable, calculatedExpression, enableWhenExpression, answerExpression, cqf-expression on _text, _prefix and _answerValueSet. Accepted: text/fhirpath everywhere, plus application/x-fhir-query for variable.
- Stay silent when:
- the language is not allowed at the location by SDC (
sdc/language-not-allowed, or the Validator'ssdc-inv1); - the language is outside the six value-set codes (
sdc/language-not-in-value-set); Smart Forms also ignores it, but core already reports the code; - the site is uncatalogued, or no component reads the location (no finding in v1; the facts record them);
- the site is an initialExpression (the next rule);
- a calculatedExpression, enableWhenExpression, answerExpression or cqf-expression has a sibling
text/fhirpathextension with the same URL on the same element that the renderer runs: the renderer uses the firsttext/fhirpathenableWhenExpression and answerExpression and everytext/fhirpathcalculatedExpression and cqf-expression, so the ignored one is an alternative, not lost behaviour (getExpressionsFromItem.ts:48-60,137-152,178-191). The alternative must have expression text: for enableWhenExpression and answerExpression, the firsttext/fhirpathone; for the others, any. One without text runs nothing, so the ignored expression is still reported; - a variable has no name, so ignoring it has no consequence, or the same element has a variable of its name that Smart Forms accepts (
text/fhirpathorapplication/x-fhir-query), which sets the name;sdc/duplicate-declarationreports the duplicate.
- the language is not allowed at the location by SDC (
- Message:
Smart Forms ignores this {extension} on {element} because it is written in {language}; it uses only text/fhirpath here, so {consequence}.The extension is namedvariable,calculatedExpression,enableWhenExpression,answerExpression, orcqf-expression on text,on prefixoron answerValueSet. Consequences:- enableWhenExpression: "this expression never disables the item" (the item's
enableWhenandhiddenstill apply); - calculatedExpression: "the answer is never calculated from it";
- answerExpression: "its answers are not computed from it" (
answerOptionandanswerValueSetstill load); - variable: "this variable never sets %{name}". It does not claim that expressions using the name fail: Smart Forms keeps one flat set of names, so another declaration, such as a root FHIRPath variable of the same name, may still supply it;
- cqf-expression on
_text: "the text is not updated from it"; on_prefix: "the prefix is not updated from it"; - cqf-expression on
_answerValueSet: "the answer value set is not computed from it".
- enableWhenExpression: "this expression never disables the item" (the item's
- Help:
- a
text/x-fhir-queryvariable:set the language of this variable to application/x-fhir-query: Smart Forms runs a query in a variable only with that code. It suggests the R4 code for a runtime reason, so it does not break slice 1's rule against suggesting the R4 code for spelling reasons. - otherwise:
rewrite it in FHIRPath with the language text/fhirpath; to use server data, fetch it in an application/x-fhir-query variable on this item or an ancestor and reference that variable.
- a
- Covers the core-rules follow-up "Smart Forms ignores
text/x-fhir-query(D11)".
smart-forms/expression-evaluated-as-fhirpath (error). A component evaluates a non-FHIRPath expression as FHIRPath. Locations: targetConstraint and answerOptionsToggleExpression (renderer); initialExpression and itemPopulationContext (populate). The plan named only the first two; populate's are added (S2) because an application/x-fhir-query itemPopulationContext is valid SDC and occurs in the SDC demographics example.
- Stay silent when SDC disallows the language there: either query code on answerOptionsToggleExpression is
sdc/language-not-allowed. targetConstraint has no R4 restriction, so every non-FHIRPath language is reported there. Stay silent, too, on a language outside the six codes: a misspelttext/fhir-pathis evaluated as FHIRPath and may work, and core reports the code. The eligibility helper already excludes itemPopulationContexts populate never reads or replaces by name, a second initialExpression, targetConstraints and toggles the renderer never builds, and targetConstraints a later one with the same key replaces. - Message:
Smart Forms {renderer|population} evaluates this {extension} on {element} as FHIRPath although it is written in {language}, so {consequence}.Query text throws on?and the error is caught, so for both query codes the consequences are: targetConstraint "the constraint never reports a problem"; answerOptionsToggleExpression "its options are not toggled"; initialExpression "the answer is not prefilled"; itemPopulationContext "the group is not prefilled". CQL text can be valid FHIRPath that returns a value, such astrueor an element name likestatus, and the renderer marks a targetConstraint invalid when the result is empty and the text containsintersect(targetConstraint.ts:99-107). So for the three CQL codes, and for a query targetConstraint whose text containsintersect, the consequence is only "the {language} is never run". - Help: for an itemPopulationContext query,
fetch the resources in an application/x-fhir-query variable on the Questionnaire root, and set itemPopulationContext to FHIRPath over it, such as %name.entry.resource(the GP CCMP pattern). Otherwiserewrite it in FHIRPath with the language text/fhirpath.
Reference-only Expressions: no finding in v1 (settled with P1). Core reports sdc/external-expression at info, and both rules check only sites with expression text.
Reproduction: the populate facts (variable, initialExpression, itemPopulationContext and their prerequisites) are reproduced against @aehrc/sdc-populate@4.7.1 by the harness in packages/plugin-smart-forms/test/reproduction/ (P2). Every renderer fact stays source-only until P9 (S10).
Tests. Positive: variable text/x-fhir-query at root and item; variable text/cql; calculatedExpression application/x-fhir-query; enableWhenExpression text/cql; answerExpression with both query codes; _text, _prefix and _answerValueSet cqf-expressions text/cql; a CQL item variable whose name a root FHIRPath variable declares; targetConstraint with text/cql, both query codes and text/cql-identifier; toggle with text/cql and text/cql-expression; initialExpression text/cql and application/x-fhir-query, including a child of a repeating itemPopulationContext group; itemPopulationContext with both query codes on a group with children. Negative: every stay-silent case above, with the core rule turned off for each S9 silence; an incomplete targetConstraint; application/x-fhir-query variables; and text/fhirpath at every location.
Corpus: GP CCMP 0. Other corpus forms: 1 for each rule (SDC form-behavior answerExpression application/x-fhir-query; SDC demographics itemPopulationContext application/x-fhir-query on a repeating group).
Done when both rules report as specified, each location in the language facts has evidence, and the corpus run shows only the expected findings.
P2. smart-forms/questionnaire-variable-unavailable
Decisions: S1, S5, S9, S10, S13. Implements core-plan §4.3.
Core API: stage B, referencesAt and variablesAt. Record in plan-amendments.md that it lifts D15 and D18.
Reproduction harness: @aehrc/sdc-populate@4.7.1 is an exact dev dependency of the plugin. It runs headlessly in plain Node ESM: populateQuestionnaire({ questionnaire, patient, fetchResourceCallback, fetchResourceRequestConfig, timeoutMs }), asserting on the URLs passed to fetchResourceCallback, the OperationOutcome issues, populatedContext and the response (test/reproduction/populate.ts). The lockfile resolves a single fhirpath 4.10.1. fhirclient, which populate declares but never imports at run time, is overridden with an empty local stub: the real package pulls in about 500 Expo and React Native packages (AGENTS.md). Reproduced in this slice: this rule's facts, P1's populate facts, and an item-level query variable feeding an initialExpression (used by P5 and P7).
Rule (error). A complete reference that resolves to the built-in %questionnaire or %qitem (resolve(name).origin), at a non-fragment FHIRPath site that readBy returns a component for, when a reader's unsupplied-name facts (src/facts/names.ts) list the name. No component supplies either name, and fhirpath.js throws on an undefined environment variable when the reference is evaluated. Every component catches the error, logs it with console.warn and keeps the previous result; populate also adds an OperationOutcome warning.
A form declaration of the name in scope resolves to the declaration and is
sdc/reserved-name's;%qitemat a root site resolves to nothing and issdc/undefined-variable's.The rule is also silent for a name any declaration in the form has, wherever it is:
variablesAtresolves%questionnaireto the built-in when the declaration is on another branch, or in the declaration's own expression (D25), andsdc/reserved-namereports the declaration, which is the root cause (S9). This claims nothing about whether the declaration supplies a value at run time.Fragments belong to P3.
Range: the reference.
A reference inside an
iifbranch still reports, because the branch may run;iif(false, %qitem, 1)returns[1]without failing, so the message says "when the reference is evaluated".Message, at every location except answerExpression:
Smart Forms does not supply %{name}, so this {extension} on {element} fails when the reference is evaluated, and {consequence}.The consequence states what the failed evaluation leaves:Location Consequence variable %{variable} gets no value from itcalculatedExpression the answer is not calculated from itenableWhenExpression it does not change whether the item is enabled; the item starts disabledcqf-expression on _text,_prefix,_answerValueSetthe text,the prefixorthe answer value setis not updated from ittargetConstraint the constraint result is not updated from this evaluationanswerOptionsToggleExpression its options are not toggled; they start disabledinitialExpression, read by population the answer is not prefilledinitialExpression, read only by the renderer the answer is not refilled from it when the form is repopulateditemPopulationContext the group is not prefilledAn answerExpression is never evaluated when the reference is its head, and throws when the reference comes later and is evaluated; inside an
iifbranch it may not be, and the other branch's answers are used. Head:Smart Forms does not supply %{name} to an answerExpression, so its answers on {element} are never computed from this expression.Later:…are not computed from this expression when the reference is evaluated.Dependents of a variable whose evaluation failed fail too; the rule reports only the reference itself.
Help: for
%qitemfollowed by.linkIdon an item with a linkId,use the literal value instead, such as '{linkId}', with any\or'in the linkId escaped; otherwisecopy the value the expression needs from the Questionnaire into the expression.Note, at every location except answerExpression:
if the application running the form supplies %{name}, suppress this finding with a reason that names the application.Host exceptions (S13): a renderer host can supply
%questionnairethroughadditionalContext, and a directpopulate()caller through acontextparameter; the app does neither. A host can supply%qitemcorrectly only when every expression that uses it is on one item, becauseadditionalContextis one value for the whole form. A host that does either records a suppression with a reason naming itself.hostVariablescannot express it: core resolves built-in names before host names, and from P4 config rejects them. No host can supply a name to an answerExpression: the renderer builds its context from the name before the first.alone, andadditionalContextnever reaches it.
What a failed evaluation leaves in the renderer (source only until P9; every catch logs with console.warn and keeps the previous value):
| Location | Result | Evidence |
|---|---|---|
| variable, root and item | Not assigned, so it keeps its previous value, if any. Its own references then throw too | Rutils/fhirpath.ts:295-297,347-349 |
| calculatedExpression, cqf-expression | Keeps its previous value, initially null | Rutils/calculatedExpression.ts:97,142-144,241-243; Rutils/getExpressionsFromItem.ts:79-126 |
| enableWhenExpression | Keeps the previous enabled state, initially false: the item starts disabled | Rutils/enableWhenExpression.ts:156-161,285-287; Rutils/questionnaireStoreUtils/extractOtherExtensions.ts:418,443,505 |
| answerOptionsToggleExpression | Keeps the previous state, initially undefined, which counts as disabled | Rutils/answerOptionsToggleExpressions.ts:116-121,196-200; Rhooks/useAnswerOptionsToggleExpressions.ts:46 |
| targetConstraint | Keeps the previous result, initially undefined: it never fires | Rutils/targetConstraint.ts:113-115,177-179; Rutils/validate.ts:125 |
| answerExpression | Evaluated only when its head names a launch context with a resource, or an application/x-fhir-query variable whose expression is a ValueSet URL and whose expansion resolved, with only that name in its context. A throw falls back to the answerValueSet codings | Rhooks/useValueSetCodings.ts:66-124 |
Populate behaves the same way: a variable stays unset, an initialExpression leaves no answer, and an itemPopulationContext builds no group (PcreateFhirPathContext.ts:257-264; PevaluateExpressions.ts:63-71,89-96,140-148). Reproduced in populate.
Tests. Positive: root variable %questionnaire.title; item variable %qitem.text; calculatedExpression, enableWhenExpression, targetConstraint, answerOptionsToggleExpression and _text cqf-expression with their consequences; answerExpression with %questionnaire as the head and with a later %qitem; initialExpression %qitem.linkId with the linkId help; a text/fhirpath initialExpression after a CQL one; itemPopulationContext on a group with children; a reference inside iif; two references in one expression; a backticked name; a name in hostVariables (removed in P4, when config rejects built-in names). Negative, with the core rule off for each S9 silence: a variable named questionnaire using %questionnaire; %qitem at the root; a declaration questionnaire on an ancestor and on another branch; use inside a fragment (P3); CQL; candidateExpression; a second text/fhirpath initialExpression, enableWhenExpression and answerExpression; text that does not parse; a reference after a computed defineVariable; a suppressed finding. Reproduction: the populate cases in test/reproduction/populate-names.test.ts.
Corpus: GP CCMP 0, Smart Forms forms 0, SDC STU4 2 (hunger-vital-signs, item variable thisItem = %questionnaire.item.where(…)).
Core follow-up (not in this slice): a config error for a built-in name listed in hostVariables. Done in P4. It is not a silent no-op for qitem: the built-in is added only at item sites, so a listed qitem resolves %qitem at the root.
Upstream: aehrc/smart-forms#2140 (%questionnaire) and #2141 (%qitem) report the missing names. When a release that supplies them is verified, its unsupplied-name facts omit the name, and the rule stays silent for that version without a rule change.
P3. smart-forms/unsupported-query-embedding
Decisions: S1, S9, S10. Uses the embedding facts.
Scope: fragment sites whose query site is a catalogued variable, on the root or an item, with the language application/x-fhir-query and a name. Populate fetches exactly those, and the renderer never replaces {{…}}. Other query locations belong to P1. Bundle entry.request.url in a contained sourceQueries Bundle is not an expression site and stays out of scope, except as a target of check 4.
Rule (error). Check in this order and report the first problem, one finding per fragment. A fragment ends at the first }}, as populate's non-greedy regex does.
- Not recognised. The fragment text does not match
^%[^\r\n]+$: populate needs%immediately after{{and at least one character after it. The query is sent with the braces in it. - No launch context. The Questionnaire declares no launchContext. Populate replaces embeddings only when a launch resource is supplied, so every embedding in the form is sent with its braces, and no other problem can occur. Report once per fragment (settled below).
- Invalid regular expression.
new RegExp('{{%' + text + '}}', 'g')throws. Populate builds that regular expression, unescaped, so population of the whole form fails with "An unknown error occurred". The linter decides this by compiling the same pattern. Metacharacters inside string literals count. - Replacement fails or corrupts. Simulate populate's replacement: apply the compiled pattern to the fragment's own
{{%text}}, replacing with a sentinel.- The text is unchanged: the replacement never matches and the query is sent with its braces. Function calls, indexers,
+,*,?,^,$and\do this. - The result is anything other than exactly the sentinel: the URL is corrupted.
|does this:{{%patient.id|id}}becomesp1|p1. - Also apply the pattern to the text of every other
application/x-fhir-queryvariable in the form: populate applies each embedding's replacement to every query. A match that is not an identical embedding corrupts that query; report it with the other variable as related location ({{%patient.id|id}}turnsEncounter?b={{%patient.id}}intoEncounter?b=p1}}).
- The text is unchanged: the replacement never matches and the query is sent with its braces. Function calls, indexers,
- Head is not a launch context. The text from
%to the first.is not a root launchContext name: a variable, a sourceQueries name, a built-in or a backticked name. The query is sent withundefined. A missing.lands here too and is sent with an empty value. - Depends on another name. The remainder references a name that core resolves and that populate does not supply there. Populate evaluates the remainder against the launch resource with only these names:
%context(the launch resource),%ucum,%terminologyUrl,%async, and%fhirServerUrlwhen a source server is configured. Form variables and other launch contexts,%patientincluded, are not available, so the evaluation throws and the embedding becomes empty. Names that do not resolve stay withsdc/undefined-variable.%factoryresolves, but every use of it is a call, which check 4 reports first.
- Checks 1–5 are string checks and run even when
sdc/fragment-parse-errorreports the fragment; both findings are true and their fixes differ (S9). Check 6 needs references, so it is skipped for a fragment that does not parse. - Operators and literals are not themselves a problem:
{{%patient.id = 'p1'}},{{%patient.id & 'x'}}and{{%patient.id.length div 1}}are replaced with their values. - Range: the whole fragment for 1–5; the reference for 6.
- Messages:
- 1:
Smart Forms does not replace the embedding {{ %patient.id }}: it must start with {{% with no space.Help:write it as {{%patient.id}}(only when the trimmed text starts with%). - 2:
Smart Forms replaces {{…}} only when a launch context is supplied, and this Questionnaire declares none, so the query is sent with its braces.Help:declare the launch context the embedding uses, such as patient. - 3:
Smart Forms builds a regular expression from the text inside {{…}}, and this text is not a valid one, so population of the whole form fails. - 4, braces:
Smart Forms builds a regular expression from the text inside {{…}}, and this text does not match itself, so the query is sent with its braces.Corrupts:…and it matches other text, so it corrupts the query URL(naming the other variable when it is another query). - 5:
Smart Forms fills {{%Condition.id}} only from a launch context, and Condition is not one, so the query is sent with "undefined".A missing.:…so the query is sent with an empty value.Help:use a launch context declared on the Questionnaire, such as %patient.id. - 6:
Smart Forms evaluates the text after %{head} with only the launch resource, so %{name} is not available and the embedding becomes empty.Help:inside {{…}} only %context, %ucum, %terminologyUrl and %async are available, and %fhirServerUrl when a source server is configured.
- 1:
- Known limitations, not checked: values are inserted without URL encoding; a non-primitive result such as
%patient.namebecomes[object Object]; only the first result is used; a path with no result becomesundefined; a launch context that is declared but not supplied at runtime leaves its embeddings unreplaced orundefined.
Tests. Negative: {{%patient.id}}; several embeddings in one query; a custom launch name; %context, %ucum and %terminologyUrl in the remainder; an operator with literals; a fragment in contextExpression; the R5 query code; a fragment that does not parse (no check 6). Positive: each check above, including {{%}}, a metacharacter inside a string literal, the cross-query corruption, an item-level variable, and a head that is an x-fhir-query variable. Reproduction: each check against sdc-populate 4.7.1, asserting the URL passed to fetchResourceCallback or the failed population.
Corpus: GP CCMP 0 (15 embeddings: 14 %patient.id, 1 %gpccmppractitionerrole.id, a declared launchContext). SDC STU4 1 (rxterms item variable with {{item.where(linkId=…)…}}, check 1).
Settled while building it (2026-10-11):
- Facts:
src/facts/embeddings.ts, one fact per behaviour of populate 4.7.1, reproduced intest/reproduction/populate-embeddings.test.ts. The renderer has none: it never replaces{{…}}. Each check runs only when its fact holds for the selected populate version. - Scope: a query variable
readByreturns populate for. Population fetches nothing for a variable without a name. - Check 1 follows population's pattern, whose
.excludes line breaks, so an embedding that spans lines is reported. A fragment inside which population still finds an embedding, such as{{{%patient.id}}, is not checked. - Check 2 is reported once per fragment, as in the open item's default. A launchContext of any shape counts as declared, so the check never fires on a form population might fill.
- Check 4 searches every query the form fetches, this one included, and the request URLs of the contained Bundles the root's sourceQueries reference. Any match that is not an identical embedding is a corruption; one finding names every corrupted query, and the other queries are related locations. So
{{%patient.id|id}}names both its own query andEncounter?b={{%patient.id}}. - Check 5: an empty head (
{{%.id}}) is not evaluated, so it is sent with an empty value, like a missing.. Launch context names are read by URL, as core reads them (S16).
H. Contract hardening
Decisions: S5, S9. Found in the P4 design review (2026-10-12); a small PR before P4.
- Core API, stage B2:
RuleContext.declarations, the frozen array of the document's declarations that core's own rules use, in document order. EachVariable.declarationfromvariablesAtis one of its elements, and eachdeclaration.siteone ofctx.sites. A launchContext or extractAllocateIdDeclaration.itemis a different object fromsite.itemfor the same item: compare items bypointer. - Frozen arrays.
ctx.sitesis frozen, not only its sites: a rule could otherwise add a site, which the runner then accepts as a finding location, or reorder them, which breaks "a fragment directly follows its query". - P2 fix.
questionnaire-variable-unavailabletakes the names that silence it fromctx.declarations, every kind, extractAllocateId included, as P2 states. It unionedvariablesAtover the sites, so an extractAllocateIdquestionnairesilenced it only when an extraction site sat on its element or below (reproduced). - P3 fix. A launch name is read in population's order,
valueId ?? valueCoding.code(PIinputParameters.ts:97-121,346). The rule readvalueCoding.codefirst, so avalueCodingwithout a code beside avalueIdgave a false check 5 (reproduced). "A launchContext of any shape counts as declared" stays:declareddoes not come fromctx.declarations, which holds only launchContexts whose name core can read. contract.tsdocuments thectx.compatguarantee: every required component, and every other verified one unless an explicit config object omits it. Core'ssdc/duplicate-declarationnames elements withrules/describe.ts, like every other rule.
P4. Supplied-names hook
Decisions: S1, S4, S6, S13, D1, D16, D19. Settles the core-rules follow-up "a plugin hook for supplied names". Settled in a design review on 2026-10-12; every population claim below was reproduced against @aehrc/sdc-populate@4.7.1.
Core:
- Add an optional
LintPlugin.hostVariables?({ document, compat }): readonly string[], synchronous and pure.compatis the same frozen object the plugin's rules get asctx.compat({}without compat). It only adds names. lint.tscalls it for each listed plugin with a hook, in config order, once per document (a root or Bundle-entry Questionnaire), beforefindExpressionSites. It runs whether or not any of the plugin's rules is enabled: listing a plugin activates its names, rule settings control only its findings.- The result is checked for shape only: an array, possibly empty, whose every index holds a non-empty string that does not start with
%. Duplicates are allowed, and so is a space (%`a b`). Core then drops a name FHIRPath, FHIR or SDC defines (builtinNames, or avs-orext-prefix), and merges the rest after confighostVariables. A hook name resolves withorigin: 'host'when no declaration or assembleContext name wins. - A throw, a returned promise or an invalid result fails the run with a new failure code,
PLUGIN_CRASH. It carries a requiredplugin: { name, version }andpath, the Questionnaire's file; its message names the plugin, the file and, for a Bundle entry, the resource pointer.RULE_CRASHgainspathtoo. Schema enum and if/then, report-format.md. validatePluginfails withCONFIG_INVALIDat/plugins/<i>/hostVariableswhen the key is present and not a function.RuleSetkeeps the hooks with their compat.- A config
hostVariablesentry that FHIRPath, FHIR or SDC defines isCONFIG_INVALIDat/hostVariables/<i>, the P2 core follow-up. Forquestionnaireandqitemthe help says to remove it and suppress the findings with a reason that names the application (S13); for the others, that the name is always available. Config and the hook share one per-name shape check. - This overrides core-plan §5.1, which says that plugins "do not subscribe to walker callbacks and do not extend the registry through this interface": a plugin now also supplies names to core's analysis. Recorded in plan-amendments.md (S6).
Plugin. The Smart Forms plugin returns the sourceQueries result names population binds under the supported integration (S1), assuming external fetches succeed. Choosing the name and binding a value are separate steps in the runtime:
- Read. Only root extensions with the exact sourceQueries URL, every one of them; item-level ones are ignored. Nothing when the root
subjectTypeis non-empty and lacksPatient: population stops before reading them. - Name. A
#idreference is namedid, before any resolution. Any other non-empty reference is namedsourceQuery<i>, whereicounts from 0 every sourceQueries extension with a truthyvalueReference: display-only, empty and#ones included. - Value. A
#idname is bound only when the first contained resource with that id and type Bundle has anentryelement (entry: []included, anyBundle.type), to that Bundle itself. Another reference is fetched, and bound to whatever resource returns; a failed fetch or an OperationOutcome leaves it unset. - Returned. A
#idwith such a Bundle, and everysourceQuery<i>. Never a missing or empty reference,#alone, or a name starting with%(#%xis bound as%x, but a host variable is written without%). A#idwithout such a Bundle stayssdc/undefined-variable. - Exception, not modelled. With no launch resource supplied at run time, population also passes a
#idto the fetch callback. In the app that is a GET to the server base, which fails or returns an unrelated resource. - The app fills no contained Bundle entry. Population stores an entry's result only from a callback that resolves to
{ data: resource }; the app's callback returns the resource itself. So in the app the name is bound and%id.entry.resourceis empty (reproduced). The hook still returns the name. - The facts live in
src/facts/source-queries.ts, keyed by populate version and reproduced intest/reproduction/populate-source-queries.test.ts, whose harness callback returns the resource itself, as the app does. The hook and P3's rule share one reader of sourceQueries,src/source-queries.ts. - The renderer sees these names only through
additionalContext, after a successful population (S1). Template-extract never sees them, though they are available at extraction sites, as config names are (D19). In P4 that hides nothing Smart Forms evaluates: templateExtract carriers arevalueStrings core does not analyse, and Smart Forms does not evaluate itemExtractionContext. - The app's own
additionalContextnames stay in confighostVariables(D1): they belong to the host, not to Smart Forms.
Docs: cli.md (hostVariables, PLUGIN_CRASH, and the S8 behaviour of plugin suppressions under Stale suppressions, as current behaviour), report-format.md, architecture.md, README.md, a plugin README (packages/plugin-smart-forms/README.md), and the meta.docs of sdc/undefined-variable, sdc/reserved-name and sdc/shadowed-declaration: a listed plugin can supply names, and listing a name the plugin does not supply asserts that the application supplies it.
Tests: core: the hook's merge, its compat, its runs with every rule off and per Bundle entry, the dropped built-ins, each PLUGIN_CRASH, the config error for each built-in kind. Plugin: the hook for each case above; lint cases with a contained Bundle written as raw fsh lines (explicit contained[n] indexes); JSON tests for entry: [] and valueReference: {}, which FSH cannot write; reproductions of every fact. P2's case with questionnaire in hostVariables is removed.
Corpus: 48 sdc/undefined-variable findings removed from the Smart Forms snapshot, every %PrePopQuery reference in the six forms that declare it as a sourceQueries Bundle, and none added.
Upstream: draft an issue for the unfilled contained Bundle entries (populate reads only response.data there; the app's callback returns the resource since da912b7d). File it after checking the newest populate release, and cite it here. Drafted with P4, not filed yet: it needs a maintainer to check the newest populate release and file it.
Settled while building it (2026-10-13):
- Messages.
PLUGIN_CRASH:plugin <name>@<version> crashed in hostVariables on <file>: <error>,…returned a promise from hostVariables on <file>; hostVariables must be synchronousand…returned invalid host variables on <file>: <problem>, where the problem names the entry, such asentry 1: expected a non-empty string, got a stringorentry 0 is missingfor a hole. A Bundle entry's Questionnaire is<file> at <resource pointer>.RULE_CRASHnames the file the same way:rule <id> crashed on <file>: <error>. The GitHub annotation of either crash is at the file. A built-in name in confighostVariables:<name> is defined by <FHIRPath, FHIR or SDC>, so it is not a host variable. - Core checks a hook's result inside the same
tryas the call. Every document is analysed, and so every hook called, before any rule runs: a hook's crash on a later document wins over a rule's crash on an earlier one. - Facts:
src/facts/source-queries.ts, six behaviours of populate 4.7.1 (read-from-root,requires-patient-subject,contained-named-by-id,reference-named-by-index,entries-filled-only-from-data,contained-fetched-without-launch), reproduced intest/reproduction/populate-source-queries.test.ts. The hook consults the first four; the last two are recorded for the open items. The renderer has none: it gets the names only throughadditionalContext. - The hook returns names in extension order; two references to one Bundle return its name twice, which core allows.
- Limitation: the supplied names are available at extraction sites, so
sdc/undefined-variabledoes not report a sourceQueries name in an itemExtractionContext, which a definition-based extraction engine evaluates without it. Recorded in the plugin README and the open items. - P3 takes its sourceQueries request URLs from
src/source-queries.ts, so only the first contained Bundle with an id counts, and none when that one has noentry. Its findings on the corpus are unchanged. - Corpus: 48
sdc/undefined-variablefindings removed from the Smart Forms snapshot (SDC test-fhirpath-prepop-source-query 2, 715.R4 13, 715.R4-modified 13, CVD Check 5, CVD Risk 8, CVD Risk-HISO 7), all at%PrePopQueryreferences, and none added. The core-only snapshot is unchanged.
P5. smart-forms/variable-name-collision and smart-forms/repeating-group-variable
Decisions: S1, S5, S9, S10. Uses the variable storage facts and P4's sourceQueries reader. Verified on 2026-10-13 against 7e64a22f, with the population claims reproduced against @aehrc/sdc-populate@4.7.1; the renderer claims are source-only until P9. Six design questions were settled in a review the same day (below); the overrides are in plan-amendments.md.
Core API: stage C.
dependenciesOf(declaration)anduseOf(declaration)publish the maps core already builds once per document (analysis/document-analysis.ts):Dependencies { on, analysed, known }(analysis/dependencies.ts) andDeclarationUse,'referenced' | 'unreferenced' | 'blocked' | 'unknown'(analysis/uses.ts). They compare declarations by identity and returnundefinedfor an object that is not one ofctx.declarations. Drop the "stay internal" comments and export both types.definerOf(name)and itsBuiltinDefinertype (analysis/available-variables.ts): the specification that defines a name, orundefined;sdc/reserved-namereports by it. Exported so a plugin rule stays silent on reserved names without copying core's list (decision B).readsInput(site), defined below. It returnsundefinedwherereferencesAtdoes.
readsInput(site) is true when the expression's value can differ between two inputs that share their linkId: some term is evaluated with the expression's input as its focus, or the expression uses %context. It is a syntactic test, not proof that a value differs between repetitions.
- At the top level the input is the focus: a path,
$thisor a function call with no target reads it (item.answer.value,$this.item,exists(),ofType(Coding)).%contextreads it anywhere. - fhirpath.js 4.10.1 evaluates an argument typed
Expragainst the items of the function's target:where,select,exists,all,repeat, every argument ofiif, the first ofaggregate, the second oftraceanddefineVariable(node_modules/fhirpath/src/fhirpath.js:79-120,191). A path there reads the target, not the input:%resource.item.where(linkId = 'x')and%resource.iif(true, item.answer.valueString, 0)do not read it.coalesceevaluates its arguments against its own input. - Every other argument and operand is evaluated against the enclosing focus,
$thisor else the input, not against the function's target (fhirpath.js:753-754):%resource.item.select(answer.valueString).combine(item.answer.valueString)reads the input. today(),now(),timeOfDay(), a literal,%resource,%rootResource,%qitemand other%namereferences do not read it.- The input's
linkIdis the same in every repetition, so a term that reads only the input'slinkIddoes not count (decision F):linkId,$this.linkId,%context.linkIdand%resource.item.where(linkId = %context.linkId).count()are false;linkId & item.answer.valueis true.textanddefinitionare not excluded. - Build it as a new value type in a walk that shares the visit-and-invoke skeleton of
findLinkIdReferences(analysis/fhirpath.ts). ItsfocusedArgumentsanswers a different question (whether a value is nodes of this form, forsdc/unresolved-linkid) and is not reused as is. - Tests: each bullet above, and the probes against two repetitions:
item.where(linkId='a').answer.value,%context.item.answer.valueand$this.item.answer.valuediffer;linkId,%resource.item.where(linkId='x').answer.valueand%resource.item.where(linkId=%context.linkId).count()do not.
Also with P5 (found in the P4 design review):
- A pointer finding or related location at an item gets that item's linkId in the report.
jsonLocation(run/rules.ts:254-268) takes a linkId only from a site, or from a declaration at that exact pointer, sorepeating-group-variable's related location "this group repeats" would show none. Stripdocument.resourcePointer, match a leading(/item/\d+)+and read the innermost item's linkId fromdocument.resource: a contained resource does not match, a Bundle-entry prefix is kept, and the declaration-at-pointer case is covered too. Suppression matching is unchanged. - Optionally export core's
elementName(rules/describe.ts), which the plugin copies insrc/document.ts, under the same S5 amendment asdefinerOf. The plugin has no copy ofplaceOf.
Stored names. Smart Forms keeps one value per name for the whole form (reproduced in populate, source-only in the renderer). These store a name:
- a
variablewhosesite.languageis exactlytext/fhirpathorapplication/x-fhir-query, on the root or an item. Core declares a variable in any language;text/x-fhir-query,text/cqland the others bind nothing (reproduced); - an itemPopulationContext that populate reads: the first of its URL on an item with child items, with a name and expression text (
src/facts/prerequisites.ts). Never a root one or a second one on the same item. It collides in populate always; in the renderer, throughadditionalContext, only with x-fhir-query variables and launch contexts, because a renderer FHIRPath variable of the same name overwrites it on the next pass; - a root launchContext, under the name Smart Forms reads (decision C): its name sub-extension read as population reads it,
valueId ?? valueCoding.code, by position (S16). Re-check PIinputParameters.ts:97-121,338-374and reproduce before relying on it. NotDeclaration.name, which core reads by URL andvalueCoding.codefirst. Report at the extension's pointer; - a sourceQueries result name the plugin's
hostVariables()returns (decision A), on the root. It is not a declaration, so no core rule sees it, and populate binds the Bundles before FHIRPath variables, so a variable of the same name overwrites one (reproduced). Reusesrc/source-queries.ts. Two references to one Bundle are one name, not a collision.
Not stored, so never reported: allocateId, namedExpression (P6), and any name definerOf defines (decision B): sdc/reserved-name reports that declaration, which is the root cause.
smart-forms/variable-name-collision (error). Two stored names on different elements overwrite each other.
- Group the stored names by name, in document order. The first one's element is the first element; report each stored name on another element. A sourceQueries name collides with every stored declaration of its name, the root's included: report at the declaration, with the sourceQueries extension as related location.
- Same-element pairs are
sdc/duplicate-declaration's (decision D, S9). Skip as a finding target any declaration with an earlier declaration of its name on its element, of any kind or language, taken fromctx.declarationswhether or notsdc/duplicate-declarationis enabled. Withxon the root and twoxon one item, the item's firstxgets the collision and its second only the duplicate finding. A launchContext is compared by its Smart Forms name with the root's earlier declarations and launchContexts. - Ancestor and descendant pairs are included. Items are evaluated children first, so the ancestor's value is stored last and reaches the descendant's own expressions, the reverse of SDC scoping (reproduced in populate). This co-fires with
sdc/shadowed-declarationat info, which states the SDC fact; this rule states the runtime consequence (S9). - Do not say which declaration wins: item variables are evaluated in post-order with integer-like linkIds first in both components, the renderer repeats its pass, and an async-cached renderer variable is not re-evaluated.
- Two itemPopulationContexts of one name: populate evaluates only the last one's expression, and every group whose children read
%nameis built from its values. The first group gets the last group's repetitions, or none when its children return nothing for them (reproduced). - Report at the later stored name (its site, or its pointer for a launchContext), with the first as related location:
%{name} is also declared here, or%{name} is also the name of this sourceQueries result. - Messages, with
{element}fromelementName(the Questionnaire root,item "T" (id), …):%{name} is also declared on {element}; Smart Forms keeps one value for each name, so expressions on either element can get the other's value.Help:rename one of the declarations so the name is declared on one element only.%{name} is also the name of a sourceQueries result; Smart Forms keeps one value for each name, so expressions that read either can get the other's value.Help:rename the declaration.
- Tests. Positive: siblings each declaring
%x; a root and an item variable; a variable and an itemPopulationContext; two itemPopulationContexts on different groups; a launchContextpatientand an item variablepatient; a launchContext named byvalueId; an item x-fhir-query variable and a FHIRPath variable; a root variable and an item variable each named like a sourceQueries Bundle; an ancestor and a descendant, with thesdc/shadowed-declarationco-fire;xon the root and twice on one item. Negative: same element; a named calculatedExpression and a variable; item allocateIds; atext/cqlvariable; a second itemPopulationContext on one item; a root itemPopulationContext; a reserved name declared on two items; a#idsourceQueries reference with no Bundle; two references to one Bundle; a declaration in a contained resource. - Corpus: GP CCMP 0 (58 root variables, 4 launchContexts and 7 itemPopulationContexts, all distinct). SDC 2 (sdc-modular-root:
person,linkIdPrefix). No corpus declaration is named like a sourceQueries Bundle.
smart-forms/repeating-group-variable (warning). The renderer evaluates a variable on or below a repeating group once for each response item of its linkId, in document order, and keeps one value, the last one's. With no response item at all it is evaluated once against {}. Which repetition's value is kept depends on which have response items: at 7e64a22f a question with no answer has none, but renderer 1.4.0 still keeps an emptied one in some cases (Rutils/qrItem.ts:126-163 differs at the release commit), so the message says "one of them, usually the last". Populate evaluates it once, against {} (reproduced). Source-only (Rutils/fhirpath.ts:217-236,268-289).
- A
text/fhirpathvariableon a group withrepeats: true, or on any item below one, is dependent when:readsInput(site)is true; ordependenciesOf(declaration).onholds a dependent variable on the same item, earlier or later in its array (decision E). The renderer evaluates an item's variables in array order within each repetition, in one shared context, so a later one is read with the previous repetition's value. Compute it to a fixed point.- Root variables and variables on other items never make it dependent: a root variable is evaluated once before the per-item loop, and another item's variables in that item's own loop. A descendant's variable that reads the group's variable gets the previous pass's value, or none on the first pass, because the group is evaluated after its descendants; the group's variable is reported itself.
- Report each dependent variable, except:
- one whose
useOfis exactly'unreferenced', core'ssdc/unused-declarationcondition, whatever that rule's setting;'unknown'and'blocked'still report.useOffollows SDC scoping, so a variable referenced only from another branch, legal in Smart Forms' flat namespace, reads'unreferenced'and is not reported; that reference issdc/undefined-variable's (S9); - an itemPopulationContext (populate binds it per repetition), a variable whose only repeating ancestor or self is a question, not a group, and an expression that does not parse.
- one whose
- Report at the variable's site, with the nearest repeating group, the item itself included, as related location:
this group repeats. A variable dependent only through same-item variables also gets the first of them:%{other} is calculated for each repetition. - Messages, with
{group}fromelementNameof that group:- Reads its input:
%{name} is calculated for each repetition of {group}, but Smart Forms keeps one value, so every repetition gets the value calculated for one of them, usually the last. - Dependent only through another variable:
%{name} depends on %{other}, which is calculated for each repetition of {group}; Smart Forms keeps one value of each, so %{name} may use another repetition's value. - Help:
compute it from the whole response, or move the item out of the repeating group. Do not suggest a per-repetition calculatedExpression: the renderer evaluates those once per linkId against{}(Rutils/calculatedExpression.ts:192-210).
- Reads its input:
- Tests. Positive: a variable on a repeating group reading its input; one using
%context; on a descendant; on a nested repeat; a dependency on an earlier same-item variable; on a later one;linkId & item.answer.value; a variable whose use is'unknown'. Negative: a%resourcepath with awherepredicate;linkIdand%context.linkId; a reference to a root variable; a reference to a variable on another item; a constant; each case in "Report each dependent variable, except". - Corpus: GP CCMP 0 (6 itemPopulationContexts on repeating groups, correctly silent). SDC 1 (rxterms
rxcui). sdc-modular-root'slinkIdPrefixon a repeating group is a constant, so silent.
Settled while building it (2026-10-13):
readsInputfollows fhirpath.js 4.10.1 where the plan's list is incomplete:sort's arguments are evaluated against each item of its target, likeExpr(fhirpath.js:735-750), and acoalesceargument against its target with the enclosing$this. A function on the input reads it exceptiifandselect, which read it only through their arguments, andtoday(),now()andtimeOfDay(), soiif(%a > 1, 'x', 'y')is false. The argument ofofType,isandasis a type and is not evaluated. A two-repetition test evaluates each probe with fhirpath.js and the R4 model, asQuestionnaireResponse.item, as the renderer does.- A pointer location's linkId is the innermost item's along a leading
(/item/\d+)+below the resource pointer. It also covers the declaration-at-pointer case, whichjsonLocationno longer looks up. - Population reads a launchContext only with a
namesub-extension, found by URL, that has avalueIdor avalueCoding.code, and atypesub-extension with avalueCode(PIinputParameters.ts:97-121); it then takes the name fromextension[0](:346). The check uses||and the name??, so an emptyvalueIdbeside avalueCoding.codemakes population run but binds nothing (reproduced).variable-name-collisionstores a launchContext's name only then. A launchContext is a finding target only through a sourceQueries name: root extensions come before items, so it is never the later of two declarations. - Facts:
src/facts/variables.ts, five behaviours (one-value-per-nameanditem-variable-per-response-itemfor renderer 1.4.0, source-only;one-value-per-name,population-context-stored-by-name,launch-context-named-by-positionanditem-variable-oncefor populate 4.7.1), reproduced intest/reproduction/populate-variable-storage.test.ts. The renderer's languages decide which variables store a name, through the existing language facts.suppliedSourceQueriesinsrc/host-variables.ts(now the population model'ssourceQueries) gives the hook and the rule the same sourceQueries, andpopulationReadsContextinsrc/facts/prerequisites.tsthe itemPopulationContexts population reads, a replaced one included. elementNamestays copied in the plugin; core does not export it.- From a Codex review: fhirpath.js parses
sort(…)as aFunctnwith noIdentifierorParamList, onlySortDirectionArgumentchildren, soreadsInputreads those; a%'…'name is decoded before it is compared withcontext; the function table is aMap, so__proto__(1)is an unknown function rather than a crash. Escaped identifiers, such as`link\u0049d`, are not decoded and count as reads. Population also takes a launchContext's resource type from its second sub-extension (PIinputParameters.ts:350), so one with a description before its type stores nothing (reproduced). The renderer sets anapplication/x-fhir-queryvariable's name to[]without text, so a reference-only one stores its name. - Not fixed here:
findFunctionCallsthrows on anysort(…)for the same parse shape, so linting an expression withsortfails with an unexpected error. It predates P5. Fixed in P7. - Corpus: 3 findings added to the Smart Forms snapshot, none removed.
variable-name-collision2 (sdc-modular-root/item/4/extension/0/valueExpression/expression,%person, and/item/4/extension/1/valueExpression/expression,%linkIdPrefix),repeating-group-variable1 (rxterms/item/0/extension/3/valueExpression/expression,%rxcui). GP CCMP 0 for both. The core-only snapshot is unchanged.
Settled in the P5 review
2026-10-13, with a second opinion from Codex.
- A. sourceQueries names collide. Included in
variable-name-collision, because no core rule can see them. Rejected: an open item, or a separate rule for the same runtime problem. - B. Reserved names stay with
sdc/reserved-name. Core exportsdefinerOf. Rejected: co-firing, which repeats the same fix against S9, and a plugin copy of the list, which drifts. - C. launchContext names as Smart Forms reads them. Rejected:
Declaration.name, which can claim a collision that does not happen, and skipping launchContexts where the readings differ. A launchContext written type-first is not reported (S16). - D. A same-element duplicate is not a collision target. Rejected: two findings with one fix on it, dropping every collision of an element with a duplicate, and one finding per element, which changes locations and fingerprints.
- E. Same-item dependencies count in either order. Rejected: deferring to P7, which is about population order, and ignoring the case.
- F.
readsInputexcludes reads of the input'slinkIdonly. Rejected: accepting the false warnings, excludingtextanddefinitionas well, whose values can differ, and moving the exclusion into the plugin with a richer core API, which Codex preferred.
P6. smart-forms/named-expression-not-referenceable
Decisions: S1, S5, S9, S14. Uses P5's stored names and P4's sourceQueries reader. Reviewed on 2026-10-13 against 7e64a22f, with a second opinion from Codex: the population claims were reproduced against @aehrc/sdc-populate@4.7.1 with probe forms; the renderer claims are source-only until P9, in files identical at f846e2d8. Two questions were settled the same day (below).
Available names. Smart Forms makes a declaration available to other expressions only by storing a value under its name. Move storedName and launchContextName from variable-name-collision.ts into a shared module, answering per component; P5 keeps its behaviour.
- Population stores a
text/fhirpathvariable (an item one only when the root has extensions: PcreateFhirPathContext.ts:126-128), anapplication/x-fhir-queryvariable on the root or an item, an itemPopulationContext it reads (populationReadsContext), a root launchContext under its Smart Forms name when the host supplies its resource, and the sourceQueries results P4 supplies. - The renderer stores
text/fhirpathvariables on the root and on items, and the names ofapplication/x-fhir-queryvariables. Nothing else: no other named expression, no itemPopulationContext (Rutils/questionnaireStoreUtils/extractVariables.ts:48-77; Rutils/questionnaireStoreUtils/extractOtherExtensions.ts:318-329). - A declaration is available at a site when a component that reads the site (
readBy) stores it, or, at a renderer site other than an answerExpression, when population stores it: population's context reaches the renderer asadditionalContext(S14). One set for every component would be wrong: population skips item FHIRPath variables when the root has no extensions, and the renderer does not.
Rule (error). For each complete reference at a site readBy returns a component for, other than an answerExpression (below), that variablesAt resolves to a declaration: report when the declaration is not available at the site. That is:
- (a) a named expression that is not a
variable: calculated, initial, enableWhen, answer, candidate, context or answerOptionsToggle expression, targetConstraint, cqf-expression; - (b) an itemPopulationContext population does not read: on the root, on an item with no child items (
itemabsent or[]), after another itemPopulationContext on its item, or without expression text (each reproduced: the reference fails with "undefined environment variable", or the name is absent frompopulatedContext); - (c) an itemExtractionContext: Smart Forms does not support definition-based extraction.
The consuming site and the declaration are checked separately: a reference in a candidateExpression is not reported, because nothing evaluates it. Report each reference at its range, as P2 does, with the declaration as related location.
- It never overlaps
sdc/undefined-variable, which reports only references that resolve to nothing. It is triggered by the reference, never by the declaration alone (core-plan §4.2). - An
allocateIdnever reaches it: core makes allocateIds available only at extraction sites (D19), and no component reads those.
Silent, by checking each condition itself (S9):
- the resolved declaration's name is declared more than once on its element, of any kind:
sdc/duplicate-declarationreports it, whatever its setting. Core resolves to the first one in document order, so skipping only later duplicates is not enough (reproduced: a named calculatedExpressionxbefore a variablexon one item resolves to the calculatedExpression; population binds the variable); definerOf(name)is defined:sdc/reserved-namereports the declaration;- a variable in a language no component stores, such as
text/cql:smart-forms/unsupported-expression-languagereports the variable, which is the root cause; - a launchContext: whether population binds it depends on the resources the host supplies (patient always; user, encounter and fhirContext resources only when supplied, PI
inputParameters.ts:338-374). A type-first launchContext binds nothing (reproduced) and is not reported (S16); - an itemPopulationContext that population reads although a later one of the same name replaces it:
smart-forms/variable-name-collisionreports the pair.
Another value of the name. Smart Forms keeps one value for each name, so a reference to an unavailable declaration can still get a value: from a declaration of the name that is available, anywhere in the form, or from a sourceQueries result P4 supplies (reproduced: a named expression x and a #x Bundle; an initialExpression reading %x gets the Bundle). Report it all the same, with each such declaration (%{name} is also declared here) and the sourceQueries extension (%{name} is also the name of this sourceQueries result) as further related locations. Never say which value the reference gets: item variables are evaluated in linkId key order, not document order (reproduced: items a then 2 give a's value, items b then c give c's). A name config hostVariables or assembleContext supplies gets the general message (settled).
Messages, with {extension} from extensionName of the declaration's site and {element} from elementName:
%{name} refers to the {extension} on {element}, which Smart Forms does not make available to other expressions{reason}, so the reference never gets its value.It does not say the reference fails: a name the host supplies can still give it a value, and core does not tell a plugin which supplied name a declaration hides (decision G).- With another value of the name:
%{name} refers to the {extension} on {element}, which Smart Forms does not make available to other expressions{reason}; Smart Forms keeps one value for each name, so the reference can get the value of another %{name} instead. {reason}for (b):, because the item has no child items,, because it is on the Questionnaire root,, because another itemPopulationContext comes first on the item,, because it has no expression text. Empty otherwise.- Help:
declare the value as a variable with a name no other declaration uses, on this item or an ancestor, and reference that. For (b):make it the only itemPopulationContext on a group with child items, with expression text, or declare the value as a variable. Never suggest a variable of the same name: beside the named expression it is a duplicate declaration.
answerExpression sites
Settled in the P6 review: checked here, not deferred under S15. The renderer gives an answerExpression a context of its own, with at most one name, so S14 does not apply there (Rhooks/useValueSetCodings.ts:66-112):
- The head is the text between the first
%and the first.(substring, so with no.after the first%the head names nothing). - The head is supplied when it is a root launchContext under its Smart Forms name whose resource the SMART launch has: Patient, Practitioner, Encounter, or a fhirContext resource of its type (R
utils/questionnaireStoreUtils/extractLaunchContext.ts:5-20; Rutils/valueSet.ts:197-213). Otherwise when it is anapplication/x-fhir-queryvariable, root or item, whose expression matches the renderer's ValueSet URL pattern, whose expansion resolved, and that the renderer keeps for its URL: only then does the renderer set the variable'sresult(Rutils/questionnaireStoreUtils/resolveValueSets.ts:37-66; Rutils/valueSet.ts:39-40,184-195). It collects the variables by name, root variables first and then each item's after its child items', and maps each URL to the last name in that object's key order, so of two variables with one URL only the later one gets the expansion (Rutils/questionnaireStoreUtils/extractVariables.ts:22-38; Rutils/questionnaireStoreUtils/extractOtherExtensions.ts:190-201,318-329). A search or$lookupquery variable never gets one. - Nothing else reaches it: no FHIRPath variable, no itemPopulationContext, no sourceQueries result, no
additionalContext, so no host name. fhirpath.js adds only%context,%ucumand%factory. - When the head is not supplied, the expression is never evaluated. When it is, a reference to any other name throws when it is evaluated, the error is caught, and the item keeps the answerValueSet codings, which are empty whenever the fallback runs.
At an answerExpression the renderer reads:
- Head. The reference at the first
%is complete, its name is the renderer's head, it resolves to a declaration or to a host or assembled name, and the head is not statically supplied: not a root launchContext under its Smart Forms name, and not anapplication/x-fhir-queryvariable the renderer gives the expansion, as above. Report once, at the head; the expression's other references are not reported, because it is never evaluated. Message:Smart Forms computes an answerExpression only when it starts with a launch context or with an application/x-fhir-query variable that expands a ValueSet, and %{name} is neither, so the answers on {element} are never computed from this expression.Help:start the expression with a launch context, or with an application/x-fhir-query variable whose expression is a ValueSet URL, or list the answers with answerOption or answerValueSet. - Other references, when the head is supplied: each complete reference other than the head that resolves to a declaration, a launchContext included, or to a host or assembled name. Message:
Smart Forms gives an answerExpression only %{head}, the name it starts with, so %{name} has no value here, and the answers on {element} are not computed from this expression when the reference is evaluated.Help:compute the answers from %{head} alone. - The silences above apply, except the launchContext one, which covers only a launchContext head. A head whose query fails or whose ValueSet does not expand fails at run time and is not reported. A built-in name, such as
%resource, is left to the open item;%questionnaireand%qitemarequestionnaire-variable-unavailable's.
Tests. Positive: (a) for each kind; (b) each reason: root, no item, item: [], second on its item (with its own name, so it does not exercise the duplicate silence), no expression text; (c); another available declaration of the name; a sourceQueries result of the name; two other declarations of the name, with no claim of which wins; a configured host name with the general message; answerExpression heads: a FHIRPath variable, an itemPopulationContext, a search query variable, a $lookup query variable, a named calculatedExpression, a configured host name; answerExpression references after a patient launchContext head: a FHIRPath variable, another launchContext. Negative: a reference to a variable; to an itemPopulationContext population reads, at a population site and at a renderer site; an unresolved name; a reference in a candidateExpression; a same-element duplicate; a reserved name; a text/cql variable; a launchContext, including a type-first one; a second itemPopulationContext of a name on another group; an answerExpression headed by a patient launchContext or a ValueSet URL query variable; a later reference to the head itself; a second text/fhirpath answerExpression, which the renderer does not read.
Corpus: GP CCMP 0 under S14. SDC 3: sdc-test-fhirpath-prepop-candexpr (two initialExpressions using a named candidateExpression), and rxterms (an answerExpression headed by a $lookup query variable, which the renderer never evaluates).
Settled while building it (2026-10-13):
src/stored-names.tsholdsstoredNameandlaunchContextName, moved fromvariable-name-collision.ts(launchContextNamewas later replaced bylaunchContextsOf, see launchContexts, and population's branch moved into the population model, whichstoredNamenow delegates to).storedNametakes the components to answer for, every selected one by default, which is what P5 uses; P5's lint cases are unchanged.- The rule's targets are the declarations (a), (b) and (c) list: named expressions, the itemExtractionContext among them, and itemPopulationContexts. A
variableis never a target outside an answerExpression. Population does not store an item FHIRPath variable when the root has no extensions, so an itemPopulationContext that reads one fails (reproduced: population succeeds through an item query variable, with an undefined-variable warning); an initialExpression that reads one is not prefilled either, although the renderer stores the variable. This is a known limitation inmeta.docsand an open item: it concerns a variable, not a named expression, and a check per site cannot cover the initialExpression. - The declaration the reference resolves to is the first related location,
%{name} is declared here, as insdc/dependency-cycle. A head or other reference at an answerExpression that resolves to a host or assembled name has no related location. - An answerExpression head is supplied by any launchContext in the root
extensionarray whose bound name (launchContextsOf) is the head, whether or not core declares it, or by theapplication/x-fhir-queryvariable of that name the renderer gives the expansion: the rule collects the variables as the renderer does, into an object by name, and maps each expression that matches the ValueSet URL pattern, copied from Rutils/valueSet.ts:39-40, to a name in that object's key order. The renderer looks both up by name across the form. - The silences are checked per reference, at both kinds of site: a variable no component stores, which also covers a FHIRPath variable without text, counts as a variable in an unsupported language.
- Facts: four new behaviours in
src/facts/variables.ts:named-expression-not-stored(renderer source-only, populate reproduced),item-fhirpath-variable-needs-root-extensions(populate, reproduced),additional-context-reaches-expressionsandanswer-expression-head-only(renderer, source-only, in files identical atf846e2d8).one-value-per-nameandpopulation-context-stored-by-namegain evidence. The reproductions are intest/reproduction/populate-variable-storage.test.ts: each kind of unread itemPopulationContext,item: []included, and a named initialExpression, calculatedExpression and itemExtractionContext store no name; a#xBundle supplies%xin place of a named expressionx; sibling itemsbthencgivec's value; a variable after a named calculatedExpression of its name on one item is bound. - A group with
item: []cannot be written in FSH, so its case istest/named-expression-not-referenceable.test.ts. - From a review: the general message said the reference "fails when it is evaluated", which is false when the host supplies the name; it now says the reference never gets its value. Two variables with one ValueSet URL were both taken to supply a head; only the one the renderer keeps does now (
shared-value-set). - Corpus: the rule adds 3 findings, as expected, and removes none.
named-expression-not-referenceable3: sdc-test-fhirpath-prepop-candexpr/item/0/item/1/item/0/extension/0/valueExpression/expressionand/item/0/item/1/item/1/extension/0/valueExpression/expression(%selectedContact, a named candidateExpression), and rxterms/item/0/item/1/extension/1/valueExpression/expression(head%strengthFormLookup, a$lookupquery variable). GP CCMP 0. The core-only snapshot is unchanged.
Settled in the P6 review
2026-10-13, with a second opinion from Codex.
- G. Host names keep the general message. Core resolves a reference to a declaration before a configured or assembled name, and does not tell a plugin which supplied name a declaration hides. Whether the host's value reaches the reference depends on the component, so the message claims only that the declaration is not available. Rejected: a host-specific message.
- H. answerExpression is checked in P6. Its context holds only the head, so S14 does not apply there, and every other reference, host names included, gets no value. Rejected: leaving answerExpression sites out of P6 under S15.
P7. smart-forms/forward-variable-reference
Decisions: S1, S4, S5, S9, S10, S12, S17, D8. New rule, not in core-plan §4.2. Uses P6's src/stored-names.ts and stage C. Reviewed on 2026-10-13 against 7e64a22f, with a second opinion from Codex: the population claims were reproduced against @aehrc/sdc-populate@4.7.1 with probe forms; the renderer claims are source-only until P9. Six questions were settled the same day (below); the overrides are in plan-amendments.md.
Population order (PcreateFhirPathContext.ts:68-153,199-214; Ppopulate.ts:81-111; PreadPopulationExpressions.ts:53-63; PevaluateExpressions.ts:126-153; reproduced):
- Supplied names: the launch resources, the sourceQueries results and the
application/x-fhir-queryvariables with a name and expression text, on the root and on items, all bound before any FHIRPath is evaluated. - Root
text/fhirpathvariables, in array order. - Item
text/fhirpathvariables, only when the root has extensions (:126-128). Population collects them into a plain object keyed by linkId, filled in post-order (an item after its child items), for every item with anextensionproperty, and evaluates them inObject.keysorder: integer-like keys (canonical decimal strings from0to4294967294) first, in ascending numeric order, then the others in order of first insertion. Within an item, array order. A later item with a duplicate linkId keeps the first one's position and replaces its variables when it has anextensionproperty, evenextension: []; without one, the earlier item's variables stay. A linkId of__proto__sets the object's prototype instead of a key. - The itemPopulationContexts population reads, keyed by name, filled in pre-order.
- initialExpressions.
A reference fails when its name is not yet bound: fhirpath.js throws "Attempting to access an undefined environment variable", populate catches it, adds an OperationOutcome warning (invalid) and leaves the variable's name unset, and population still succeeds (PcreateFhirPathContext.ts:250-264). Every later expression that reads the variable fails in turn. When the name is already bound by something evaluated earlier, nothing fails: the reference silently reads that value (reproduced: a root y read instead of an item y; an earlier declaration read instead of a later one). fhirpath.js looks names up with in (fhirpath.js:332), so an Object property name such as %constructor or %toString never fails.
Reproduced examples: a child a reading its group g's variable fails; a child a or 1.1 of group 1 gets the value, because 1 is integer-like and comes first; a child 1 of group 2, or 2 of group 10, fails.
Core API: VariableReference.conditional (stage D). True when fhirpath.js 4.10.1 may skip evaluating the reference, so a failure is not certain. Positions:
- an argument of
iifafter the criterion, in function and method form; - an argument of
coalesceafter the first: it stops at the first non-empty one; - an argument evaluated once per item of its target, so skipped on an empty one:
where,select,exists,all,repeat, the first ofaggregate, andsort's selectors, which are skipped on a single item, and of which later ones run only for ties (filtering.js:171).
The flag passes down to every reference below such an argument: a nested iif, or a function inside a branch. A call whose name is escaped, so toCall returns undefined, counts as conditional. Never conditional: the iif criterion, the operands of and, or, xor, implies and every other operator, combine and union arguments, trace's projection, defineVariable's value and aggregate's second argument, all evaluated eagerly (reproduced: false and %fwd, {}.combine(%fwd) and defineVariable('z', %fwd) on {} fail).
- Compute it in
findVariableReferences(analysis/fhirpath.ts:240-274), passing the flag down throughvisit. Do not reuseitemArguments(:489) as it is: it also marks theiifcriterion,trace's projection anddefineVariable's value, which answerreadsInput's question, not this one. - Fix the
sort(…)crash in the same change (P5, "Not fixed here"):toCallthrows on sort's parse shape, aFunctnwith onlySortDirectionArgumentchildren, in bothfindFunctionCallsandfindVariableReferences, andfindVariableReferencesvisits onlyParamList, so it must also visit those children. Tests:(1|2).sort($this)and(1|2).sort(%x)lint without a crash, and%xis a conditional reference. - Additive: the report, the schema, fingerprints and suppressions do not change. No existing rule uses it: P2 and P6 report every complete reference.
Population runs only when the form has a root launchContext population reads, a root sourceQueries with a valueReference, or an application/x-fhir-query variable with a name and expression text (PIinputParameters.ts:70-76,96-180; reproduced in populate-prerequisites.test.ts). Otherwise the rule reports nothing.
Rule (warning). The referencing variable is a text/fhirpath variable that readBy gives to populate and that population evaluates (population(ctx).bindingOf(declaration), which already leaves out item variables when the root has no extensions). Its references come from ctx.referencesAt(site): complete, not conditional, and resolved by variablesAt to a declaration. Not from dependenciesOf, which drops ranges and flags. A reference is forward when the declaration it resolves to is:
- (a) a root
text/fhirpathvariable later in array order, read from a root variable; - (b) a
text/fhirpathvariable later in array order on the same item (D8); - (c) a
text/fhirpathvariable on an ancestor item whose linkId comes after the referencing item's in population's key order. Build that order as population does: a plain object filled in post-order,object[linkId] = …for each item with anextensionproperty, read withObject.keys; - (d) an itemPopulationContext population reads (
populationReadsContext), on the referencing variable's item or an ancestor: every itemPopulationContext is evaluated after every variable.
Never forward: an application/x-fhir-query variable, a launchContext or a sourceQueries result, all bound first (the renderer also presets query names to []); a root variable read from an item. Out of scope: an initialExpression, evaluated last; an itemPopulationContext, read in pre-order, so a parent's before its child's; calculatedExpression and the other renderer-only sites, which population does not evaluate. A sibling, cousin or root-to-item reference resolves to nothing in core and is sdc/undefined-variable's.
Population consumer (decision M). Report only when population uses the referencing variable's value: an initialExpression or itemPopulationContext that population reads (readBy) references it, directly or through a chain of variables population evaluates. Without one, the only effect is the OperationOutcome warning. Do not use useOf: it also counts renderer-only consumers.
Silent, by checking each condition itself (S9):
- the name is bound before the referencing variable is evaluated: by another stored declaration of the name that population evaluates earlier, a launchContext under its Smart Forms name, a sourceQueries result, or an
application/x-fhir-queryvariable with a name and expression text, anywhere in the form. The reference then reads that value, andvariable-name-collisionreports the pair. Decide from the form, not from that rule's setting. population stores the name of a query variable without text but does not fetch it, sobindingOfgives itat: 'never'; - the name is an Object property name, such as
constructorortoString, so fhirpath.js finds it within; - the name is declared more than once on the declaration's element:
sdc/duplicate-declaration; definerOf(name)is defined:sdc/reserved-name;- the reference is an edge of a dependency cycle: the declaration depends, through
dependenciesOf, on the referencing variable, a self-reference (D25) included.sdc/dependency-cyclereports it, and reordering cannot fix it. Another forward reference in the same variable is still reported; - population does not store the declaration: a
text/cqlvariable (P1), a named expression or an itemPopulationContext population does not read (P6), or a replaced itemPopulationContext (P5); - the referencing item or the declaration's item has no linkId, a linkId another item also has, or the linkId
__proto__: population's order is not the document's, and the Validator reports the first two.
Co-firing with repeating-group-variable on a later variable on the same item (P5 decision E) is allowed (decision L, S9): reordering fixes population, while every repetition still shares one value.
Location. One finding per referencing variable and resolved declaration, at the range of its first eligible reference (decision N). Related locations: the declaration, %{name} is declared here, then the first population consumer, this prefill expression uses %{variable}.
Messages, with {variable} the referencing variable's name, {name} the referenced one, and {element} from elementName of the declaration's element:
- (a), (b), (c):
%{variable} references %{name} on {element}, which Smart Forms evaluates after %{variable} when it prefills the form, so %{variable} has no value while the form is prefilled. - (d):
%{variable} references the itemPopulationContext %{name} on {element}, which Smart Forms evaluates only after every variable when it prefills the form, so %{variable} has no value while the form is prefilled. - Help:
- (a), (b):
move %{name} before %{variable} on {element}; - (c):
compute the value in %{variable} itself, with, or declare %{name} on the Questionnaire rootwhenreadsInputis false for the declaration's site: root variables are evaluated against the response, not the item; - (d):
compute %{variable} from the itemPopulationContext's expression instead of %{name}, or read %{name} only in initialExpressions.
- (a), (b):
The renderer is not the basis of the finding (source-only, kept out of the message under S10). Its first pass starts from {}, so the reference throws, and the error is caught with a console warning. After additionalContext is merged, the variable reads the later variable's value from the previous update, through every iteration of the calculation loop, so it may recover with a stale value (Rutils/fhirpath.ts:193-197,295-297; Rutils/calculatedExpression.ts:838-856; Rstores/questionnaireStore.ts:267; Rutils/manageForm.ts:149; Rutils/questionnaireStoreUtils/createQuestionnaireModel.ts:130,153).
Facts in src/facts/variables.ts: population-evaluation-order (populate 4.7.1, reproduced: the five steps and the key order), population-needs-input (populate 4.7.1, reproduced) and the renderer's stale forward read (renderer 1.4.0, source-only). Make the comment "Items are evaluated children first, so an ancestor's value reaches a descendant" cite the order fact.
Tests.
- Positive: (a); (b) on the root and on an item; (c) with child
aof groupg, a grandchild and its grandparent, child1of group2, child2of group10; (d) from the group's own variable and from a descendant's; aniifcriterion; anandoperand; acombineargument; a consumer through a chain of variables; two references to one name (one finding); references to two later names (two findings); a co-fire withrepeating-group-variable. - Negative: an earlier variable; an item variable reading a root variable; child
a,bor1.1of group1; a laterapplication/x-fhir-queryvariable, on the root and on an item; a launchContext; a sourceQueries result; aniifbranch in function, method and nested form; a latercoalesceargument; awhereover%resource; asortselector; no population consumer; a consumer only the renderer reads (a calculatedExpression); a form population does not run for; a root without extensions; a name bound earlier by a root variable of the same name, a launchContext or a query variable;%constructor; each silence above with its core rule off; atext/cqlreferencing variable; text that does not parse; an incomplete reference; a suppressed finding. - Reproductions against populate 4.7.1 in
test/reproduction/: (a) with a dependent initialExpression left empty; (b); (c) withg/afailing,1/aand1/1.1succeeding, and2/1failing; (d); aniifbranch with no issue against aniifcriterion with one;coalesce; awhereover%resource; a name bound earlier by a launchContext or a#idBundle giving that value; the duplicate-linkId cases; a form with no input that population does not run.
Docs: README rule table, the rules-library example (docs/.vitepress/rules.ts), meta.docs known limitations (references at conditional positions, the renderer, a root without extensions), architecture.md and GLOSSARY "Reference" for conditional, the comments on VariableReference and referencesAt, and core-rules.md's plugin follow-up for D8 and its "Forward references" runtime bullets, marked applied in P7.
Corpus: 0 in all 66 forms, GP CCMP included, from a scan in population's order (470 variables, 34 itemPopulationContexts population reads, 485 references from variables to declarations; no ancestor reference that only the integer-like order makes safe). Evidence is synthetic only. The Smart Forms corpus snapshot predates P5 and P6 and shows their 6 findings as added: refresh it in its own commit before the rule, so P7's diff shows only P7.
Settled while building it (2026-10-13):
- Population runs only when the form has a root launchContext with a
namesub-extension that has avalueIdor avalueCoding.codeand atypesub-extension with avalueCode, found by URL as population finds them (PIinputParameters.ts:96-121), so a type-first one, which binds nothing, still makes it run; a root sourceQueries with avalueReference; or anapplication/x-fhir-queryvariable with a name and expression text, on the root or an item. It also needs asubjectTypethat is empty or includes Patient (PIinputParameters.ts:293-303). Each is reproduced, and settled with the user. - A linkId
__proto__keeps the plan's per-item silence. Population never evaluates that item's variables, and fails altogether only when the response gets an item with that linkId, because adding display text callsmaponanswerOptions['__proto__'](PprocessValueSets.ts:99); a question with no answer gets none. Both are reproduced. Treating any__proto__item as stopping population, as first settled, was based on a probe with a top-level group only. - Population binds a launch resource only for
patient,userorencounterof the matching type, or a fhirContext resource of the launchContext's type (PIinputParameters.ts:338-374). The silence for a name bound earlier still counts every root launchContext under its Smart Forms name, since the host decides which it supplies. - A population consumer's reference counts wherever it is, at a conditional position included: the consumer uses the value whenever it evaluates it. The chain follows
dependenciesOfthroughtext/fhirpathvariables population evaluates; an itemPopulationContext is a consumer itself. Settled with the user. - Another stored declaration of the name on an item whose linkId is missing or duplicated may be evaluated first, so the rule stays silent (settled with the user). Declarations in a contained resource take no part: population does not evaluate them.
- The rule gives each declaration population evaluates a position: its step (1 for the names bound first, 2 for root variables, 3 for item variables, 4 for itemPopulationContexts), its item's place in the key order and its place in its extension array. A reference is forward when the declaration's position is later than the variable's, which covers (a) to (d). (a) and (b) share their message and help. The cycle silence follows
dependenciesOfto any depth. - Facts: three new behaviours in
src/facts/variables.ts:population-evaluation-orderandpopulation-needs-input(populate 4.7.1, reproduced intest/reproduction/populate-evaluation-order.test.tsand the environment tests ofpopulate-prerequisites.test.ts) andstale-forward-read(renderer 1.4.0, source-only;utils/manageForm.tsdiffers atf846e2d8, so it is cited at line 144 there). - Core:
findFunctionCallsreportssort, and`sort`, with the name and position theFunctionInvocationgives, andfindVariableReferencesvisits sort's arguments;readsInputshares the helpers.findLinkIdReferencesvisits them too, after a second review: it did not, sosdc/unresolved-linkidmissed a linkId selector there. - An item without a linkId cannot be written in a lint case, so its silences are in
test/forward-variable-reference.test.ts. - From a review: an itemPopulationContext got its position before its item's linkId was checked, so (d) was reported on an item with no linkId or a duplicate one; the check now comes first. A variable in an item's
modifierExtension, which core declares but population never reads, took a position from that array; only declarations in theextensionarray of the root or an item take part now. - From a second review: a linkId was counted as written, while population keys items by
String(linkId), so an itemundefinedand a later item without a linkId, which replaces its variables, were not seen as sharing a key; linkIds are counted as strings now. A variable on a__proto__item counted as possibly evaluated earlier for the earlier-binding silence, which hid findings; population never evaluates it, so it never binds the name, and its own references stay silent. - Corpus: the rule adds no finding in the 66 forms, GP CCMP included, and removes none; the core-only snapshot is unchanged by stage D and the
sortfix.corpus/is gitignored, so the refresh of the Smart Forms snapshot before the rule, which added only P5's and P6's 6 findings, is local and has no commit.
Settled in the P7 review
2026-10-13, with a second opinion from Codex.
- I. S12 and S17 are accepted, amended. The renderer may recover with a stale value, not "still produces a value";
conditionalmeans that evaluation may be skipped, not only aniifbranch. - J.
conditionalcovers every position fhirpath.js may skip (the list above). Rejected:iifbranches only, with the rest a known limitation: population's%resourceis an empty response, so awhereover it never evaluates its predicate. - K. References at conditional positions are not reported. Rejected: a hedged message, as P2 has, which would report references an author deliberately put behind a branch and predicates over the empty response. Listed as a known limitation.
- L. Co-firing with
repeating-group-variableis allowed; S9 lists the pair. Rejected: silencing P7 there. The findings state different consequences with different fixes. - M. A finding needs a population consumer. Rejected: reporting every forward reference, whose only effect would be an OperationOutcome warning, and
useOf, which counts renderer-only consumers. - N. One finding per referencing variable and resolved declaration, at its first eligible reference. Rejected: one per reference, as P2 and P6 do: every reference to one later declaration needs the same fix.
P8. smart-forms/unsupported-template-extract-context
Decisions: S1, S3, S4, S9, S10, S11, S17. Renamed from core-plan §4.2's template-extract-context-string-only (decision Q). Reviewed on 2026-10-13 against template-extract's release commit 0fca4c36, with a second opinion from Codex: the claims marked reproduced were run against the published @aehrc/sdc-template-extract@1.0.15 with probe forms; the others are from the source at 0fca4c36, and P8's reproduction tests confirm them. Four questions were settled the same day (below); the overrides are in plan-amendments.md. No core change: suppression by exact pointer moved out of P8 (decision P).
Runs only when template-extract is selected in compat (S3). ctx.compat['template-extract'] is the selected version, or absent. requireCompat stays for renderer and populate: add a lookup for template-extract that returns undefined when it is not selected, and keep Component, components and FactTable as they are. The facts go in their own table, src/facts/extraction.ts, keyed by template-extract version, and facts.test.ts covers them. readBy does not apply: templates are not catalogued sites, and nothing in the eligibility helper knows extraction.
Reproduction harness: @aehrc/sdc-template-extract@1.0.15 as an exact dev dependency of the plugin. Its ESM build fails in plain Node and in Vitest (ERR_UNSUPPORTED_DIR_IMPORT on fhirpath/fhir-context/r4); load the CJS build with createRequire(import.meta.url) in test/reproduction/extract.ts, beside populate.ts, and call inAppExtract(response, questionnaire, null). It returns { extractSuccess, extractResult }; extractResult is { extractedBundle, issues, debugInfo }, or an OperationOutcome on failure, and each issue's text is in details.text. No Vitest config change and no override: it adds clean-deep, three lodash packages, deepmerge, js-base64 and a nested uuid@11.1.1, has no install script, and its fhirpath ^4.3.0 dedupes to 4.10.1. Its type declarations import fhir/r4; use the loose types populate.ts uses.
Which templates are extracted. Copy template-extract's selection (TtemplateExtractRef.ts:131-186,228-240; TtemplateMap.ts:43-110; Tmisc.ts:15-32; the root-only case and the templateExtractBundle case reproduced):
- Collect each
templateExtractextension's reference: the firsttemplateslice whosevalueReference.referencestarts with#, without the#. An extension with none contributes nothing: a full URL,#alone or a missing slice gives no template. - Key the references by owner: the root's under
QuestionnaireLevel, set first, then each item's under its linkId, in pre-order; a later owner with the same key replaces the earlier one's references. - Map each template id to its key; the last mapping wins, so a template referenced from several owners is extracted once.
- A template is a top-level
containedresource with that id, the last one when ids repeat, whose key names an item:getQuestionnaireItemfinds the first item in pre-order with that linkId. A root reference therefore names no item, and its template is never extracted, unless an item has the linkIdQuestionnaireLevel. A#idthat matches no contained resource is ignored without a warning.
Extraction also skips a template, with a warning, when the response has no item for its key (TpopulateIntoTemplates.ts:38-43). That depends on the response, so the rule does not check it, and the message says "when Smart Forms extracts this template".
Rule (error). In a template, a templateExtractContext extension with a valueExpression and no valueString, when it is the first templateExtractContext in its extension array and an expression site exists at the Expression (decision O). Template-extract reads a context only from a truthy valueString (TwalkTemplate.ts:89-111; reproduced): it returns the warning Template <id>: TemplateExtractContext extension found on <path> but no valueString found., carries on extracting, creates no context, evaluates the values below against an enclosing context when there is one and against the item's response item otherwise (TtemplateExtractPath.ts:50-81; TevaluateTemplateExtractPath.ts:35-70), never repeats for each result of this context, and leaves the extension in the output (TremoveTemplateArtifacts.ts:29-35). Enclosing contexts and a repeating response item still repeat the extraction.
- The walk covers every
extensionarray at any depth: in arrays, backbone elements,_fieldprimitive wrappers, nested extensions and resources contained in the template (TwalkTemplate.ts:19-68). NevermodifierExtension. - Report at the expression site:
ctx.siteshas an uncatalogued site whoseexpressionPointeris the Expression's pointer when the Expression has a stringlanguageand anexpressionor areference(expression-sites.ts:140-146). Pick the site with carrierexpression: a query's fragments share itsexpressionPointer. Never report withpointeralone. - One finding per context, however many owners reference its template.
- Stay silent:
valueStringis a string, empty included: the Validator reports an empty string, andvalueStringwins when both are present (reproduced);- the Expression has no site: no
language, or neitherexpressionnorreference(decision O). R4 makesExpression.language1..1, so the Validator reports the first; - a later templateExtractContext in the same
extensionarray: template-extract reads only the first (findIndex, TwalkTemplate.ts:90), so it never reads this one whatever its value. An open item; - a templateExtractContext in the template resource's own
extension: template-extract reads it, but misextracts such a context even asvalueString(reproduced: one entry with a nested resource-type key and no repetition), so the help would mislead. An open item; - a contained resource that is not a template under the selection above, a root-only reference and a
templateExtractBundleincluded. Nothing readstemplateExtractBundle, and a form with only it is not extracted at all (reproduced); compatwithout template-extract.
- Co-firing (S9):
sdc/uncatalogued-expression(info) fires on the same site, andsdc/external-expression,sdc/parse-errorandsdc/unknown-functioncan. Each states a different fact with a different fix. - Message:
When Smart Forms extracts this template, it ignores this templateExtractContext because it reads only valueString, so the values below it are extracted without this context.Withnameset, a variant in the style of P6's{reason}:…without this context, and %{name} is never bound to its value.Template-extract binds no name to any context,valueStringincluded: its only names are%resource,%rootResourceand the extractAllocateId names (TcreateFhirPathContext.ts:10-19; reproduced: a value reading%ctxfails with "Attempting to access an undefined environment variable"). Do not say%{name}is unavailable: an extractAllocateId can supply the same name. - Help:
text/fhirpathwithexpression:set the context as valueString with the same FHIRPath;- another language, or a
referenceonly:set the context as valueString with a FHIRPath expression; - with
name, append:, and rewrite the values below it without %{name}: Smart Forms binds no name to a context.
Facts in src/facts/extraction.ts, for template-extract 1.0.15, reproduced: the context read only from valueString (the warning, extraction going on, the extension left in the output, valueString winning when both are present, only the first context in an extension array), the template selection (the four steps, a root-only reference not extracted, the last mapping winning), no name bound to a context, and templateExtractBundle unread. Each names its reproduction test, as S4 requires.
Tests.
- Positive: a
text/fhirpathcontext on a sub-element, such asObservation.componentorPatient.name; one withname(the variant and its help); a CQL context and areference-only one (their help); a context in a_fieldwrapper; one in a resource contained in the template; a template referenced from two items (one finding); from the root and an item; an explicitcompatwith template-extract; a suppression bypointer, which core-plan §6.3 already allows for a site in a contained resource, applied. - Negative:
valueString;valueStringwithvalueExpression;valueString: ""withvalueExpression; a context with nolanguage, and one with neitherexpressionnorreference, in JSON fixtures as P6'sitem: []; a later context in the sameextensionarray; a context in the template resource's ownextension; one inmodifierExtension; a root-only reference; a full-URL or bare#template reference; a#idmatching no contained resource; a contained resource notemplateExtractreferences; atemplateExtractBundle; an explicitcompatwithout template-extract. - Reproductions against template-extract 1.0.15 in
test/reproduction/: each fact above. Match an issue by its logical path as well as its text: atemplateExtractValuewith novalueStringgets the same "TemplateExtractContext … but no valueString found." text. Put the context on a sub-element, never on the template root.
Docs: README rule table, the plugin README, the rules-library example (docs/.vitepress/rules.ts), meta.docs known limitations (a context with no language, a later context in an extension array, the template root, templates the response gives no item).
Corpus: 0. All 28 templateExtractContexts (SDC 19, Smart Forms 9, GP CCMP 0) use valueString; 21 item templateExtracts, all with a #id; 1 root templateExtract, with no template slice; 1 templateExtractBundle, whose contexts no templateExtract references. Synthetic fixtures only.
Settled while building it (2026-10-13):
- A
templateslice whose reference is#alone is the first#slice: template-extract takes the empty id from it and reads no later slice of that extension, so the extension gives no template (reproduced). The reader insrc/templates.tscopies this with the other selection steps, andtest/templates.test.tsmirrors each reproduced selection case. - Facts: five behaviours in
src/facts/extraction.ts, keyed by template-extract version (context-read-only-from-value-string,first-context-only,template-selection,no-context-name,template-extract-bundle-unread), each with reproduced evidence only; the source lines are cited in the module's comments, since the evidence permalinks point at the renderer's and populate's release commit.templateExtractVersioninsrc/eligibility.tsis the lookup. - A lint case can give a contained resource a
resourceType, so a template is an inline instance mapped to its FSH line; each case's template has its own id: with one id shared by every case's template, no finding in a template was mapped. A resource contained in the template is written by path, so its finding is not mapped. - SUSHI does not write both
valueStringandvalueExpressionon one extension, an Expression with nolanguageor with neitherexpressionnorreference, and a case config cannot setcompat; those cases are intest/unsupported-template-extract-context.test.ts. - Corpus: the rule adds no finding in the 66 forms and removes none.
Settled in the P8 review
2026-10-13, with a second opinion from Codex.
- O. Report only at an expression site. A context with no
language, or with neitherexpressionnorreference, has no site and is not reported: R4 makesExpression.language1..1, so the Validator reports it. Rejected: reporting it withpointer, which no suppression can hide today, and making core treat a pointer finding at a site's pointer as that site. - P. Suppression by exact pointer moves out of P8 into an open item, with what this review found about it. Rejected: building it in P8, which would override core-plan §6.3 and guidelines §6 and needs S8, for findings decision O no longer produces.
- Q. The rule is
unsupported-template-extract-context(S17). Rejected: core-plan'stemplate-extract-context-string-only, which describes the runtime rather than the author's problem, and says nothing of areference-only Expression. - R. A root-only
templateExtract, a template reference that is not#id, and a#idmatching no contained resource are open items. Each is ignored without a warning, with a different cause and fix from an ignored context. Rejected: a rule in P8.
P9. Renderer reproduction harness (optional)
@aehrc/smart-forms-renderer@1.4.0cannot be imported in plain Node (lib/index.jsimports@fontsourceCSS). It needs a separate jsdom Vitest project withserver.deps.inline,react,react-domand@tanstack/react-query.buildForm,questionnaireStoreandquestionnaireResponseStoreare exported, so store-level checks without mounting React may work. Unverified.- Keep it out of
npm run checkif it is slow; run it in a separate CI job. - Upgrade the renderer facts from
source-onlyto reproduced, starting with the P7 timing and the P1 ignored locations. - Do not depend on
@aehrc/questionnaire-test-toolkit: it is unpublished and a React harness. Its pipeline passes{ patient, ...populatedContext }; the app, which S1 models, passespopulatedContextalone.
P10. launchContext sub-extension order
Dropped on 2026-10-14. Reading a launchContext by position is a Smart Forms bug, not a form problem: SDC slices the sub-extensions by url, unordered, and populate's own isLaunchContext finds them by URL before createLaunchContextParam reads them by position (PIinputParameters.ts:97-115,346-351). Type-first and description before type are reproduced (populate-variable-storage.test.ts, the fact launch-context-named-by-position). Reported upstream as aehrc/smart-forms#2161; no rule, as for #2160 (open items).
The fact stays: variable-name-collision, named-expression-not-referenceable and forward-variable-reference read launchContext names as population does through it.
P11. smart-forms/unsupported-template-extract-value
Dropped on 2026-10-14, and S18 withdrawn: the slice rested on a wrong premise. SDC types a templateExtractValue's value[x] as string only, in 4.0.0 and in the CI build of 2026-09-19, while a templateExtractContext allows string and Expression. A valueExpression value is invalid FHIR, which the Validator reports, so the rule would be silent on every one (S9). template-extract reads a value only from valueString, as the spec defines it, so this is not a Smart Forms bug either. The corpus has none: all 127 templateExtractValues are valueString.
Only template-extract's warning for such a value is wrong: it names TemplateExtractContext (Template extraction). Cosmetic, and not reported upstream.
P12. Release contract
Core changes before the first release (S19). Not a Smart Forms bug: they clean up core's own public contract. The first two are breaking once released; the other two are additive, and ship with them (decision X).
- The report's compat versions become a generic map, by plugin namespace, of exact versions by component:
SmartFormsVersionsand the'smart-forms'key ofRunCompat(report/types.ts:112-122) and$defs/smartFormsVersionsin the schema go. The JSON is already generic, so the report examples do not change. The text summary, which the GitHub job summary reuses (report/github.ts:128), andscripts/corpus.tsprint one line per namespace (decision T). Which components a plugin requires stays in compat resolution (decision U). - Remove
RuleContext.options(plugins/contract.ts:82, set toundefinedinrun/rules.ts:342): rule options are not built (S7), and config already rejects a non-string rule setting (config/validate.ts:226-231). It can be added back without breaking a plugin. explainprints the resolved compat, which it already resolves (plugins/rule-set.ts:35-36), with the text summary's formatter (decision W).- Copy the rule's
meta.docsUrlinto each Questionnaire finding: the value validated when the rule set is resolved (decision Y), which must be a URI the report schema accepts (decision Z). SARIF already takes it from the resolved rules (#31). No built-in rule has one yet (decision V).
Done when the schema, types, reporters, explain, report-format.md, architecture.md, adding-a-rule.md and cli.md match, the report examples validate against the schema in a test, the corpus's JSON report is byte-identical, and npm run check passes.
Settled in the P12 review
2026-10-14, with a review of the implementation plan by Codex. Letters continue from P8's, skipping S, which names this plan's decisions.
- T. One compat line per namespace in the text and GitHub summaries and the corpus script, components in the plugin's order:
Compat smart-forms: renderer 1.4.0, populate 4.7.1, template-extract 1.0.15.A namespace with no selected component prints none. Rejected: one line for all namespaces, harder to read with two plugins, and a display name in pluginmeta, more API surface for one line. - U. Every compat version in the schema is a non-empty string other than
latest, in any namespace. Which components must be present stays a resolution check (CONFIG_INVALID), and a namespace's map may be empty. Rejected: a schema per plugin, which core cannot know. - V. Built-in rules get no
docsUrlin P12: no documentation site is published. An open item. - W.
explainprints compat after the config line: the listing, every plugin's line; a rule's page, its own namespace's line, and none forsdcandlintrules or a plugin without compat. - X. The additive items stay in P12. Rejected: shipping only the breaking items before the release; the rest is about 30 lines of code.
- Y. A finding's
docsUrlis the value validated when the rule set is resolved, kept inResolvedRule.docsUrl. Rejected: readingrule.meta.docsUrlagain, when the rule set is built or a finding is, where a plugin's getter can return a value that was never validated. - Z. A plugin rule's
meta.docsUrlmust be a URI the report schema accepts:URL.canParseand only RFC 3986 characters; otherwise loading the plugin fails, as an invaliddocsUrldoes today.URL.canParsealone acceptshttps://example.org/rules/a b, which the schema'sformat: urirejects, so copying it would make the JSON report invalid; SARIF'shelpUrihas the same gap today. Rejected: copying the normalizednew URL(…).href, which still keeps characters such as|and^, and which would differ from thehelpUriSARIF prints. Built stricter than RFC 3986 characters alone, whichformat: uristill rejects in a malformed percent-encoding (%zz), a[or]outside an IPv6 host, a second#and a scheme with nothing after it (urn:): the check requires each of those to be well formed, and rejects[and]everywhere.
Decisions
Proposed on 2026-10-09 and revised after review. S1, S3, S4, S7 and S11 were accepted on 2026-10-09, S2, S5, S9 and S10 with P1, and S13 with P2, the same day, and S6 for P4 on 2026-10-12, and S14 for P6, and S12 and S17 for P7, on 2026-10-13, and S8, S15 and S16, with S18 and S19 added, on 2026-10-14; S18 was withdrawn the same day. The accepted ones are recorded in plan-amendments.md.
Model
- S1. The supported integration is population followed by rendering, assuming population succeeded. The app populates only with a SMART client, a patient and a user, and only for an unedited, unsaved response; when population fails it renders without populated context. After a successful population it passes
populatedContextasadditionalContext(usePopulate.tsx:62-151), which holds%patientonly when a patient launchContext is declared. Template extraction is a separate step on the response, checked when selected (S3). Renderer-only and extraction-only targets are deferred: a renderer used without population, and a run where population failed, are outside the supported integration, and rules may report or miss findings there. Why: this is the pipeline the app runs when it works, and a rule that assumed the renderer alone would report every itemPopulationContext reference in GP CCMP. Suppressions remove false findings in other integrations, but cannot add findings S14 omits for them, which is why those targets are deferred rather than claimed. - S2. Plugin rules map a location to a component through the runtime facts, never through
site.useCases.Why: D20 made use cases follow the SDC table, not Smart Forms' evaluators. Populate evaluates initialExpression without a language check although SDC files it under both rendering and population. - S9. A plugin rule stays silent where a core rule or the Validator reports the same root cause, by checking the condition itself, never whether that rule ran. Co-firing is allowed only when the two findings state different facts with different fixes:
variable-name-collisionwithsdc/shadowed-declaration, embedding checks 1–5 withsdc/fragment-parse-error,forward-variable-referencewithrepeating-group-variable(P7 review, decision L), andunsupported-template-extract-contextwithsdc/uncatalogued-expression. Why: one root cause should produce one finding, and a plugin finding must not appear or disappear when a core rule is turned off. - S14. Within S1,
named-expression-not-referenceabledoes not report populate-only names at renderer sites, except at an answerExpression.Why: after a successful population they reach the renderer throughadditionalContext. An answerExpression gets a context of its own, whichadditionalContextnever reaches (P6 review).
Compat
- S3.
compatis validated by core and resolved against the plugin's declared versions."latest"is the default and selects every component at its newest verified version in the installed plugin. An explicit object must name renderer and populate with exact verified versions, and may omit template-extract; extraction rules run only when template-extract is selected. There is no per-component"latest"and no nearest-version fallback. A plugin lists each component's verified versions oldest to newest, and"latest"takes the last; core treats versions as opaque strings and never compares them, so keeping the order is the plugin's job. Each component's versions are verified independently: under S1 any combination of listed versions is accepted, and a selected stack is never described as verified as a whole. If evidence shows that one component's behaviour depends on another's version, add supported combinations then. Why: core-plan §2.3 forbids silent fallback, C1 sets the default, and a project that does not use template extraction should not have to pin it. Validation in core makeslintandexplainfail identically before inputs are read. - S4. Runtime facts are small typed structures in the plugin, each with its evidence. Group them by what they describe: language handling by location and component, eligibility prerequisites, supplied names, the evaluation order. Each fact that varies by version is keyed by component version, and carries its evidence: a reproduction test, or
source-onlywith a permalink. A location can relate to several rules; a fact does not name a rule. JSON is optional packaging. A unit test checks that every referenced reproduction test exists. Each fact, and that test, arrive with the first slice that consumes a fact (P1); P0 ships only the verified versions. Why: the facts have different shapes, and one universal entry schema would become a second rule engine. A new Smart Forms version can change facts, reproductions and rules. - S11. Runtime evidence is cited at a release commit where possible. The local checkout is
7e64a22f; the release commit for renderer 1.4.0 and populate 4.7.1 isf846e2d8. They differ by three UI fixes in the renderer (includingutils/manageForm.ts), the addition ofquestionnaire-test-toolkit, and a change tosdc-assemble/src/utils/assemble.ts. The populate source, the app's prepopulate code, and the renderer'sutils/fhirpath.tsandstores/questionnaireStore.tsare identical in both. Link tof846e2d8for a file after checking it is identical (git diff f846e2d8 7e64a22f -- <file>). Check each cited file against the release of the package it belongs to: one commit need not cover every component, and template-extract 1.0.15 has its own:0fca4c36, where itssrc/is identical to7e64a22f(onlypackage.jsondiffers: fhirpath^4.3.0, as published). Cite template-extract at0fca4c36, notf846e2d8, whosepackage.jsonalready says^4.10.1.smart-forms-replay(branchfeature/questionnaire-test-toolkit) holds unreleased code under the same version numbers and is not evidence. Why: a fact claims behaviour of a published version.
Core contract
- S5. Core publishes analysis to plugins in stages, each with the slice that first needs it and with everything that slice needs (table). References and name resolution go together in P2; declarations, dependencies, uses and
readsInputin P5. Types are readonly, and "unavailable" (undefined) is distinct from "empty". Why: D15 and D18 deferred publication until a plugin rule needs it. Publishing references without resolution would make plugins reconstruct core's answers. - S6. A plugin can supply host names through an optional, synchronous
hostVariables({ document, compat })hook. Core merges them with confighostVariables. The hook only adds names.compatis the plugin's resolved versions, as its rules get them, so the names can depend on the selected version (S4). The Smart Forms plugin supplies only sourceQueries result names that get a value. This overrides core-plan §5.1, under which a plugin only reads core's analysis. Why: D16 left these names to config until a plugin could supply them. HostadditionalContextnames belong to the host, not to Smart Forms, so they stay in config (D1). - S7. No
presets,languagesor rule options. Listing a plugin already enables its rules at their defaults, none of the nine rules needs options, and deployment facts belong incompatorhostVariables. Amend core-plan §6.1 and §6.2, whose examples usepresetsandlanguages. Why: the keys would add configuration with no consumer. - S8. Plugin rules take part in no completion evidence. A suppression of a plugin finding is applied or stale as usual, but an existing target with no plugin finding cannot be shown stale, so it is not assessed. Plugin rules never make a site unchecked. Why: core-rules.md forbids a public completion framework; this is the current behaviour of
assessinrun/suppressions.ts, now documented. - S19. Before the first release, core's public contract names no Smart Forms component and no unbuilt option (P12). The report's compat versions are a generic map from component to version,
RuleContext.optionsis removed, and findings carry their rule'sdocsUrl. Why: each is a breaking change to the report or the plugin contract once released, and each is small now.
Rules
- S10. Every runtime fact has a reproduction test against the pinned npm package where a harness exists. Populate runs in plain Node ESM; template-extract through its CJS build; the renderer needs jsdom (P9). Until a harness exists, a fact is
source-onlywith a permalink, and a message based on it claims only what the source shows. Why: core-plan §2.1 asks for reproduction tests. Reproduction corrected several facts in this plan's first draft: item query variables, sourceQueries naming, the embedding replacement. - S12. Add
smart-forms/forward-variable-reference(warning) for the D8 follow-up.Why: it is a different failure from a collision or an unexposed name, with a different fix. Warning, because the demonstrated failure is during population; the renderer may recover, with a stale value (P7 review, decision I). - S13. A host-supplied
%questionnaireor%qitemis handled by a suppression with a reason. From P4, confighostVariablesrejects both names, and its help points here. A host can supply%qitemcorrectly only when every expression that uses it is on one item. This does not apply to an answerExpression: the renderer builds its context from the name before the first.alone, soadditionalContextnever reaches it, and its finding has no suppression note. Why:hostVariablescannot express it (config rejects built-in names) and names no component, and rule options are not built (S7). - S15. No rule for the answerExpression fallback or for
%contextbeing{}. The names an answerExpression references are checked by P6 (P6 review, decision H); the fallback itself is not. The fallback runs only when answerValueSet produced no codings, supplies only the first%namefound by string operations, and has one FHIRPath answerExpression in the corpus.%contextis{}at most renderer and populate evaluators, but the result is a silent empty value and the corpus has no real case. Record both as facts; revisit with corpus evidence. Why: low value now. - S16. A launchContext is read by position in Smart Forms. Populate and the renderer read
extension[0]and[1]of a launchContext, while core finds sub-extensions by URL. A launchContext in any order other than name, type binds nothing: type-first and description before type are reproduced. No rule reports it: it is a Smart Forms bug, reported upstream as aehrc/smart-forms#2161 (P10, dropped on 2026-10-14). The plugin's rules read launchContext names by position, as population does. Why: SDC slices are unordered, so the form is valid and only Smart Forms can fix the reading. - S17. Rule IDs name the author's problem and avoid report vocabulary. Five IDs differ from core-plan §4.2:
unchecked-language-evaluated-as-fhirpath→expression-evaluated-as-fhirpath: "unchecked" already means a site an applicable check could not complete (expressions.unchecked).x-fhir-query-embedding-shape→unsupported-query-embedding: "shape" does not say what is wrong.flat-variable-context→variable-name-collision: names the author's problem; "flat variable context" stays the glossary term for the runtime behaviour.- The new D8 rule is
forward-variable-reference, notvariable-evaluation-order. template-extract-context-string-only→unsupported-template-extract-context: names the author's problem, like the otherunsupported-*rules, and covers areference-only Expression (P8 review, decision Q). The reference flag isconditional, notlazy: it marks a reference fhirpath.js may not evaluate, at the positions P7 lists, and nothing about whether the branch is meant to guard it (P7 review, decision I). Use the glossary terms available variables, declarations, host variables and expression sites throughout.
- S18. Withdrawn on 2026-10-14. It proposed
smart-forms/unsupported-template-extract-value(error), a sibling ofunsupported-template-extract-contextfor avalueExpressiontemplateExtractValue (P11, dropped). Why withdrawn: SDC allows a templateExtractValue only asvalueString, so avalueExpressionvalue is invalid FHIR, which the Validator reports (S9), and template-extract follows the spec.
Open items
Triaged on 2026-10-14 (open-items review). Items planned for the first release became P10 to P12; P10 and P11 were then dropped.
| Item | Blocks |
|---|---|
Smart Forms-specific Expressions outside the core catalogue (binding-parameter expression, QuestionnaireItemTextAriaLabelExpression, calculatedExpression on _text and _prefix) cannot be targeted without an extension URL on uncatalogued sites. Add one, or catalogue them in the plugin? | Nothing in v1. |
Should P3 check embeddings in sourceQueries Bundle request.url through pointer findings? 25 embeddings in 5 corpus forms, all fine. Waits for suppression by exact pointer. | Nothing in v1. |
Renderer-only and extraction-only targets (S1): an explicit compat without populate, and the findings S14 omits for such hosts. When built, the integration is selected explicitly, not inferred from the components compat names: component versions do not say whether populated context reaches the renderer. | Nothing in v1. |
Repeating groups population does not prefill as written, one rule for both. (a) Populate evaluates the initialExpressions of the children of a repeating group without an itemPopulationContext, but builds that group only from an itemPopulationContext, so their answers are never prefilled whatever the language; expression-evaluated-as-fhirpath still reports a non-FHIRPath one there, and its consequence holds, but the language is not its cause. (b) itemPopulationContext on a repeating group binds only direct children, chosen by text between the first % and the first . of a child's initialExpression, so iif(%flag.exists(), %med.code, {}) selects flag. The context can come from any direct child, not only the group's own. When built, tell an answer never prefilled apart from one evaluated against the whole context value instead of its repetition's. The population model's readsContext gives the itemPopulationContexts population reads; which children it evaluates stays in populateEvaluatesInitialExpression (src/facts/prerequisites.ts). | Nothing in v1. |
Population skips item FHIRPath variables when the Questionnaire root has no extensions (PcreateFhirPathContext.ts:126-128, reproduced), so an itemPopulationContext or initialExpression that reads one fails during population although the renderer stores the variable. A rule at the declaration, reporting only when an initialExpression or itemPopulationContext uses the variable, directly or through other variables? named-expression-not-referenceable does not report it (P6). It can build on the population model: bindingOf already leaves such a variable out, and the consumer walk is forward-variable-reference's. | Nothing in v1. |
Built-in names other than %questionnaire and %qitem, such as %resource, are not supplied to an answerExpression either: fhirpath.js adds only %context, %ucum and %factory (P6). Extend questionnaire-variable-unavailable? | Nothing in v1. |
A plugin diagnostic for a sourceQueries reference population never binds under a usable name: a #id with no first contained Bundle with entry, an empty or display-only reference, and #%x, which binds under the literal name %x that no plain %x reference reaches (populate-source-queries.test.ts). A pointer finding at the extension, so it waits for suppression by exact pointer. Stay silent on references the Validator reports (S9). It cannot prove every binding failure: a fetched reference can still fail at run time. | Nothing in v1. |
Populate 4.7.1 leaves %<sourceQueries Bundle>.entry.resource empty for every fetched entry in the app: its contained-batch path takes only a { data } result (PcreateFhirPathContext.ts:432-437), while its other fetch paths, its documented FetchResourceCallback and every first-party callback use the resource itself (sourceQueries). All 48 %PrePopQuery references in the corpus are affected. No rule: the form is correct and only populate can fix it. Reported upstream as aehrc/smart-forms#2160; track the fix. | Nothing. |
Populate 4.7.1 and the renderer read a launchContext's name from extension[0] and its type from extension[1] (S16), so a valid launchContext in any other order binds nothing, and every expression that uses its name prefills nothing; population still reports success. No rule: the form is valid FHIR and only Smart Forms can fix it (P10, dropped). Reported upstream as aehrc/smart-forms#2161; track the fix. | Nothing. |
A search entry's resource in a sourceQueries Bundle is a searchset Bundle, so %PrePopQuery.entry.resource.where(resourceType = 'Observation') is empty even when the entry resolves; it needs .entry.resource.entry.resource. Only a read entry, such as Patient/{{%patient.id}}, holds the resource itself. A rule? | Nothing in v1. |
| Host names, from config or a plugin, are available at extraction sites (D19), but template-extract receives none. Report them at extraction sites when template carriers become sites, or in a template-extract rule. When built, cover assembleContext names and other named contexts as well, only at locations template-extract reads. | Nothing in v1. |
Template expressions written as valueString (templateExtractContext, templateExtractValue) are not expression sites, so they get no syntax, function or name checks at all. | Nothing in v1. |
Suppression by exact pointer, moved out of P8 (P8 review, decision P). A finding reported with only a pointer cannot be suppressed: assess searches expression sites (run/suppressions.ts:97-106), so the entry is stale (:173) and the finding stays. No finding has only a pointer today: siteless declarations go through assessDeclaration, and related locations get no fingerprint. Proposed: a suppression pointer that equals a finding's jsonLocation.pointer hides it when no expression site has that pointer; it is stale when the pointer no longer resolves, and not assessed when it resolves with no finding (S8). linkId and resource are filters that must all match (resource names the owning Questionnaire, linkId is linkIdAt of the pointer); extensionUrl and name never match such a target. Not CONFIG_INVALID: whether a pointer has a site depends on the file. Keep declaration pointers on their own path (D22). Key the fingerprint as an uncatalogued site's (fingerprint.ts:20-22), so a finding keeps it whether reported with a site or a pointer; reordering contained resources or extensions still changes it. A pointer can drift to another extension when an earlier one is removed, widening the suppression. Overrides core-plan §6.3 and guidelines §6 ("not array-based JSON pointers"), and needs S8 accepted. Build it with the first rule that reports a finding with only a pointer. | A context with no language in unsupported-template-extract-context; the sourceQueries diagnostic above; P3's request.url check. |
Templates template-extract ignores without a warning (P8 review, decision R): a templateExtract only on the root (its key QuestionnaireLevel names no item, unless an item has that linkId), a template reference that is not #id, and a #id matching no contained resource. A template referenced from several items is extracted for only the last. One rule with a message per case, silent where the Validator reports (S9)? | Nothing in v1. |
Template-extract reads only the first templateExtractContext in an extension array, so a later one is never read, whatever its value. unsupported-template-extract-context is silent on it. A rule? | Nothing in v1. |
Template-extract misextracts a templateExtractContext in the template resource's own extension, even as valueString. Not reproduced: reproduce it before planning a rule. | Nothing in v1. |
Features no Smart Forms component reads, whatever the language: templateExtractBundle (reproduced), and candidateExpression, contextExpression, itemExtractionContext, the root _title cqf-expression and a root itemPopulationContext (Languages by location). A rule for unsupported features, beside the unsupported-language rule? | Nothing in v1. |
Embedding limits P3 does not check: substituted values are not URL-encoded, an object or several results give [object Object] or only the first, and the order in which embeddings are replaced across queries. | Nothing in v1. |
run.rules (rule metadata, effective severities, docsUrl) and a config-only run.hostVariables in the JSON report. SARIF already renders the rule set (#31). | Nothing in v1. |
Built-in rules have no meta.docsUrl, so their findings, explain and SARIF link no documentation. Give each one its page's URL when the documentation site is published (P12 review, decision V). | Nothing in v1. |
Settled in the open-items review
2026-10-14, with a second opinion from Codex; item 11's populate bug was confirmed by reading upstream and by a run with the app's callback.
- S8, S15 and S16 are accepted. S16's wording changes from "type-first" to any order other than name, type.
- Planned for the first release: the launchContext order rule (P10),
unsupported-template-extract-value(P11, S18) and the release contract (P12, S19). - Suppression by exact pointer is built with the first rule that needs it, not before.
- The empty
entry.resourceof a sourceQueries Bundle is a populate bug, not a form problem: no rule, reported upstream as aehrc/smart-forms#2160. - Every other item stays for after the first release; the two repeating-group items are merged, and the template-root context is split from the ignored second context.
- Added: search entries in sourceQueries Bundles, template
valueStringchecks, unsupported features, and embedding limits. - Removed: the
smartFormsVersionsandRuleContext.optionsitems (P12), the launchContext item (P10) and the templateExtractValue item (P11). - Later the same day, P10 was dropped: reading a launchContext by position is a Smart Forms bug, reported upstream as aehrc/smart-forms#2161, with no rule, as for #2160. It is an open item again, to track the fix.
- Also later that day, P11 was dropped and S18 withdrawn: SDC allows a templateExtractValue only as
valueString, so avalueExpressionvalue is invalid FHIR, which the Validator reports, and template-extract follows the spec. The removed templateExtractValue item does not come back.
Corrections to existing docs
Apply with the first slice that relies on each fact.
- core-plan §4.2, rule IDs. Renamed by S17, and
smart-forms/forward-variable-referenceadded (S12). - core-plan §4.2, unchecked-language rule. Add populate's initialExpression and itemPopulationContext.
- core-plan §4.2, x-fhir-query-embedding-shape. Applied in P3 (plan-amendments.md). Scope is
application/x-fhir-queryvariables, root and item. "Not the supported%launchContext.pathshape" is replaced by the six checks in P3: operators and literals can be replaced, and the failures come from the unescaped regular expression. - core-plan §4.2, "itemPopulationContext on a repeating group is a valid population pattern". Valid, but bound per repetition only for direct children, as described in Variable storage and order.
- core-plan §4.2, template-extract rule. The failure is not silent: template-extract returns an OperationOutcome warning.
- core-rules.md, Smart Forms runtime, "Variables are evaluated in array order, in one flat namespace." True for root variables only. Item variables are evaluated in post-order of items, with integer-like linkIds first (JavaScript key order), and each repetition overwrites the name. Applied in P5.
- core-rules.md, "The renderer sets every x-fhir-query variable name to
[]". Onlyapplication/x-fhir-query. Atext/x-fhir-queryvariable's name is unbound, so references to it throw. Applied in P5. - core-rules.md, x-fhir-query variables. Populate fetches
application/x-fhir-queryvariables on items as well as on the root. Applied in P5. - core-rules.md, sourceQueries. The name of a
#idreference is chosen before resolution; a value is supplied only for the first contained Bundle of that id with anentryelement;iinsourceQuery<i>counts every extension with avalueReference. Applied in P4. - core-rules.md, Plugin follow-ups, "Smart Forms ignores
text/x-fhir-query(D11)". Applied in P1. Only at filtering locations. At targetConstraint, answerOptionsToggleExpression, initialExpression and itemPopulationContext it is evaluated as FHIRPath.application/x-fhir-queryis also ignored everywhere exceptvariable. D11's attribution should cover both P1 rules. - core-rules.md, x-fhir-query embeddings, "reproduce one before a plugin check rejects an expression". Applied in P3. Reproduced: populate builds an unescaped regular expression from each embedding and applies it to every query. Replace "
%contextand%ucumcan still resolve" with the full list in P3 check 6, and note there is no model. - report-format.md, "invalid rule options". Cannot happen (S7); remove or mark as reserved. Removed in P12.
- report schema,
smartFormsVersions.template-extractbecomes optional. Applied in P0. - plugin
package.json,files: ["dist", "compat"]. Fixed in P0.
Reference: Smart Forms runtime facts
Checked on 2026-10-09 at 7e64a22f; see S11 for which files are identical at the release commit. Facts marked reproduced were run against @aehrc/sdc-populate@4.7.1 with fhirpath 4.10.1. Permalink base: https://github.com/aehrc/smart-forms/blob/7e64a22f32129b36660392738b8541e8876d931d/. Abbreviations: R = packages/smart-forms-renderer/src/, P = packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/, PI = packages/sdc-populate/src/inAppPopulation/utils/, T = packages/sdc-template-extract/src/SDCExtractQuestionnaireResponseOperation/utils/.
Languages by location
Language checks are exact string comparisons. No text/x-fhir-query string appears anywhere in Smart Forms history.
| Location | Component and handling | Any other language |
|---|---|---|
| variable, root and item | Renderer: text/fhirpath evaluated; application/x-fhir-query names preset to [] when unset or falsy, so a value from additionalContext or from a FHIRPath variable of the same name survives (Rutils/fhirpath.ts:242-253), values from additionalContext (Rutils/questionnaireStoreUtils/extractVariables.ts:48-77; Rutils/questionnaireStoreUtils/extractOtherExtensions.ts:318-329). Populate: text/fhirpath evaluated; application/x-fhir-query fetched, root and item alike (PIinputParameters.ts:143-193,272-277,435-451; PcreateFhirPathContext.ts:272-283,342-369). Reproduced: root FHIRPath, root and item queries feeding an initialExpression; text/x-fhir-query and text/cql neither fetched nor evaluated. | Ignored; this variable never binds its name, and references throw unless another declaration supplies it (Smart Forms keeps one flat set of names). Not lost when a variable of the same name on the same element is accepted |
| calculatedExpression | Renderer: every text/fhirpath one (Rutils/getExpressionsFromItem.ts:137-144). Populate does not evaluate it. | Ignored; an alternative when a text/fhirpath one with expression text is on the same element |
| enableWhenExpression | Renderer: the first text/fhirpath one (Rutils/getExpressionsFromItem.ts:48-60). The hidden extension and the item's enableWhen still apply (Rhooks/useHidden.ts:37-48; Rutils/qItem.ts:49-55). | Ignored; never disables the item, unless the first text/fhirpath one on the same element has expression text and is used instead |
| answerExpression | Renderer: the first text/fhirpath one, as a fallback when answerValueSet produced no codings (Rutils/getExpressionsFromItem.ts:178-191; Rhooks/useValueSetCodings.ts:66-124). Populate does not read it. | Ignored; answerOption and answerValueSet still load; an alternative when the first text/fhirpath one on the same element has expression text |
cqf-expression on _text, _prefix, _answerValueSet | Renderer: every text/fhirpath one (Rutils/getExpressionsFromItem.ts:78-126,146-152). On _answerValueSet the result selects the value set (Rutils/calculatedExpression.ts:225-238). | Ignored; an alternative when a text/fhirpath one with expression text is on the same element |
| initialExpression | Populate: no check; only the first on an item, found by URL; skips an Expression with no text (PreadPopulationExpressions.ts:68,91,108-121; PevaluateExpressions.ts:49-75). Below a repeating itemPopulationContext group, only direct children of a reached repeating group are evaluated (PconstructResponse.ts:574-713). Reproduced. Renderer: the first text/fhirpath one, for repopulation only (Rutils/getExpressionsFromItem.ts:29-41). | Evaluated as FHIRPath. Reproduced in populate. |
| itemPopulationContext | Populate: no check; only the first on an item with children, found by URL, with a name and text; one per name, the last winning (PreadPopulationExpressions.ts:53-63,128-141; PevaluateExpressions.ts:77-157). Reproduced. The renderer does not read it. | Evaluated as FHIRPath. Reproduced. |
| answerOptionsToggleExpression | Renderer: no check; needs an option sub-extension with valueCoding, valueString or valueInteger, and uses the first expression sub-extension with a valueExpression (Rutils/answerOptionsToggleExpressions.ts:69-122; Rutils/getExpressionsFromItem.ts:198-262). | Evaluated as FHIRPath |
| targetConstraint | Renderer: no check; root and item; built only with key (valueId), severity (valueCode error or warning), human (valueString) and an expression sub-extension, using the first expression sub-extension with a valueExpression; one per key, the last built one winning (Rutils/questionnaireStoreUtils/extractTargetConstraint.ts:11-90; Rutils/targetConstraint.ts:68-180). | Evaluated as FHIRPath |
candidateExpression, contextExpression, itemExtractionContext, root _title cqf-expression, root itemPopulationContext | No reader. The root _title reaches only styled-text rendering (R.../QuestionnaireTitleText.tsx:52). | Unsupported whatever the language |
- Evaluating non-FHIRPath text as FHIRPath: query text such as
Observation?…throws on?, and the error is caught. A bare CQL identifier or a query without?usually returns[]: a targetConstraint never fires, unless its text containsintersect, which the renderer then treats as a violation (Rutils/targetConstraint.ts:99-107); a toggle disables its options; a population answer is empty. CQL text can also be valid FHIRPath that returns a value, such astrue, or an identifier that names an element, such asstatus. - A reference-only Expression is ignored at every reader: filtering locations drop it by language, and unchecked locations skip an Expression with no text. Smart Forms has no CQL or Library handling.
- History: renderer filters since August 2023 (cqf-expression since May 2024); targetConstraint unchecked since 6722e4ce (renderer 1.0.0-alpha.22); answerOptionsToggleExpression unchecked since 6d1dbecf (alpha.49); populate unchecked since at least 2.0.0; template-extract reads only
valueStringsince 0.1.0.
Environments
- App pipeline. Population runs only with a SMART client, a patient and a user, while the form is loading, for a Questionnaire with
containedorextension, and with no edits and no saved response (apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx:62-76). On success it passespopulatedContextasadditionalContext(:128); on failure it shows a message and keeps the build without it.populateQuestionnairereports no success when the form has no launchContexts, sourceQueries or query variables (PIpopulateQuestionnaire.ts:120-125); a query variable alone is enough. Reproduced.populatedContextholdspatientonly when a patient launchContext is declared (PIinputParameters.ts:346-374). Reproduced. - Renderer.
{ ...existing, resource: QR, rootResource: QR }; then everyapplication/x-fhir-queryvariable name set to[]if unset; then variables in order (Rutils/fhirpath.ts:178-357). Root variables are evaluated against the response, item variables against the response item or{}.additionalContextis merged after the first pass (Rstores/questionnaireStore.ts:244-267), andbuildFormimmediately reevaluates with it (Rutils/manageForm.ts:126-150). Each update starts from the previous update's context, and the calculation loop (at most 10 iterations) reuses it (Rutils/calculatedExpression.ts:838-856); the store saves the new context at the end of the update (Rstores/questionnaireStore.ts:614). When the form has no targetConstraints, enableWhenExpressions, toggles or processed value sets, other-expression evaluation short-circuits (Rutils/fhirpath.ts:59-68). Not reproduced. - Populate.
{ resource: emptyQR, rootResource: emptyQR }, plus everycontextparameter carrying a non-Bundle resource, contained-batch and reference contexts (fetched), thentext/fhirpathvariables (PcreateFhirPathContext.ts:68-110,241-283,342-369,505-515). In-app population builds context parameters from launchContexts, sourceQueries andapplication/x-fhir-queryvariables on the root and on items (PIinputParameters.ts:143-193,244-277,338-451). FHIRPath variables are skipped when the root has no extensions; query variables are still fetched (PcreateFhirPathContext.ts:126-128). Reproduced. A failing initialExpression on a child of a repeating itemPopulationContext group only warns, with no OperationOutcome issue (PconstructResponse.ts:671-677). Reproduced. - Template-extract.
{ resource, rootResource, ...allocateIds }from the root only (TcreateFhirPathContext.ts:10-19; TextractAllocateId.ts:4-29). No host names. %questionnaire,%qitem. No component supplies either. fhirpath.js 4.10.1 throws "Attempting to access an undefined environment variable" when the reference is evaluated;iif(false, %qitem, 1)returns[1]. A host'sadditionalContextreaches every later evaluation (Rstores/questionnaireStore.ts:267,634). Reproduced in populate. Reported upstream as aehrc/smart-forms#2140 and #2141.%context.{}at renderer calculatedExpression, enableWhenExpression, answerOptionsToggleExpression, targetConstraint, parameterised value sets, and populate initialExpression, itemPopulationContext and variables. Exceptions: renderer root and item variables (Rutils/fhirpath.ts:275,326).- answerExpression fallback. It supplies one name: the text between the first
%and the first., resolved from patient, user, encounter or fhirContext, or anapplication/x-fhir-queryvariable whose result was set (Rhooks/useValueSetCodings.ts:66-124). The renderer sets a result only for a query whose expression matches its ValueSet URL pattern, to the expanded ValueSet, so a search or$lookupquery variable never supplies one (Rutils/questionnaireStoreUtils/resolveValueSets.ts:37-66; Rutils/valueSet.ts:39-40,184-195). - sourceQueries. Read from root extensions only, every one; nothing when
subjectTypeis non-empty and lacksPatient(PIinputParameters.ts:127-141,209-212). Name:<id>for a#idreference, chosen before resolution;sourceQuery<i>for any other reference,icounting from 0 every extension with a truthyvalueReference, display-only, empty and#ones included (PI:262-269,395-429). Value: a#idname is bound only when the first contained resource with that id and type Bundle has anentryelement, to that Bundle; anentry: []Bundle is bound without fetching (PcreateFhirPathContext.ts:78-80,391-414,559-597). A missing or entry-less Bundle leaves the name unset. Another reference is fetched and bound to the returned resource, any type, unless the fetch fails or returns an OperationOutcome (P:291-371). An entry's result is stored only from a callback that resolves to{ data }(P:432-437), unlike populate's other fetch paths and its documentedFetchResourceCallback, which take the resource itself. The app's callback returns the resource, so in the app a fetched entry'sentry.resourcestays empty; an entry the form already fills keeps itsresource. A populate bug since the app moved tofetch(da912b7d), reproduced with the app's callback verbatim on 2026-10-14, and reported upstream as aehrc/smart-forms#2160. With no launch resource,#references are not filtered and are passed tofetchResourceCallbacktoo (P:523-550,716-721). Reproduced (populate-source-queries.test.ts, with the app's callback shape as the harness default).
Variable storage and order
- Renderer. Declarations are collected by linkId (
QuestionnaireLevelfor the root); values live in one object keyed by name. Every consumer, calculatedExpression, enableWhenExpression, targetConstraint, answerOptionsToggleExpression and value sets, evaluates with that object, so a name declared on one element reaches expressions on every element (Rutils/fhirpath.ts:94-149,193-237). Order: root variables in array order; then each linkId of the response item map, inserted in post-order (children first), with integer-like linkIds moved first by JavaScript; then each response item with that linkId, in document order: one per repetition, including those inside every repetition of an ancestor, the last overwriting (Rutils/fhirpath.ts:217-289,319-341; Rutils/questionnaireResponseStoreUtils/updatableResponseItems.ts:35-97). Descendants run under their own linkId, so an ancestor's variables are evaluated after its descendants'. A linkId with no response item is evaluated once against{}. At 7e64a22f a question with no answer has no response item (Rutils/qrItem.ts:126-160), but that file differs at the release commit f846e2d8: renderer 1.4.0 still adds an emptied item to a group with no other items, or before a later item, and drops it only when it replaces an item at its own position. The context carries over from the previous pass. A variable with no%whose value came from an async terminology call is cached and not re-evaluated (Rutils/fhirpath.ts:270-272,321-323). Item variables need aname, root variables do not (:269). A later item with a duplicate linkId replaces the earlier one's variables (Rutils/questionnaireStoreUtils/extractOtherExtensions.ts:320). calculatedExpression is evaluated once per linkId against{}(Rutils/calculatedExpression.ts:192-210). No named expression other thanvariableis exposed. - Repetition dependence (probed with fhirpath.js against two repetitions). A path or
$thison the input and%contextdiffer per repetition, except the input'slinkId; paths inside a predicate over%resource, and references to root variables, do not. Within a repetition an item's variables are evaluated in array order in the shared object: a variable referencing an earlier variable on the same item inherits the difference, and one referencing a later one reads the previous repetition's value. A variable on a descendant item referencing the group's variable gets one value: the group is evaluated after its descendants, so it reads the previous pass's last value, and on the first pass the reference throws and is caught (Rutils/fhirpath.ts:295-297). - Populate. One object, in order (P
createFhirPathContext.ts:68-153): the resource context parameters, which hold the launchContexts; the contained#sourceQueries Bundles; the fetched references in parameter order, other sourceQueries and thenapplication/x-fhir-queryvariables, root then items (PIinputParameters.ts:244-277); the#Bundles with entries again, so a Bundle beats a query variable of the same name (:93,465); root FHIRPath variables in array order; item FHIRPath variables in post-order with integer-like linkIds first (a record keyed by linkId,:141-153,213), once per linkId, against{}; itemPopulationContexts keyed by name (PevaluateExpressions.ts:126-153); initialExpressions (PreadPopulationExpressions.ts:32-63; PevaluateExpressions.ts:49-152). A failure is an OperationOutcome warning and the name stays unset.populatedContext, which the app passes to the renderer, is the whole object (Ppopulate.ts:81-101,143). Reproduced: an item variablexoverwrites a rootx; itemsaand2that both declarexgivea's value; an item variablepatientreplaces the launch Patient, inpopulatedContexttoo; an itemPopulationContextxoverwrites a variablex; an inner item's%xgets the outer item's value. - itemPopulationContext on a repeating group. The context name of an initialExpression is the text between its first
%and its first.(PreadPopulationExpressions.ts:143-148):iif(%ctx.exists(), %ctx.code, {})givesctx;(%ctx).codeand%ctxgive no match. The first direct child, in array order, whose name is a known itemPopulationContext with a value selects it; it need not be the group's own. Every direct child's initialExpression is then evaluated with[name]: [value]in each repetition. Deeper items get a binding only through a child that has its own itemPopulationContext (PconstructResponse.ts:577-703). Two itemPopulationContexts of one name: only the last one's expression is evaluated, and every group whose children read the name is built from its values, so the first group gets the last group's repetitions, or none when its children return nothing for them. Reproduced. - Version notes. Populate's repeating-group logic changed in 1ab3e980 and 68b09beb (2026) before 4.7.1. The renderer's variable files are unchanged since October 2025.
x-fhir-query embeddings
All reproduced with populateQuestionnaire, a patient launchContext and an application/x-fhir-query variable, asserting the URL passed to fetchResourceCallback.
- Only populate replaces embeddings, in
application/x-fhir-queryvariables on the root and items and in contained sourceQueries Bundle URLs, and only when at least one launch resource is supplied (PcreateFhirPathContext.ts:523,605-755). With no launchContext declared, every embedding is sent with its braces, even one that is not a valid regular expression. - Recognition:
/{{%(.*?)}}/g, non-greedy, so a fragment ends at the first}}; an empty capture ({{%}}) is skipped. The head issplit('.')[0], looked up only among launch resources. The remainder is evaluated withfhirpath.evaluate(launchResource, rest, { async, terminologyUrl, fhirServerUrl? }): the options land in the environment slot and there is no model.%context,%ucum,%factory,%terminologyUrl,%asyncresolve, and%fhirServerUrlwhen a source server is set; everything else, other launch contexts included, throws. A throw leaves''; a missing or empty result givesundefined; only the first result is used. - Outcomes: an undeclared or unsupplied head gives
undefined; a missing.gives an empty value;%patient.namegives[object Object]. - Replacement:
new RegExp('{{%' + embedding + '}}', 'g'), unescaped, applied to every query in the form (PcreateFhirPathContext.ts:706,729).- Braces stay: function calls, indexers,
+,*,?,^,$,\. - URL corrupted:
|({{%patient.id|id}}→p1|p1, andEncounter?b={{%patient.id}}in another variable →Encounter?b=p1}}); a{that makes the fragment end early. - SyntaxError, so population returns no result ("An unknown error occurred",
populateQuestionnaire.ts:225-233): unbalanced(,)or[,++,**,{2,1}, also inside string literals such as= ')'. - Replaced: operators and literals such as
= 'p1'(true),& 'x'(p1x),.length div 1.
- Braces stay: function calls, indexers,
- Values are not URL-encoded. Unchanged since 79454c9e (May 2023).
Template extraction
Cited at template-extract's release commit 0fca4c36 (S11), where these files are identical to 7e64a22f. Reproduced against @aehrc/sdc-template-extract@1.0.15 in test/reproduction/extract-contexts.test.ts and extract-templates.test.ts (P8).
- templateExtractContext is read only from a truthy
valueString, and only the first one in eachextensionarray (TwalkTemplate.ts:89-111); otherwise the warningTemplate <id>: TemplateExtractContext extension found on <path> but no valueString found.is returned (TpopulateIntoTemplates.ts:46-50,158-161) and extraction goes on. The values below are evaluated against an enclosing context, or else against the response item of the item that references the template, once per repetition (TtemplateExtractPath.ts:50-81; TevaluateTemplateExtractPath.ts:35-70), and the extension is not removed (TremoveTemplateArtifacts.ts:29-35).valueStringwins when both are present. No context binds a name: the only names are%resource,%rootResourceand the extractAllocateId names (TcreateFhirPathContext.ts:10-19). - templateExtractValue is read only from
valueStringtoo, the only type SDC allows for it; avalueExpressionvalue, which is invalid FHIR, is dropped with the same warning text, which names TemplateExtractContext (TwalkTemplate.ts:216-241,358-372). - Templates are found by the first
#idtemplateslice of atemplateExtracton an item (a#alone is such a slice, and gives no template), in the Questionnaire's top-levelcontained(TtemplateExtractRef.ts:131-186,228-240; TtemplateMap.ts:43-110). A roottemplateExtractis keyedQuestionnaireLevel, which names no item unless an item has that linkId, so its template is otherwise never extracted, without a warning (extract-templates.test.ts). A template referenced from several items is extracted for the last; a#idmatching nothing is ignored.templateExtractBundlehas no reader.
Packages for reproduction
@aehrc/sdc-populate@4.7.1: plain Node ESM, verified.@aehrc/sdc-template-extract@1.0.15: CJS throughcreateRequire, in plain Node and in Vitest; ESM fails on a directory import (ERR_UNSUPPORTED_DIR_IMPORT). Verified in the P8 review.@aehrc/smart-forms-renderer@1.4.0: needs jsdom, React and CSS handling; store-level use unverified.@aehrc/questionnaire-test-toolkit: unpublished; added after the release commit.- The aehrc packages require
fhirpath ^4.10.1(template-extract^4.3.0); check the lockfile dedupes to the pinned 4.10.1.
Corpus at a glance
| Rule | GP CCMP | Other corpus forms |
|---|---|---|
| unsupported-expression-language | 0 | 1 |
| expression-evaluated-as-fhirpath | 0 | 1 |
| questionnaire-variable-unavailable | 0 | 2 |
| unsupported-query-embedding | 0 | 1 |
| variable-name-collision | 0 | 2 |
| repeating-group-variable | 0 | 1 |
| named-expression-not-referenceable | 0 | 3 |
| forward-variable-reference | 0 | 0 |
| unsupported-template-extract-context | 0 | 0 |
Counts come from read-only scans during research, not from the rules. They are expectations to investigate; each slice reviews its own findings.