From 597e7f7a018cc8af6b4a13756ae1f74d7fd87434 Mon Sep 17 00:00:00 2001 From: Iwan Eising Date: Wed, 5 Aug 2026 18:04:27 +0400 Subject: [PATCH 1/2] feat(gherkin-to-asciidoc)!: add forceRewrite to skip renumbering already-numbered lines Adds a forceRewrite DSL property (default false) and matching -PgherkinToAsciidoc.forceRewrite CLI override. When false, a Feature/Scenario line whose existing number already matches the format the currently configured indexing value would itself produce is left completely untouched; only lines not yet correctly numbered for that mode are numbered. A newly added feature file that sorts alphabetically before already-numbered files is given the next number not already in use, rather than bumping every file after it. A number left over from a *different* indexing value still doesn't "reflect" the current one, so it's stripped and replaced as before - e.g. switching from SCENARIO to ALL renumbers old single-integer scenario numbers to the new . format. OFF still always strips every number regardless of forceRewrite, since OFF's canonical state for every line is unnumbered. Rewrites FeatureIndexer around a two-phase parse-then-resolve model (collect every Feature/Scenario line first, then resolve numbers - pinned or freshly assigned - before rewriting any file) to support this; forceRewrite = true restores the previous single-pass strip-then-renumber-everything behaviour unchanged. BREAKING CHANGE: indexing's default numbering behaviour changes. Previously every generateFeatureDocs run fully renumbered every Feature/Scenario from scratch; by default it now preserves numbers that already match the current indexing value's format instead. Set forceRewrite = true (or -PgherkinToAsciidoc.forceRewrite=true) to keep the old always-renumber-everything behaviour. Co-Authored-By: Claude Sonnet 5 --- .../gherkin-to-asciidoc/indexing/README.adoc | 56 ++++- .../indexing/gradle/libs.versions.toml | 2 +- gherkin-to-asciidoc/README.adoc | 89 ++++++- .../gherkin/GenerateFeatureDocsTask.java | 13 +- .../gherkin/GherkinToAsciidocExtension.java | 59 ++++- .../gherkin/GherkinToAsciidocPlugin.java | 44 +++- .../gherkin/indexing/FeatureIndexer.java | 222 ++++++++++++++---- ...erkinToAsciidocMultiProjectPluginTest.java | 21 ++ .../gherkin/GherkinToAsciidocPluginTest.java | 131 +++++++++++ .../gherkin/indexing/FeatureIndexerTest.java | 145 ++++++++++-- 10 files changed, 683 insertions(+), 99 deletions(-) diff --git a/examples/gherkin-to-asciidoc/indexing/README.adoc b/examples/gherkin-to-asciidoc/indexing/README.adoc index 43f1631..cfec3a4 100644 --- a/examples/gherkin-to-asciidoc/indexing/README.adoc +++ b/examples/gherkin-to-asciidoc/indexing/README.adoc @@ -24,11 +24,17 @@ This example shows: * That indexing rewrites the source `.feature` files in place, and that this is reflected automatically in the generated report, since the report is generated *from* the (now numbered) source files. -* That running the build a second time is a no-op: indexing strips any numbering left over from a - previous run before reapplying it, so already-correctly-numbered files are left untouched. -* That the `-PgherkinToAsciidoc.indexing=` command-line override forces the same `indexing` - value onto every sub-project at once, regardless of what each one's own `build.gradle` configures - - typically used to force `ci` for the whole build in a CI pipeline. +* That running the build a second time is a no-op: with the default `forceRewrite = false`, a line + whose number already reflects the currently configured `indexing` value is left completely untouched. +* That a new feature file added later, even one that sorts alphabetically *before* already-numbered + files, does *not* renumber them - it's given the next number not already in use instead, so + existing numbers stay stable across runs. Setting `forceRewrite = true` (or the equivalent + `-PgherkinToAsciidoc.forceRewrite=true` override) instead recomputes every number from scratch on + every run, the way `indexing` always behaved before `forceRewrite` existed. +* That the `-PgherkinToAsciidoc.indexing=` and `-PgherkinToAsciidoc.forceRewrite=` + command-line overrides each force the same value onto every sub-project at once, regardless of what + each one's own `build.gradle` configures - `indexing` typically used to force `ci` for the whole + build in a CI pipeline. == Project Layout @@ -166,6 +172,35 @@ each project's pristine, un-numbered starting content) land under each sub-proje Run the plain `./gradlew generateFeatureDocs` afterwards to see `off/`, `feature/`, `scenario/`, and `all/` get numbered as normal - proving the override only affects the run it's passed to. +=== Not renumbering already-numbered files (`forceRewrite`) + +Starting from a clean checkout, run the plain command once so `feature/` gets numbered normally, then add +a new feature file that sorts alphabetically *before* the existing one and run it again: + +[source,bash] +---- +./gradlew generateFeatureDocs +cat > feature/src/test/resources/features-auth/access-control.feature <<'EOF' +Feature: Access control + + Scenario: Admin views the audit log + Given an admin user +EOF +./gradlew generateFeatureDocs +---- + +`feature/src/test/resources/features-auth/authentication.feature` still reads `Feature: 1 - User +authentication` and `features-billing/invoice.feature` still reads `Feature: 2 - Invoice payment` - +unchanged by the second run - while the new `access-control.feature` is numbered `Feature: 3 - Access +control`, even though `access-control.feature` sorts alphabetically before `authentication.feature`. This +is `forceRewrite`'s default (`false`): only lines not yet correctly numbered get numbered. + +Run `git checkout -- feature && rm feature/src/test/resources/features-auth/access-control.feature` to +reset, then repeat the same steps but with `-PgherkinToAsciidoc.forceRewrite=true` on the second command +instead - `authentication.feature` becomes `Feature: 2 -` and `invoice.feature` becomes `Feature: 3 -`, +renumbered to make room for `access-control.feature` at `Feature: 1 -`, matching alphabetical order exactly +as `indexing` always did before `forceRewrite` existed. + == What To Expect Before the build runs, `off/`, `feature/`, `scenario/`, and `all/` all contain the exact same @@ -319,9 +354,10 @@ state of the source files, indexing or not: [NOTE] ==== -This example is pinned to `gherkin-to-asciidoc = "2.1.0"` in `gradle/libs.versions.toml` - the version the -`ci` indexing value and the `-PgherkinToAsciidoc.indexing` command-line override are expected to release -as (a purely additive change on top of `indexing`, itself released as `2.0.0`). Verified locally against -the plugin's own source (via a temporary `includeBuild`, since removed) before pinning; won't build -against the Gradle Plugin Portal until `2.1.0` is actually released. +This example is pinned to `gherkin-to-asciidoc = "3.0.0"` in `gradle/libs.versions.toml` - the version the +`forceRewrite` property (and the breaking change to `indexing`'s default numbering behaviour it ships +alongside) is expected to release as. `indexing` itself released as `2.0.0`; its `ci` value and the +`-PgherkinToAsciidoc.indexing` command-line override as the purely additive `2.1.0`. Verified locally +against the plugin's own source (via a temporary `includeBuild`, since removed) before pinning; won't build +against the Gradle Plugin Portal until `3.0.0` is actually released. ==== diff --git a/examples/gherkin-to-asciidoc/indexing/gradle/libs.versions.toml b/examples/gherkin-to-asciidoc/indexing/gradle/libs.versions.toml index 7d9b697..2fd8ec3 100644 --- a/examples/gherkin-to-asciidoc/indexing/gradle/libs.versions.toml +++ b/examples/gherkin-to-asciidoc/indexing/gradle/libs.versions.toml @@ -6,4 +6,4 @@ gherkin-to-asciidoc = { id = "com.arc-e-tect.gherkin-to-asciidoc", version.ref = [versions] -gherkin-to-asciidoc = "2.1.0" +gherkin-to-asciidoc = "3.0.0" diff --git a/gherkin-to-asciidoc/README.adoc b/gherkin-to-asciidoc/README.adoc index cad3fbb..cfac967 100644 --- a/gherkin-to-asciidoc/README.adoc +++ b/gherkin-to-asciidoc/README.adoc @@ -133,6 +133,12 @@ gherkinToAsciidoc { // require groupByFeature = true. // Default: IndexingMode.OFF indexing = IndexingMode.OFF + + // When true, indexing renumbers every Feature/Scenario from scratch. When false, a line + // whose existing number already reflects the currently configured indexing value is left + // untouched - only lines that aren't yet correctly numbered are numbered. + // Default: false + forceRewrite = false } ---- @@ -153,6 +159,7 @@ gherkinToAsciidoc { // template.set(file("templates/report.mustache")) // systemUnderTestVersion.set("v1.0.0") indexing.set(IndexingMode.OFF) + forceRewrite.set(false) } ---- @@ -182,6 +189,8 @@ Only consulted when `trackProgress` is `true`. | `systemUnderTestVersion` | String | the project's `version` | Version of the system under test that the reported scenarios exercise, printed near the top of the generated document (see "System Under Test Version" below). | `indexing` | `IndexingMode` (`OFF`/`FEATURE`/`SCENARIO`/`ALL`/`CI`) | `OFF` | Numbers `Feature`/`Scenario` titles directly in the source `.feature` files (see "Numbering Features and Scenarios" below). `FEATURE`/`SCENARIO`/`ALL` require `includeSubDirs = true`; `FEATURE` and `ALL` additionally require `groupByFeature = true`. `CI` skips indexing entirely and is always allowed; overridable for the whole build via the `-PgherkinToAsciidoc.indexing` project property. +| `forceRewrite` | Boolean | `false` | When `true`, `indexing` renumbers every `Feature`/`Scenario` from scratch. When `false`, a line already correctly numbered for the currently configured `indexing` value is left untouched. +Has no effect when `indexing` is `OFF` or `CI`. Overridable for the whole build via the `-PgherkinToAsciidoc.forceRewrite` project property. |=== [IMPORTANT] @@ -204,10 +213,11 @@ to be `true`; `FEATURE` and `ALL` additionally require `groupByFeature` to be `t `groupByFeature = false`, only `SCENARIO` of those three is allowed. Setting an invalid combination fails `generateFeatureDocs` with a descriptive error. -The `-PgherkinToAsciidoc.indexing=` project property, when set, overrides `indexing` for every -project in the build regardless of what any project's own `gherkinToAsciidoc { }` block configures - see -"Overriding `indexing` from the Command Line" below. An unrecognised value fails `generateFeatureDocs` with -a descriptive error too. +The `-PgherkinToAsciidoc.indexing=` and `-PgherkinToAsciidoc.forceRewrite=` project +properties, when set, each override their respective DSL property for every project in the build +regardless of what any project's own `gherkinToAsciidoc { }` block configures - see "Overriding +`indexing`/`forceRewrite` from the Command Line" below. An unrecognised value fails `generateFeatureDocs` +with a descriptive error too. ==== == Running the Task @@ -262,8 +272,12 @@ sub-project that inherits or sets `trackProgress = true` still needs its own `gl | `indexing` | Inherited from the root project by default; a sub-project can override it independently, subject to the usual `includeSubDirs`/`groupByFeature` validation constraints for whichever value is in effect. The -`-PgherkinToAsciidoc.indexing` command-line override (see "Overriding `indexing` from the Command Line" -above) takes precedence over both, for every project in the build at once. +`-PgherkinToAsciidoc.indexing` command-line override (see "Overriding `indexing`/`forceRewrite` from the +Command Line" above) takes precedence over both, for every project in the build at once. +| `forceRewrite` +| Inherited from the root project by default; a sub-project can override it independently. The +`-PgherkinToAsciidoc.forceRewrite` command-line override takes precedence over both, for every project in +the build at once. | `outputFileName` | Inherited from the root project by default. | `template` @@ -446,7 +460,7 @@ a Cucumber test run, ...), not only in `generateFeatureDocs`'s own output. | `FEATURE` | Every feature is numbered, e.g. `Feature: 1 - User authentication`. Scenario titles are untouched. | `SCENARIO` | Every scenario is numbered continuously across all feature files, e.g. `Scenario: 1 - User logs in`. Feature titles are untouched. | `ALL` | Both are numbered; scenarios are numbered per feature as `.`, e.g. `Scenario: 1.1 - User logs in` within `Feature: 1 - User authentication`. -| `CI` | Indexing is skipped entirely: the source files aren't touched at all - unlike `OFF`, not even to strip prior numbering. See "Overriding `indexing` from the Command Line" below. +| `CI` | Indexing is skipped entirely: the source files aren't touched at all - unlike `OFF`, not even to strip prior numbering. See "Overriding `indexing`/`forceRewrite` from the Command Line" below. |=== Feature files are processed - and numbered - in the same order the generated report lists them in: for each @@ -513,14 +527,55 @@ and the generated report reflects the same numbering, since it's parsed from the [IMPORTANT] ==== -Changing `indexing` - including setting it back to `OFF` - rewrites the source `.feature` files on the next -`generateFeatureDocs` run: any numbering left over from a previous run is stripped first, then fresh -numbering is applied for the new mode. This makes the operation idempotent (re-running with the same mode is -a no-op once the files are already correctly numbered), but it does mean the task mutates files that are also -its own inputs - commit the resulting numbered `.feature` files like any other source change. +`indexing` mutates files that are also its own inputs - commit the resulting numbered `.feature` files like +any other source change. ==== -=== Overriding `indexing` from the Command Line +=== Not Renumbering Already-Numbered Lines (`forceRewrite`) + +By default (`forceRewrite = false`), a `Feature`/`Scenario` line whose existing number already matches the +format the currently configured `indexing` value would itself produce is left completely untouched - only +lines that aren't yet correctly numbered are numbered. Continuing the `ALL`-mode example above, adding a +third feature file that sorts alphabetically *before* `authentication.feature` does *not* renumber the +two files already numbered `1`/`2`: + +[source,gherkin] +---- +Feature: Access control +---- + +[source,asciidoc] +---- +== 3 - Access control + +== 1 - User authentication + +* Scenario: 1.1 - User requests a password reset +* Scenario: 1.2 - User logs in successfully + +== 2 - Invoice payment + +* Scenario: 2.1 - User pays an invoice +---- + +The new feature is numbered `3` - the next number not already in use - rather than `1`, which would have +required renumbering the two already-numbered files after it. Re-running `generateFeatureDocs` with no +further changes is a complete no-op: every line is already correctly numbered, so nothing is rewritten. + +Numbering isn't preserved *unconditionally*, though - only when it already reflects the *currently +configured* `indexing` value. A number left over from a *different* `indexing` value doesn't reflect the +current one, and is still stripped and replaced. For example, with `indexing = IndexingMode.SCENARIO`, a +scenario already reading `Scenario: 3 - ...` keeps that number - but changing `indexing` to +`IndexingMode.ALL` gives that same scenario a fresh `.` number instead, since its old `3` +doesn't match `ALL`'s format. Setting `indexing` back to `OFF` still always strips every number, +regardless of `forceRewrite`, since `OFF`'s canonical state for every line is unnumbered. + +Setting `forceRewrite = true` instead restores the original behaviour (present before `forceRewrite` +existed): every `Feature`/`Scenario` number is recomputed from scratch on every run, exactly as if none of +them had ever been numbered before - the third feature file above would instead become `1`, bumping +`authentication.feature`/`invoice.feature` to `2`/`3`. + +=== Overriding `indexing`/`forceRewrite` from the Command Line A CI pipeline typically shouldn't have `generateFeatureDocs` rewrite committed `.feature` files during a build - a fresh checkout has no numbering to add, and a build that mutates its own source tree can leave a @@ -541,6 +596,14 @@ property's value is matched against `IndexingMode` case-insensitively and accept (`off`, `feature`, `scenario`, `all`, `ci`), not just `ci` - useful for e.g. temporarily forcing a specific mode across an entire multi-project build without editing every sub-project's configuration. +`-PgherkinToAsciidoc.forceRewrite=` works the same way for `forceRewrite`, e.g. to force a full +renumber for a single run without permanently setting `forceRewrite = true` in any build script: + +[source,bash] +---- +./gradlew generateFeatureDocs -PgherkinToAsciidoc.forceRewrite=true +---- + See the link:../examples/gherkin-to-asciidoc/indexing/README.adoc[indexing example] for all five modes applied to the same feature files side by side, with the exact before/after content and diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GenerateFeatureDocsTask.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GenerateFeatureDocsTask.java index 9138a63..06569b0 100644 --- a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GenerateFeatureDocsTask.java +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GenerateFeatureDocsTask.java @@ -172,6 +172,17 @@ public abstract class GenerateFeatureDocsTask extends DefaultTask { @Input public abstract Property getIndexing(); + /** + * Whether {@link #getIndexing()} renumbers every {@code Feature}/{@code Scenario} from scratch, + * or only the ones not already correctly numbered for the currently configured + * {@link IndexingMode}. Has no effect when {@link #getIndexing()} is {@link IndexingMode#OFF} + * or {@link IndexingMode#CI}. + * + * @return mutable boolean property controlling whether existing numbering is preserved + */ + @Input + public abstract Property getForceRewrite(); + /** * Root directory of the project, used to resolve the default source directory * when neither {@link #getSourceDirs()} nor {@link #getSourceFile()} is set. @@ -244,7 +255,7 @@ public void generate() { // CI skips indexing entirely - the feature files are left completely untouched, not even // to strip numbering left over from a previous run, unlike OFF. if (indexing != IndexingMode.CI) { - new FeatureIndexer().reindex(featureFiles, indexing); + new FeatureIndexer().reindex(featureFiles, indexing, getForceRewrite().get()); } List scenarios = new ArrayList<>(); diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocExtension.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocExtension.java index b8544ac..52739dd 100644 --- a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocExtension.java +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocExtension.java @@ -22,11 +22,13 @@ * // template = file('templates/report.mustache') // optional * // systemUnderTestVersion = 'v1.0.0' // optional; default: project.version * indexing = IndexingMode.OFF // default; requires includeSubDirs = true + * forceRewrite = false // default; see getForceRewrite() * } * * - *

{@code indexing} can be overridden for the whole build from the command line, e.g. - * {@code -PgherkinToAsciidoc.indexing=ci} - see {@link #getIndexing()}.

+ *

{@code indexing} and {@code forceRewrite} can each be overridden for the whole build from the + * command line, e.g. {@code -PgherkinToAsciidoc.indexing=ci} - see {@link #getIndexing()} and + * {@link #getForceRewrite()}.

*/ public abstract class GherkinToAsciidocExtension { @@ -53,6 +55,14 @@ public GherkinToAsciidocExtension() {} */ public static final String INDEXING_OVERRIDE_PROPERTY = "gherkinToAsciidoc.indexing"; + /** + * Name of the Gradle project property that overrides {@link #getForceRewrite()} from the + * command line for every project in the build, e.g. + * {@code -PgherkinToAsciidoc.forceRewrite=true}. Takes precedence over any project's own + * configured {@code forceRewrite} value. The value is parsed as a boolean. + */ + public static final String FORCE_REWRITE_OVERRIDE_PROPERTY = "gherkinToAsciidoc.forceRewrite"; + /** * Source directories that contain the {@code .feature} files to process. One or more * directories may be configured, e.g. via {@code sourceDirs.from(file('a'), file('b'))}. @@ -184,10 +194,10 @@ public GherkinToAsciidocExtension() {} * configured), that directory's own feature files first - alphabetically by file name - and only * then, when {@link #getIncludeSubDirs()} is {@code true}, its sub-directories' files, each * sub-directory visited the same way, alphabetically by name. Scenario numbers additionally - * follow document order within each file. Changing this property rewrites the source - * {@code .feature} files: any numbering left over from a previous run is removed first, then - * fresh numbering is applied for the new mode - including removing all numbering when set back - * to {@link IndexingMode#OFF}.

+ * follow document order within each file. A line whose existing number already reflects the + * currently configured mode is left untouched, rather than being renumbered to fit that + * processing order - see {@link #getForceRewrite()} for exactly what "reflects the currently + * configured mode" means and how to opt out of it.

* *

{@link IndexingMode#OFF} and {@link IndexingMode#CI} are always allowed. * {@link IndexingMode#FEATURE}, {@link IndexingMode#SCENARIO}, and {@link IndexingMode#ALL} are @@ -204,4 +214,41 @@ public GherkinToAsciidocExtension() {} * @return mutable property for the indexing mode */ public abstract Property getIndexing(); + + /** + * Whether {@link #getIndexing()} renumbers every {@code Feature}/{@code Scenario} from scratch + * (ignoring any existing numbers), or only numbers the ones that aren't already correctly + * numbered for the currently configured {@link IndexingMode}. Defaults to {@code false}. Has + * no effect when {@link #getIndexing()} is {@link IndexingMode#OFF} (which always strips every + * number, regardless) or {@link IndexingMode#CI} (which never touches anything, regardless). + * + *

    + *
  • {@code true} - every {@code Feature}/{@code Scenario} number is recomputed from + * scratch, exactly as if none of them had ever been numbered before - the same behaviour + * as before this property existed.
  • + *
  • {@code false} (default) - a line whose existing number already matches the format + * {@link #getIndexing()}'s mode would itself produce is left completely untouched: for a + * {@code Feature}, a single integer; for a {@code Scenario}/{@code Scenario Outline}, + * either a single integer ({@link IndexingMode#SCENARIO}) or + * {@code .} matching that scenario's own feature's number + * ({@link IndexingMode#ALL}). Every other numbered line - the wrong format, or a leftover + * from a previously configured, different {@code indexing} value - is stripped and + * renumbered, same as {@code true}. A newly added feature file that happens to sort + * alphabetically before already-numbered files is given the next number not already in + * use, rather than bumping every already-numbered file after it.
  • + *
+ * + *

For example, with {@code indexing = IndexingMode.SCENARIO} and {@code forceRewrite = false}, + * a {@code Scenario} already reading {@code Scenario: 3 - ...} keeps that number. Changing + * {@code indexing} to {@link IndexingMode#ALL} affords that same scenario a fresh number - its + * old {@code 3} doesn't match {@code ALL}'s {@code .} format, so it no longer + * "reflects" the currently configured mode.

+ * + *

The {@value #FORCE_REWRITE_OVERRIDE_PROPERTY} project property, when set (e.g. + * {@code -PgherkinToAsciidoc.forceRewrite=true}), overrides this property for every project in + * the build regardless of what any project configures here.

+ * + * @return mutable boolean property controlling whether existing numbering is preserved + */ + public abstract Property getForceRewrite(); } diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPlugin.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPlugin.java index 69668e0..d448d16 100644 --- a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPlugin.java +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPlugin.java @@ -36,6 +36,7 @@ *
  • Output directory: {@code build/generated-docs}
  • *
  • Output file name: {@code features.adoc}
  • *
  • Indexing: {@code off}
  • + *
  • Force rewrite: {@code false}
  • * * *

    Multi-project builds

    @@ -60,11 +61,13 @@ * own build output rather than colliding with another project's. Set these explicitly on a * specific project to relocate that project's report.

    * - *

    Overriding {@code indexing} from the command line

    - *

    The {@code -PgherkinToAsciidoc.indexing=<value>} project property overrides {@code indexing} - * for every project in the build, regardless of what any project's own {@code gherkinToAsciidoc { }} - * block configures - typically set to {@code ci} in a CI pipeline so {@code generateFeatureDocs} never - * mutates source {@code .feature} files there, without having to change the build script itself.

    + *

    Overriding {@code indexing}/{@code forceRewrite} from the command line

    + *

    The {@code -PgherkinToAsciidoc.indexing=<value>} and + * {@code -PgherkinToAsciidoc.forceRewrite=<true|false>} project properties each override their + * respective DSL property for every project in the build, regardless of what any project's own + * {@code gherkinToAsciidoc { }} block configures - {@code indexing} typically set to {@code ci} in a + * CI pipeline so {@code generateFeatureDocs} never mutates source {@code .feature} files there, + * without having to change the build script itself.

    */ public class GherkinToAsciidocPlugin implements Plugin { @@ -87,12 +90,14 @@ public void apply(Project project) { ext.getTemplate().convention(rootExt.getTemplate()); ext.getSystemUnderTestVersion().convention(rootExt.getSystemUnderTestVersion()); ext.getIndexing().convention(rootExt.getIndexing()); + ext.getForceRewrite().convention(rootExt.getForceRewrite()); } else { ext.getTrackProgress().convention(false); ext.getOutputFileName().convention(GherkinToAsciidocExtension.DEFAULT_OUTPUT_FILE_NAME); ext.getSystemUnderTestVersion().convention( project.provider(() -> String.valueOf(project.getVersion()))); ext.getIndexing().convention(IndexingMode.OFF); + ext.getForceRewrite().convention(false); } // outputDir/snippetDir intentionally always default to this project's own build directory, @@ -118,12 +123,16 @@ public void apply(Project project) { Project rootProject = project.getRootProject(); - // The -PgherkinToAsciidoc.indexing= project property, when set, overrides indexing - // for every project in the build - regardless of what any project's own extension - // configures - typically used to force `ci` in a CI pipeline without touching build scripts. + // The -PgherkinToAsciidoc.indexing= and -PgherkinToAsciidoc.forceRewrite= + // project properties, when set, override indexing/forceRewrite for every project in the + // build - regardless of what any project's own extension configures - typically used to + // force `ci` in a CI pipeline without touching build scripts. Provider indexingCliOverride = project.getProviders() .gradleProperty(GherkinToAsciidocExtension.INDEXING_OVERRIDE_PROPERTY) .map(GherkinToAsciidocPlugin::parseIndexingMode); + Provider forceRewriteCliOverride = project.getProviders() + .gradleProperty(GherkinToAsciidocExtension.FORCE_REWRITE_OVERRIDE_PROPERTY) + .map(GherkinToAsciidocPlugin::parseForceRewrite); project.getTasks().register(TASK_NAME, GenerateFeatureDocsTask.class, task -> { wireSourceLocation(project, rootProject, ext, rootExt, task); @@ -137,6 +146,7 @@ public void apply(Project project) { task.getTemplate().set(ext.getTemplate()); task.getSystemUnderTestVersion().set(ext.getSystemUnderTestVersion()); task.getIndexing().set(indexingCliOverride.orElse(ext.getIndexing())); + task.getForceRewrite().set(forceRewriteCliOverride.orElse(ext.getForceRewrite())); task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); }); } @@ -157,6 +167,24 @@ private static IndexingMode parseIndexingMode(String value) { } } + /** + * Parses the {@code -PgherkinToAsciidoc.forceRewrite=} project property's value, + * accepting {@code true}/{@code false} case-insensitively. + */ + private static boolean parseForceRewrite(String value) { + String normalized = value.trim().toLowerCase(Locale.ROOT); + if ("true".equals(normalized)) { + return true; + } + if ("false".equals(normalized)) { + return false; + } + throw new GradleException( + "gherkinToAsciidoc: invalid value '" + value + "' for -P" + + GherkinToAsciidocExtension.FORCE_REWRITE_OVERRIDE_PROPERTY + + "; expected 'true' or 'false'"); + } + /** * Wires the task's {@code sourceDirs}/{@code sourceFile} from this project's own extension, or - * when neither is configured locally and a root extension exists - from the root project's own diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexer.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexer.java index ab9d5ee..a041273 100644 --- a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexer.java +++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexer.java @@ -7,7 +7,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -15,11 +18,19 @@ * Numbers {@code Feature}/{@code Scenario} titles directly in the source {@code .feature} files * according to a configured {@link IndexingMode}. * - *

    Every run first strips any numbering left over from a previous run (recognised by the - * {@code - } prefix this class itself adds), then - unless the mode is - * {@link IndexingMode#OFF} - applies fresh numbering. This makes the operation idempotent and - * makes switching between modes (including back to {@code OFF}) simply undo the previous - * numbering rather than requiring any state to be tracked between runs.

    + *

    A line whose existing number already matches the format {@code mode} would itself produce - + * a single integer for a {@code Feature}, or for a {@code Scenario}/{@code Scenario Outline} + * either a single integer ({@link IndexingMode#SCENARIO}) or {@code .} + * ({@link IndexingMode#ALL}, matched against that line's own feature's resolved number) - is left + * completely untouched: its number is "pinned". Every other numbered line (wrong format, or a + * leftover from a different mode, or {@link IndexingMode#OFF} itself never expecting a number at + * all) is stripped and, if {@code mode} numbers lines of that kind, assigned a fresh number one + * past the highest pinned number seen so far (or from 1, if none are pinned yet) - so a newly + * added file that happens to sort alphabetically before already-numbered files never bumps their + * numbers, and fresh numbers always read as a continuation of the existing sequence rather than + * backfilling a gap earlier in it. Passing {@code forceRewrite = true} instead ignores existing + * numbers entirely and renumbers everything from scratch, exactly as if every file were being + * numbered for the first time.

    * *

    Never called with {@link IndexingMode#CI}: the caller skips invoking this class entirely for * that mode, since {@code CI} means the feature files must be left completely untouched, not even @@ -30,68 +41,150 @@ public class FeatureIndexer { private static final Pattern KEYWORD_LINE = Pattern.compile("^(\\s*)(Feature|Scenario Outline|Scenario):(\\s*)(.*)$"); private static final Pattern EXISTING_INDEX = Pattern.compile("^\\d+(?:\\.\\d+)? - (.*)$"); + private static final Pattern SINGLE_INDEX = Pattern.compile("^(\\d+) - (.*)$"); /** Creates a new {@code FeatureIndexer}. */ public FeatureIndexer() {} /** - * Rewrites every file in {@code featureFiles} in place: strips any {@code Feature}/ - * {@code Scenario} numbering added by a previous run, then applies numbering per - * {@code mode}. Files are numbered in the order they appear in {@code featureFiles} - the - * caller is responsible for ordering that list the way numbers should be assigned. A file is - * only rewritten on disk when its content actually changes. + * Rewrites every file in {@code featureFiles} in place per {@code mode} and + * {@code forceRewrite} - see the class documentation for exactly what changes and what's left + * alone. Files are numbered in the order they appear in {@code featureFiles} - the caller is + * responsible for ordering that list the way numbers should be assigned. A file is only + * rewritten on disk when its content actually changes. * * @param featureFiles the feature files collected for this run, in the order to number them in * @param mode the indexing mode to apply + * @param forceRewrite when {@code true}, ignores existing numbers and renumbers everything from + * scratch; when {@code false}, leaves already-correctly-numbered lines alone */ - public void reindex(List featureFiles, IndexingMode mode) { - int featureNumber = 0; - int scenarioNumber = 0; + public void reindex(List featureFiles, IndexingMode mode, boolean forceRewrite) { + List parsedFiles = new ArrayList<>(); for (File featureFile : featureFiles) { - featureNumber++; - int scenarioInFeature = 0; - List lines = readLines(featureFile); - List rewritten = new ArrayList<>(lines.size()); - boolean changed = false; - - for (String line : lines) { - Matcher matcher = KEYWORD_LINE.matcher(line); - if (!matcher.matches()) { - rewritten.add(line); + parsedFiles.add(parse(featureFile)); + } + + boolean numberFeatures = mode == IndexingMode.FEATURE || mode == IndexingMode.ALL; + if (numberFeatures) { + List allFeatures = new ArrayList<>(); + for (ParsedFile parsedFile : parsedFiles) { + allFeatures.addAll(parsedFile.featureMatches); + } + resolveSequential(allFeatures, forceRewrite, SINGLE_INDEX); + } + + if (mode == IndexingMode.SCENARIO) { + List allScenarios = new ArrayList<>(); + for (ParsedFile parsedFile : parsedFiles) { + allScenarios.addAll(parsedFile.scenarioMatches); + } + resolveSequential(allScenarios, forceRewrite, SINGLE_INDEX); + } else if (mode == IndexingMode.ALL) { + for (ParsedFile parsedFile : parsedFiles) { + if (parsedFile.featureMatches.isEmpty()) { continue; } + Integer featureNumber = parsedFile.featureMatches.get(0).number; + Pattern perFeatureIndex = Pattern.compile("^" + featureNumber + "\\.(\\d+) - (.*)$"); + resolveSequential(parsedFile.scenarioMatches, forceRewrite, perFeatureIndex); + } + } + + for (ParsedFile parsedFile : parsedFiles) { + applyAndWrite(parsedFile, mode); + } + } - String indent = matcher.group(1); - String keyword = matcher.group(2); - String gap = matcher.group(3); - String name = stripExistingIndex(matcher.group(4)); - - String newName; - if ("Feature".equals(keyword)) { - newName = (mode == IndexingMode.FEATURE || mode == IndexingMode.ALL) - ? featureNumber + " - " + name - : name; - } else if (mode == IndexingMode.SCENARIO) { - scenarioNumber++; - newName = scenarioNumber + " - " + name; - } else if (mode == IndexingMode.ALL) { - scenarioInFeature++; - newName = featureNumber + "." + scenarioInFeature + " - " + name; - } else { - newName = name; + /** + * Determines the number for every entry in {@code matches}: entries whose {@link + * LineMatch#rawName} already matches {@code pinPattern} (and {@code forceRewrite} is + * {@code false}) keep that number ("pinned"); every other entry is assigned a fresh number, + * counting up from one past the highest pinned number (or from 1, if none are pinned), in list + * order. Fresh numbers are never lower than an already-pinned one, so they read as a + * continuation of the existing sequence rather than backfilling a gap earlier in it. + */ + private void resolveSequential(List matches, boolean forceRewrite, Pattern pinPattern) { + Set taken = new HashSet<>(); + if (!forceRewrite) { + for (LineMatch match : matches) { + Matcher matcher = pinPattern.matcher(match.rawName); + if (matcher.matches()) { + match.number = Integer.parseInt(matcher.group(1)); + taken.add(match.number); } + } + } + int next = taken.isEmpty() ? 1 : Collections.max(taken) + 1; + for (LineMatch match : matches) { + if (match.number != null) { + continue; + } + while (taken.contains(next)) { + next++; + } + match.number = next; + taken.add(next); + next++; + } + } - String newLine = indent + keyword + ":" + gap + newName; - changed |= !newLine.equals(line); - rewritten.add(newLine); + private ParsedFile parse(File file) { + List lines = readLines(file); + ParsedFile parsedFile = new ParsedFile(file, new ArrayList<>(lines)); + for (int lineIndex = 0; lineIndex < lines.size(); lineIndex++) { + Matcher matcher = KEYWORD_LINE.matcher(lines.get(lineIndex)); + if (!matcher.matches()) { + continue; + } + LineMatch match = new LineMatch( + lineIndex, matcher.group(1), matcher.group(2), matcher.group(3), matcher.group(4)); + if ("Feature".equals(match.keyword)) { + parsedFile.featureMatches.add(match); + } else { + parsedFile.scenarioMatches.add(match); } + } + return parsedFile; + } - if (changed) { - writeLines(featureFile, rewritten); + private void applyAndWrite(ParsedFile parsedFile, IndexingMode mode) { + boolean changed = false; + for (LineMatch match : parsedFile.featureMatches) { + String cleanName = stripExistingIndex(match.rawName); + String newName = match.number != null ? match.number + " - " + cleanName : cleanName; + changed |= applyLine(parsedFile.lines, match, newName); + } + + Integer featureNumber = parsedFile.featureMatches.isEmpty() + ? null : parsedFile.featureMatches.get(0).number; + for (LineMatch match : parsedFile.scenarioMatches) { + String cleanName = stripExistingIndex(match.rawName); + String newName; + if (mode == IndexingMode.SCENARIO && match.number != null) { + newName = match.number + " - " + cleanName; + } else if (mode == IndexingMode.ALL && match.number != null && featureNumber != null) { + newName = featureNumber + "." + match.number + " - " + cleanName; + } else { + newName = cleanName; } + changed |= applyLine(parsedFile.lines, match, newName); + } + + if (changed) { + writeLines(parsedFile.file, parsedFile.lines); } } + private boolean applyLine(List lines, LineMatch match, String newName) { + String newLine = match.indent + match.keyword + ":" + match.gap + newName; + String oldLine = lines.get(match.lineIndex); + if (newLine.equals(oldLine)) { + return false; + } + lines.set(match.lineIndex, newLine); + return true; + } + private String stripExistingIndex(String name) { Matcher matcher = EXISTING_INDEX.matcher(name); return matcher.matches() ? matcher.group(1) : name; @@ -112,4 +205,41 @@ private void writeLines(File file, List lines) { throw new GradleException("gherkinToAsciidoc: could not update feature file: " + file, e); } } + + /** A single file's lines, plus its parsed {@code Feature}/{@code Scenario} keyword lines. */ + private static final class ParsedFile { + final File file; + final List lines; + final List featureMatches = new ArrayList<>(); + final List scenarioMatches = new ArrayList<>(); + + ParsedFile(File file, List lines) { + this.file = file; + this.lines = lines; + } + } + + /** + * A single parsed {@code Feature}/{@code Scenario}/{@code Scenario Outline} line. Deliberately + * not a record: instances are used as mutable carriers for the resolved {@link #number}, and + * must never be treated as equal to another instance with coincidentally identical field + * values (e.g. two different files' first scenario, both unnumbered) - only the default + * identity-based {@link Object#equals(Object)} is safe here. + */ + private static final class LineMatch { + final int lineIndex; + final String indent; + final String keyword; + final String gap; + final String rawName; + Integer number; + + LineMatch(int lineIndex, String indent, String keyword, String gap, String rawName) { + this.lineIndex = lineIndex; + this.indent = indent; + this.keyword = keyword; + this.gap = gap; + this.rawName = rawName; + } + } } diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocMultiProjectPluginTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocMultiProjectPluginTest.java index 54719aa..5558d79 100644 --- a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocMultiProjectPluginTest.java +++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocMultiProjectPluginTest.java @@ -250,6 +250,27 @@ void subProjectIndexingOverridesRoot() { assertThat(extension(sub).getIndexing().get()).isEqualTo(IndexingMode.OFF); } + @Test + @DisplayName("sub-project without its own configuration inherits forceRewrite from the root project") + void subProjectInheritsForceRewriteFromRoot() { + Project root = rootProject(); + extension(root).getForceRewrite().set(true); + Project sub = subProject(root, "sub"); + + assertThat(extension(sub).getForceRewrite().get()).isTrue(); + } + + @Test + @DisplayName("sub-project's own forceRewrite takes precedence over the root project's") + void subProjectForceRewriteOverridesRoot() { + Project root = rootProject(); + extension(root).getForceRewrite().set(true); + Project sub = subProject(root, "sub"); + extension(sub).getForceRewrite().set(false); + + assertThat(extension(sub).getForceRewrite().get()).isFalse(); + } + // --- helpers --- private Project rootProject() { diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPluginTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPluginTest.java index e443461..c9666ef 100644 --- a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPluginTest.java +++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPluginTest.java @@ -159,6 +159,15 @@ void extensionDefaultIndexingIsOff() { assertThat(ext.getIndexing().get()).isEqualTo(IndexingMode.OFF); } + @Test + @DisplayName("extension default: forceRewrite is false") + void extensionDefaultForceRewriteIsFalse() { + Project project = projectWithPlugin(); + GherkinToAsciidocExtension ext = extension(project); + + assertThat(ext.getForceRewrite().get()).isFalse(); + } + @Test @DisplayName("generates features.adoc from a flat source directory") void generatesAsciidocFromFlatDirectory() throws IOException { @@ -1020,6 +1029,128 @@ void cliPropertyInvalidValueThrowsDescriptiveError() throws IOException { + "expected one of: off, feature, scenario, all, ci"); } + @Test + @DisplayName("forceRewrite default (false): a new alphabetically-earlier feature file added on a later " + + "run does not renumber an already-numbered file") + void forceRewriteDefaultDoesNotRenumberOnLaterRun() throws IOException { + Project project = projectWithPlugin(); + File featuresDir = new File(tempDir.toFile(), "features"); + featuresDir.mkdirs(); + writeFeatureFile(featuresDir, "z.feature", + "Feature: Z Feature\n\n Scenario: Z scenario\n Given z\n"); + File outputDir = new File(tempDir.toFile(), "output"); + + GenerateFeatureDocsTask firstRun = task(project); + firstRun.getSourceDirs().from(featuresDir); + firstRun.getIndexing().set(IndexingMode.FEATURE); + firstRun.getOutputDir().set(outputDir); + firstRun.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + firstRun.generate(); + assertThat(Files.readString(featuresDir.toPath().resolve("z.feature"))) + .contains("Feature: 1 - Z Feature"); + + // A new file that sorts alphabetically before z.feature is added on a later run. + writeFeatureFile(featuresDir, "a.feature", + "Feature: A Feature\n\n Scenario: A scenario\n Given a\n"); + + GenerateFeatureDocsTask secondRun = task(project); + secondRun.getSourceDirs().from(featuresDir); + secondRun.getIndexing().set(IndexingMode.FEATURE); + secondRun.getOutputDir().set(outputDir); + secondRun.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + secondRun.generate(); + + assertThat(Files.readString(featuresDir.toPath().resolve("z.feature"))) + .contains("Feature: 1 - Z Feature"); + assertThat(Files.readString(featuresDir.toPath().resolve("a.feature"))) + .contains("Feature: 2 - A Feature"); + } + + @Test + @DisplayName("forceRewrite true: a new alphabetically-earlier feature file added on a later run " + + "renumbers the already-numbered file to fit alphabetical order") + void forceRewriteTrueRenumbersOnLaterRun() throws IOException { + Project project = projectWithPlugin(); + File featuresDir = new File(tempDir.toFile(), "features"); + featuresDir.mkdirs(); + writeFeatureFile(featuresDir, "z.feature", + "Feature: Z Feature\n\n Scenario: Z scenario\n Given z\n"); + File outputDir = new File(tempDir.toFile(), "output"); + + GenerateFeatureDocsTask firstRun = task(project); + firstRun.getSourceDirs().from(featuresDir); + firstRun.getIndexing().set(IndexingMode.FEATURE); + firstRun.getForceRewrite().set(true); + firstRun.getOutputDir().set(outputDir); + firstRun.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + firstRun.generate(); + + writeFeatureFile(featuresDir, "a.feature", + "Feature: A Feature\n\n Scenario: A scenario\n Given a\n"); + + GenerateFeatureDocsTask secondRun = task(project); + secondRun.getSourceDirs().from(featuresDir); + secondRun.getIndexing().set(IndexingMode.FEATURE); + secondRun.getForceRewrite().set(true); + secondRun.getOutputDir().set(outputDir); + secondRun.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + secondRun.generate(); + + assertThat(Files.readString(featuresDir.toPath().resolve("a.feature"))) + .contains("Feature: 1 - A Feature"); + assertThat(Files.readString(featuresDir.toPath().resolve("z.feature"))) + .contains("Feature: 2 - Z Feature"); + } + + @Test + @DisplayName("the -PgherkinToAsciidoc.forceRewrite project property overrides forceRewrite regardless " + + "of the configured value") + void cliPropertyOverridesConfiguredForceRewrite() throws IOException { + Files.writeString(tempDir.resolve("gradle.properties"), "gherkinToAsciidoc.forceRewrite=true\n"); + Project project = projectWithPlugin(); + File featuresDir = new File(tempDir.toFile(), "features"); + featuresDir.mkdirs(); + writeFeatureFile(featuresDir, "z.feature", + "Feature: 1 - Z Feature\n\n Scenario: Z scenario\n Given z\n"); + writeFeatureFile(featuresDir, "a.feature", + "Feature: A Feature\n\n Scenario: A scenario\n Given a\n"); + + GenerateFeatureDocsTask task = task(project); + task.getSourceDirs().from(featuresDir); + task.getIndexing().set(IndexingMode.FEATURE); + // Configured to false, but the CLI override must win. + extension(project).getForceRewrite().set(false); + task.getOutputDir().set(new File(tempDir.toFile(), "output")); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + task.generate(); + + assertThat(task.getForceRewrite().get()).isTrue(); + assertThat(Files.readString(featuresDir.toPath().resolve("a.feature"))) + .contains("Feature: 1 - A Feature"); + assertThat(Files.readString(featuresDir.toPath().resolve("z.feature"))) + .contains("Feature: 2 - Z Feature"); + } + + @Test + @DisplayName("an invalid -PgherkinToAsciidoc.forceRewrite value throws a descriptive GradleException") + void cliPropertyInvalidForceRewriteValueThrowsDescriptiveError() throws IOException { + Files.writeString(tempDir.resolve("gradle.properties"), "gherkinToAsciidoc.forceRewrite=maybe\n"); + Project project = projectWithPlugin(); + File featuresDir = new File(tempDir.toFile(), "features"); + featuresDir.mkdirs(); + writeFeatureFile(featuresDir, "sample.feature", "Feature: Sample\n\n Scenario: A scenario\n Given g\n"); + + GenerateFeatureDocsTask task = task(project); + task.getSourceDirs().from(featuresDir); + task.getOutputDir().set(new File(tempDir.toFile(), "output")); + task.getProjectDirectory().set(project.getLayout().getProjectDirectory()); + + assertThatThrownBy(task::generate) + .hasRootCauseInstanceOf(org.gradle.api.GradleException.class) + .hasRootCauseMessage("gherkinToAsciidoc: invalid value 'maybe' for " + + "-PgherkinToAsciidoc.forceRewrite; expected 'true' or 'false'"); + } + // --- helpers --- private Project projectWithPlugin() { diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexerTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexerTest.java index e1daaf7..2847353 100644 --- a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexerTest.java +++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexerTest.java @@ -27,7 +27,7 @@ void offModeLeavesTitlesUntouched() throws IOException { File file = writeFeature("authentication.feature", "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); - indexer.reindex(List.of(file), IndexingMode.OFF); + indexer.reindex(List.of(file), IndexingMode.OFF, false); assertThat(content(file)) .contains("Feature: User authentication") @@ -42,7 +42,7 @@ void featureModeNumbersFeaturesInGivenOrder() throws IOException { File invoice = writeFeature("invoice.feature", "Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n"); - indexer.reindex(List.of(auth, invoice), IndexingMode.FEATURE); + indexer.reindex(List.of(auth, invoice), IndexingMode.FEATURE, false); assertThat(content(auth)) .contains("Feature: 1 - User authentication") @@ -67,7 +67,7 @@ void scenarioModeNumbersScenariosContinuously() throws IOException { File invoice = writeFeature("invoice.feature", "Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n"); - indexer.reindex(List.of(auth, invoice), IndexingMode.SCENARIO); + indexer.reindex(List.of(auth, invoice), IndexingMode.SCENARIO, false); assertThat(content(auth)) .contains("Feature: User authentication") @@ -93,7 +93,7 @@ void allModeNumbersFeaturesAndScenariosPerFeature() throws IOException { File invoice = writeFeature("invoice.feature", "Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n"); - indexer.reindex(List.of(auth, invoice), IndexingMode.ALL); + indexer.reindex(List.of(auth, invoice), IndexingMode.ALL, false); assertThat(content(auth)) .contains("Feature: 1 - User authentication") @@ -113,7 +113,7 @@ void numbersFilesInGivenOrderNotAlphabetically() throws IOException { "Feature: A Feature\n\n Scenario: A scenario\n Given a\n"); // Given in z-then-a order: the caller (not the indexer) is responsible for ordering. - indexer.reindex(List.of(zFile, aFile), IndexingMode.FEATURE); + indexer.reindex(List.of(zFile, aFile), IndexingMode.FEATURE, false); assertThat(content(zFile)).contains("Feature: 1 - Z Feature"); assertThat(content(aFile)).contains("Feature: 2 - A Feature"); @@ -133,7 +133,7 @@ void numbersScenarioOutline() throws IOException { | admin | """); - indexer.reindex(List.of(file), IndexingMode.ALL); + indexer.reindex(List.of(file), IndexingMode.ALL, false); assertThat(content(file)).contains("Scenario Outline: 1.1 - User logs in with "); } @@ -150,7 +150,7 @@ void numbersScenariosInsideRule() throws IOException { Given a premium user """); - indexer.reindex(List.of(file), IndexingMode.SCENARIO); + indexer.reindex(List.of(file), IndexingMode.SCENARIO, false); assertThat(content(file)).contains(" Scenario: 1 - Premium user views protected page"); } @@ -162,9 +162,9 @@ void switchingToOffRemovesNumbering() throws IOException { "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); File invoice = writeFeature("invoice.feature", "Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n"); - indexer.reindex(List.of(auth, invoice), IndexingMode.ALL); + indexer.reindex(List.of(auth, invoice), IndexingMode.ALL, false); - indexer.reindex(List.of(auth, invoice), IndexingMode.OFF); + indexer.reindex(List.of(auth, invoice), IndexingMode.OFF, false); assertThat(content(auth)) .contains("Feature: User authentication") @@ -181,10 +181,10 @@ void switchingToOffRemovesNumbering() throws IOException { void switchingModesReplacesNumbering() throws IOException { File auth = writeFeature("authentication.feature", "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); - indexer.reindex(List.of(auth), IndexingMode.SCENARIO); + indexer.reindex(List.of(auth), IndexingMode.SCENARIO, false); assertThat(content(auth)).contains("Scenario: 1 - User logs in"); - indexer.reindex(List.of(auth), IndexingMode.FEATURE); + indexer.reindex(List.of(auth), IndexingMode.FEATURE, false); assertThat(content(auth)) .contains("Feature: 1 - User authentication") @@ -197,10 +197,10 @@ void switchingModesReplacesNumbering() throws IOException { void reindexingWithSameModeIsIdempotent() throws IOException { File auth = writeFeature("authentication.feature", "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); - indexer.reindex(List.of(auth), IndexingMode.ALL); + indexer.reindex(List.of(auth), IndexingMode.ALL, false); String firstPass = content(auth); - indexer.reindex(List.of(auth), IndexingMode.ALL); + indexer.reindex(List.of(auth), IndexingMode.ALL, false); assertThat(content(auth)).isEqualTo(firstPass); } @@ -215,13 +215,130 @@ void doesNotTouchUnrelatedLines() throws IOException { Given a user with role "Scenario: not a keyword" """); - indexer.reindex(List.of(file), IndexingMode.SCENARIO); + indexer.reindex(List.of(file), IndexingMode.SCENARIO, false); assertThat(content(file)) .contains("Scenario: 1 - User logs in") .contains("Given a user with role \"Scenario: not a keyword\""); } + // --- forceRewrite = false (default): preserve already-correctly-numbered lines --- + + @Test + @DisplayName("forceRewrite false: a new alphabetically-earlier file does not steal an already-numbered " + + "file's number") + void unpinnedFileDoesNotStealAlreadyNumberedFilesNumber() throws IOException { + File zFile = writeFeature("z.feature", + "Feature: 1 - Z Feature\n\n Scenario: Z scenario\n Given z\n"); + File aFile = writeFeature("a.feature", + "Feature: A Feature\n\n Scenario: A scenario\n Given a\n"); + + // Given in correct alphabetical order: a before z. + indexer.reindex(List.of(aFile, zFile), IndexingMode.FEATURE, false); + + assertThat(content(zFile)).contains("Feature: 1 - Z Feature"); + assertThat(content(aFile)).contains("Feature: 2 - A Feature"); + } + + @Test + @DisplayName("forceRewrite true: an already-numbered file is renumbered to fit alphabetical order, " + + "same as before this property existed") + void forceRewriteTrueRenumbersEverythingFromScratch() throws IOException { + File zFile = writeFeature("z.feature", + "Feature: 1 - Z Feature\n\n Scenario: Z scenario\n Given z\n"); + File aFile = writeFeature("a.feature", + "Feature: A Feature\n\n Scenario: A scenario\n Given a\n"); + + indexer.reindex(List.of(aFile, zFile), IndexingMode.FEATURE, true); + + assertThat(content(aFile)).contains("Feature: 1 - A Feature"); + assertThat(content(zFile)).contains("Feature: 2 - Z Feature"); + } + + @Test + @DisplayName("forceRewrite false: a scenario number in the wrong format for the current mode is " + + "renumbered, e.g. switching from SCENARIO to ALL") + void scenarioNumberNotMatchingNewModeFormatIsRenumbered() throws IOException { + File file = writeFeature("authentication.feature", + "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); + indexer.reindex(List.of(file), IndexingMode.SCENARIO, false); + assertThat(content(file)).contains("Scenario: 1 - User logs in"); + + indexer.reindex(List.of(file), IndexingMode.ALL, false); + + assertThat(content(file)) + .contains("Feature: 1 - User authentication") + .contains("Scenario: 1.1 - User logs in") + .doesNotContain("Scenario: 1 -"); + } + + @Test + @DisplayName("forceRewrite false: a feature number left over from a mode that doesn't number features " + + "is stripped, e.g. switching from ALL to SCENARIO") + void featureNumberNotExpectedByNewModeIsStripped() throws IOException { + File file = writeFeature("authentication.feature", + "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); + indexer.reindex(List.of(file), IndexingMode.ALL, false); + assertThat(content(file)).contains("Feature: 1 - User authentication"); + + indexer.reindex(List.of(file), IndexingMode.SCENARIO, false); + + assertThat(content(file)) + .contains("Feature: User authentication") + .contains("Scenario: 1 - User logs in"); + } + + @Test + @DisplayName("forceRewrite false: an already-numbered scenario keeps its number; only the unnumbered " + + "one gets a fresh number, not colliding with the pinned one") + void pinnedScenarioKeepsItsNumberUnnumberedOneGetsNextAvailable() throws IOException { + File file = writeFeature("sample.feature", """ + Feature: Sample + + Scenario: 5 - Already numbered + Given a user + + Scenario: Not yet numbered + Given a user + """); + + indexer.reindex(List.of(file), IndexingMode.SCENARIO, false); + + assertThat(content(file)) + .contains("Scenario: 5 - Already numbered") + .contains("Scenario: 6 - Not yet numbered"); + } + + @Test + @DisplayName("forceRewrite false: re-running the same mode twice is idempotent, same as before this " + + "property existed") + void forceRewriteFalseIsIdempotentAcrossRuns() throws IOException { + File auth = writeFeature("authentication.feature", + "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n"); + indexer.reindex(List.of(auth), IndexingMode.ALL, false); + String firstPass = content(auth); + + indexer.reindex(List.of(auth), IndexingMode.ALL, false); + + assertThat(content(auth)).isEqualTo(firstPass); + } + + @Test + @DisplayName("forceRewrite false: an ALL-mode scenario number matching a different feature number than " + + "its own resolved one is renumbered") + void scenarioNumberMismatchedWithOwnFeatureNumberIsRenumbered() throws IOException { + // "3.1" would only be pinned if this feature's own resolved number were 3 - it isn't (no + // other feature is numbered here, so this one resolves to 1), so it must be renumbered. + File file = writeFeature("sample.feature", + "Feature: Sample\n\n Scenario: 3.1 - Stale scenario\n Given g\n"); + + indexer.reindex(List.of(file), IndexingMode.ALL, false); + + assertThat(content(file)) + .contains("Feature: 1 - Sample") + .contains("Scenario: 1.1 - Stale scenario"); + } + private File writeFeature(String name, String content) throws IOException { File file = tempDir.resolve(name).toFile(); Files.writeString(file.toPath(), content, StandardCharsets.UTF_8); From c83d528c9cc099bef1f7f625afd87233c2ab2dbe Mon Sep 17 00:00:00 2001 From: Iwan Eising Date: Sun, 9 Aug 2026 18:48:08 +0400 Subject: [PATCH 2/2] ci(github-actions): update NVD cache refresh schedule to remove Monday cron job --- .github/workflows/nvd-cache-refresh.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/nvd-cache-refresh.yml b/.github/workflows/nvd-cache-refresh.yml index 5686535..44afae0 100644 --- a/.github/workflows/nvd-cache-refresh.yml +++ b/.github/workflows/nvd-cache-refresh.yml @@ -2,7 +2,6 @@ name: NVD Cache Refresh & Vulnerability Scan on: schedule: - - cron: '17 2 * * 1' # Every Monday at 02:17 UTC - cron: '0 7 * * 5' # Every Friday at 07:00 UTC workflow_dispatch: