Skip to content

Style guide

This guide fixes one written form for each yamaa file: a specification, a project file, a codelists file and a function file. Each departure from it is a lint finding. A finding names how a file is written, never what it means: it has no phase and no condition, and a file runs exactly as its canonical form does. The rules define what a file means; this guide fixes how it is written, and lint with fix rewrites what it can prove.

Running the check

lint() checks the files it names, and every yamaa file beneath a directory it names:

result <- yamaa_lint(c("sdtm/dm.yaml", "adam/"))
remaining <- yamaa_lint("sdtm/dm.yaml", fix = TRUE)
result = yamaa.lint(["sdtm/dm.yaml", "adam/"])
remaining = yamaa.lint("sdtm/dm.yaml", fix=True)
yamaa lint sdtm/dm.yaml adam/
yamaa lint --fix sdtm/dm.yaml

The check reads each file as its author wrote it, before any inherited layer is resolved. The result has status, findings and paths; status is failed while findings remain, and paths lists the files a fix rewrote, as the command line JSON does. Printing the result gives the engine summary. Its findings table has one row per finding: the file's absolute path with forward slashes, the one-based line and column where the departure starts, the finding, its spec path and a message. Only partial_restatement and restated_inherited_value also read the layers a specification inherits and includes.

file line column finding spec_path message
/study/adam/adsl.yaml 12 1 root_spacing output leave one blank line before output

The spec path is written as the issues table writes it, so a finding joins the issues of check() and the paths resolve() shows: a list entry that writes name or id is named by it, as in columns.AGE.label or rows.screening.derivations.VISIT, any other entry by its index, as in verifications[0], and $ names the root. The command line prints each finding as file:line:column: finding message, then a summary line such as 2 lint findings in 1 of 3 files, and exits 1 while a finding remains.

A directory expands, in sorted path order, to the .yaml files beneath it whose root declares the 1.0 header (yamaa and a type naming a file type); hidden directories are left out. Rows follow the files, then line, column and finding. A file that cannot be read or parsed, or a directory that cannot be listed, refuses the call with the issue that says why, and nothing is written. Field orders follow the 1.0 schemas. A directory skips a file that declares schema_version; a file the call names that declares it was written for language version 0.5, and refuses the call as unsupported_language_version, naming version 0.5 (SCHEMA-2).

The findings

The findings, in the order the lint lists them:

Finding Reported where Fixed by fix
field_order A field written after one its class orders later yes
root_spacing A root field of a section with no blank line before it yes
entry_spacing Two multi-line list entries with no blank line between yes
blank_lines A leading, trailing, repeated or misplaced blank line yes
line_width A line longer than 79 characters when it can wrap
invalid_suppression A suppression comment that suppresses nothing no
repeated_row_derivation A derivation repeated in every row template no
redundant_intermediate A named intermediate equal to the implicit join no
missing_label An output column without a label no
redundant_long_form A long form that only restates its shorthand yes
restated_default A field or handler that writes its default yes
scalar_as_text Grammar text for a missing value, number or Boolean yes
unused_input_key An input's key that no read uses no
equality_filter A lookup filter comparing a donor field and a row value no
partial_restatement A layer restating an inherited mapping with entries left out no
restated_inherited_value A layer value written exactly as it is inherited no
unused_declaration A declaration the resolved specification prunes no
uncalled_function A listed function file no specification of the project calls no

Field order

The fields of a mapping are written in the order the schema declares them for its class. A specification orders its root, each input, the output, each named intermediate, each column and each row template. A project, codelists or function file orders its root and every mapping beneath it that a class of its schema describes: a project's environments, study, codelist sources and submission sections, with their standards, documents and metadata version, each codelist with its items, and a function file's params and tests entries. A field written after one the schema declares later is reported as field_order.

A specification's root reads in this order:

  • what the specification is (yamaa, type, model, parent, include, clear, domain, keys);
  • what it reads (input, base, filter);
  • what it writes (output);
  • how it derives its rows (windows, intermediates, columns, rows);
  • what it checks and publishes (verifications, submission, metadata).
yamaa: "1.0"
type: spec
model: ADaM
domain: ADSL
keys: [STUDYID, USUBJID]
input:
  DM: input/dm.csv
  EX: input/ex.csv
base: DM

output:
  path: adsl.csv
  columns: [STUDYID, USUBJID, TRTSDT]

intermediates:
  - id: FIRSTDOSE
    dataset: EX
    key: [STUDYID, USUBJID]
    order_by: [EX.EXSTDTC]
    keep: first

