eslint-plugin-traceability 1.11.4 → 1.12.1

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.1](https://github.com/voder-ai/eslint-plugin-traceability/compare/v1.12.0...v1.12.1) (2025-12-07)
2
2
 
3
3
 
4
4
  ### Bug Fixes
5
5
 
6
- * add else-if branch annotation support and tests ([15652db](https://github.com/voder-ai/eslint-plugin-traceability/commit/15652db094d23a261a39acaab76de585f460fda3))
6
+ * support single-line else-if annotations and enable Prettier tests ([967b7e0](https://github.com/voder-ai/eslint-plugin-traceability/commit/967b7e02e12c4415efa0df2772ccde77b94cd1a8))
7
7
 
8
8
  # Changelog
9
9
 
@@ -98,6 +98,46 @@ exports.STORY_PATH = "docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md";
98
98
  * Allowed values for export priority option.
99
99
  */
100
100
  exports.EXPORT_PRIORITY_VALUES = ["all", "exported", "non-exported"];
101
+ /**
102
+ * Safely execute a reporting operation, swallowing unexpected errors so that
103
+ * traceability rules never break ESLint runs. When TRACEABILITY_DEBUG=1 is
104
+ * set in the environment, a diagnostic message is logged to stderr.
105
+ * @supports docs/stories/007.0-DEV-ERROR-REPORTING.story.md REQ-ERROR-RESILIENCE
106
+ */
107
+ function withSafeReporting(label, fn) {
108
+ try {
109
+ fn();
110
+ }
111
+ catch (error) {
112
+ if (process.env.TRACEABILITY_DEBUG === "1") {
113
+ // Debug logging only when explicitly enabled for troubleshooting helper failures.
114
+ console.error(`[traceability] ${label} failed`, error?.message ?? error);
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * Build the shared ESLint report descriptor for a missing @story annotation.
120
+ * This keeps the core helpers focused on computing names, targets, and
121
+ * templates while centralizing the diagnostic wiring.
122
+ * @supports docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md REQ-ERROR-SPECIFIC
123
+ * @supports docs/stories/007.0-DEV-ERROR-REPORTING.story.md REQ-ERROR-RESILIENCE
124
+ */
125
+ function createMissingStoryReportDescriptor(config) {
126
+ const { nameNode, name, resolvedTarget, effectiveTemplate, allowFix, createFix, } = config;
127
+ const baseFix = createFix(resolvedTarget, effectiveTemplate);
128
+ return {
129
+ node: nameNode,
130
+ messageId: "missingStory",
131
+ data: { name, functionName: name },
132
+ fix: allowFix ? baseFix : undefined,
133
+ suggest: [
134
+ {
135
+ desc: `Add JSDoc @story annotation for function '${name}', e.g., ${effectiveTemplate}`,
136
+ fix: baseFix,
137
+ },
138
+ ],
139
+ };
140
+ }
101
141
  /**
102
142
  * Core helper to report a missing @story annotation for a function-like node.
103
143
  * This reporting utility delegates behavior to injected dependencies so that
@@ -111,7 +151,7 @@ exports.EXPORT_PRIORITY_VALUES = ["all", "exported", "non-exported"];
111
151
  */
112
152
  function coreReportMissing(deps, context, sourceCode, config) {
113
153
  const { node, target: passedTarget, options = {} } = config;
114
- try {
154
+ withSafeReporting("coreReportMissing", () => {
115
155
  if (deps.hasStoryAnnotation(sourceCode, node)) {
116
156
  return;
117
157
  }
@@ -120,30 +160,15 @@ function coreReportMissing(deps, context, sourceCode, config) {
120
160
  const nameNode = deps.getNameNodeForReport(node);
121
161
  const { effectiveTemplate, allowFix } = deps.buildTemplateConfig(options);
122
162
  const name = functionName;
123
- context.report({
124
- node: nameNode,
125
- messageId: "missingStory",
126
- data: { name, functionName: name },
127
- fix: allowFix
128
- ? deps.createAddStoryFix(resolvedTarget, effectiveTemplate)
129
- : undefined,
130
- suggest: [
131
- {
132
- desc: `Add JSDoc @story annotation for function '${name}', e.g., ${effectiveTemplate}`,
133
- fix: deps.createAddStoryFix(resolvedTarget, effectiveTemplate),
134
- },
135
- ],
136
- });
137
- }
138
- catch (error) {
139
- // Intentionally swallow unexpected helper errors so traceability checks never
140
- // break lint runs. When TRACEABILITY_DEBUG=1 is set, log a debug message to
141
- // help diagnose misbehaving helpers in local development without affecting
142
- // normal CI or production usage.
143
- if (process.env.TRACEABILITY_DEBUG === "1") {
144
- console.error("[traceability] coreReportMissing failed for node", error?.message ?? error);
145
- }
146
- }
163
+ context.report(createMissingStoryReportDescriptor({
164
+ nameNode,
165
+ name,
166
+ resolvedTarget,
167
+ effectiveTemplate,
168
+ allowFix,
169
+ createFix: deps.createAddStoryFix,
170
+ }));
171
+ });
147
172
  }
148
173
  /**
149
174
  * Core helper to report a missing @story annotation for a method-like node.
@@ -158,37 +183,21 @@ function coreReportMissing(deps, context, sourceCode, config) {
158
183
  */
159
184
  function coreReportMethod(deps, context, sourceCode, config) {
160
185
  const { node, target: passedTarget, options = {} } = config;
161
- try {
186
+ withSafeReporting("coreReportMethod", () => {
162
187
  if (deps.hasStoryAnnotation(sourceCode, node)) {
163
188
  return;
164
189
  }
165
190
  const resolvedTarget = passedTarget ?? deps.resolveAnnotationTargetNode(sourceCode, node, null);
166
191
  const name = deps.extractName(node);
167
192
  const nameNode = (node.key && node.key.type === "Identifier" && node.key) || node;
168
- const effectiveTemplate = deps.getAnnotationTemplate(options.annotationTemplateOverride);
169
- const allowFix = deps.shouldApplyAutoFix(options.autoFixToggle);
170
- context.report({
171
- node: nameNode,
172
- messageId: "missingStory",
173
- data: { name, functionName: name },
174
- fix: allowFix
175
- ? deps.createMethodFix(resolvedTarget, effectiveTemplate)
176
- : undefined,
177
- suggest: [
178
- {
179
- desc: `Add JSDoc @story annotation for function '${name}', e.g., ${effectiveTemplate}`,
180
- fix: deps.createMethodFix(resolvedTarget, effectiveTemplate),
181
- },
182
- ],
183
- });
184
- }
185
- catch (error) {
186
- // Intentionally swallow unexpected helper errors so traceability checks never
187
- // break lint runs. When TRACEABILITY_DEBUG=1 is set, log a debug message to
188
- // help diagnose misbehaving helpers in local development without affecting
189
- // normal CI or production usage.
190
- if (process.env.TRACEABILITY_DEBUG === "1") {
191
- console.error("[traceability] coreReportMethod failed for node", error?.message ?? error);
192
- }
193
- }
193
+ const { effectiveTemplate, allowFix } = deps.buildTemplateConfig(options);
194
+ context.report(createMissingStoryReportDescriptor({
195
+ nameNode,
196
+ name,
197
+ resolvedTarget,
198
+ effectiveTemplate,
199
+ allowFix,
200
+ createFix: deps.createMethodFix,
201
+ }));
202
+ });
194
203
  }
@@ -86,6 +86,70 @@ function validateBranchTypes(context) {
86
86
  function extractCommentValue(_c) {
87
87
  return _c.value;
88
88
  }
89
+ /**
90
+ * Extract trimmed comment text for a given source line index or return null
91
+ * when the line is blank or not a comment. This helper centralizes the
92
+ * formatter-aware rules used by branch helpers when scanning for contiguous
93
+ * comment lines around branches.
94
+ * @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-COMMENT-ASSOCIATION
95
+ * @supports docs/stories/025.0-DEV-CATCH-ANNOTATION-POSITION.story.md REQ-DUAL-POSITION-DETECTION REQ-FALLBACK-LOGIC
96
+ * @supports docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md REQ-DUAL-POSITION-DETECTION-ELSE-IF REQ-FALLBACK-LOGIC-ELSE-IF
97
+ */
98
+ function getCommentTextAtLine(lines, index) {
99
+ const line = lines[index];
100
+ if (!line || !line.trim()) {
101
+ return null;
102
+ }
103
+ if (!/^\s*(\/\/|\/\*)/.test(line)) {
104
+ return null;
105
+ }
106
+ return line.trim();
107
+ }
108
+ /**
109
+ * Collect a single contiguous comment line at the given index, appending its
110
+ * trimmed text to the accumulator. Returns true when a valid comment was
111
+ * collected and false when scanning should stop (blank or non-comment line).
112
+ * @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-COMMENT-ASSOCIATION
113
+ * @supports docs/stories/025.0-DEV-CATCH-ANNOTATION-POSITION.story.md REQ-DUAL-POSITION-DETECTION REQ-FALLBACK-LOGIC
114
+ * @supports docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md REQ-DUAL-POSITION-DETECTION-ELSE-IF REQ-FALLBACK-LOGIC-ELSE-IF
115
+ */
116
+ function collectCommentLine(lines, index, comments) {
117
+ const commentText = getCommentTextAtLine(lines, index);
118
+ if (!commentText) {
119
+ return false;
120
+ }
121
+ comments.push(commentText);
122
+ return true;
123
+ }
124
+ /**
125
+ * Scan contiguous formatter-aware comment lines between the provided 0-based
126
+ * start and end indices (inclusive), stopping when a non-comment or blank line
127
+ * is encountered. This helper is used as a line-based fallback when
128
+ * structured comment APIs are not available for branch bodies.
129
+ * @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-COMMENT-ASSOCIATION
130
+ * @supports docs/stories/025.0-DEV-CATCH-ANNOTATION-POSITION.story.md REQ-FALLBACK-LOGIC
131
+ * @supports docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md REQ-FALLBACK-LOGIC-ELSE-IF
132
+ */
133
+ function scanCommentLinesInRange(lines, startIndex, endIndexInclusive) {
134
+ if (!Array.isArray(lines) || lines.length === 0) {
135
+ return "";
136
+ }
137
+ if (startIndex < 0 ||
138
+ startIndex >= lines.length ||
139
+ startIndex > endIndexInclusive) {
140
+ return "";
141
+ }
142
+ const comments = [];
143
+ const lastIndex = Math.min(endIndexInclusive, lines.length - 1);
144
+ let i = startIndex;
145
+ while (i <= lastIndex) {
146
+ if (!collectCommentLine(lines, i, comments)) {
147
+ break;
148
+ }
149
+ i++;
150
+ }
151
+ return comments.join(" ");
152
+ }
89
153
  function isElseIfBranch(node, parent) {
90
154
  return (node &&
91
155
  node.type === "IfStatement" &&
@@ -120,65 +184,115 @@ function gatherCatchClauseCommentText(sourceCode, node, beforeText) {
120
184
  const lines = sourceCode.lines;
121
185
  const startIndex = node.body.loc.start.line - 1;
122
186
  const endIndex = node.body.loc.end.line - 1;
123
- const comments = [];
124
- let i = startIndex + 1;
125
- while (i <= endIndex) {
126
- const line = lines[i];
127
- if (!line || !line.trim()) {
128
- break;
129
- }
130
- if (!/^\s*(\/\/|\/\*)/.test(line)) {
131
- break;
132
- }
133
- comments.push(line.trim());
134
- i++;
135
- }
136
- const insideText = comments.join(" ");
187
+ const insideText = scanCommentLinesInRange(lines, startIndex + 1, endIndex);
137
188
  if (insideText) {
138
189
  return insideText;
139
190
  }
140
191
  }
141
192
  return beforeText;
142
193
  }
194
+ /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */
195
+ function scanElseIfPrecedingComments(sourceCode, node) {
196
+ const lines = sourceCode.lines;
197
+ if (!node.loc || !node.loc.start || typeof node.loc.start.line !== "number") {
198
+ return "";
199
+ }
200
+ const startLine = node.loc.start.line - 1;
201
+ const comments = [];
202
+ let i = startLine - 1;
203
+ let scanned = 0;
204
+ while (i >= 0 && scanned < PRE_COMMENT_OFFSET) {
205
+ const commentText = getCommentTextAtLine(lines, i);
206
+ if (!commentText) {
207
+ break;
208
+ }
209
+ comments.unshift(commentText);
210
+ i--;
211
+ scanned++;
212
+ }
213
+ return comments.join(" ");
214
+ }
215
+ /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */
216
+ function hasValidElseIfBlockLoc(node) {
217
+ const hasBlockConsequent = node.consequent &&
218
+ node.consequent.type === "BlockStatement" &&
219
+ node.consequent.loc &&
220
+ node.consequent.loc.start;
221
+ return !!(node.test &&
222
+ node.test.loc &&
223
+ node.test.loc.end &&
224
+ hasBlockConsequent);
225
+ }
226
+ /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */
227
+ function scanElseIfBetweenConditionAndBody(sourceCode, node) {
228
+ const lines = sourceCode.lines;
229
+ const conditionEndLine = node.test.loc.end.line;
230
+ const consequentStartLine = node.consequent.loc.start.line;
231
+ // Lines in sourceCode are 0-based indexes, but loc.line values are 1-based.
232
+ // We want to scan comments strictly between the condition and the
233
+ // consequent body, so we start at the line after the condition's end and
234
+ // stop at the line immediately before the consequent's starting line.
235
+ const startIndex = conditionEndLine; // already the next logical line index when 0-based
236
+ const endIndexExclusive = consequentStartLine - 1;
237
+ if (endIndexExclusive <= startIndex) {
238
+ return "";
239
+ }
240
+ return scanCommentLinesInRange(lines, startIndex, endIndexExclusive - 1);
241
+ }
242
+ /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */
243
+ function scanElseIfInsideBlockComments(sourceCode, node) {
244
+ const lines = sourceCode.lines;
245
+ const consequentStartLine = node.consequent.loc.start.line;
246
+ const comments = [];
247
+ // Intentionally start from the block's start line (using the same 1-based line value as provided by the parser)
248
+ // so that, when indexing into sourceCode.lines, this corresponds to the first logical line inside the block body
249
+ // for typical formatter layouts.
250
+ let lineIndex = consequentStartLine;
251
+ while (lineIndex < lines.length) {
252
+ if (!collectCommentLine(lines, lineIndex, comments)) {
253
+ break;
254
+ }
255
+ lineIndex++;
256
+ }
257
+ return comments.join(" ");
258
+ }
143
259
  /**
144
260
  * Gather annotation text for IfStatement else-if branches, supporting comments placed
145
- * between the else-if condition and the consequent statement body.
261
+ * before the else keyword, between the else-if condition and the consequent body,
262
+ * and in the first comment-only lines inside the consequent block body.
146
263
  * @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md
147
264
  * @supports REQ-DUAL-POSITION-DETECTION
148
265
  * @supports REQ-FALLBACK-LOGIC
149
266
  */
150
267
  function gatherElseIfCommentText(sourceCode, node, parent, beforeText) {
151
- if (/@story\b/.test(beforeText) || /@req\b/.test(beforeText)) {
268
+ if (beforeText &&
269
+ (/@story\b/.test(beforeText) ||
270
+ /@req\b/.test(beforeText) ||
271
+ /@supports\b/.test(beforeText))) {
152
272
  return beforeText;
153
273
  }
154
274
  if (!isElseIfBranch(node, parent)) {
155
275
  return beforeText;
156
276
  }
157
- if (!node.consequent ||
158
- node.consequent.type !== "BlockStatement" ||
159
- !node.consequent.loc ||
160
- !node.consequent.loc.start) {
161
- return beforeText;
277
+ const beforeElseText = scanElseIfPrecedingComments(sourceCode, node);
278
+ if (beforeElseText &&
279
+ (/@story\b/.test(beforeElseText) ||
280
+ /@req\b/.test(beforeElseText) ||
281
+ /@supports\b/.test(beforeElseText))) {
282
+ return beforeElseText;
162
283
  }
163
- if (!node.test || !node.test.loc || !node.test.loc.end) {
284
+ if (!hasValidElseIfBlockLoc(node)) {
164
285
  return beforeText;
165
286
  }
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());
287
+ const betweenText = scanElseIfBetweenConditionAndBody(sourceCode, node);
288
+ if (betweenText) {
289
+ return betweenText;
290
+ }
291
+ const insideText = scanElseIfInsideBlockComments(sourceCode, node);
292
+ if (insideText) {
293
+ return insideText;
179
294
  }
180
- const betweenText = comments.join(" ");
181
- return betweenText || beforeText;
295
+ return beforeText;
182
296
  }
183
297
  /**
184
298
  * Gather leading comment text for a branch node.
@@ -345,11 +459,13 @@ function getBaseBranchIndentAndInsertPos(sourceCode, node) {
345
459
  * @req REQ-ANNOTATION-PARSING - Parse @story and @req annotations from branch comments
346
460
  * @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md
347
461
  * @supports REQ-DUAL-POSITION-DETECTION
462
+ * @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-SUPPORTS-ALTERNATIVE
348
463
  */
349
464
  function getBranchAnnotationInfo(sourceCode, node, parent) {
350
465
  const text = gatherBranchCommentText(sourceCode, node, parent);
351
- const missingStory = !/@story\b/.test(text);
352
- const missingReq = !/@req\b/.test(text);
466
+ const hasSupports = /@supports\b/.test(text);
467
+ const missingStory = !/@story\b/.test(text) && !hasSupports;
468
+ const missingReq = !/@req\b/.test(text) && !hasSupports;
353
469
  let { indent, insertPos } = getBaseBranchIndentAndInsertPos(sourceCode, node);
354
470
  if (isElseIfBranch(node, parent) &&
355
471
  node.consequent &&
@@ -378,13 +494,11 @@ function getBranchAnnotationInfo(sourceCode, node, parent) {
378
494
  function reportMissingAnnotations(context, node, storyFixCountRef) {
379
495
  const sourceCode = context.getSourceCode();
380
496
  /**
381
- * Determine the direct parent of the node using the ancestors stack when available.
497
+ * Determine the direct parent of the node using the parent reference on the node.
382
498
  * @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md
383
499
  * @supports REQ-DUAL-POSITION-DETECTION
384
500
  */
385
- const contextAny = context;
386
- const ancestors = contextAny.getAncestors?.() || [];
387
- const parent = ancestors.length > 0 ? ancestors[ancestors.length - 1] : undefined;
501
+ const parent = node.parent;
388
502
  const { missingStory, missingReq, indent, insertPos } = getBranchAnnotationInfo(sourceCode, node, parent);
389
503
  const actions = [
390
504
  {
@@ -35,10 +35,12 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  /**
37
37
  * Dogfooding validation integration tests
38
- * @supports docs/stories/023.0-MAINT-DOGFOODING-VALIDATION.story.md REQ-DOGFOODING-TEST REQ-DOGFOODING-CI
38
+ * @supports docs/stories/023.0-MAINT-DOGFOODING-VALIDATION.story.md REQ-DOGFOODING-TEST REQ-DOGFOODING-CI REQ-DOGFOODING-VERIFY REQ-DOGFOODING-PRESET
39
39
  */
40
40
  const path = __importStar(require("path"));
41
41
  const child_process_1 = require("child_process");
42
+ const use_at_your_own_risk_1 = require("eslint/use-at-your-own-risk");
43
+ const index_1 = __importStar(require("../../src/index"));
42
44
  /**
43
45
  * @supports docs/stories/023.0-MAINT-DOGFOODING-VALIDATION.story.md REQ-DOGFOODING-TEST
44
46
  */
@@ -91,4 +93,34 @@ describe("Dogfooding Validation (Story 023.0-MAINT-DOGFOODING-VALIDATION)", () =
91
93
  expect(result.stdout).toContain("error");
92
94
  expect(result.stdout).toContain("src/dogfood.ts");
93
95
  });
96
+ it("[REQ-DOGFOODING-VERIFY] should report at least one traceability rule active for TS sources", () => {
97
+ /**
98
+ * @supports docs/stories/023.0-MAINT-DOGFOODING-VALIDATION.story.md REQ-DOGFOODING-VERIFY
99
+ */
100
+ const eslintConfig = require("../../eslint.config.js");
101
+ const tsConfig = getTsConfigFromEslintConfig(eslintConfig);
102
+ expect(tsConfig).toBeDefined();
103
+ const rules = tsConfig.rules || {};
104
+ const hasTraceabilityRule = Object.keys(rules).some((key) => key.startsWith("traceability/"));
105
+ expect(hasTraceabilityRule).toBe(true);
106
+ });
107
+ it("[REQ-DOGFOODING-PRESET] should be compatible with recommended preset usage without throwing", async () => {
108
+ /**
109
+ * @supports docs/stories/023.0-MAINT-DOGFOODING-VALIDATION.story.md REQ-DOGFOODING-PRESET
110
+ */
111
+ const config = [
112
+ { plugins: { traceability: index_1.default }, rules: {} },
113
+ ...index_1.configs.recommended,
114
+ ];
115
+ const eslint = new use_at_your_own_risk_1.FlatESLint({
116
+ overrideConfig: config,
117
+ overrideConfigFile: true,
118
+ ignore: false,
119
+ });
120
+ const results = await eslint.lintText("function foo() {}", {
121
+ filePath: "example.ts",
122
+ });
123
+ expect(results.length).toBeGreaterThanOrEqual(1);
124
+ expect(Array.isArray(results[0].messages)).toBe(true);
125
+ });
94
126
  });
@@ -50,9 +50,8 @@ describe("Else-if annotations with Prettier (Story 026.0-DEV-ELSE-IF-ANNOTATION-
50
50
  }
51
51
  return result.stdout;
52
52
  }
53
- if (process.env.TRACEABILITY_EXPERIMENTAL_ELSE_IF === "1") {
54
- it("[REQ-PRETTIER-COMPATIBILITY-ELSE-IF-BEFORE] accepts code where annotations start before else-if but are moved between condition and body by Prettier", () => {
55
- const original = `
53
+ it("[REQ-PRETTIER-COMPATIBILITY-ELSE-IF-BEFORE] accepts code where annotations start before else-if but are moved between condition and body by Prettier", () => {
54
+ const original = `
56
55
  function doA() {
57
56
  return 1;
58
57
  }
@@ -72,15 +71,16 @@ else if (anotherVeryLongConditionThatForcesWrapping && someOtherCondition) {
72
71
  doB();
73
72
  }
74
73
  `;
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 (");
78
- expect(formatted).toContain("@story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md");
79
- const result = runEslintWithRequireBranchAnnotation(formatted);
80
- expect(result.status).toBe(0);
81
- });
82
- it("[REQ-PRETTIER-COMPATIBILITY-ELSE-IF-INSIDE] accepts code where annotations start between condition and body and are preserved by Prettier", () => {
83
- const original = `
74
+ const formatted = formatWithPrettier(original);
75
+ // Sanity checks: Prettier should keep both the else-if branch and the associated story annotation,
76
+ // but the exact layout and comment movement may vary between versions.
77
+ expect(formatted).toContain("else if");
78
+ expect(formatted).toContain("@story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md");
79
+ const result = runEslintWithRequireBranchAnnotation(formatted);
80
+ expect(result.status).toBe(0);
81
+ });
82
+ it("[REQ-PRETTIER-COMPATIBILITY-ELSE-IF-INSIDE] accepts code where annotations start between condition and body and are preserved by Prettier", () => {
83
+ const original = `
84
84
  function doA() {
85
85
  return 1;
86
86
  }
@@ -101,16 +101,10 @@ if (aVeryLongConditionThatForcesPrettierToWrapTheElseIfBranch && anotherConditio
101
101
  doB();
102
102
  }
103
103
  `;
104
- const formatted = formatWithPrettier(original);
105
- // Note: Prettier's exact layout of the else-if and its comments may differ between versions;
106
- // the rule should accept any of the supported annotation positions regardless of formatting.
107
- const result = runEslintWithRequireBranchAnnotation(formatted);
108
- expect(result.status).toBe(0);
109
- });
110
- }
111
- 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.
114
- });
115
- }
104
+ const formatted = formatWithPrettier(original);
105
+ // Note: Prettier's exact layout of the else-if and its comments may differ between versions;
106
+ // the rule should accept any of the supported annotation positions regardless of formatting.
107
+ const result = runEslintWithRequireBranchAnnotation(formatted);
108
+ expect(result.status).toBe(0);
109
+ });
116
110
  });
@@ -8,7 +8,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
8
8
  * @story docs/stories/008.0-DEV-AUTO-FIX.story.md
9
9
  * @req REQ-AUTOFIX-MISSING - Verify ESLint --fix automatically adds missing @story annotations to functions
10
10
  * @req REQ-AUTOFIX-FORMAT - Verify ESLint --fix corrects simple annotation format issues for @story annotations
11
- * @supports docs/stories/008.0-DEV-AUTO-FIX.story.md REQ-AUTOFIX-MISSING REQ-AUTOFIX-FORMAT
11
+ * @req REQ-AUTOFIX-IDEMPOTENT - Verify ESLint --fix is idempotent and produces no changes on subsequent runs
12
+ * @req REQ-AUTOFIX-SINGLE-APPLICATION - Verify ESLint --fix does not apply the same fix multiple times or create duplicate annotations
13
+ * @supports docs/stories/008.0-DEV-AUTO-FIX.story.md REQ-AUTOFIX-MISSING REQ-AUTOFIX-FORMAT REQ-AUTOFIX-IDEMPOTENT REQ-AUTOFIX-SINGLE-APPLICATION
12
14
  */
13
15
  const eslint_1 = require("eslint");
14
16
  const require_story_annotation_1 = __importDefault(require("../../src/rules/require-story-annotation"));
@@ -196,4 +198,88 @@ describe("Auto-fix behavior (Story 008.0-DEV-AUTO-FIX)", () => {
196
198
  ],
197
199
  });
198
200
  });
201
+ describe("[REQ-AUTOFIX-IDEMPOTENT] and [REQ-AUTOFIX-SINGLE-APPLICATION] require-story-annotation", () => {
202
+ functionRuleTester.run("require-story-annotation --fix idempotent behavior", require_story_annotation_1.default, {
203
+ valid: [
204
+ {
205
+ name: "[REQ-AUTOFIX-IDEMPOTENT] second run on already fixed function produces no changes",
206
+ code: `/** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */\nfunction fixedOnce() {}`,
207
+ },
208
+ {
209
+ name: "[REQ-AUTOFIX-SINGLE-APPLICATION] already annotated code does not receive duplicate annotations",
210
+ code: `class E {\n /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */\n method() {}\n}`,
211
+ },
212
+ ],
213
+ invalid: [
214
+ {
215
+ name: "[REQ-AUTOFIX-IDEMPOTENT] first run adds annotation; subsequent run is a no-op for function declarations",
216
+ code: `function needsFixOnce() {}`,
217
+ output: `/** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */\nfunction needsFixOnce() {}`,
218
+ errors: [
219
+ {
220
+ messageId: "missingStory",
221
+ suggestions: [
222
+ {
223
+ desc: "Add JSDoc @story annotation for function 'needsFixOnce', e.g., /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */",
224
+ output: `/** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */\nfunction needsFixOnce() {}`,
225
+ },
226
+ ],
227
+ },
228
+ ],
229
+ },
230
+ {
231
+ name: "[REQ-AUTOFIX-SINGLE-APPLICATION] does not duplicate annotations for class methods on subsequent runs",
232
+ code: `class F {\n method() {}\n}`,
233
+ output: `class F {\n /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */\n method() {}\n}`,
234
+ errors: [
235
+ {
236
+ messageId: "missingStory",
237
+ suggestions: [
238
+ {
239
+ desc: "Add JSDoc @story annotation for function 'method', e.g., /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */",
240
+ output: `class F {\n /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */\n method() {}\n}`,
241
+ },
242
+ ],
243
+ },
244
+ ],
245
+ },
246
+ ],
247
+ });
248
+ });
249
+ describe("[REQ-AUTOFIX-IDEMPOTENT] and [REQ-AUTOFIX-SINGLE-APPLICATION] valid-annotation-format", () => {
250
+ formatRuleTester.run("valid-annotation-format --fix idempotent behavior", valid_annotation_format_1.default, {
251
+ valid: [
252
+ {
253
+ name: "[REQ-AUTOFIX-IDEMPOTENT] second run after suffix normalization produces no changes",
254
+ code: `// @story docs/stories/005.0-DEV-ANNOTATION-VALIDATION.story.md`,
255
+ },
256
+ {
257
+ name: "[REQ-AUTOFIX-SINGLE-APPLICATION] already-correct suffix is not altered or extended again",
258
+ code: `// @story docs/stories/005.0-DEV-EXAMPLE.story.md`,
259
+ },
260
+ ],
261
+ invalid: [
262
+ {
263
+ name: "[REQ-AUTOFIX-IDEMPOTENT] adds .story.md once; subsequent run sees no further change",
264
+ code: `// @story docs/stories/005.0-DEV-ANNOTATION-VALIDATION`,
265
+ output: `// @story docs/stories/005.0-DEV-ANNOTATION-VALIDATION.story.md`,
266
+ errors: [
267
+ {
268
+ messageId: "invalidStoryFormat",
269
+ },
270
+ ],
271
+ },
272
+ {
273
+ name: "[REQ-AUTOFIX-SINGLE-APPLICATION] converts .story to .story.md only once and does not double-append",
274
+ code: `// @story docs/stories/005.0-DEV-ANNOTATION-VALIDATION.story`,
275
+ output: `// @story docs/stories/005.0-DEV-ANNOTATION-VALIDATION.story.md`,
276
+ errors: [
277
+ {
278
+ messageId: "invalidStoryFormat",
279
+ },
280
+ ],
281
+ },
282
+ ],
283
+ });
284
+ });
199
285
  });
@@ -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
  {
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
5
5
  * @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md
6
6
  * @req REQ-AUTOFIX - Cover additional branch cases in require-story-core (addStoryFixer/reportMissing)
7
7
  * @supports docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md REQ-AUTOFIX
8
+ * @supports docs/stories/007.0-DEV-ERROR-REPORTING.story.md REQ-ERROR-RESILIENCE
8
9
  */
9
10
  const require_story_core_1 = require("../../src/rules/helpers/require-story-core");
10
11
  const require_story_helpers_1 = require("../../src/rules/helpers/require-story-helpers");
@@ -37,4 +38,29 @@ describe("Require Story Core (Story 003.0)", () => {
37
38
  expect(call.node).toBe(node);
38
39
  expect(call.messageId).toBe("missingStory");
39
40
  });
41
+ test("coreReportMissing swallows dependency errors and does not break lint run", () => {
42
+ const deps = {
43
+ hasStoryAnnotation: () => {
44
+ throw new Error("boom");
45
+ },
46
+ getReportedFunctionName: () => "fnX",
47
+ resolveAnnotationTargetNode: () => ({ type: "FunctionDeclaration" }),
48
+ getNameNodeForReport: (node) => node,
49
+ buildTemplateConfig: () => ({
50
+ effectiveTemplate: "/** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */",
51
+ allowFix: true,
52
+ }),
53
+ extractName: () => "fnX",
54
+ getAnnotationTemplate: () => "/** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */",
55
+ shouldApplyAutoFix: () => true,
56
+ createAddStoryFix: () => () => ({}),
57
+ createMethodFix: () => () => ({}),
58
+ };
59
+ const context = {
60
+ report: jest.fn(),
61
+ };
62
+ const node = { type: "FunctionDeclaration" };
63
+ expect(() => (0, require_story_core_1.coreReportMissing)(deps, context, {}, { node })).not.toThrow();
64
+ expect(context.report).not.toHaveBeenCalled();
65
+ });
40
66
  });
@@ -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,145 @@
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
+ it("[REQ-SINGLE-LINE-ELSE-IF-SUPPORT] detects annotations on single-line else-if without braces when placed before the else-if keyword", () => {
108
+ const lines = [
109
+ "let suggestion;",
110
+ "// @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md",
111
+ "// @req REQ-SINGLE-LINE-ELSE-IF-SUPPORT",
112
+ "if (arg === \"--json\") suggestion = \"--format=json\";",
113
+ "// @story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md",
114
+ "// @req REQ-SINGLE-LINE-ELSE-IF-SUPPORT",
115
+ "else if (arg.startsWith(\"--format\")) suggestion = \"--format\";",
116
+ ];
117
+ const sourceCode = createMockSourceCode({
118
+ commentsBefore: [
119
+ {
120
+ value: "@story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md",
121
+ },
122
+ { value: "@req REQ-SINGLE-LINE-ELSE-IF-SUPPORT" },
123
+ ],
124
+ lines,
125
+ });
126
+ const node = {
127
+ type: "IfStatement",
128
+ loc: { start: { line: 7 } },
129
+ test: { loc: { end: { line: 7 } } },
130
+ consequent: {
131
+ // single-line consequent without BlockStatement braces in the real-world source;
132
+ // for this helper-level test we only care that loc values exist and are consistent.
133
+ type: "ExpressionStatement",
134
+ loc: { start: { line: 7 } },
135
+ },
136
+ };
137
+ const parent = {
138
+ type: "IfStatement",
139
+ alternate: node,
140
+ };
141
+ const text = (0, branch_annotation_helpers_1.gatherBranchCommentText)(sourceCode, node, parent);
142
+ expect(text).toContain("@story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md");
143
+ expect(text).toContain("@req REQ-SINGLE-LINE-ELSE-IF-SUPPORT");
144
+ });
145
+ });
@@ -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,101 @@ 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
+ });
312
+ it("[REQ-ANNOTATION-REQ-DETECTION] hasReqAnnotation returns true when advanced heuristics find req via linesBeforeHasReq", () => {
313
+ const context = {
314
+ getSourceCode() {
315
+ return createMockSourceCode({
316
+ lines: [
317
+ "// header without req",
318
+ "/** @req REQ-ADV-LINES */",
319
+ "function bar() {}",
320
+ ],
321
+ });
322
+ },
323
+ };
324
+ const node = {
325
+ loc: { start: { line: 3 } },
326
+ parent: {},
327
+ };
328
+ const jsdoc = { value: "/** no req here */" };
329
+ const comments = [{ value: "no req or supports here" }];
330
+ const has = (0, reqAnnotationDetection_1.hasReqAnnotation)(jsdoc, comments, context, node);
331
+ expect(has).toBe(true);
332
+ });
333
+ it("[REQ-ANNOTATION-REQ-DETECTION] hasReqAnnotation returns true when advanced heuristics find req via parentChainHasReq", () => {
334
+ const sourceCode = {
335
+ getCommentsBefore(n) {
336
+ if (n && n.isReqParent) {
337
+ return [{ value: "/* @req REQ-ADV-PARENT */" }];
338
+ }
339
+ return [{ value: "no req here" }];
340
+ },
341
+ };
342
+ const context = {
343
+ getSourceCode() {
344
+ return sourceCode;
345
+ },
346
+ };
347
+ const node = {
348
+ parent: {
349
+ isReqParent: true,
350
+ parent: {},
351
+ },
352
+ };
353
+ const jsdoc = { value: "/** jsdoc without requirement */" };
354
+ const comments = [{ value: "comment without requirement" }];
355
+ const has = (0, reqAnnotationDetection_1.hasReqAnnotation)(jsdoc, comments, context, node);
356
+ expect(has).toBe(true);
357
+ });
247
358
  });
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.1",
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: