Lint rules

Anatomy of a rule

A rule names a target, a field on that target, and a condition the field has to satisfy.

yaml
rules:
  DB-001:
    type: custom
    enabled: true
    severity: ERROR
    target: DATABASE_CONNECTION
    targetField: password
    condition: NO_HARDCODED
    name: "Hardcoded Database Password"
    description: "Database connections must take the password from a variable"

The key (DB-001) is the rule id. Ids share one namespace across every installed rule pack, so a pack that is not Hop’s own should prefix its ids.

Targets

Target Applies to

PIPELINE

The pipeline as a whole

WORKFLOW

The workflow as a whole

TRANSFORM

Every transform in a pipeline

ACTION

Every action in a workflow

HOP

Every hop between transforms or actions

DATABASE_CONNECTION

Relational database connections

METADATA

Any registered metadata type

METADATA reaches run configurations, servers, REST connections, variable resolvers and anything a plugin registers, because types are resolved through the plugin registry rather than a fixed list. Scope such a rule with appliesTo, naming the metadata key — the folder name under metadata/:

yaml
  ACME-SRV-001:
    target: METADATA
    appliesTo: [server]
    targetField: password
    condition: NO_HARDCODED
    severity: ERROR
    name: "Hop Server Password Not From a Variable"

hop lint --list-metadata-types prints the keys this installation has registered.

Conditions

Condition Passes when the field

NOT_EMPTY, NOT_NULL

has a value

IS_EMPTY

has no value

NOT_EMPTY_COLLECTION

is a collection with at least one entry

NO_HARDCODED