columns:
  - name: STUDYID
    type: str
    label: Study Identifier
    derivation: DM.STUDYID

  - name: USUBJID
    type: str
    label: Unique Subject Identifier
    derivation: DM.USUBJID

  - name: TRTSDT
    type: str
    label: Date of First Exposure to Treatment
    derivation: FIRSTDOSE.EXSTDTC

A project file's root reads yamaa, type, functions, environments, codelists, study, then the submission sections sdtm, adam and send. A codelists file's root reads yamaa, type, standard, codelists, and a function file's yamaa, type, name, description, params, returns, may_return_missing, comparison_decimals, implementations, tests.

Blank lines

The root fields of a file's header may stand together, and one blank line precedes each root field after it, which opens a section. The header ends where the sections begin: before output in a specification, before the first submission section in a project file, before tests in a function file and before codelists in a codelists file. A comment written directly above a field belongs to that field, and the blank line precedes the comment. A missing blank line is reported as root_spacing.

One blank line separates two adjacent list entries when either spans more than one line: the entries of a specification's intermediates, columns and rows, of a function file's tests and of a codelists file's codelists. Entries of one line each may stand together. A comment written directly above an entry belongs to that entry, and the blank line precedes the comment. A missing blank line is reported as entry_spacing.

yamaa: "1.0"
type: project
functions: [functions/bmi.yaml]
environments:
  python: {lock: uv.lock}
  r: {lock: renv.lock}

sdtm:
  specs: [sdtm/dm.yaml]
  standards:
    - {name: SDTMIG, version: "3.4", status: Final}

adam:
  specs: [adam/adsl.yaml]

A file has no blank line before its first line or after its last, never two blank lines in a row, and no blank line between a field and the block that field opens. It ends with one line break. A line inside a multi-line scalar or flow collection is value text, not layout, and is not counted. A departure is reported as blank_lines.

Line width

A line holds at most 79 characters. A longer line is reported as line_width at its eightieth character. A flow list may continue on following lines, and a quoted or plain scalar may continue on following lines under the YAML folding rules, which read each line break inside it as one space.

    filter: "DS.DSCAT = 'DISPOSITION EVENT' AND DS.EPOCH = 'FOLLOW-UP'
      AND DS.DSDECOD = 'COMPLETED'"

Canonical spellings

A 1.0 file has one canonical spelling for each value, and the check reads the file through its file type's schema, as validation reads it, to report the spellings that depart from it.

One grammar in expression fields. A derivation, a row template's derivations entry, a case result, a handled wrapper's value, a key match value and a function argument hold one expression of the closed grammar: a bare name is a reference, quoted text such as 'DM' is a literal, and a YAML number or null is a literal (EXPR-5). Value fields, such as a handler, a dict value or a label, hold constants, in which bare text is text.

columns:
  - name: DOMAIN
    type: str
    label: Domain Abbreviation
    derivation: "'DM'"

  - name: AGE
    type: int
    label: Age
    derivation:
      case:
        - when: "DM.AGE >= 0"
          then: DM.AGE
        - otherwise: null

A missing value, a number and a Boolean can each be written as a YAML scalar or as grammar text, such as null and "NULL" or 0 and "0". The YAML scalar is canonical: an expression field whose whole value is such text is reported as scalar_as_text, and fix writes the scalar. Text for a Boolean, which an expression field refuses (FORMULA-27), is reported too, so fix writes the YAML Boolean. Quoted text, a name, a predicate and a value field keep their text.

Null is the missing value. YAML null is the missing value wherever a value is written: in an expression field, a handler or a value list (COLUMN-10). An omitted handler that answers with missing needs no null: a lookup's absent, and missing on first_available, mapping, decode, cut, str_extract, str_replace, str_case, str_substr, str_length and str_decimal, and a flag's false_value and missing, each written as null, restate their default. So does any field that writes the default its schema declares, such as an input's relative_to: declaring, a section's context: Submission or a codelist's data_type: text. Each is reported as restated_default at its key, and fix removes it. A handler whose omission fails, such as unmapped, declares an answer when it is null, a flag's missing beside a declared false_value is required (EXPR-8), and an absent beside require_match: true conflicts with it (LOOKUP-24); none is reported. A layer is not read for restated defaults, since a value it writes may replace one it inherits. A layer is a file that declares parent or include, or a fragment that a checked specification includes, itself or through the parent chain it inherits, since a fragment composes on whatever includes it. Check a fragment together with the specifications that use it, so that fix keeps the values it writes.

clear: removes an inherited value. A layer that removes a value it inherits lists its spec path in clear; null never clears (LAYER-9).

yamaa: "1.0"
type: spec
parent: adsl-base.yaml
clear:
  - columns.TRTSDT.verifications
domain: ADSL

A shorthand is written as itself. A field that takes several values is a list even for one. A long form that holds only what its shorthand fills, such as {variable: X} for the order term X, aggregate: {expr: E}, D: {path: d.csv} or - not_missing: {}, means what the shorthand means and runs, but the check reports it as the finding redundant_long_form (SCHEMA-15) at the long form, and fix writes the shorthand.

input:
  DM: {path: input/dm.csv, relative_to: declaring}
  EX:
    path: input/ex.csv

Here relative_to: declaring is a restated_default and the EX mapping a redundant_long_form. fix writes DM: input/dm.csv and EX: input/ex.csv: the removed default leaves a long form, which the next pass writes as its shorthand.

Authoring findings

These findings read a specification's value, not only its layout. Each names a rewrite that keeps the derivation's behavior, and its author makes it.

repeated_row_derivation. A derivation that is identical in every row template belongs at column level, where it becomes the default each template inherits (SPEC-6). The check reports it at the first template's entry when all of these hold:

  • two or more row templates derive the same column with the same derivation;
  • the column has no column-level derivation;
  • the derivation is row-local.

A derivation is row-local unless it holds an aggregate or a window operation anywhere, a case result included, reads a SELF intermediate, or reads a column whose column-level derivation is not row-local, a structured source's between value among its reads. A read through the implicit join or of an intermediate an input backs is row-local. A derivation reading a driver dataset also stays when the templates build from different inputs, since the driver read and column derivation's implicit join bind different records; only a literal is driver-independent across drivers: quoted text, a YAML number or null. A column read by any row filter stays as well, since a filter resolves only columns its own template derives. A single row template, a column derived in only some templates, differing derivations per template, and a column that already has a column-level default are never reported.

rows:
  - id: screening
    dataset: SV
    derivations:
      DOMAIN: "'SV'"
      VISIT: SV.VISIT

  - id: unplanned
    dataset: UV
    derivations:
      DOMAIN: "'SV'"
      VISIT: UV.VISIT

DOMAIN is reported: the same literal in every template, whose drivers differ. VISIT reads each template's own driver and is not.

redundant_intermediate. A named intermediate equal to the implicit join to its input is reported at its id, with the advice to read the input directly (LOOKUP-1). It is one that narrows nothing (no filter, between, order_by, keep, derivations, verifications or columns), answers absence as the join does (absent omitted or null), requires no match on itself or on its input, and whose key pairs exactly the columns of its input's declared key, each with the same-named value. Its key written as that list fails validation as rename_only_intermediate (LOOKUP-4), so only the mapping that spells the same pairs is a lint finding. An unread intermediate is unused, not redundant, and is reported as unused_declaration; an input without key has no implicit join to read instead.

input:
  ADSL: {path: adsl.csv, key: [STUDYID, USUBJID]}
intermediates:
  - id: SUBJECT
    dataset: ADSL
    key: {STUDYID: STUDYID, USUBJID: USUBJID}

Read ADSL.TRTSDT instead of SUBJECT.TRTSDT here. While only the intermediate reads ADSL, its key is also reported as unused_input_key; the implicit join then uses it.

missing_label. Every output column declares label:. A column named in output.columns and declared in the same file without a non-empty label is reported at its columns entry. A column not declared in the same file, and any layer, a file with a parent or an include or a fragment a checked specification includes, is never reported: the label may come from an inherited layer. Labels carry the reader-facing description a submission document is generated from.

unused_input_key. An input's declared key that no read uses is reported at that key (LOOKUP-5). A read uses it when it is an implicit join: a qualified read of the input that is not a read of the current row's driver, a qualified aggregate over the input that declares no key of its own, or a copy entry from the input. A named intermediate over the input reads its records by its own key, and when every row is built from the input, each qualified read of it reads the driver. Validation admits such a key, so a shared parent layer may declare it for the layers that inherit it and suppress the finding there; a layer, a file that declares parent or include or a fragment a checked specification includes, is not read, since its columns may come from the layers it composes on.

input:
  DM: {path: input/dm.csv, key: [STUDYID, USUBJID]}
base: DM

Every row comes from DM here, so DM.AGE reads the driver and the key is unused.

equality_filter. A lookup filter conjunct that compares a donor field with a current-row value by equality is reported at the filter: such a comparison is a key pair, which key states. The filters of a named intermediate, a structured source and a qualified aggregate are read, each over the input whose records it narrows. A conjunct that compares a donor field with a constant stays in filter, as do other comparisons, disjunctions and negations.

