From c94c33790ae21552f7ef73033b9eab75d18dc6bf Mon Sep 17 00:00:00 2001 From: Etienne Zwiebel Date: Mon, 12 Oct 2026 16:26:17 +0200 Subject: [PATCH 1/2] #632: Allow multi-line full coverage tags in source files A full coverage tag whose line ends with a comma after a covered ID now continues on the following lines. Leading whitespace and punctuation comment markers (e.g. "--", "//", "#") are ignored on continuation lines, so covered IDs can be aligned: -- [SRC~ICM45686-CORE~1->CPR~ICM45686-0001~1, -- CPR~ICM45686-0002~1] The joined tag is imported like a single-line tag located at the opening line, with source ranges of covered IDs pointing to their actual lines. An invalid continuation line or a missing closing bracket fails the import instead of silently dropping coverage. Only the Tag importer enables multi-line tags; Markdown, RST and Gherkin comments keep single-line tags. Co-Authored-By: Claude Opus 5.5 (1M context) --- .agents/skills/openfasttrace/SKILL.md | 7 + doc/changes/changes_4.11.0.md | 1 + doc/spec/design.md | 35 ++++ doc/spec/system_requirements.md | 15 ++ .../reference/input_format_support.md | 26 +++ .../tag/common/AbstractRegexLineConsumer.java | 17 ++ .../tag/common/CoverageTagParser.java | 26 ++- .../common/LongTagImportingLineConsumer.java | 198 ++++++++++++++++-- .../TestLongTagImportingLineConsumer.java | 140 +++++++++++++ .../importer/tag/TagImporter.java | 2 +- .../importer/tag/TestTagImporter.java | 29 ++- 11 files changed, 471 insertions(+), 25 deletions(-) diff --git a/.agents/skills/openfasttrace/SKILL.md b/.agents/skills/openfasttrace/SKILL.md index 6ec32786..380916ee 100644 --- a/.agents/skills/openfasttrace/SKILL.md +++ b/.agents/skills/openfasttrace/SKILL.md @@ -74,6 +74,13 @@ Multiple coverage: // [dsn -> req~local-stability~1,arch~dimensional-input~1] ``` +Multi-line coverage (source files only; a trailing comma continues the tag, leading comment markers and whitespace are ignored): + +```Java +// [dsn~stability~1 -> req~local-stability~1, +// arch~dimensional-input~1] +``` + ## Tracing ## Syntax: Gherkin diff --git a/doc/changes/changes_4.11.0.md b/doc/changes/changes_4.11.0.md index 36bc0b7f..80c4061d 100644 --- a/doc/changes/changes_4.11.0.md +++ b/doc/changes/changes_4.11.0.md @@ -16,6 +16,7 @@ The user guide documentation now has automatic lateral navigation. * #610: Allow adding plugins via configuration using `Oft.builder().addPlugin(name, jars...)` * #612: Add support for Visual Basic in the source tag importer +* #632: Allow full coverage tags in source files to span multiple lines ## Bugfixes diff --git a/doc/spec/design.md b/doc/spec/design.md index 3a3c819a..63e2567f 100644 --- a/doc/spec/design.md +++ b/doc/spec/design.md @@ -1204,6 +1204,41 @@ Covers: Needs: impl, utest +#### Full Coverage Tag Format Spanning Multiple Lines +`dsn~import.full-coverage-tag-multi-line~1` + +The Tag importer imports full coverage tags whose list of covered IDs continues on following lines: + + multi-line-full-tag = opening-line *continuation-line closing-line + + opening-line = "[" *WSP reference *WSP "->" *WSP requirement-id + *(*WSP "," *WSP requirement-id) *WSP "," *WSP EOL + + continuation-line = *WSP [comment-marker *WSP] requirement-id + *(*WSP "," *WSP requirement-id) *WSP "," *WSP EOL + + closing-line = *WSP [comment-marker *WSP] requirement-id + *(*WSP "," *WSP requirement-id) *WSP + [">>" *WSP artifact-type *WSP *("," *WSP artifact-type) *WSP] "]" + + comment-marker = 1*(any character except letters, digits, whitespace, "[" and "]") + +A comma at the end of the opening line starts the multi-line tag. The importer removes the comment marker and whitespace at the start of each following line, joins the lines and imports the result like a single-line full coverage tag located at the opening line. + +If a following line is neither a continuation line nor a closing line, or if the file ends before the closing `]`, the import fails with an error naming the file and line. + +Markdown, RST and Gherkin comments only support single-line coverage tags. + +Rationale: + +Aligning covered IDs on separate lines keeps long coverage lists readable and reviewable. Requiring the trailing comma avoids changing how existing files with unclosed tag-like text are imported. Failing instead of ignoring a broken multi-line tag prevents silently losing coverage. + +Covers: + +* `req~import.full-coverage-tag-multi-line~1` + +Needs: impl, utest + #### Full Coverage Tag Format Allows Specifying a Revision `dsn~import.full-coverage-tag-with-revision~1` diff --git a/doc/spec/system_requirements.md b/doc/spec/system_requirements.md index 92e6371c..981e0c36 100644 --- a/doc/spec/system_requirements.md +++ b/doc/spec/system_requirements.md @@ -418,6 +418,21 @@ Covers: Needs: scn, dsn +##### Import Multi-line Full Coverage Tags +`req~import.full-coverage-tag-multi-line~1` + +OFT imports full coverage tags whose list of covered IDs spans multiple comment lines in source files. + +Rationale: + +Source and test artifacts often cover many specification items. Listing them on a single line makes the tag hard to read and review. Spreading the list over several aligned lines keeps the coverage tag maintainable. + +Covers: + +* [feat~coverage-tag-import~2](#coverage-tag-import) + +Needs: dsn + ##### Import Short Coverage Tag Format `req~import.short-coverage-tag-format~2` diff --git a/doc/user_guide/reference/input_format_support.md b/doc/user_guide/reference/input_format_support.md index e9ac39b5..831eb91c 100644 --- a/doc/user_guide/reference/input_format_support.md +++ b/doc/user_guide/reference/input_format_support.md @@ -61,6 +61,32 @@ Examples: // [impl~validate-password~2->dsn~validate-authentication-request~1] ``` +##### Covering Multiple Items + +A single tag can cover several specification items. Separate the covered IDs with commas: + +``` +[ -> , ... ] +``` + +##### Multi-line Tags + +When a tag covers many items, you can spread the list of covered IDs over multiple comment lines: + +```ada +-- [SRC~ICM45686-CORE~1->CPR~ICM45686-0001~1, +-- CPR~ICM45686-0002~1, +-- CPR~ICM45686-0010~1] +``` + +This is equivalent to writing all covered IDs on the first line. The item is located at the line where the tag starts. Needed coverage (`>> ...`) goes on the last line, before the closing bracket. + +> Note: Comment markers made of punctuation are supported (e.g. `--`, `//`, `#`, `*`, `'`, `;`, `%`, `!`). Comment markers made of letters, like Visual Basic's `REM`, are not. + +If a tag ends with a comma but the following line does not continue the list or close it with `]`, OFT reports an error with the file and line instead of silently ignoring the tag. + +Multi-line tags are only supported by the Tag Importer. Coverage tags in Markdown, RST and Gherkin comments must stay on a single line. + ##### Needed Coverage When using UML models as design document files like UML models it is useful to add needed coverage as well. To do this, you can use the following format: diff --git a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/AbstractRegexLineConsumer.java b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/AbstractRegexLineConsumer.java index da70364b..bdadb304 100644 --- a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/AbstractRegexLineConsumer.java +++ b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/AbstractRegexLineConsumer.java @@ -18,12 +18,29 @@ private AbstractRegexLineConsumer(final Pattern pattern) { @Override public void readLine(final int lineNumber, final String line) { + processMatches(lineNumber, line, 0); + } + + /** + * Process all matches in a line, starting at the given column. + * + * @param lineNumber + * line number, starting at {@code 1} + * @param line + * line content + * @param startColumn + * column where the search starts + * @return number of processed matches + */ + int processMatches(final int lineNumber, final String line, final int startColumn) { final Matcher matcher = this.pattern.matcher(line); + matcher.region(startColumn, line.length()); int counter = 0; while (matcher.find()) { processMatch(matcher, lineNumber, counter); counter++; } + return counter; } abstract void processMatch(Matcher matcher, int lineNumber, int lineMatchCount); diff --git a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/CoverageTagParser.java b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/CoverageTagParser.java index 34951b72..bc754d1a 100644 --- a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/CoverageTagParser.java +++ b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/CoverageTagParser.java @@ -29,8 +29,32 @@ private CoverageTagParser() { */ public static LineConsumer create(final PathConfig config, final InputFile file, final ImportEventListener listener) { + return create(config, file, listener, false); + } + + /** + * Create a line consumer for coverage tags in an input file that also + * imports full coverage tags spanning multiple lines. + * + * @param config + * optional configuration for short coverage tags + * @param file + * input file that contains the tags + * @param listener + * listener receiving imported specification items + * @return line consumer that imports single- and multi-line full coverage + * tags and configured short coverage tags + */ + // [impl->dsn~import.full-coverage-tag-multi-line~1] + public static LineConsumer createWithMultiLineTags(final PathConfig config, final InputFile file, + final ImportEventListener listener) { + return create(config, file, listener, true); + } + + private static LineConsumer create(final PathConfig config, final InputFile file, + final ImportEventListener listener, final boolean multiLineTags) { final List parsers = new ArrayList<>(); - parsers.add(new LongTagImportingLineConsumer(file, listener)); + parsers.add(new LongTagImportingLineConsumer(file, listener, multiLineTags)); if (config != null) { parsers.add(new ShortTagImportingLineConsumer(config, file, listener)); } diff --git a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/LongTagImportingLineConsumer.java b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/LongTagImportingLineConsumer.java index 0aeb24bc..e8324d07 100644 --- a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/LongTagImportingLineConsumer.java +++ b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/LongTagImportingLineConsumer.java @@ -6,9 +6,11 @@ import java.util.function.Predicate; import java.util.logging.Logger; import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.itsallcode.openfasttrace.api.core.*; import org.itsallcode.openfasttrace.api.importer.ImportEventListener; +import org.itsallcode.openfasttrace.api.importer.ImporterException; import org.itsallcode.openfasttrace.api.importer.input.InputFile; // [impl->dsn~import.full-coverage-tag~1] @@ -21,6 +23,7 @@ class LongTagImportingLineConsumer extends AbstractRegexLineConsumer private static final String CUSTOM_NAME_GROUP = "customName"; private static final String REVISION_GROUP = "revision"; private static final String COVERED_IDS_GROUP = "coveredIds"; + private static final String CONTENT_GROUP = "content"; private static final String COVERING_ARTIFACT_TYPE_PATTERN = "\\p{Alpha}+"; // [impl->dsn~import.full-coverage-tag-with-revision~1] private static final String OPTIONAL_WHITESPACE = "\\s*"; @@ -32,30 +35,125 @@ class LongTagImportingLineConsumer extends AbstractRegexLineConsumer private static final String NEEDS_COVERAGE = ">>" + OPTIONAL_WHITESPACE + "(?\\p{Alpha}+(?:" + OPTIONAL_WHITESPACE + "," + OPTIONAL_WHITESPACE + "\\p{Alpha}+)*)"; - private static final String TAG_REGEX = TAG_PREFIX + OPTIONAL_WHITESPACE + private static final String TAG_HEAD = TAG_PREFIX + OPTIONAL_WHITESPACE + "(?<" + ARTIFACT_TYPE_GROUP + ">" + COVERING_ARTIFACT_TYPE_PATTERN + ")" + "(?:" + SpecificationItemId.ARTIFACT_TYPE_SEPARATOR // [impl->dsn~import.full-coverage-tag-with-name-and-revision~1] + "(?<" + CUSTOM_NAME_GROUP + ">" + SpecificationItemId.ITEM_NAME_PATTERN + ")?" + SpecificationItemId.REVISION_SEPARATOR + "(?<" + REVISION_GROUP + ">" + SpecificationItemId.ITEM_REVISION_PATTERN + "))?" - + OPTIONAL_WHITESPACE + "->" + OPTIONAL_WHITESPACE - + "(?<" + COVERED_IDS_GROUP + ">" + COVERED_IDS + ")" - + OPTIONAL_WHITESPACE + "(?:" + NEEDS_COVERAGE + OPTIONAL_WHITESPACE + ")?" + + OPTIONAL_WHITESPACE + "->" + OPTIONAL_WHITESPACE; + private static final String TAG_TAIL = OPTIONAL_WHITESPACE + "(?:" + NEEDS_COVERAGE + OPTIONAL_WHITESPACE + ")?" + TAG_SUFFIX; + private static final String TAG_REGEX = TAG_HEAD + + "(?<" + COVERED_IDS_GROUP + ">" + COVERED_IDS + ")" + + TAG_TAIL; + private static final Pattern TAG_PATTERN = Pattern.compile(TAG_REGEX); + private static final String LINE_CONTINUATION = OPTIONAL_WHITESPACE + "," + OPTIONAL_WHITESPACE + "$"; + private static final String COMMENT_MARKER = "^" + OPTIONAL_WHITESPACE + "(?:[^\\p{Alnum}\\s\\[\\]]+" + + OPTIONAL_WHITESPACE + ")?"; + private static final Pattern OPENING_LINE_PATTERN = Pattern.compile(TAG_HEAD + COVERED_IDS + LINE_CONTINUATION); + private static final Pattern CONTINUATION_LINE_PATTERN = Pattern + .compile(COMMENT_MARKER + "(?<" + CONTENT_GROUP + ">" + COVERED_IDS + LINE_CONTINUATION + ")"); + private static final Pattern CLOSING_LINE_PATTERN = Pattern + .compile(COMMENT_MARKER + "(?<" + CONTENT_GROUP + ">" + COVERED_IDS + TAG_TAIL + ")"); private final InputFile file; private final ImportEventListener listener; + private final boolean multiLineTags; + private MultiLineTag openTag; LongTagImportingLineConsumer(final InputFile file, final ImportEventListener listener) + { + this(file, listener, false); + } + + LongTagImportingLineConsumer(final InputFile file, final ImportEventListener listener, + final boolean multiLineTags) { super(TAG_REGEX); this.file = file; this.listener = listener; + this.multiLineTags = multiLineTags; + } + + @Override + public void readLine(final int lineNumber, final String line) + { + if (!this.multiLineTags) + { + super.readLine(lineNumber, line); + } + else if (this.openTag == null) + { + readLineOutsideOfTag(lineNumber, line, 0); + } + else + { + continueMultiLineTag(lineNumber, line); + } + } + + // [impl->dsn~import.full-coverage-tag-multi-line~1] + private void readLineOutsideOfTag(final int lineNumber, final String line, final int startColumn) + { + final int lineMatchCount = processMatches(lineNumber, line, startColumn); + final Matcher matcher = OPENING_LINE_PATTERN.matcher(line); + matcher.region(startColumn, line.length()); + if (matcher.find()) + { + this.openTag = new MultiLineTag(lineNumber, lineMatchCount); + this.openTag.append(lineNumber, matcher.start(), matcher.group()); + } + } + + // [impl->dsn~import.full-coverage-tag-multi-line~1] + private void continueMultiLineTag(final int lineNumber, final String line) + { + final Matcher continuation = CONTINUATION_LINE_PATTERN.matcher(line); + if (continuation.find()) + { + this.openTag.append(lineNumber, continuation.start(CONTENT_GROUP), continuation.group(CONTENT_GROUP)); + return; + } + final Matcher closing = CLOSING_LINE_PATTERN.matcher(line); + if (!closing.lookingAt()) + { + throw new ImporterException("Multi-line coverage tag started at line " + this.openTag.lineNumber + + " is not continued with a comma-separated list of specification item IDs " + + "or terminated with ']'."); + } + final MultiLineTag closedTag = this.openTag; + this.openTag = null; + closedTag.append(lineNumber, closing.start(CONTENT_GROUP), closing.group(CONTENT_GROUP)); + final Matcher tag = TAG_PATTERN.matcher(closedTag.text); + if (!tag.matches()) + { + throw new IllegalStateException("Joined multi-line coverage tag '" + closedTag.text + + "' does not match the coverage tag format."); + } + importTag(tag, closedTag.lineNumber, closedTag.lineMatchCount, closedTag::positionOf); + readLineOutsideOfTag(lineNumber, line, closing.end()); + } + + @Override + public void finish() + { + if (this.openTag != null) + { + throw new ImporterException("Multi-line coverage tag started at line " + this.openTag.lineNumber + + " is not terminated with ']'."); + } } @Override public void processMatch(final Matcher matcher, final int lineNumber, final int lineMatchCount) + { + importTag(matcher, lineNumber, lineMatchCount, column -> new SourcePosition(lineNumber - 1, column)); + } + + private void importTag(final Matcher matcher, final int lineNumber, final int lineMatchCount, + final PositionMapper positions) { final List coveredIds = parseCoveredIds(matcher.group(COVERED_IDS_GROUP)); final List neededArtifactTypes = parseNeededArtifactTypes(matcher.group("neededArtifactTypes")); @@ -67,37 +165,38 @@ public void processMatch(final Matcher matcher, final int lineNumber, final int assert generatedIds.size() == coveredIds.size(); for (int i = 0; i < generatedIds.size(); i++) { - addSpecificationItem(lineNumber, matcher, generatedIds.get(i), List.of(coveredIds.get(i)), + addSpecificationItem(lineNumber, matcher, positions, generatedIds.get(i), List.of(coveredIds.get(i)), neededArtifactTypes); } } else { - addSpecificationItem(lineNumber, matcher, generatedIds.get(0), coveredIds, neededArtifactTypes); + addSpecificationItem(lineNumber, matcher, positions, generatedIds.get(0), coveredIds, + neededArtifactTypes); } } - private void addSpecificationItem(final int lineNumber, final Matcher matcher, + private void addSpecificationItem(final int lineNumber, final Matcher matcher, final PositionMapper positions, final SpecificationItemId generatedId, final List coveredIds, final List neededArtifactTypes) { final SpecificationItem.Builder item = SpecificationItem.builder() - .id(locatedGeneratedId(lineNumber, matcher, generatedId)) + .id(locatedGeneratedId(matcher, positions, generatedId)) .location(this.file.getPath(), lineNumber); int searchStart = 0; for (final SpecificationItemId coveredId : coveredIds) { final int start = matcher.group(COVERED_IDS_GROUP).indexOf(coveredId.toString(), searchStart); searchStart = start + coveredId.toString().length(); - item.addCoveredId(locatedId(lineNumber, matcher.start(COVERED_IDS_GROUP) + start, coveredId)); + item.addCoveredId(locatedId(positions, matcher.start(COVERED_IDS_GROUP) + start, coveredId)); } neededArtifactTypes.forEach(item::addNeedsArtifactType); this.listener.addSpecificationItem(item.build()); logItem(lineNumber, coveredIds, neededArtifactTypes, generatedId); } - private static LocatedSpecificationItemId locatedGeneratedId(final int lineNumber, final Matcher matcher, - final SpecificationItemId id) + private static LocatedSpecificationItemId locatedGeneratedId(final Matcher matcher, + final PositionMapper positions, final SpecificationItemId id) { // [impl->dsn~located-specification-item-id-tag-ranges~1] if (matcher.group(CUSTOM_NAME_GROUP) == null) @@ -106,28 +205,28 @@ private static LocatedSpecificationItemId locatedGeneratedId(final int lineNumbe } final int start = matcher.start(ARTIFACT_TYPE_GROUP); final int end = matcher.end(REVISION_GROUP); - return LocatedSpecificationItemId.builder().id(id).range(sourceRange(lineNumber, start, end)) - .artifactTypeRange(sourceRange(lineNumber, start, matcher.end(ARTIFACT_TYPE_GROUP))) - .nameRange(sourceRange(lineNumber, matcher.start(CUSTOM_NAME_GROUP), matcher.end(CUSTOM_NAME_GROUP))) - .revisionRange(sourceRange(lineNumber, matcher.start(REVISION_GROUP), matcher.end(REVISION_GROUP))) + return LocatedSpecificationItemId.builder().id(id).range(sourceRange(positions, start, end)) + .artifactTypeRange(sourceRange(positions, start, matcher.end(ARTIFACT_TYPE_GROUP))) + .nameRange(sourceRange(positions, matcher.start(CUSTOM_NAME_GROUP), matcher.end(CUSTOM_NAME_GROUP))) + .revisionRange(sourceRange(positions, matcher.start(REVISION_GROUP), matcher.end(REVISION_GROUP))) .build(); } - private static LocatedSpecificationItemId locatedId(final int lineNumber, final int start, + private static LocatedSpecificationItemId locatedId(final PositionMapper positions, final int start, final SpecificationItemId id) { final String text = id.toString(); final int typeEnd = text.indexOf('~'); final int revisionStart = text.lastIndexOf('~') + 1; - return LocatedSpecificationItemId.builder().id(id).range(sourceRange(lineNumber, start, start + text.length())) - .artifactTypeRange(sourceRange(lineNumber, start, start + typeEnd)) - .nameRange(sourceRange(lineNumber, start + typeEnd + 1, start + revisionStart - 1)) - .revisionRange(sourceRange(lineNumber, start + revisionStart, start + text.length())).build(); + return LocatedSpecificationItemId.builder().id(id).range(sourceRange(positions, start, start + text.length())) + .artifactTypeRange(sourceRange(positions, start, start + typeEnd)) + .nameRange(sourceRange(positions, start + typeEnd + 1, start + revisionStart - 1)) + .revisionRange(sourceRange(positions, start + revisionStart, start + text.length())).build(); } - private static SourceRange sourceRange(final int lineNumber, final int start, final int end) + private static SourceRange sourceRange(final PositionMapper positions, final int start, final int end) { - return new SourceRange(new SourcePosition(lineNumber - 1, start), new SourcePosition(lineNumber - 1, end)); + return new SourceRange(positions.positionOf(start), positions.positionOf(end)); } private static List parseCoveredIds(final String input) @@ -213,4 +312,59 @@ private String generateUniqueName(final SpecificationItemId coveredId, final int final String checksum = Long.toString(ChecksumCalculator.calculateCrc32(uniqueName)); return coveredId.getName() + "-" + checksum; } + + /** + * Maps an offset in the matched tag text to a position in the source file. + */ + @FunctionalInterface + private interface PositionMapper + { + SourcePosition positionOf(int offset); + } + + /** + * Coverage tag spanning multiple lines, joined into a single line of text. + */ + // [impl->dsn~import.full-coverage-tag-multi-line~1] + private static final class MultiLineTag + { + private final int lineNumber; + private final int lineMatchCount; + private final List segments = new ArrayList<>(); + private String text = ""; + + private MultiLineTag(final int lineNumber, final int lineMatchCount) + { + this.lineNumber = lineNumber; + this.lineMatchCount = lineMatchCount; + } + + private void append(final int segmentLineNumber, final int column, final String segmentText) + { + if (!this.text.isEmpty()) + { + this.text += " "; + } + this.segments.add(new Segment(this.text.length(), segmentLineNumber, column)); + this.text += segmentText; + } + + private SourcePosition positionOf(final int offset) + { + Segment containingSegment = this.segments.get(0); + for (final Segment segment : this.segments) + { + if (segment.offset() <= offset) + { + containingSegment = segment; + } + } + return new SourcePosition(containingSegment.lineNumber() - 1, + containingSegment.column() + offset - containingSegment.offset()); + } + + private record Segment(int offset, int lineNumber, int column) + { + } + } } diff --git a/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestLongTagImportingLineConsumer.java b/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestLongTagImportingLineConsumer.java index 43f25933..9f4af14e 100644 --- a/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestLongTagImportingLineConsumer.java +++ b/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestLongTagImportingLineConsumer.java @@ -3,6 +3,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.BufferedReader; import java.io.StringReader; @@ -11,6 +12,7 @@ import java.util.stream.Stream; import org.itsallcode.openfasttrace.api.core.*; +import org.itsallcode.openfasttrace.api.importer.ImporterException; import org.itsallcode.openfasttrace.api.importer.SpecificationListBuilder; import org.itsallcode.openfasttrace.api.importer.input.InputFile; import org.itsallcode.openfasttrace.testutil.importer.input.StreamInput; @@ -18,6 +20,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; class TestLongTagImportingLineConsumer { @@ -90,6 +93,143 @@ void importsLocatedLongTagIdWithGeneratedName() () -> assertThat(item.getLocatedCoveredIds().get(0).getRange(), is(range(2, 12, 25)))); } + // [utest->dsn~import.full-coverage-tag-multi-line~1] + @ParameterizedTest + @ValueSource(strings = { "--", "//", "#", " *", "'", ";", "%", "!", "" }) + void importsMultiLineTagWithCommentMarker(final String marker) + { + final List items = importMultiLine( + marker + " [" + "impl~tag~1->dsn~first~1,", + marker + " dsn~second~2,", + marker + " dsn~third~3" + "]"); + + assertThat(items, hasSize(1)); + final SpecificationItem item = items.get(0); + assertAll( + () -> assertThat(item.getId(), equalTo(SpecificationItemId.parseId("impl~tag~1"))), + () -> assertThat(item.getCoveredIds(), contains(SpecificationItemId.parseId("dsn~first~1"), + SpecificationItemId.parseId("dsn~second~2"), SpecificationItemId.parseId("dsn~third~3"))), + () -> assertThat(item.getLocation().getLine(), equalTo(1))); + } + + // [utest->dsn~import.full-coverage-tag-multi-line~1] + @Test + void importsMultiLineTagWithNeededCoverageOnLastLine() + { + final List items = importMultiLine( + "-- [" + "impl~tag~1 -> dsn~first~2,", + "-- dsn~second~3 >> utest, itest" + "]"); + + assertThat(items, hasSize(1)); + assertAll( + () -> assertThat(items.get(0).getCoveredIds(), contains(SpecificationItemId.parseId("dsn~first~2"), + SpecificationItemId.parseId("dsn~second~3"))), + () -> assertThat(items.get(0).getNeedsArtifactTypes(), contains("utest", "itest"))); + } + + // [utest->dsn~import.full-coverage-tag-multi-line~1] + @Test + void importsUnnamedMultiLineTagAsOneItemPerCoveredId() + { + final List items = importMultiLine( + "// [" + "impl -> dsn~first~2,", + "// dsn~second~3 >> utest" + "]"); + + assertThat(items.stream().map(SpecificationItem::getId).toList(), + contains(SpecificationItemId.parseId("impl~first~0"), SpecificationItemId.parseId("impl~second~0"))); + } + + // [utest->dsn~import.full-coverage-tag-multi-line~1] + // [utest->dsn~located-specification-item-id-tag-ranges~1] + @Test + void importsLocatedMultiLineTagIds() + { + final String secondLine = "// dsn~second~3" + "]"; + final List items = importMultiLine( + "// [" + "impl~tag~1 -> dsn~first~2,", + secondLine); + + final SpecificationItem item = items.get(0); + final int secondStart = secondLine.indexOf("dsn~second~3"); + assertAll( + () -> assertThat(item.getLocatedId().getRange(), is(range(0, 4, 14))), + () -> assertThat(item.getLocatedCoveredIds().get(0).getRange(), is(range(0, 18, 29))), + () -> assertThat(item.getLocatedCoveredIds().get(1).getRange(), + is(range(1, secondStart, secondStart + 12)))); + } + + // [utest->dsn~import.full-coverage-tag-multi-line~1] + @Test + void importsSingleLineTagsAroundMultiLineTag() + { + final List items = importMultiLine( + "[" + "impl->dsn~before~1] [" + "impl~tag~1->dsn~first~1,", + "dsn~second~1] [" + "impl~after~1->dsn~after~1" + "]"); + + assertThat(items, hasSize(3)); + assertAll( + () -> assertThat(items.get(0).getCoveredIds(), contains(SpecificationItemId.parseId("dsn~before~1"))), + () -> assertThat(items.get(1).getId(), equalTo(SpecificationItemId.parseId("impl~tag~1"))), + () -> assertThat(items.get(2).getId(), equalTo(SpecificationItemId.parseId("impl~after~1"))), + () -> assertThat(items.get(2).getLocation().getLine(), equalTo(2))); + } + + // [utest->dsn~import.full-coverage-tag-multi-line~1] + @Test + void rejectsInvalidContinuationLine() + { + final LongTagImportingLineConsumer consumer = multiLineConsumer(SpecificationListBuilder.create()); + consumer.readLine(1, "-- [" + "impl~tag~1->dsn~first~1,"); + final ImporterException exception = assertThrows(ImporterException.class, + () -> consumer.readLine(2, "-- not an ID")); + assertThat(exception.getMessage(), containsString("started at line 1")); + } + + // [utest->dsn~import.full-coverage-tag-multi-line~1] + @Test + void rejectsUnterminatedMultiLineTagAtEndOfFile() + { + final LongTagImportingLineConsumer consumer = multiLineConsumer(SpecificationListBuilder.create()); + consumer.readLine(1, "-- [" + "impl~tag~1->dsn~first~1,"); + consumer.readLine(2, "-- dsn~second~1,"); + final ImporterException exception = assertThrows(ImporterException.class, consumer::finish); + assertThat(exception.getMessage(), containsString("not terminated")); + } + + @Test + void ignoresUnclosedTagWithoutTrailingComma() + { + assertThat(importMultiLine("-- [" + "impl~tag~1->dsn~first~1", "-- dsn~second~1" + "]"), empty()); + } + + @Test + void ignoresMultiLineTagWhenDisabled() + { + final SpecificationListBuilder listener = SpecificationListBuilder.create(); + final LongTagImportingLineConsumer consumer = new LongTagImportingLineConsumer(inputFile(), listener); + consumer.readLine(1, "-- [" + "impl~tag~1->dsn~first~1,"); + consumer.readLine(2, "-- dsn~second~1" + "]"); + consumer.finish(); + assertThat(listener.build(), empty()); + } + + private static List importMultiLine(final String... lines) + { + final SpecificationListBuilder listener = SpecificationListBuilder.create(); + final LongTagImportingLineConsumer consumer = multiLineConsumer(listener); + for (int i = 0; i < lines.length; i++) + { + consumer.readLine(i + 1, lines[i]); + } + consumer.finish(); + return listener.build(); + } + + private static LongTagImportingLineConsumer multiLineConsumer(final SpecificationListBuilder listener) + { + return new LongTagImportingLineConsumer(inputFile(), listener, true); + } + private static SourceRange range(final int line, final int start, final int end) { return new SourceRange(new SourcePosition(line, start), new SourcePosition(line, end)); diff --git a/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/TagImporter.java b/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/TagImporter.java index 264d9757..0d0fa426 100644 --- a/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/TagImporter.java +++ b/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/TagImporter.java @@ -26,7 +26,7 @@ class TagImporter implements Importer static TagImporter create(final PathConfig config, final InputFile file, final ImportEventListener listener) { - return new TagImporter(CoverageTagParser.create(config, file, listener), file); + return new TagImporter(CoverageTagParser.createWithMultiLineTags(config, file, listener), file); } @Override diff --git a/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporter.java b/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporter.java index 3cff0181..76a875bb 100644 --- a/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporter.java +++ b/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporter.java @@ -2,7 +2,9 @@ import static java.util.Collections.emptyList; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.BufferedReader; import java.io.StringReader; @@ -16,10 +18,12 @@ import org.itsallcode.openfasttrace.api.core.SpecificationItem.Builder; import org.itsallcode.openfasttrace.api.core.SpecificationItemId; import org.itsallcode.openfasttrace.api.importer.ImporterContext; +import org.itsallcode.openfasttrace.api.importer.ImporterException; import org.itsallcode.openfasttrace.api.importer.SpecificationListBuilder; import org.itsallcode.openfasttrace.api.importer.input.InputFile; import org.itsallcode.openfasttrace.testutil.core.ItemBuilderFactory; import org.itsallcode.openfasttrace.testutil.importer.input.StreamInput; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -228,7 +232,30 @@ static Stream tagImporterTests() noItemDetected("[impl~missing-forward~1->dsn~name2~2>>]"), noItemDetected("[impl~trailing-comma~1->dsn~name2~2>>test,]"), noItemDetected("[impl~duplicate-comma~1->dsn~name2~2>>test,,other]"), - noItemDetected("[impl~name1~1->dsn~name2~2>>test,other,]")); + noItemDetected("[impl~name1~1->dsn~name2~2>>test,other,]"), + // [utest->dsn~import.full-coverage-tag-multi-line~1] + parsedItem("[impl~component-core~1->llr~component-0001~1," + UNIX_NEWLINE + + " llr~component-0002~1," + UNIX_NEWLINE + + " llr~component-0010~1," + UNIX_NEWLINE + + " llr~other_component-0999~1]" + UNIX_NEWLINE + + "begining of the source code" + UNIX_NEWLINE + + "that can be anything really", + itemBuilder().id(SpecificationItemId.parseId("impl~component-core~1")) + .addCoveredId(SpecificationItemId.parseId("llr~component-0001~1")) + .addCoveredId(SpecificationItemId.parseId("llr~component-0002~1")) + .addCoveredId(SpecificationItemId.parseId("llr~component-0010~1")) + .addCoveredId(SpecificationItemId.parseId("llr~other_component-0999~1"))), + parsedItem("[impl~component-core~1->llr~component-1000~1," + UNIX_NEWLINE + + " llr~component-5896~1," + UNIX_NEWLINE + + " llr~component-1~1,llr~other_component~1]" + UNIX_NEWLINE + + "begining of the source code" + UNIX_NEWLINE + + "that can be anything really", + itemBuilder().id(SpecificationItemId.parseId("impl~component-core~1")) + .addCoveredId(SpecificationItemId.parseId("llr~component-1000~1")) + .addCoveredId(SpecificationItemId.parseId("llr~component-5896~1")) + .addCoveredId(SpecificationItemId.parseId("llr~component-1~1")) + .addCoveredId(SpecificationItemId.parseId("llr~other_component~1"))), + noItemDetected("[" + "impl~name1~1->dsn~name2~2" + UNIX_NEWLINE + "dsn~name3~3" + "]")); } private static SpecificationItem.Builder itemACoveringB(final String id, final String coveredId) From 5132d6db1fdab9b7a9d08d607298117a4aad1f9a Mon Sep 17 00:00:00 2001 From: Etienne Zwiebel Date: Mon, 12 Oct 2026 16:27:58 +0200 Subject: [PATCH 2/2] #632: Allow multi-line coverage tags in Gherkin comments The Gherkin importer now uses the multi-line coverage-tag parser for its comment lines, so tests in .feature files can cover several requirements with an aligned list: # [TST~ICM45686-CORE-TEST~1 -> CPR~ICM45686-0001~1, # CPR~ICM45686-0002~1] Executable and blank lines are still never scanned for coverage tags. If such a line interrupts an open multi-line tag, the import fails with the file and line instead of continuing the tag across Gherkin text. Co-Authored-By: Claude Opus 5.5 (1M context) --- .agents/skills/openfasttrace/SKILL.md | 2 +- doc/changes/changes_4.11.0.md | 2 +- doc/spec/design.md | 18 ++++++++- doc/spec/system_requirements.md | 2 +- .../reference/input_format_support.md | 11 +++++- .../importer/gherkin/GherkinLineConsumer.java | 23 +++++++++--- .../importer/gherkin/GherkinImporterTest.java | 37 +++++++++++++++++++ 7 files changed, 84 insertions(+), 11 deletions(-) diff --git a/.agents/skills/openfasttrace/SKILL.md b/.agents/skills/openfasttrace/SKILL.md index 380916ee..5b82e75b 100644 --- a/.agents/skills/openfasttrace/SKILL.md +++ b/.agents/skills/openfasttrace/SKILL.md @@ -74,7 +74,7 @@ Multiple coverage: // [dsn -> req~local-stability~1,arch~dimensional-input~1] ``` -Multi-line coverage (source files only; a trailing comma continues the tag, leading comment markers and whitespace are ignored): +Multi-line coverage (source files and Gherkin comments; a trailing comma continues the tag, leading comment markers and whitespace are ignored): ```Java // [dsn~stability~1 -> req~local-stability~1, diff --git a/doc/changes/changes_4.11.0.md b/doc/changes/changes_4.11.0.md index 80c4061d..cba6c412 100644 --- a/doc/changes/changes_4.11.0.md +++ b/doc/changes/changes_4.11.0.md @@ -16,7 +16,7 @@ The user guide documentation now has automatic lateral navigation. * #610: Allow adding plugins via configuration using `Oft.builder().addPlugin(name, jars...)` * #612: Add support for Visual Basic in the source tag importer -* #632: Allow full coverage tags in source files to span multiple lines +* #632: Allow full coverage tags in source files and Gherkin comments to span multiple lines ## Bugfixes diff --git a/doc/spec/design.md b/doc/spec/design.md index 63e2567f..7ba4fc29 100644 --- a/doc/spec/design.md +++ b/doc/spec/design.md @@ -385,6 +385,20 @@ Covers: Needs: impl, utest +#### Support Multi-line Coverage Tags in Comments +`dsn~gherkin.multi-line-comment-coverage-tags~1` + +The Gherkin importer imports full coverage tags that span consecutive comment lines, using the shared multi-line coverage-tag parser (see `dsn~import.full-coverage-tag-multi-line~1`). + +An executable or blank line ends the comment block. If it occurs while a multi-line coverage tag is still open, the import fails with an error naming the file and line, because the tag would otherwise continue across executable Gherkin text. + +Covers: + +* `req~import.full-coverage-tag-multi-line~1` +* `req~gherkin-comment-coverage-tags~1` + +Needs: impl, utest + ### ReqM2 File Detection `dsn~import.reqm2-file-detection~1` @@ -1207,7 +1221,7 @@ Needs: impl, utest #### Full Coverage Tag Format Spanning Multiple Lines `dsn~import.full-coverage-tag-multi-line~1` -The Tag importer imports full coverage tags whose list of covered IDs continues on following lines: +The Tag importer and the Gherkin importer import full coverage tags whose list of covered IDs continues on following lines: multi-line-full-tag = opening-line *continuation-line closing-line @@ -1227,7 +1241,7 @@ A comma at the end of the opening line starts the multi-line tag. The importer r If a following line is neither a continuation line nor a closing line, or if the file ends before the closing `]`, the import fails with an error naming the file and line. -Markdown, RST and Gherkin comments only support single-line coverage tags. +Markdown and RST comments only support single-line coverage tags. Rationale: diff --git a/doc/spec/system_requirements.md b/doc/spec/system_requirements.md index 981e0c36..f93c165f 100644 --- a/doc/spec/system_requirements.md +++ b/doc/spec/system_requirements.md @@ -421,7 +421,7 @@ Needs: scn, dsn ##### Import Multi-line Full Coverage Tags `req~import.full-coverage-tag-multi-line~1` -OFT imports full coverage tags whose list of covered IDs spans multiple comment lines in source files. +OFT imports full coverage tags whose list of covered IDs spans multiple comment lines in source files and Gherkin feature files. Rationale: diff --git a/doc/user_guide/reference/input_format_support.md b/doc/user_guide/reference/input_format_support.md index 831eb91c..e9fcf6ea 100644 --- a/doc/user_guide/reference/input_format_support.md +++ b/doc/user_guide/reference/input_format_support.md @@ -85,7 +85,7 @@ This is equivalent to writing all covered IDs on the first line. The item is loc If a tag ends with a comma but the following line does not continue the list or close it with `]`, OFT reports an error with the file and line instead of silently ignoring the tag. -Multi-line tags are only supported by the Tag Importer. Coverage tags in Markdown, RST and Gherkin comments must stay on a single line. +Multi-line tags are supported by the Tag Importer and in Gherkin comments. Coverage tags in Markdown and RST comments must stay on a single line. ##### Needed Coverage @@ -177,6 +177,15 @@ The ID tag becomes the item location and the scenario header becomes its title; Existing full coverage tags remain supported in Gherkin comments, for example `# [impl~login~1 -> dsn~authentication~1]`. OFT deliberately ignores coverage-tag-shaped text in executable Gherkin lines. +A comment coverage tag can span several consecutive comment lines (see [Multi-line Tags](#multi-line-tags)): + +```gherkin +# [tst~login-test~1 -> req~authentication~1, +# req~session-timeout~2] +``` + +An executable or blank line inside such a tag is reported as an error. + #### Markdown The main importer of OFT accepts Markdown files with the extensions `.md` and `.markdown`. diff --git a/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java index 5bf5ee4b..7f8721d5 100644 --- a/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java +++ b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java @@ -47,16 +47,13 @@ final class GherkinLineConsumer implements LineConsumer { this.file = file; this.listener = listener; - this.coverageTagParser = CoverageTagParser.create(null, file, listener); + this.coverageTagParser = CoverageTagParser.createWithMultiLineTags(null, file, listener); } @Override public void readLine(final int lineNumber, final String line) { - if (line.trim().startsWith("#")) - { - this.coverageTagParser.readLine(lineNumber, line); - } + readCoverageTags(lineNumber, line); final Matcher scenario = SCENARIO.matcher(line); if (scenario.matches()) { @@ -92,9 +89,25 @@ public void readLine(final int lineNumber, final String line) readMetadata(lineNumber, line); } + // [impl->dsn~gherkin.multi-line-comment-coverage-tags~1] + private void readCoverageTags(final int lineNumber, final String line) + { + if (line.trim().startsWith("#")) + { + this.coverageTagParser.readLine(lineNumber, line); + } + else + { + // Executable lines are never scanned for coverage tags. Passing an empty line instead lets a + // multi-line coverage tag that is still open fail instead of continuing across executable lines. + this.coverageTagParser.readLine(lineNumber, ""); + } + } + @Override public void finish() { + this.coverageTagParser.finish(); endScenario(); } diff --git a/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java b/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java index 7e068126..7950237b 100644 --- a/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java +++ b/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java @@ -3,6 +3,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -15,6 +16,7 @@ import org.itsallcode.openfasttrace.api.core.*; import org.itsallcode.openfasttrace.api.importer.ImportEventListener; +import org.itsallcode.openfasttrace.api.importer.ImporterException; import org.itsallcode.openfasttrace.api.importer.input.InputFile; import org.itsallcode.openfasttrace.testutil.importer.ImportAssertions; import org.itsallcode.openfasttrace.testutil.importer.input.StreamInput; @@ -365,6 +367,41 @@ void testImportsCommentCoverageTagsWhileScenarioIsOpen() events.verify(listener).endSpecificationItem(); } + // [utest->dsn~gherkin.multi-line-comment-coverage-tags~1] + @Test + void testImportsMultiLineCommentCoverageTag() + { + final String source = """ + # [%s, + # req~second~2, + # req~third~3] + @id:scn~ordinary~1 + Scenario: ordinary + Given a step + """.formatted("tst~login-test~1 -> req~first~1"); + final List items = importText(source); + + assertThat(items, containsInAnyOrder( + hasProperty("id", hasToString("scn~ordinary~1")), + allOf(hasProperty("id", hasToString("tst~login-test~1")), + hasProperty("coveredIds", contains(hasToString("req~first~1"), hasToString("req~second~2"), + hasToString("req~third~3")))))); + } + + // [utest->dsn~gherkin.multi-line-comment-coverage-tags~1] + @Test + void testRejectsMultiLineCommentCoverageTagInterruptedByExecutableLine() + { + final String source = """ + # [%s, + Scenario: ordinary + # req~second~2] + """.formatted("tst~login-test~1 -> req~first~1"); + + final ImporterException exception = assertThrows(ImporterException.class, () -> importText(source)); + assertThat(exception.getMessage(), containsString("specification.feature:2")); + } + private static List importText(final String source) { return ImportAssertions.runImporterOnText(Path.of("specification.feature"), source, FACTORY);