is empty, or takes its value from a variable (${NAME}, %%NAME%%) or a variable resolver (#{vault:path:key}) rather than holding a literal value. An Encrypted value is a literal value: Hop obfuscates it, and anyone with Hop and the same encoder key can read it back

MAX_VALUE, MIN_VALUE, EXACT_VALUE

compares as required against conditionValue

MAX_COLLECTION_SIZE, MIN_COLLECTION_SIZE

is a collection of an acceptable size

MATCHES_PATTERN, NOT_MATCHES_PATTERN

does or does not match the regular expression in conditionValue

CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH

does or does not contain the text in conditionValue

MUST_BE_TRUE, MUST_BE_FALSE

is a boolean with the required value

conditionValue is used as written: variables are not resolved in it. A value the condition cannot use, such as abc or 3,0 for MAX_VALUE, or [a- for MATCHES_PATTERN, would make the rule report nothing at all. The rule editor refuses it, and a rule loaded from hop-lint.yml or a rule pack gets a warning in the log that names the rule and the problem. The same goes for a condition that does not fit one of the fields the linter provides, such as NOT_NULL on transformCount.

Fields

targetField is the name of a property on the target. Besides the properties Hop exposes, the linter computes a few of its own:

Field Meaning

transformCount, actionCount, hopCount

How many of each the file contains

hasDisabledHops

The file has at least one disabled hop

hasOrphanedTransforms, hasOrphanedActions

Something on the canvas is not connected to anything

noteCount, hasNotes

How many notes the canvas carries, and whether it carries any

isReferenced

Whether any other file in the project calls this pipeline or workflow. Only a project lint can answer this, see Rules which need the whole project

hasDefaultName

The transform or action still carries the name Hop Gui gave it: the plugin’s name, such as Table input, followed by a number when that name was taken, as in Dummy (do nothing) 2. The plugin name is the one of the language Hop runs in. A workflow’s Start action never counts

isDummy

The transform is a Dummy

isBlockingTransform

The transform blocks until all rows have arrived

errorHandling

A transform sends its error rows to another transform; an action has a hop that is followed on failure

targetTransforms, targetActions

The names of the transforms or actions that the enabled hops lead to

databaseType

The type of a database connection, as its plugin id, for example POSTGRESQL

attributes

The attributes of a database connection, including its extra options. A finding lists the attribute names, not their values

A database connection rule that names a field the connection does not have is reported as an error against the rule.

Nested properties are reached with a dotted path:

yaml
    targetField: fileSettings.fileName

Field names are resolved against the name Hop stores the property under, which is the name you see in the .hpl or .hwf file and in the metadata JSON. Rather than guessing, ask:

bash
hop lint --list-fields TableInput

which prints every name a rule can use for that transform or action, with its type.

Checking more than one thing

A rule which names a single targetField and condition reports whenever that one thing is wrong. Some problems are only worth reporting when several things are wrong together, and for those a rule takes an allOf: or an anyOf: block instead:

yaml
  SQL-002:
    type: custom
    enabled: true
    severity: WARNING
    target: TRANSFORM
    appliesTo:
      - TableInput
    allOf:
      - targetField: sql
        condition: NOT_MATCHES_PATTERN
        conditionValue: "(?is).*\\bselect\\s+\\*.*"
      - targetField: rowLimit
        condition: NOT_MATCHES_PATTERN
        conditionValue: "^\\s*([+-]?0*|-\\d+)\\s*$"
    name: "Unbounded SELECT *"
    description: "A Table Input that selects every column and sets no row limit"

As everywhere else, each condition says what is required, and the rule reports when that is not met. allOf reports only when every condition is not met, anyOf when at least one is not. The rule above reports a Table Input whose SQL selects every column and whose row limit is unset, 0 or negative, which Hop reads as no limit.

To report something that is set, require it to be empty: a plain http:// URL with a login is url MATCHES_PATTERN ^https://.* (not met for http://) together with httpLogin IS_EMPTY (not met when a login is set), under allOf.

A rule with allOf or anyOf and a target is a rule definition even without type: custom. Each clause names its own field, condition and value, and the finding says which clauses were broken and what the values actually were.

A rule with neither block behaves exactly as before, so nothing that was written against the single-condition form needs changing.

The rule editor in Hop Gui lists a composed rule’s clauses and edits them one at a time; Add clause and Remove clause change the list.

Narrowing a rule to one kind of transform

A rule aimed at TRANSFORM runs against every transform in the pipeline. appliesTo restricts it to the plugin ids where the field means anything:

yaml
  SQL-001:
    type: custom
    target: TRANSFORM
    appliesTo:
      - TableInput
    targetField: sql
    condition: NOT_MATCHES_PATTERN
    conditionValue: "(?is).*\\bselect\\s+\\*.*"
    name: "SELECT * in Table Input"

A scoped rule naming a field the transform does not have is reported as a configuration error rather than passing quietly, so that a typo in a field name is visible instead of looking like a clean result.

Parameters

A few rules read extra settings from a parameters: block:

Parameter Read by

fieldPatterns

A NO_HARDCODED rule on TRANSFORM or ACTION whose targetField is password, secret or credential. The list of field name endings that hold a secret, replacing the default list

blockingTransforms

isBlockingTransform. The transform plugin ids that count as blocking, replacing the default list

yaml
  SEC-002:
    parameters:
      fieldPatterns:
        - "password"
        - "apiKey"

Any other parameter is ignored, and a warning in the log names it, in a rule pack and in hop-lint.yml alike.

Hop’s own verify remarks

Every transform and action has a check() method of its own, the one behind the Verify button, and the linter reports what those find alongside its own rules. They are not the same kind of claim. Verify is a question somebody asked, and reads the answers to in context; the linter asks it on every save and every build, and a remark it repeats is a remark it stands behind. check() also works from the row stream Hop infers at design time, which is right for a plain pipeline and wrong wherever fields arrive at runtime — through metadata injection, a mapping, or a transform filled in by a parameter.

A native rule says how those remarks are reported. It checks nothing itself:

yaml
rules:
  HOP-CHECK:
    type: native
    enabled: true
    severity: WARNING

severity on a rule that covers every remark is a cap: a remark above it is lowered to it, and one below it keeps the severity the transform gave it, so a comment is never reported as a warning. On a rule narrowed with appliesTo or messageKey, severity is what the linter reports those remarks as, higher or lower than the transform gave them. enabled: false drops the remarks the rule covers. Two optional keys narrow a rule to less than every remark:

  • appliesTo — the plugin ids of the transforms or actions it covers, as for any other rule.

  • messageKey — one single check, named as <i18n package>:<key> for the message it prints, the same form Hop’s plugin annotations use. The key is resolved through the plugin’s own message bundle, so the rule keeps matching in every language rather than depending on the English wording. For a check that fills values into its message, such as a connection or field name, the rule matches whatever values the check filled in. A key that no longer resolves matches nothing rather than everything.

The most specific rule wins — one naming the check beats one naming only the plugin, which beats the blanket rule — so a pack can hold a general policy and an exception to it, and the order of the file does not decide which applies.

To have the linter treat Hop’s remarks exactly as the transform meant them, put the severity back in your project’s hop-lint.yml:

yaml
rules:
  HOP-CHECK:
    severity: ERROR

Findings from a native rule carry that rule’s id, so they suppress by id like any other. A check that sets an error code of its own, such as CONNECTION_DOES_NOT_EXIST, is reported under that code instead, unless a rule naming its plugin or its message matched it. A suppression or baseline entry can name either the code or the native rule that classified the finding, and one naming the code keeps working when a rule naming that check is added later:

yaml
suppress:
  - rule: HOP-CHECK
    path: "templates/**"
    reason: "Fields in these templates arrive through metadata injection"

Tags and help links

A rule can carry tags: labels a team or vendor attaches to it, such as a category, the team that owns it or the policy it enforces. Each value is a string or a list of strings.

yaml
rules:
  ACME-002:
    type: custom
    # ...the rule as before...
    helpUri: https://wiki.example.com/lint/ACME-002
    tags:
      category: secrets
      owner: platform-team
      policy: ["SEC-POL-4", "SEC-POL-7"]

The linter never evaluates tags, so changing them cannot change what a rule finds. They are written to the reports, on every finding the rule produces:

  • sarif: on the rule, properties.tags holds one key:value string per value (category:secrets, policy:SEC-POL-4, policy:SEC-POL-7), the form GitHub code scanning and Azure DevOps read, and properties.hopTags holds the same tags as a map. The rule’s description is written as fullDescription, and helpUri as helpUri.

  • json: each finding has a tags object. Every value is written as a list, also a single one.

Tags work the same on a native rule.

A project sets tags on any rule, a pack’s included, in its own hop-lint.yml. Each key replaces the pack’s values for that key and leaves the pack’s other tags as they are; a key with no values removes the pack’s tag:

yaml
rules:
  DB-001:
    tags:
      owner: data-team    # replaces the pack's owner, if it has one
      policy: []          # removes the pack's policy tag
    helpUri: https://wiki.example.com/lint/DB-001

In Hop Gui, the rule editor has a Help URL field and a Tags table with one row per key and value; repeat a key to give it several values. Saving a pack rule there writes only the changed keys to hop-lint.yml, as above. The rule manager lists each rule’s tags in its Tags column.

A key the linter does not know, such as tag: for tags:, is logged as a warning and ignored.

The rules Hop ships

Hop’s core rule pack is deliberately small. A rule is enabled by default only when a violation is defensible as a defect in any project, whatever the house style.

Rule Severity Reports

DB-001

ERROR

A database connection with a hardcoded password

SEC-002

ERROR

A hardcoded secret on a transform

SEC-003

ERROR

A hardcoded secret on an action

TRANS-002

WARNING

A transform nothing is connected to

WORKFLOW-002

WARNING

An action nothing is connected to

NAMING-004

WARNING

A transform still carrying the name Hop Gui gave it, such as Table input or Table input 2

HOP-CHECK

WARNING

Every remark from Hop’s own transform and action checks

SYSTEM-001

ERROR

A pipeline, workflow or metadata file that could not be loaded, so nothing was checked against it

SYSTEM-002

ERROR

A lint run that stopped before it finished

SYSTEM-003

ERROR

A metadata file, such as a connection, that could not be read

The SYSTEM rules are of type: system: like a native rule they check nothing, and say how the linter reports a file it could not read or a run it could not finish. A project can change their severity, or switch one off, in its hop-lint.yml like any other rule.

TRANS-002 and WORKFLOW-002 report on the transform or action itself, so the finding names it and the canvas can point at it. The only element in a file is not an orphan — there is nothing for it to be disconnected from — and neither is one whose hops are all disabled: it has hops, and whether a disabled hop is a problem is what STRUCT-003 asks.

An orphaned action never runs, because nothing leads to it from Start. An orphaned transform does run: Hop starts every transform in a pipeline, connected or not. TRANS-002 warns that it runs as a stream of its own, which is rarely what was meant. A pipeline that deliberately runs two independent streams can accept the finding on that transform, see Accepting findings on a single transform or action.

SEC-002 and SEC-003 look at every text field of the transform or action whose name ends in one of the rule’s fieldPatterns, such as password, token or passphrase. They also look inside the settings the transform or action stores in the file, such as the list of files PGP Decrypt Files decrypts, each with its own passphrase. DB-001, SEC-002 and SEC-003 count an Encrypted value as hardcoded.

Hop ships no narrowed native rule of its own. A check that is simply wrong is fixed in the transform rather than silenced from a rule pack, which would leave it firing for everyone who presses Verify. appliesTo and messageKey are there for a project that disagrees with a check the platform is right to ship.

A further set ships disabled, as worked examples of the format. Switch one on by id in your project’s hop-lint.yml.

Rule Reports

DOC-001

A pipeline without a description

DOC-002

A workflow without a description

DOC-003

A pipeline with no notes on the canvas

DOC-004

A workflow with no notes on the canvas

STRUCT-001

A pipeline with more transforms than the configured ceiling

STRUCT-002

A workflow with more actions than the configured ceiling

STRUCT-003

A file containing disabled hops

BEST-003

A Dummy transform left in a pipeline

NAMING-001

A pipeline whose file name breaks the configured naming convention

NAMING-002

A workflow whose file name breaks the configured naming convention

PERF-003

A transform running more copies than the configured ceiling

SQL-002

A Table Input that selects every column and sets no row limit. The worked example of a multi-clause rule

STRUCT-004

A pipeline nothing in the project calls

STRUCT-005

A workflow nothing in the project calls

Rules which need the whole project

Most rules read one file. A few — those using isReferenced — answer a question the file cannot answer about itself: whether anything else in the project calls it.

These are evaluated only when the linter is given a folder, because only then is there a project to look at. hop lint <one file> and the editor’s lint-on-save skip them rather than guess; a rule that reported "nothing calls this" from a single file would be wrong every time.

References are followed through the same mechanism Hop itself uses to export a project’s resources, so a pipeline called from a third-party transform or action is seen without the linter knowing anything about that plugin. Where a reference is written through a variable the linter cannot resolve, the file name alone is matched. That can miss a dead file which happens to share a name with a live one, which is the safer way to be wrong: a linter that calls live code dead is one people switch off.

hop lint --list-rules prints the effective set, with the pack each rule came from.

Configuring a project

Put a hop-lint.yml in the project root. It holds overrides and project-local rules only, not a copy of the rule packs:

yaml
rules:
  # Switch on a rule the core pack ships disabled
  DOC-001:
    enabled: true
    severity: ERROR

  # Retune a threshold
  STRUCT-001:
    enabled: true
    conditionValue: "30"

  # Use your own wording for a pack rule
  DOC-002:
    name: "Workflows need a description"
    description: "Say what the workflow does and who to ask about it"

  # A rule that exists only in this project
  LOCAL-001:
    type: custom
    enabled: true
    severity: ERROR
    target: PIPELINE
    targetField: description
    condition: NOT_EMPTY
    name: "Pipeline Description Required"

Tools → Lint → Manage Custom Rules writes this file for you. It changes only the entry of the rule you added, edited, switched or deleted: the other rules, the exclude and suppress sections and your comments stay as you wrote them.

A rule you add there gets its id in the Rule ID field, suggested as PROJECT-001, PROJECT-002 and so on. Findings, SARIF reports, hop lint --list-rules and hop-lint.yml all name the rule by this id, so pick one that says what the rule is about. An id holds letters, digits, hyphens, underscores and dots, and no two rules can share one, whatever the case. A project rule’s id can be changed later; a suppress entry naming the old id does not follow it. A pack rule keeps the pack’s id.

A parse error in hop-lint.yml is reported rather than ignored, so that a typo cannot leave you linting with the defaults while believing otherwise.

Excluding files, and accepting findings

The same file decides what is out of scope. exclude keeps files out of the run entirely. suppress accepts a specific finding on the record, which is preferable to switching a rule off everywhere, because the decision stays visible and reviewable:

yaml
exclude:
  - "generated/**"
  - "tests/**"

suppress:
  - rule: ACME-ENV-001
    path: "legacy/**"
    reason: "Legacy connections are pinned until the 2026 migration"
  - rule: TRANS-002
    source: "Reserved for phase 2"
    reason: "Placeholder kept deliberately, agreed with the data team"

path and source narrow a suppression; leaving one out matches anything. A suppression has to name a rule and give a reason: without a rule it would silence everything, and without a reason nobody can review the decision later. Entries missing either are refused and logged rather than applied.

Exclusions and suppressions apply in Hop Gui as well as on the command line.

Explorer → right-click a file or folder → Exclude From Linting writes an exclude entry for you, keeping the rest of the file, comments included, exactly as you wrote it, and your reason as a comment above the entry. On something already excluded the same item reads Include In Linting Again and takes the entry back out, comment and all. Both are in Tools → Lint as well.

A file covered by a broader pattern, templates/** rather than its own name, is left alone: which entry to change is a decision for you rather than a guess by the menu.

Accepting findings on a single transform or action

An exclusion covers a whole file, which is right for a template that is dynamic from end to end and too much for one that is only partly dynamic. The usual example is a metadata injection template: its query and its fields arrive at runtime, so design-time checks report the transforms that are deliberately empty, on every open, while the rest of the pipeline is worth checking as normal.

Right-click the transform or action and choose Ignore lint findings…​. You are asked whether to accept everything reported there or only the rules reported right now, and for a reason, which is required. Check this transform again on the same menu takes the entries back out. Neither is offered on a file that is excluded from linting altogether: there is nothing to accept when nothing is checked.

Both write to the project’s hop-lint.yml, never to the pipeline or workflow: those files are opened by people who do not run the linter, and bookkeeping for a plugin they do not have installed has no business in them.

yaml
suppress:
  - rule: "*"
    path: "templates/load-customers.hpl"
    source: "Fonte Sql"
    reason: "Connection and SQL are injected at runtime"

rule: "*" accepts whatever is reported on that element, including a rule that starts reporting on it later, which is what a transform filled in at runtime needs. It is only accepted together with a path or a source: on its own it would be the linter switched off under another name, and it is refused the same way an entry without a rule is.

An element whose findings are accepted is drawn with a dashed outline and a small greyed-out info badge on the canvas rather than nothing at all, so the absence of a warning reads as somebody’s decision rather than as a check that never ran. Turn that off with Mark Ignored Transforms and Actions in the linter options.

How the effective rule set is built

Rules are resolved by merging every installed rule pack in priority order, and then applying the project’s hop-lint.yml on top. hop lint --list-rules prints the result.