eslint-plugin-traceability 1.12.0 → 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.12.0](https://github.com/voder-ai/eslint-plugin-traceability/compare/v1.11.4...v1.12.0) (2025-12-07)
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
- ### Features
4
+ ### Bug Fixes
5
5
 
6
- * accept [@supports](https://github.com/supports) annotations on branches as alternative format ([6773a3a](https://github.com/voder-ai/eslint-plugin-traceability/commit/6773a3ae190e9b9adc605c7d17112f44401e5b24))
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
  }
@@ -87,24 +87,69 @@ function extractCommentValue(_c) {
87
87
  return _c.value;
88
88
  }
89
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).
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.
93
94
  * @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-COMMENT-ASSOCIATION
94
95
  * @supports docs/stories/025.0-DEV-CATCH-ANNOTATION-POSITION.story.md REQ-DUAL-POSITION-DETECTION REQ-FALLBACK-LOGIC
95
96
  * @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
  */
97
- function collectCommentLine(lines, index, comments) {
98
+ function getCommentTextAtLine(lines, index) {
98
99
  const line = lines[index];
99
100
  if (!line || !line.trim()) {
100
- return false;
101
+ return null;
101
102
  }
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) {
103
119
  return false;
104
120
  }
105
- comments.push(line.trim());
121
+ comments.push(commentText);
106
122
  return true;
107
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
+ }
108
153
  function isElseIfBranch(node, parent) {
109
154
  return (node &&
110
155
  node.type === "IfStatement" &&
@@ -139,15 +184,7 @@ function gatherCatchClauseCommentText(sourceCode, node, beforeText) {
139
184
  const lines = sourceCode.lines;
140
185
  const startIndex = node.body.loc.start.line - 1;
141
186
  const endIndex = node.body.loc.end.line - 1;
142
- const comments = [];
143
- let i = startIndex + 1;
144
- while (i <= endIndex) {
145
- if (!collectCommentLine(lines, i, comments)) {
146
- break;
147
- }
148
- i++;
149
- }
150
- const insideText = comments.join(" ");
187
+ const insideText = scanCommentLinesInRange(lines, startIndex + 1, endIndex);
151
188
  if (insideText) {
152
189
  return insideText;
153
190
  }
@@ -165,14 +202,11 @@ function scanElseIfPrecedingComments(sourceCode, node) {
165
202
  let i = startLine - 1;
166
203
  let scanned = 0;
167
204
  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)) {
205
+ const commentText = getCommentTextAtLine(lines, i);
206
+ if (!commentText) {
173
207
  break;
174
208
  }
175
- comments.unshift(line.trim());
209
+ comments.unshift(commentText);
176
210
  i--;
177
211
  scanned++;
178
212
  }
@@ -194,13 +228,16 @@ function scanElseIfBetweenConditionAndBody(sourceCode, node) {
194
228
  const lines = sourceCode.lines;
195
229
  const conditionEndLine = node.test.loc.end.line;
196
230
  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
- }
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 "";
202
239
  }
203
- return comments.join(" ");
240
+ return scanCommentLinesInRange(lines, startIndex, endIndexExclusive - 1);
204
241
  }
205
242
  /** @story docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md */
206
243
  function scanElseIfInsideBlockComments(sourceCode, node) {
@@ -228,7 +265,10 @@ function scanElseIfInsideBlockComments(sourceCode, node) {
228
265
  * @supports REQ-FALLBACK-LOGIC
229
266
  */
230
267
  function gatherElseIfCommentText(sourceCode, node, parent, beforeText) {
231
- 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))) {
232
272
  return beforeText;
233
273
  }
234
274
  if (!isElseIfBranch(node, parent)) {
@@ -236,7 +276,9 @@ function gatherElseIfCommentText(sourceCode, node, parent, beforeText) {
236
276
  }
237
277
  const beforeElseText = scanElseIfPrecedingComments(sourceCode, node);
238
278
  if (beforeElseText &&
239
- (/@story\b/.test(beforeElseText) || /@req\b/.test(beforeElseText))) {
279
+ (/@story\b/.test(beforeElseText) ||
280
+ /@req\b/.test(beforeElseText) ||
281
+ /@supports\b/.test(beforeElseText))) {
240
282
  return beforeElseText;
241
283
  }
242
284
  if (!hasValidElseIfBlockLoc(node)) {
@@ -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,16 +71,16 @@ else if (anotherVeryLongConditionThatForcesWrapping && someOtherCondition) {
72
71
  doB();
73
72
  }
74
73
  `;
75
- const formatted = formatWithPrettier(original);
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");
79
- expect(formatted).toContain("@story docs/stories/026.0-DEV-ELSE-IF-ANNOTATION-POSITION.story.md");
80
- const result = runEslintWithRequireBranchAnnotation(formatted);
81
- expect(result.status).toBe(0);
82
- });
83
- it("[REQ-PRETTIER-COMPATIBILITY-ELSE-IF-INSIDE] accepts code where annotations start between condition and body and are preserved by Prettier", () => {
84
- 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 = `
85
84
  function doA() {
86
85
  return 1;
87
86
  }
@@ -102,70 +101,10 @@ if (aVeryLongConditionThatForcesPrettierToWrapTheElseIfBranch && anotherConditio
102
101
  doB();
103
102
  }
104
103
  `;
105
- const formatted = formatWithPrettier(original);
106
- // Note: Prettier's exact layout of the else-if and its comments may differ between versions;
107
- // the rule should accept any of the supported annotation positions regardless of formatting.
108
- const result = runEslintWithRequireBranchAnnotation(formatted);
109
- expect(result.status).toBe(0);
110
- });
111
- }
112
- else {
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);
169
- });
170
- }
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
+ });
171
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
  });
@@ -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
  });
@@ -104,4 +104,42 @@ describe("gatherBranchCommentText else-if behavior (Story 026.0-DEV-ELSE-IF-ANNO
104
104
  expect(text).toContain("@req REQ-POSITION-PRIORITY-ELSE-IF");
105
105
  expect(text).not.toContain("REQ-POSITION-PRIORITY-ELSE-IF-BETWEEN");
106
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
+ });
107
145
  });
@@ -309,4 +309,50 @@ describe("reqAnnotationDetection advanced heuristics (Story 003.0-DEV-FUNCTION-A
309
309
  const has = (0, reqAnnotationDetection_1.hasReqAnnotation)(jsdoc, [], undefined, node);
310
310
  expect(has).toBe(true);
311
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
+ });
312
358
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-traceability",
3
- "version": "1.12.0",
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",