intermediates:
  - id: RANGE
    dataset: RANGES
    key: [LBTESTCD]
    filter: "RANGES.SEX = LB.SEX AND RANGES.LOW IS NOT NULL"

RANGES.SEX = LB.SEX is reported: write key: {LBTESTCD: LBTESTCD, SEX: LB.SEX} and keep RANGES.LOW IS NOT NULL in the filter.

partial_restatement. A layer that writes a member it inherits merges with it, a mapping key by key, so an entry the layer leaves out of an inherited mapping comes back from its parent or from a fragment it includes (LAYER-6, LAYER-8). A layer that restates such a mapping, writing it empty or writing again an entry exactly as it inherits it, and leaves out entries that then come back is reported at the mapping, and the message names the entries that come back. The mappings read are an input's types, a named intermediate's key written as a mapping and its derivations, a row template's derivations, the settings of a named window and the fields of output and submission (LAYER-5); a required field of output or submission that comes back is not named, since a layer writes it to replace it. A layer that writes only what it adds or changes is patching and is never reported, and neither is a mapping the layer clears: clearing a mapping and writing it in the same layer replaces it (LAYER-9).

yamaa: "1.0"
type: spec
parent: scores-base.yaml

rows:
  - id: scores
    derivations:
      USUBJID: SC.USUBJID
      VISIT: SC.VISIT
      AVAL: SC.SCORE

When scores-base.yaml also derives NOTE in the scores template, NOTE comes back and rows.scores.derivations is reported. List the mapping in clear, as clear: [rows.scores.derivations], to replace it, or write only the derivations this layer changes to keep the parent's. A named window is a keyed member, which clear never names, so each setting the layer leaves out is cleared by its own path, such as windows.BYSUBJ.filter, and so is each field a layer leaves out of output or submission, such as output.report. Either meaning may be intended, so fix makes neither rewrite. Each derivation the layer writes as the parent does is also reported, as restated_inherited_value, and either rewrite settles both. The check follows the file's parent chain and the fragments its layers include through the same resolver and composes them as the engine does; a file whose layers cannot be read or composed is not read for this finding, and validation reports why.

restated_inherited_value. A layer's root fields compose apart, and so do the fields, entries and members of a value that merges with the one it inherits: a keyed member, a class, a mapping, or a registry value naming the same keyword (LAYER-5, LAYER-6). A value written there exactly as the layer inherits it composes the same specification as the layer without it, yet reads as a change to a reviewer, and it stops following the parent when the parent changes. It is reported at the outermost such value, a scalar, a list, a whole registry value such as a derivation, a mapping or a whole member, and the message names the layers the value is inherited from, spelled from the file's directory. A value that differs is a change and is never reported, and neither is the header every layer writes, a member's name or id, a column a copy entry declares, or a value the layer lists in clear, which it no longer inherits (LAYER-9). A value that replaces what it inherits whole, such as the root metadata or a list, is compared whole.

yamaa: "1.0"
type: spec
parent: adtte_v1.yaml

columns:
  - name: PARAMCD
    derivation: "'TTDE'"

  - name: CNSR
    label: Censor

  - name: PARAM
    label: Parameter

When adtte_v1.yaml derives PARAMCD as 'TTDE' and labels CNSR as Censor, columns.PARAMCD.derivation and columns.CNSR.label are reported, and the new PARAM label is a change. Remove a reported value to keep inheriting it. Either meaning may be intended, so fix makes no rewrite: a layer that restates a value on purpose, such as a label a package keeps whatever its parent says, suppresses the finding with its reason. The layers are read as the engine prepares them: the parent chain and the fragments its layers include, through the same resolver, each copy entry expanded from its producer, so a copied column's label is compared too, and each relative path respelled from the file, so a path restates only the location it inherits (LAYER-10, SPEC-9). A file whose layers cannot be read, expanded or composed is not read for this finding, and validation reports why.

unused_declaration. The resolved specification keeps only the declarations its roots reach: the columns output.columns, keys and output.order_by name, the columns dataset verifications read, each column and intermediate with its own verification, every row template and base (LAYER-11). An input, a named intermediate, a column or a row template's derivation of a column that no root reaches is pruned. It cannot affect the artifact, and a reference inside it is never checked (LAYER-12), so a typo in a declaration nothing reads passes validation. Such a declaration is reported where the file writes it, and the message names what reads it, each of which is pruned too.

output:
  path: adsl.csv
  columns: [STUDYID, USUBJID, AGE]

intermediates:
  - id: WEIGHT
    dataset: VS
    key: [STUDYID, USUBJID]
    filter: "VS.VSTESTCD = 'WEIGHT'"

columns:
  - name: WEIGHTBL
    type: float
    label: Weight (kg) at Baseline
    derivation: WEIGHT.VSSTRESN

output.columns leaves WEIGHTBL out and nothing reads it, so columns.WEIGHTBL is reported, intermediates.WEIGHT as read only by columns.WEIGHTBL, and the input VS, when nothing else reads it, as read only by intermediates.WEIGHT. A row template's derivation of a pruned column is reported too. Delete the declaration, or list the column in output.columns; either may be intended, so fix makes neither.

A file reports only what it writes. A template offers more members than any one study keeps, and a layer cannot clear an inherited member, so a study layer drops one by leaving it out of output.columns (LAYER-9): what a parent or a fragment declares and the file does not write is never reported in it. A member the file writes into, such as a new label for an inherited column, is reported at the member it writes, naming the layer that declares it. A template linted on its own reports a member that only the layers inheriting it read, and suppresses the finding with its reason. An intermediate without key that nothing reads survives to reach its refusal (LOOKUP-3), so neither it nor an input only it reads is reported. An expression that cannot be compiled reads nothing, so a declaration only such an expression names is reported as unread until the expression is fixed. The specification is resolved as the engine prepares it, through the same resolver, each copy entry expanded from its producer, so a copied column is reported at the name its entry lists. A fragment composes on whatever includes it and is not read for this finding, and neither is a file whose specification does not resolve: validation reports why.

uncalled_function. Every function file a project lists in functions is read and admitted on each check and run (FUNC-2), but only the test cases of a function a specification calls are run (FUNC-12), so a listed function no specification calls goes stale without a word. The listing is reported at its entry in functions, naming the function and its file.

yamaa: "1.0"
type: project
functions: [functions/bmi.yaml, functions/bsa.yaml]

adam:
  specs: [adam/advs.yaml]

When adam/advs.yaml calls only bmi, functions[1] is reported. A call counts once its specification is resolved: a call in a pruned declaration binds nothing, so it calls nothing. Every section's specifications count. functions replaces whole across project files (PROJECT-16), so only the file that writes it is read, and only when the project it composes lists a specification: a shared parent project that lists functions and no specifications is never reported, and one that lists both suppresses the finding with its reason. A project whose function files or listed specifications cannot be read or resolved is not read for this finding, and validation reports why. Either remedy, dropping the listing or adding the call, may be intended, so fix makes neither.

Fixes

With fix, the check first applies the fixes it can prove, then reports what remains. A layout fix changes how the lines of a file are laid out, never the written text of a value. It may:

  • move a field, with the comments written directly above it, into field order;
  • insert or remove blank lines;
  • continue a flow list, a quoted scalar or a plain scalar on following lines;
  • write a one-line flow mapping as a block mapping, keeping each member's written text.

A layout fix is kept only when all of these hold for the fixed file:

  • it parses under the YAML 1.2 core schema to the same value, with the same types, as the file before it;
  • it carries the same comment lines;
  • it does not change when it is fixed again.

A canonical rewrite, for restated_default, scalar_as_text or redundant_long_form, changes the written value and keeps its meaning: it removes the restated entry, writes the YAML scalar the text stands for, or writes the shorthand in place of the long form. It is kept only when the fixed file reads as exactly the values before it with that one rewrite applied, with the same comment lines, and does not change when it is fixed again. A rewrite that would lose a comment, or that has no single line to write the shorthand on, is not made. A fixed 1.0 file must also give no validation finding that the file as written did not, so a removed default whose long form cannot be rewritten leaves the file as written.

Otherwise the file stays as written and the finding stays reported. A fixed file is replaced atomically, keeping its permissions; the named files are the only files the check writes. The authoring findings name a rewrite their author makes, so fix offers none.

Suppression

A comment # yamaa-lint: allow <finding>, ... -- <reason> written directly above a field or an entry suppresses the named findings reported within that field or entry. Other comments may stand between the suppression and the field, and the suppression states a reason. A suppression that names no finding, names an unknown finding or gives no reason suppresses nothing and is itself reported as invalid_suppression, which no suppression covers.

input:
  # yamaa-lint: allow unused_input_key -- the inheriting layers join LB
  LB: {path: input/lb.csv, key: [STUDYID, USUBJID, LBSEQ]}