Lint rules
Anatomy of a rule
A rule names a target, a field on that target, and a condition the field has to satisfy.
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 |
|---|---|
|
The pipeline as a whole |
|
The workflow as a whole |
|
Every transform in a pipeline |
|
Every action in a workflow |
|
Every hop between transforms or actions |
|
Relational database connections |
|
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/:
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 |
|---|---|
|
has a value |
|
has no value |
|
is a collection with at least one entry |
|
is empty, or takes its value from a variable ( |
|
compares as required against |
|
is a collection of an acceptable size |
|
does or does not match the regular expression in |
|
does or does not contain the text in |
|
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 |
|---|---|
|
How many of each the file contains |
|
The file has at least one disabled hop |
|
Something on the canvas is not connected to anything |
|
How many notes the canvas carries, and whether it carries any |
|
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 |
|
The transform or action still carries the name Hop Gui gave it: the plugin’s name, such as |
|
The transform is a Dummy |
|
The transform blocks until all rows have arrived |
|
A transform sends its error rows to another transform; an action has a hop that is followed on failure |
|
The names of the transforms or actions that the enabled hops lead to |
|
The type of a database connection, as its plugin id, for example |
|
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:
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:
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:
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:
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 |
|---|---|
|
A |
|
|
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:
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:
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:
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.
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.tagsholds onekey:valuestring per value (category:secrets,policy:SEC-POL-4,policy:SEC-POL-7), the form GitHub code scanning and Azure DevOps read, andproperties.hopTagsholds the same tags as a map. The rule’sdescriptionis written asfullDescription, andhelpUriashelpUri. -
json: each finding has atagsobject. 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:
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 |
|---|---|---|
|
ERROR |
A database connection with a hardcoded password |
|
ERROR |
A hardcoded secret on a transform |
|
ERROR |
A hardcoded secret on an action |
|
WARNING |
A transform nothing is connected to |
|
WARNING |
An action nothing is connected to |
|
WARNING |
A transform still carrying the name Hop Gui gave it, such as |
|
WARNING |
Every remark from Hop’s own transform and action checks |
|
ERROR |
A pipeline, workflow or metadata file that could not be loaded, so nothing was checked against it |
|
ERROR |
A lint run that stopped before it finished |
|
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 |
|---|---|
|
A pipeline without a description |
|
A workflow without a description |
|
A pipeline with no notes on the canvas |
|
A workflow with no notes on the canvas |
|
A pipeline with more transforms than the configured ceiling |
|
A workflow with more actions than the configured ceiling |
|
A file containing disabled hops |
|
A Dummy transform left in a pipeline |
|
A pipeline whose file name breaks the configured naming convention |
|
A workflow whose file name breaks the configured naming convention |
|
A transform running more copies than the configured ceiling |
|
A Table Input that selects every column and sets no row limit. The worked example of a multi-clause rule |
|
A pipeline nothing in the project calls |
|
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:
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:
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.
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.