eslint-plugin-traceability 1.11.4 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,9 @@
1
- ## [1.11.4](https://github.com/voder-ai/eslint-plugin-traceability/compare/v1.11.3...v1.11.4) (2025-12-06)
1
+ # [1.12.0](https://github.com/voder-ai/eslint-plugin-traceability/compare/v1.11.4...v1.12.0) (2025-12-07)
2
2
 
3
3
 
4
- ### Bug Fixes
4
+ ### Features
5
5
 
6
- * add else-if branch annotation support and tests ([15652db](https://github.com/voder-ai/eslint-plugin-traceability/commit/15652db094d23a261a39acaab76de585f460fda3))
6
+ * accept [@supports](https://github.com/supports) annotations on branches as alternative format ([6773a3a](https://github.com/voder-ai/eslint-plugin-traceability/commit/6773a3ae190e9b9adc605c7d17112f44401e5b24))
7
7
 
8
8
  # Changelog
9
9
 
@@ -86,6 +86,25 @@ function validateBranchTypes(context) {
86
86
  function extractCommentValue(_c) {
87
87
  return _c.value;
88
88
  }
89
+ /**
90
+ * Collect a single contiguous comment line at the given index, appending its
91
+ * trimmed text to the accumulator. Returns true when a valid comment was
92
+ * collected and false when scanning should stop (blank or non-comment line).
93
+ * @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-COMMENT-ASSOCIATION
94
+ * @supports docs/stories/025.0-DEV-CATCH-ANNOTATION-POSITION.story.md REQ-DUAL-POSITION-DETECTION REQ-FALLBACK-LOGIC
95
+ * @supports docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md REQ-DUAL-POSITION-DETECTION-ELSE-IF REQ-FALLBACK-LOGIC-ELSE-IF
96
+ */
97
+ function collectCommentLine(lines, index, comments) {
98
+ const line = lines[index];
99
+ if (!line || !line.trim()) {
100
+ return false;
101
+ }
102
+ if (!/^\s*(\/\/|\/\*)/.test(line)) {
103
+ return false;
104
+ }
105
+ comments.push(line.trim());
106
+ return true;
107
+ }
89
108
  function isElseIfBranch(node, parent) {
90
109
  return (node &&
91
110
  node.type === "IfStatement" &&
@@ -123,14 +142,9 @@ function gatherCatchClauseCommentText(sourceCode, node, beforeText) {
123
142
  const comments = [];
124
143
  let i = startIndex + 1;
125
144
  while (i <= endIndex) {
126
- const line = lines[i];
127
- if (!line || !line.trim()) {
128
- break;
129
- }
130
- if (!/^\s*(\/\/|\/\*)/.test(line)) {
145
+ if (!collectCommentLine(lines, i, comments)) {
131
146
  break;
132
147
  }
133
- comments.push(line.trim());
134
148
  i++;
135
149
  }
136
150
  const insideText = comments.join(" ");
@@ -140,9 +154,75 @@ function gatherCatchClauseCommentText(sourceCode, node, beforeText) {
140
154
  }
141
155
  return beforeText;
142
156
  }
157
+ /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */
158
+ function scanElseIfPrecedingComments(sourceCode, node) {
159
+ const lines = sourceCode.lines;
160
+ if (!node.loc || !node.loc.start || typeof node.loc.start.line !== "number") {
161
+ return "";
162
+ }
163
+ const startLine = node.loc.start.line - 1;
164
+ const comments = [];
165
+ let i = startLine - 1;
166
+ let scanned = 0;
167
+ while (i >= 0 && scanned < PRE_COMMENT_OFFSET) {
168
+ const line = lines[i];
169
+ if (!line || !line.trim()) {
170
+ break;
171
+ }
172
+ if (!/^\s*(\/\/|\/\*)/.test(line)) {
173
+ break;
174
+ }
175
+ comments.unshift(line.trim());
176
+ i--;
177
+ scanned++;
178
+ }
179
+ return comments.join(" ");
180
+ }
181
+ /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */
182
+ function hasValidElseIfBlockLoc(node) {
183
+ const hasBlockConsequent = node.consequent &&
184
+ node.consequent.type === "BlockStatement" &&
185
+ node.consequent.loc &&
186
+ node.consequent.loc.start;
187
+ return !!(node.test &&
188
+ node.test.loc &&
189
+ node.test.loc.end &&
190
+ hasBlockConsequent);
191
+ }
192
+ /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */
193
+ function scanElseIfBetweenConditionAndBody(sourceCode, node) {
194
+ const lines = sourceCode.lines;
195
+ const conditionEndLine = node.test.loc.end.line;
196
+ const consequentStartLine = node.consequent.loc.start.line;
197
+ const comments = [];
198
+ for (let lineIndex = conditionEndLine; lineIndex < consequentStartLine - 1; lineIndex++) {
199
+ if (!collectCommentLine(lines, lineIndex, comments)) {
200
+ break;
201
+ }
202
+ }
203
+ return comments.join(" ");
204
+ }
205
+ /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */
206
+ function scanElseIfInsideBlockComments(sourceCode, node) {
207
+ const lines = sourceCode.lines;
208
+ const consequentStartLine = node.consequent.loc.start.line;
209
+ const comments = [];
210
+ // Intentionally start from the block's start line (using the same 1-based line value as provided by the parser)
211
+ // so that, when indexing into sourceCode.lines, this corresponds to the first logical line inside the block body
212
+ // for typical formatter layouts.
213
+ let lineIndex = consequentStartLine;
214
+ while (lineIndex < lines.length) {
215
+ if (!collectCommentLine(lines, lineIndex, comments)) {
216
+ break;
217
+ }
218
+ lineIndex++;
219
+ }
220
+ return comments.join(" ");
221
+ }
143
222
  /**
144
223
  * Gather annotation text for IfStatement else-if branches, supporting comments placed
145
- * between the else-if condition and the consequent statement body.
224
+ * before the else keyword, between the else-if condition and the consequent body,
225
+ * and in the first comment-only lines inside the consequent block body.
146
226
  * @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md
147
227
  * @supports REQ-DUAL-POSITION-DETECTION
148
228
  * @supports REQ-FALLBACK-LOGIC
@@ -154,31 +234,23 @@ function gatherElseIfCommentText(sourceCode, node, parent, beforeText) {
154
234
  if (!isElseIfBranch(node, parent)) {
155
235
  return beforeText;
156
236
  }
157
- if (!node.consequent ||
158
- node.consequent.type !== "BlockStatement" ||
159
- !node.consequent.loc ||
160
- !node.consequent.loc.start) {
161
- return beforeText;
237
+ const beforeElseText = scanElseIfPrecedingComments(sourceCode, node);
238
+ if (beforeElseText &&
239
+ (/@story\b/.test(beforeElseText) || /@req\b/.test(beforeElseText))) {
240
+ return beforeElseText;
162
241
  }
163
- if (!node.test || !node.test.loc || !node.test.loc.end) {
242
+ if (!hasValidElseIfBlockLoc(node)) {
164
243
  return beforeText;
165
244
  }
166
- const lines = sourceCode.lines;
167
- const conditionEndLine = node.test.loc.end.line;
168
- const consequentStartLine = node.consequent.loc.start.line;
169
- const comments = [];
170
- for (let lineIndex = conditionEndLine; lineIndex < consequentStartLine; lineIndex++) {
171
- const line = lines[lineIndex];
172
- if (!line || !line.trim()) {
173
- break;
174
- }
175
- if (!/^\s*(\/\/|\/\*)/.test(line)) {
176
- break;
177
- }
178
- comments.push(line.trim());
245
+ const betweenText = scanElseIfBetweenConditionAndBody(sourceCode, node);
246
+ if (betweenText) {
247
+ return betweenText;
248
+ }
249
+ const insideText = scanElseIfInsideBlockComments(sourceCode, node);
250
+ if (insideText) {
251
+ return insideText;
179
252
  }
180
- const betweenText = comments.join(" ");
181
- return betweenText || beforeText;
253
+ return beforeText;
182
254
  }
183
255
  /**
184
256
  * Gather leading comment text for a branch node.
@@ -345,11 +417,13 @@ function getBaseBranchIndentAndInsertPos(sourceCode, node) {
345
417
  * @req REQ-ANNOTATION-PARSING - Parse @story and @req annotations from branch comments
346
418
  * @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md
347
419
  * @supports REQ-DUAL-POSITION-DETECTION
420
+ * @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-SUPPORTS-ALTERNATIVE
348
421
  */
349
422
  function getBranchAnnotationInfo(sourceCode, node, parent) {
350
423
  const text = gatherBranchCommentText(sourceCode, node, parent);
351
- const missingStory = !/@story\b/.test(text);
352
- const missingReq = !/@req\b/.test(text);
424
+ const hasSupports = /@supports\b/.test(text);
425
+ const missingStory = !/@story\b/.test(text) && !hasSupports;
426
+ const missingReq = !/@req\b/.test(text) && !hasSupports;
353
427
  let { indent, insertPos } = getBaseBranchIndentAndInsertPos(sourceCode, node);
354
428
  if (isElseIfBranch(node, parent) &&
355
429
  node.consequent &&
@@ -378,13 +452,11 @@ function getBranchAnnotationInfo(sourceCode, node, parent) {
378
452
  function reportMissingAnnotations(context, node, storyFixCountRef) {
379
453
  const sourceCode = context.getSourceCode();
380
454
  /**
381
- * Determine the direct parent of the node using the ancestors stack when available.
455
+ * Determine the direct parent of the node using the parent reference on the node.
382
456
  * @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md
383
457
  * @supports REQ-DUAL-POSITION-DETECTION
384
458
  */
385
- const contextAny = context;
386
- const ancestors = contextAny.getAncestors?.() || [];
387
- const parent = ancestors.length > 0 ? ancestors[ancestors.length - 1] : undefined;
459
+ const parent = node.parent;
388
460
  const { missingStory, missingReq, indent, insertPos } = getBranchAnnotationInfo(sourceCode, node, parent);
389
461
  const actions = [
390
462
  {
@@ -73,8 +73,9 @@ else if (anotherVeryLongConditionThatForcesWrapping && someOtherCondition) {
73
73
  }
74
74
  `;
75
75
  const formatted = formatWithPrettier(original);
76
- // Sanity check: Prettier should keep both the else-if branch and the associated story annotation.
77
- expect(formatted).toContain("else if (");
76
+ // Sanity checks: Prettier should keep both the else-if branch and the associated story annotation,
77
+ // but the exact layout and comment movement may vary between versions.
78
+ expect(formatted).toContain("else if");
78
79
  expect(formatted).toContain("@story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md");
79
80
  const result = runEslintWithRequireBranchAnnotation(formatted);
80
81
  expect(result.status).toBe(0);
@@ -109,8 +110,62 @@ if (aVeryLongConditionThatForcesPrettierToWrapTheElseIfBranch && anotherConditio
109
110
  });
110
111
  }
111
112
  else {
112
- it.skip("Else-if Prettier integration tests are pending full else-if formatter support (set TRACEABILITY_EXPERIMENTAL_ELSE_IF=1 to enable)", () => {
113
- // Pending full else-if formatter support.
113
+ it.skip("[REQ-PRETTIER-COMPATIBILITY-ELSE-IF-BEFORE] accepts code where annotations start before else-if but are moved between condition and body by Prettier", () => {
114
+ const original = `
115
+ function doA() {
116
+ return 1;
117
+ }
118
+
119
+ function doB() {
120
+ return 2;
121
+ }
122
+
123
+ // @story docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md
124
+ // @req REQ-BRANCH-DETECTION
125
+ if (aVeryLongConditionThatForcesPrettierToWrapTheElseIfBranch && anotherCondition) {
126
+ doA();
127
+ }
128
+ // @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md
129
+ // @req REQ-DUAL-POSITION-DETECTION-ELSE-IF
130
+ else if (anotherVeryLongConditionThatForcesWrapping && someOtherCondition) {
131
+ doB();
132
+ }
133
+ `;
134
+ const formatted = formatWithPrettier(original);
135
+ // Sanity checks: Prettier should keep both the else-if branch and the associated story annotation,
136
+ // but the exact layout and comment movement may vary between versions.
137
+ expect(formatted).toContain("else if");
138
+ expect(formatted).toContain("@story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md");
139
+ const result = runEslintWithRequireBranchAnnotation(formatted);
140
+ expect(result.status).toBe(0);
141
+ });
142
+ it.skip("[REQ-PRETTIER-COMPATIBILITY-ELSE-IF-INSIDE] accepts code where annotations start between condition and body and are preserved by Prettier", () => {
143
+ const original = `
144
+ function doA() {
145
+ return 1;
146
+ }
147
+
148
+ function doB() {
149
+ return 2;
150
+ }
151
+
152
+ // @story docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md
153
+ // @req REQ-BRANCH-DETECTION
154
+ if (aVeryLongConditionThatForcesPrettierToWrapTheElseIfBranch && anotherCondition) {
155
+ doA();
156
+ } else if (
157
+ anotherVeryLongConditionThatForcesWrapping && someOtherCondition
158
+ ) {
159
+ // @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md
160
+ // @req REQ-DUAL-POSITION-DETECTION-ELSE-IF
161
+ doB();
162
+ }
163
+ `;
164
+ const formatted = formatWithPrettier(original);
165
+ // Note: Prettier's exact layout of the else-if and its comments may differ between versions;
166
+ // the rule should accept any of the supported annotation positions regardless of formatting.
167
+ const result = runEslintWithRequireBranchAnnotation(formatted);
168
+ expect(result.status).toBe(0);
114
169
  });
115
170
  }
116
171
  });
@@ -13,7 +13,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
13
13
  * @req REQ-ERROR-CONSISTENCY - Branch-level missing-annotation error messages follow shared conventions
14
14
  * @req REQ-ERROR-SUGGESTION - Branch-level missing-annotation errors include suggestions when applicable
15
15
  * @req REQ-NESTED-HANDLING - Nested branch annotations are correctly enforced without duplicative reporting
16
- * @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-BRANCH-DETECTION REQ-NESTED-HANDLING
16
+ * @req REQ-SUPPORTS-ALTERNATIVE - Branches annotated only with @supports are treated as fully annotated
17
+ * @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-BRANCH-DETECTION REQ-NESTED-HANDLING REQ-SUPPORTS-ALTERNATIVE
17
18
  * @supports docs/stories/007.0-DEV-ERROR-REPORTING.story.md REQ-ERROR-SPECIFIC REQ-ERROR-CONSISTENCY REQ-ERROR-SUGGESTION
18
19
  * @supports docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md REQ-DUAL-POSITION-DETECTION-ELSE-IF REQ-FALLBACK-LOGIC-ELSE-IF REQ-POSITION-PRIORITY-ELSE-IF REQ-PRETTIER-AUTOFIX-ELSE-IF
19
20
  */
@@ -158,6 +159,34 @@ if (outer) {
158
159
  if (condition) {}`,
159
160
  options: [{ branchTypes: ["IfStatement", "SwitchCase"] }],
160
161
  },
162
+ {
163
+ name: "[REQ-SUPPORTS-ALTERNATIVE] if-statement with only @supports annotation is treated as fully annotated",
164
+ code: `// @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-SUPPORTS-ALTERNATIVE
165
+ if (shouldHandleAlternative) {
166
+ handleAlternative();
167
+ }`,
168
+ },
169
+ {
170
+ name: "[REQ-SUPPORTS-ALTERNATIVE] try/catch where both branches are annotated only with @supports",
171
+ code: `// @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-SUPPORTS-ALTERNATIVE
172
+ try {
173
+ mightThrow();
174
+ }
175
+ // @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-SUPPORTS-ALTERNATIVE
176
+ catch (error) {
177
+ recoverFrom(error);
178
+ }`,
179
+ },
180
+ {
181
+ name: "[REQ-SUPPORTS-ALTERNATIVE] else-if branch with @supports inside the block body",
182
+ code: `// @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-SUPPORTS-ALTERNATIVE
183
+ if (mode === 'primary') {
184
+ handlePrimary();
185
+ } else if (mode === 'alternative') {
186
+ // @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-SUPPORTS-ALTERNATIVE
187
+ handleAlternativeMode();
188
+ }`,
189
+ },
161
190
  ],
162
191
  invalid: [
163
192
  {
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Unit tests for else-if insert position calculation.
5
+ * @story docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md
6
+ * @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md
7
+ * @supports docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md REQ-PRETTIER-AUTOFIX-ELSE-IF
8
+ */
9
+ const branch_annotation_helpers_1 = require("../../src/utils/branch-annotation-helpers");
10
+ describe("Else-if insert position (Story 026.0-DEV-ELSE-IF-ANNOTATION-POSITION)", () => {
11
+ it("[REQ-PRETTIER-AUTOFIX-ELSE-IF] inserts annotations on a dedicated line inside the else-if block body", () => {
12
+ const lines = [
13
+ "if (a) {",
14
+ " doA();",
15
+ "}",
16
+ "else if (b) {",
17
+ " doB();",
18
+ "}",
19
+ ];
20
+ const fixer = {
21
+ insertTextBeforeRange: jest.fn((r, t) => ({
22
+ r,
23
+ t,
24
+ })),
25
+ };
26
+ const context = {
27
+ getSourceCode() {
28
+ return {
29
+ lines,
30
+ getCommentsBefore() {
31
+ return [];
32
+ },
33
+ getIndexFromLoc({ line, column }) {
34
+ // simple line/column to index mapping for the test: assume each line ends with "\n"
35
+ const prefix = lines.slice(0, line - 1).join("\n");
36
+ return prefix.length + (line > 1 ? 1 : 0) + column;
37
+ },
38
+ };
39
+ },
40
+ report({ fix }) {
41
+ // immediately invoke the fixer to exercise the insert position
42
+ if (typeof fix === "function") {
43
+ fix(fixer);
44
+ }
45
+ },
46
+ };
47
+ const node = {
48
+ type: "IfStatement",
49
+ loc: { start: { line: 4 } },
50
+ test: { loc: { end: { line: 4 } } },
51
+ consequent: {
52
+ type: "BlockStatement",
53
+ loc: { start: { line: 4 } },
54
+ body: [
55
+ {
56
+ type: "ExpressionStatement",
57
+ loc: { start: { line: 5 } },
58
+ },
59
+ ],
60
+ },
61
+ };
62
+ const parent = {
63
+ type: "IfStatement",
64
+ alternate: node,
65
+ };
66
+ node.parent = parent;
67
+ const storyFixCountRef = { count: 0 };
68
+ (0, branch_annotation_helpers_1.reportMissingAnnotations)(context, node, storyFixCountRef);
69
+ expect(fixer.insertTextBeforeRange).toHaveBeenCalledTimes(1);
70
+ const [range, text] = fixer.insertTextBeforeRange.mock
71
+ .calls[0];
72
+ // ensure we are inserting before the first statement in the else-if body (line 5)
73
+ const expectedIndex = context
74
+ .getSourceCode()
75
+ .getIndexFromLoc({ line: 5, column: 0 });
76
+ expect(range).toEqual([expectedIndex, expectedIndex]);
77
+ // and that the inserted text is prefixed with the inner indentation from line 5
78
+ expect(text.startsWith(" ")).toBe(true);
79
+ });
80
+ });
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const branch_annotation_helpers_1 = require("../../src/utils/branch-annotation-helpers");
4
+ function createMockSourceCode(options) {
5
+ const { lines = [], commentsBefore = [] } = options;
6
+ return {
7
+ lines,
8
+ getCommentsBefore() {
9
+ return commentsBefore;
10
+ },
11
+ };
12
+ }
13
+ describe("gatherBranchCommentText else-if behavior (Story 026.0-DEV-ELSE-IF-ANNOTATION-POSITION)", () => {
14
+ it("[REQ-DUAL-POSITION-DETECTION-ELSE-IF] detects annotations placed before the else-if keyword", () => {
15
+ const sourceCode = createMockSourceCode({
16
+ commentsBefore: [
17
+ {
18
+ value: "@story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md",
19
+ },
20
+ { value: "@req REQ-DUAL-POSITION-DETECTION-ELSE-IF" },
21
+ ],
22
+ // lines are unused in this case because we short-circuit on before-text annotations.
23
+ lines: [],
24
+ });
25
+ const node = {
26
+ type: "IfStatement",
27
+ loc: { start: { line: 10 } },
28
+ };
29
+ const parent = {
30
+ type: "IfStatement",
31
+ alternate: node,
32
+ };
33
+ const text = (0, branch_annotation_helpers_1.gatherBranchCommentText)(sourceCode, node, parent);
34
+ expect(text).toContain("@story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md");
35
+ expect(text).toContain("@req REQ-DUAL-POSITION-DETECTION-ELSE-IF");
36
+ });
37
+ it("[REQ-FALLBACK-LOGIC-ELSE-IF] falls back to annotations between condition and body when before-else-if comments lack annotations", () => {
38
+ const lines = [
39
+ "if (a) {",
40
+ " doA();",
41
+ "} else if (b && c) {",
42
+ " // @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md",
43
+ " // @req REQ-FALLBACK-LOGIC-ELSE-IF",
44
+ " doB();",
45
+ "}",
46
+ ];
47
+ const sourceCode = createMockSourceCode({
48
+ commentsBefore: [{ value: "// some unrelated comment" }],
49
+ lines,
50
+ });
51
+ const node = {
52
+ type: "IfStatement",
53
+ loc: { start: { line: 3 } },
54
+ test: { loc: { end: { line: 3 } } },
55
+ consequent: {
56
+ type: "BlockStatement",
57
+ loc: { start: { line: 6 } },
58
+ },
59
+ };
60
+ const parent = {
61
+ type: "IfStatement",
62
+ alternate: node,
63
+ };
64
+ const text = (0, branch_annotation_helpers_1.gatherBranchCommentText)(sourceCode, node, parent);
65
+ expect(text).toContain("@story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md");
66
+ expect(text).toContain("@req REQ-FALLBACK-LOGIC-ELSE-IF");
67
+ });
68
+ it("[REQ-POSITION-PRIORITY-ELSE-IF] prefers before-else-if annotations when both positions are present", () => {
69
+ const lines = [
70
+ "if (a) {",
71
+ " doA();",
72
+ "} else if (b) {",
73
+ " // @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md",
74
+ " // @req REQ-POSITION-PRIORITY-ELSE-IF-BETWEEN",
75
+ " doB();",
76
+ "}",
77
+ ];
78
+ const sourceCode = createMockSourceCode({
79
+ commentsBefore: [
80
+ {
81
+ value: "@story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md",
82
+ },
83
+ { value: "@req REQ-POSITION-PRIORITY-ELSE-IF" },
84
+ ],
85
+ lines,
86
+ });
87
+ const node = {
88
+ type: "IfStatement",
89
+ loc: { start: { line: 3 } },
90
+ test: { loc: { end: { line: 3 } } },
91
+ consequent: {
92
+ type: "BlockStatement",
93
+ loc: { start: { line: 6 } },
94
+ },
95
+ };
96
+ const parent = {
97
+ type: "IfStatement",
98
+ alternate: node,
99
+ };
100
+ const text = (0, branch_annotation_helpers_1.gatherBranchCommentText)(sourceCode, node, parent);
101
+ // The helper should use the before-else-if annotations and not need to
102
+ // fall back to between-condition-and-body comments.
103
+ expect(text).toContain("@story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md");
104
+ expect(text).toContain("@req REQ-POSITION-PRIORITY-ELSE-IF");
105
+ expect(text).not.toContain("REQ-POSITION-PRIORITY-ELSE-IF-BETWEEN");
106
+ });
107
+ });
@@ -157,6 +157,20 @@ describe("reqAnnotationDetection advanced heuristics (Story 003.0-DEV-FUNCTION-A
157
157
  const has = (0, reqAnnotationDetection_1.hasReqAnnotation)(null, [], context, node);
158
158
  expect(has).toBe(false);
159
159
  });
160
+ it("[REQ-ANNOTATION-REQ-DETECTION] fallbackTextBeforeHasReq returns false when range[0] is not a number", () => {
161
+ const context = {
162
+ getSourceCode() {
163
+ return createMockSourceCode({ text: "/* @req REQ-IN-TEXT-BUT-INVALID-RANGE */" });
164
+ },
165
+ };
166
+ const node = {
167
+ // First element of range is not a number; guard on numeric start index should trigger
168
+ range: ["not-a-number", 10],
169
+ parent: {},
170
+ };
171
+ const has = (0, reqAnnotationDetection_1.hasReqAnnotation)(null, [], context, node);
172
+ expect(has).toBe(false);
173
+ });
160
174
  it("[REQ-ANNOTATION-REQ-DETECTION] fallbackTextBeforeHasReq returns true when text window contains @req", () => {
161
175
  const fullText = `
162
176
  // some header
@@ -244,4 +258,55 @@ describe("reqAnnotationDetection advanced heuristics (Story 003.0-DEV-FUNCTION-A
244
258
  const has = (0, reqAnnotationDetection_1.hasReqAnnotation)(jsdoc, [], context, { parent: {} });
245
259
  expect(has).toBe(true);
246
260
  });
261
+ it("[REQ-ANNOTATION-REQ-DETECTION] linesBeforeHasReq returns true when preceding lines contain @req marker", () => {
262
+ const context = {
263
+ getSourceCode() {
264
+ return createMockSourceCode({
265
+ lines: [
266
+ "// some header",
267
+ "/** @req REQ-LINE-BEFORE */",
268
+ "function foo() {}",
269
+ ],
270
+ });
271
+ },
272
+ };
273
+ const node = {
274
+ // Node starts on line 3 (1-based), so line 2 is inspected by linesBeforeHasReq
275
+ loc: { start: { line: 3 } },
276
+ parent: {},
277
+ };
278
+ const has = (0, reqAnnotationDetection_1.hasReqAnnotation)(null, [], context, node);
279
+ expect(has).toBe(true);
280
+ });
281
+ it("[REQ-ANNOTATION-REQ-DETECTION] parentChainHasReq returns true when leadingComments contain @supports and getCommentsBefore is unusable", () => {
282
+ const context = {
283
+ getSourceCode() {
284
+ return {
285
+ // Not a callable function; forces parentChainHasReq to rely on leadingComments
286
+ getCommentsBefore: 42,
287
+ };
288
+ },
289
+ };
290
+ const node = {
291
+ parent: {
292
+ leadingComments: [
293
+ { value: "some other comment" },
294
+ {
295
+ value: "@supports docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md REQ-FROM-LEADING-COMMENT",
296
+ },
297
+ ],
298
+ parent: {},
299
+ },
300
+ };
301
+ const has = (0, reqAnnotationDetection_1.hasReqAnnotation)(null, [], context, node);
302
+ expect(has).toBe(true);
303
+ });
304
+ it("[REQ-ANNOTATION-REQ-DETECTION] returns true when jsdoc has @req even if context is undefined", () => {
305
+ const jsdoc = { value: "/** @req REQ-JSDOC-NO-CONTEXT */" };
306
+ const node = {
307
+ parent: {},
308
+ };
309
+ const has = (0, reqAnnotationDetection_1.hasReqAnnotation)(jsdoc, [], undefined, node);
310
+ expect(has).toBe(true);
311
+ });
247
312
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-traceability",
3
- "version": "1.11.4",
3
+ "version": "1.12.0",
4
4
  "description": "A customizable ESLint plugin that enforces traceability annotations in your code, ensuring each implementation is linked to its requirement or test case.",
5
5
  "main": "lib/src/index.js",
6
6
  "types": "lib/src/index.d.ts",
@@ -15,7 +15,7 @@ In addition to the core `@story` and `@req` annotations, the plugin also underst
15
15
  `@supports docs/stories/010.0-PAYMENTS.story.md#REQ-PAYMENTS-REFUND`
16
16
  to indicate that a given function supports a particular requirement from a payments story document within that project’s own `docs/stories` tree. For a detailed explanation of `@supports` behavior and validation, see [Migration Guide](migration-guide.md) (section **3.1 Multi-story @supports annotations**). Additional background on multi-story semantics is available in the project’s internal rule documentation, which is intended for maintainers rather than end users.
17
17
 
18
- The `prefer-supports-annotation` rule is an **opt-in migration helper** that is disabled by default and **not** part of any built-in preset. It can be enabled and given a severity like `"warn"` or `"error"` using normal ESLint rule configuration when you want to gradually encourage multi-story `@supports` usage. The legacy rule key `traceability/prefer-implements-annotation` remains available as a **deprecated alias** for backward compatibility, but new configurations should prefer `traceability/prefer-supports-annotation`. Detailed behavior and migration guidance are documented in the project’s internal rule documentation, which is targeted at maintainers; typical end users can rely on the high-level guidance in this API reference and the [Migration Guide](migration-guide.md).
18
+ The `prefer-supports-annotation` rule is an **opt-in migration helper** that is disabled by default and **not** part of any built-in preset. It can be enabled and given a severity like `"warn"` or `"error"` using normal ESLint rule configuration when you want to gradually encourage multi-story `@supports` usage. The legacy rule key `traceability/prefer-implements-annotation` remains available as a **deprecated alias** for backward compatibility, but new configurations should prefer `traceability/prefer-supports-annotation`. Detailed behavior and migration guidance are documented in the project’s internal rule documentation, which is targeted for maintainers; typical end users can rely on the high-level guidance in this API reference and the [Migration Guide](migration-guide.md).
19
19
 
20
20
  ### traceability/require-story-annotation
21
21
 
@@ -68,7 +68,9 @@ function initAuth() {
68
68
 
69
69
  ### traceability/require-branch-annotation
70
70
 
71
- Description: Ensures significant code branches (if/else, loops, switch cases, try/catch) have both `@story` and `@req` annotations in preceding comments. For `catch` clauses specifically, the rule accepts annotations either immediately before the `catch` keyword or as the first comment-only lines inside the catch block; for `else if` branches, the rule accepts annotations either immediately before the `else if` keyword or on comment-only lines between the `else if (condition)` and the first statement of the consequent body, matching Prettier’s wrapped style.
71
+ Description: Ensures significant code branches (if/else chains, loops, switch cases, try/catch) have both `@story` and `@req` annotations in nearby comments. When you adopt multi-story `@supports` annotations, a single `@supports <storyPath> <REQ-ID>...` line placed in any of the valid branch comment locations is treated as satisfying both the story and requirement presence checks for that branch, while detailed format validation of the `@supports` value (including story paths and requirement IDs) continues to be handled by `traceability/valid-annotation-format`, `traceability/valid-story-reference`, and `traceability/valid-req-reference`.
72
+
73
+ For most branches, the rule looks for annotations in comments immediately preceding the branch keyword (for example, the line above an `if` or `for` statement). For `catch` clauses and `else if` branches, the rule is formatter-aware and accepts annotations in additional positions that common formatters like Prettier use when they reflow code.
72
74
 
73
75
  Options:
74
76
 
@@ -76,8 +78,22 @@ Options:
76
78
 
77
79
  Behavior notes:
78
80
 
79
- - When both before-`catch` and inside-block annotations are present for the same catch clause, the comments immediately before `catch` take precedence for validation and reporting.
80
- - When auto-fixing missing annotations on a catch clause, the rule inserts placeholder comments inside the catch body so that formatters like Prettier preserve them and do not move them to unexpected locations.
81
+ - **Catch clauses**:
82
+ - Valid locations for `@story` / `@req` annotations are either immediately before the `catch` keyword or on the first comment-only lines inside the catch block (before any executable statements). A single `@supports` annotation in either of these locations is also accepted as covering both story and requirement presence for the catch branch.
83
+ - If annotations exist in both locations, the comments immediately before `catch` take precedence for validation and reporting.
84
+ - When auto-fixing missing annotations on a catch clause, the rule inserts placeholder comments inside the catch block body so that formatters like Prettier keep them attached to the branch.
85
+
86
+ - **Else-if branches**:
87
+ - Valid locations for `@story` / `@req` annotations include:
88
+ - Line or block comments immediately before the `else if` line.
89
+ - Comment-only lines between the `else if (condition)` and the opening `{` of the consequent block (for styles where the condition and block are on separate lines).
90
+ - The first comment-only lines inside the consequent block body, which is where formatters like Prettier often move comments when they wrap long `else if` conditions. For a concrete before/after example of this formatter-aware behavior, see [user-docs/examples.md](examples.md) (section **6. Branch annotations with if/else/else-if and Prettier**).
91
+ - When annotations appear in more than one of these locations, the rule prefers the comments immediately before the `else if` line, then comments between the condition and the block, and finally comments inside the block body. This precedence is designed to closely mirror real-world formatter behavior and matches the formatter-aware scenarios described in stories 025.0 and 026.0.
92
+ - When auto-fixing missing annotations on an `else if` branch, the rule inserts placeholder comments as the first comment-only line inside the consequent block body (just after the opening `{`), which is a stable location under Prettier and similar formatters. As with catch clauses, a single `@supports` annotation placed in any of these accepted locations is treated as equivalent to having both `@story` and `@req` comments for that branch, with deep format and existence checks delegated to the other validation rules.
93
+
94
+ For a concrete illustration of how these rules interact with Prettier, see the formatter-aware if/else/else-if example in [user-docs/examples.md](examples.md) (section **6. Branch annotations with if/else/else-if and Prettier**), which shows both the hand-written and formatted code that the rule considers valid.
95
+
96
+ These behaviors are intentionally limited to `catch` clauses and `else if` branches; other branch types (plain `if`, `else`, loops, and `switch` cases) continue to use the simpler "comments immediately before the branch" association model for both validation and auto-fix placement.
81
97
 
82
98
  Default Severity: `error`
83
99
  Example:
@@ -689,4 +705,5 @@ If `--from` or `--to` is missing, the CLI prints an error, shows the help text,
689
705
  In CI:
690
706
 
691
707
  ```bash
692
- npm run traceability:verify
708
+ npm run traceability:verify
709
+ ```
@@ -114,3 +114,65 @@ function performOperation(input: string): string {
114
114
  return "ok";
115
115
  }
116
116
  ```
117
+
118
+ ## 6. Branch annotations with if/else/else-if and Prettier
119
+
120
+ This example shows how to keep `traceability/require-branch-annotation` satisfied while still running Prettier on your code.
121
+
122
+ ### 6.1 Before formatting
123
+
124
+ In this version, annotations are placed immediately before each significant branch. This is a simple layout that is easy to read and accepted by the rule:
125
+
126
+ ```ts
127
+ function pickCategory(score: number): string {
128
+ // @story docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md
129
+ // @req REQ-BRANCH-DETECTION
130
+ if (score >= 80) {
131
+ return "high";
132
+ }
133
+ // @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md
134
+ // @req REQ-DUAL-POSITION-DETECTION-ELSE-IF
135
+ else if (score >= 50) {
136
+ return "medium";
137
+ }
138
+ // You can annotate `else` using the same pattern if you treat it as a significant branch.
139
+ else {
140
+ return "low";
141
+ }
142
+ }
143
+ ```
144
+
145
+ You can run just the branch-annotation rule via the CLI:
146
+
147
+ ```bash
148
+ npx eslint --no-eslintrc \
149
+ --rule "traceability/require-branch-annotation:error" \
150
+ pick-category.ts
151
+ ```
152
+
153
+ ### 6.2 After formatting with Prettier
154
+
155
+ Prettier may reflow your `else if` line, wrap the condition, or move comments into the body of the branch. The `traceability/require-branch-annotation` rule is formatter-aware and will still recognize valid annotations in supported positions, such as the first comment-only lines inside the block body:
156
+
157
+ ```ts
158
+ function pickCategory(score: number): string {
159
+ // @story docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md
160
+ // @req REQ-BRANCH-DETECTION
161
+ if (score >= 80) {
162
+ return "high";
163
+ } else if (score >= 50) {
164
+ // @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md
165
+ // @req REQ-DUAL-POSITION-DETECTION-ELSE-IF
166
+ return "medium";
167
+ } else {
168
+ return "low";
169
+ }
170
+ }
171
+ ```
172
+
173
+ Depending on your Prettier version and configuration, the exact layout of the `else if` line and braces may differ, but as long as your annotations are in one of the supported locations, the rule will accept them.
174
+
175
+ - Notes:
176
+ - For most branch types, `traceability/require-branch-annotation` associates comments immediately before the branch keyword (such as `if`, `else`, `switch`, `case`) with that branch.
177
+ - For `catch` clauses and `else if` branches, the rule is formatter-aware and also looks at comments between the condition and the block, as well as the first comment-only lines inside the block body, so you do not need to fight Prettier if it moves your annotations.
178
+ - When annotations exist in more than one place around an `else if` branch, the rule prefers comments immediately before the `else if` line, then comments between the condition and the block, and finally comments inside the block body, matching the behavior described in the API reference and stories `025.0` and `026.0`.
@@ -188,6 +188,15 @@ You can introduce `@supports` gradually without breaking existing code:
188
188
 
189
189
  Detailed semantics and edge cases (path validation, scoped requirement IDs, and multi-story fixtures) are ultimately governed by your own stories and requirements. For typical migrations, this guide together with the plugin’s API reference is sufficient.
190
190
 
191
+ ### 3.2 Else-if branch annotations and formatter compatibility
192
+
193
+ Versions 1.x of `eslint-plugin-traceability` extend the `traceability/require-branch-annotation` rule to better support formatter-driven layouts for `else if` branches. In most projects you **do not need to change existing annotations**:
194
+
195
+ - Comments immediately before an `else if` line remain valid and continue to satisfy the rule.
196
+ - When formatters such as Prettier move comments between the `else if (condition)` and the opening `{`, or into the first comment-only lines inside the `{ ... }` block, those annotations are now also recognized and associated with the correct branch.
197
+
198
+ If you previously added suppressions or workaround comments around `else if` branches due to formatter conflicts, you can usually remove those workarounds after upgrading to 1.x as long as your annotations live in one of the supported locations. For new code, you can place annotations either directly above the `else if` or, when you know a formatter will wrap a long condition, on the first comment-only line inside the consequent block body, which is where the rule places auto-fix placeholders by default.
199
+
191
200
  ## 4. Test and Validate
192
201
 
193
202
  Run your test suite to confirm everything passes: