Aug 09, 2026
# Why Is My Tag Missing? Tracing the ATDD Tag Extraction Pipeline
[7 min read]
The pre-merge ATDD pipeline scans open GitLab merge requests targeting main and computes the minimal set of Gherkin tags describing the acceptance tests affected by that MR. Those tags drive the downstream test run — so if a tag doesn't appear, the tests it guards don't run.
Which leads to the question this post exists to answer:
"I changed something in an MR, but the tag I expected did not appear in the final output. Why?"
The short answer is that detection is only the first of five stages, and a tag can be dropped at any of them. A tag correctly detected in stage 2 can still vanish in stage 4 or stage 5. Diagnosing this means walking the stages in order rather than jumping to the one you suspect.
The End-to-End Flow
- An open MR is fetched. It is skipped entirely if its CI is not green, or if it has a real merge conflict with
main. - The MR's changed files are classified by extension and routed to the matching channel's detector.
- Each detector appends qualifying tag blocks to a shared
tag.txt(the Java channel also feedsjava.json). processTagBlockaggregates all blocks into one de-duplicated,or-joined tag string, applying feature-level filters.tag_filter.pyintersects that string with the curatedEpicTags.txtallow-list and appends@LMSODO*pass-through tags, producing the finaltag_filter.txt.
Every change reaches the output through one of three independent channels, decided purely by file extension: .feature, .java, and .xlsx.
Channel 1 — Feature Files (.feature)
Handled by detect_changes.py, which runs four detection paths (A–D) over a git diff --unified=0 of the changed file.
Tags are brought when any of these is true:
- Path A — a scenario's tag line or its
Scenario:keyword line was added in the diff (is_new). Source:extract_scenario_tag_groups. - Path B — a Scenario Outline's Examples table changed, classified as
HEADER,DATA_ROW_NUM, orSHEET_NAME. Source:tag_extractor.py:357-436. - Path C — a feature-level tag line (above
Feature:) changed. Emits only the changed feature tags, not every scenario. Source:extract_feature_tags_from_diff. - Path D — any line added or removed inside a scenario's line span: steps, tables, doc-strings. This catches the edits paths A–C miss. Source:
detect_changes.py:225-248.
The change is ignored when:
- The feature carries
@UnImplemented— the whole file is skipped immediately (detect_changes.py:277). - The Scenario Outline or its Examples block is tagged
@UnImplemented(tag_extractor.py:363,:369). - The added line is whitespace-only; indentation and blank-line edits do not count (
tag_extractor.py:80-89). This applies separately to Examples header and data-row changes (:405-408,:435-436). - The scenario's tag set was already emitted by an earlier path — de-duplication (
detect_changes.py:243-248). - The tags are
@AuthoredBy-*or@ImplementedBy-*attribution tags. The scenario is kept; these tags are stripped (tag_extractor.py:183-188).
Channel 2 — Java Files (.java)
detect_java_changes.py resolves changed .java files to the method names that changed, writes them to java.json, and an external atdd-cucumber-engine JAR maps those methods to Gherkin tags.
A method is flagged as changed when an added line falls within a HEAD method's span [signature_line, end_line], or a removed line falls within a BASE method's span — so a fully deleted method is still traced, marked (deleted).
The change is ignored when:
- The changed line is an annotation above the signature, outside the method span.
- The line matches a control-flow keyword with a
word(...)shape (if,for,while) — not treated as a method. - The method identity
<package> | <signature>was already recorded. - The method maps to no Gherkin tag in the cucumber-engine JAR. This is the quiet one: detection succeeded, but nothing downstream claims the method.
Channel 3 — Excel Workbooks (.xlsx)
A Scenario Outline can draw its data from an Excel workbook via an Examples: table referencing the workbook filename, often with a sheet name and row number. When an MR changes an .xlsx, three complementary passes run, all appending to tag.txt:
- Pass 1 — the workbook is matched to scenarios strictly by filename as referenced in an Examples table. Source:
excel_diff_parser.py→excel_tag_resolver.py. - Pass 2 — every
.featurereferencing the workbook, with the Excel column auto-detected by.xlsx/.xlscell values. Source:excel_feature_lookup.py. - Pass 3 — only scenarios whose Examples row points at a row that actually changed, matched by filename + sheet name + row number. The most precise pass. Source:
extract_tags_by_excel_change.py.
The change is ignored when:
- No prior version of the workbook exists — a brand-new file means precise Pass 3 is skipped entirely (
Jenkinsfile:1005). excel_diff_parser.pyexits non-zero — Excel tag resolution for that MR is skipped (Jenkinsfile:943).- The workbook is not referenced by any
.featureExamples table. Filename matching finds nothing, by design. - The cell change is whitespace-only (
tag_extractor.py:76-77). - A referenced scenario is
@UnImplementedor carries attribution tags — filtered like feature tags.
One trap worth internalizing: Passes 1 and 3 assume a fixed workbook layout — row 1 is the title, row 2 the header, data from row 3 (HEADER_ROW_INDEX = 2, _EXCEL_ROW_OFFSET = 2). A workbook not following this layout can silently mis-map rows, so an expected tag may not appear even though the row genuinely changed.
Pipeline-Wide Filters — The Usual Culprits
After all three channels have written to tag.txt, two further stages run once for the whole MR set. These are the most common reason a correctly detected tag disappears.
Aggregation (processTagBlock): a feature block whose feature tags include @UnImplemented is skipped (Jenkinsfile:795); @AuthoredBy-* / @ImplementedBy-* are dropped; and only tokens starting with @ are kept — bare words are discarded (Jenkinsfile:800).
The final allow-list gate (tag_filter.py) is the single biggest "why is my tag missing" cause. A tag survives to tag_filter.txt only if it is either present in the curated EpicTags.txt allow-list, or a @LMSODO* ticket/story tag, which always passes through. Everything else — including a perfectly detected, entirely valid scenario tag — is silently removed (tag_filter.py:158-171).
If the filtered result is empty (no epic match and no @LMSODO tags), tag_filter.py exits with code 2 and the downstream test run is skipped.
MR-Level Skips
Before any tag detection runs at all, an entire MR — and all its scenarios — is skipped when it has no CI pipeline (Jenkinsfile:661), the pipeline is still running (:670) or was canceled (:673), the required java_compilation job was not successful (:202-211, yielding mergeStatus = SKIPPED_CI, DO NOT MERGE), or there is a real merge conflict with main on the MR's own files (:248, :304).
Diagnostic Checklist
Work through these in order. The first "yes" explains the missing tag:
- Was the whole MR skipped — CI not green, still running, canceled, or a real merge conflict?
- Did the change land in the channel you assumed, given that routing is purely by file extension?
- Is the feature, scenario, or Examples block tagged
@UnImplemented? - Was the edit whitespace-only?
- For Java: did the line land inside a method body, and does that method map to a tag in the cucumber-engine JAR?
- For Excel: is the workbook referenced by a
.feature, does a prior version exist, and does it follow the expected row layout? - Is the tag in
EpicTags.txt, or does it start with@LMSODO? If neither, it was silently dropped at the final gate regardless of how cleanly it was detected.
Most investigations that feel mysterious end at step 7. The detection stages are comparatively loud about what they do; the allow-list gate is the one that removes a valid tag without saying a word.