eslint-plugin-traceability 1.12.1 → 1.13.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.1](https://github.com/voder-ai/eslint-plugin-traceability/compare/v1.12.0...v1.12.1) (2025-12-07)
1
+ ## [1.13.1](https://github.com/voder-ai/eslint-plugin-traceability/compare/v1.13.0...v1.13.1) (2025-12-08)
2
2
 
3
3
 
4
4
  ### Bug Fixes
5
5
 
6
- * support single-line else-if annotations and enable Prettier tests ([967b7e0](https://github.com/voder-ai/eslint-plugin-traceability/commit/967b7e02e12c4415efa0df2772ccde77b94cd1a8))
6
+ * refine no-redundant-annotation rule tests and behavior ([7d72670](https://github.com/voder-ai/eslint-plugin-traceability/commit/7d726702e3ad2268778c06de3f3a9673033e3a61))
7
7
 
8
8
  # Changelog
9
9
 
package/lib/src/index.js CHANGED
@@ -19,6 +19,7 @@ const RULE_NAMES = [
19
19
  "valid-req-reference",
20
20
  "prefer-implements-annotation",
21
21
  "require-test-traceability",
22
+ "no-redundant-annotation",
22
23
  ];
23
24
  const rules = {};
24
25
  exports.rules = rules;
@@ -156,6 +157,7 @@ const TRACEABILITY_RULE_SEVERITIES = {
156
157
  "traceability/valid-story-reference": "error",
157
158
  "traceability/valid-req-reference": "error",
158
159
  "traceability/require-test-traceability": "error",
160
+ "traceability/no-redundant-annotation": "warn",
159
161
  };
160
162
  /**
161
163
  * @story docs/stories/007.0-DEV-ERROR-REPORTING.story.md
@@ -0,0 +1,3 @@
1
+ import type { Rule } from "eslint";
2
+ declare const rule: Rule.RuleModule;
3
+ export default rule;
@@ -0,0 +1,308 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const branch_annotation_helpers_1 = require("../utils/branch-annotation-helpers");
4
+ const annotation_scope_analyzer_1 = require("../utils/annotation-scope-analyzer");
5
+ /**
6
+ * ESLint rule to detect redundant traceability annotations on statements
7
+ * that are already covered by their containing scope.
8
+ *
9
+ * This rule focuses on simple, statement-level patterns that the
10
+ * existing branch and function rules already treat as covered by
11
+ * surrounding annotations. It treats redundant annotations as
12
+ * maintainability concerns rather than correctness issues, and is
13
+ * therefore exposed as a warning-level rule by default.
14
+ *
15
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SCOPE-ANALYSIS REQ-DUPLICATION-DETECTION REQ-STATEMENT-SIGNIFICANCE REQ-SAFE-REMOVAL REQ-DIFFERENT-REQUIREMENTS REQ-CONFIGURABLE-STRICTNESS REQ-SCOPE-INHERITANCE
16
+ */
17
+ const DEFAULT_ALWAYS_COVERED_STATEMENTS = [
18
+ "ReturnStatement",
19
+ "VariableDeclaration",
20
+ ];
21
+ const DEFAULT_STRICTNESS = "moderate";
22
+ const DEFAULT_ALLOW_EMPHASIS_DUPLICATION = false;
23
+ const DEFAULT_MAX_SCOPE_DEPTH = 3;
24
+ function normalizeOptions(raw) {
25
+ const strictness = raw && typeof raw.strictness === "string"
26
+ ? raw.strictness
27
+ : DEFAULT_STRICTNESS;
28
+ const allowEmphasisDuplication = typeof raw?.allowEmphasisDuplication === "boolean"
29
+ ? raw.allowEmphasisDuplication
30
+ : DEFAULT_ALLOW_EMPHASIS_DUPLICATION;
31
+ const maxScopeDepth = typeof raw?.maxScopeDepth === "number" && raw.maxScopeDepth > 0
32
+ ? raw.maxScopeDepth
33
+ : DEFAULT_MAX_SCOPE_DEPTH;
34
+ const alwaysCovered = Array.isArray(raw?.alwaysCovered)
35
+ ? raw.alwaysCovered
36
+ : Array.from(DEFAULT_ALWAYS_COVERED_STATEMENTS);
37
+ return {
38
+ strictness,
39
+ allowEmphasisDuplication,
40
+ maxScopeDepth,
41
+ alwaysCovered,
42
+ };
43
+ }
44
+ /**
45
+ * Compute the story/requirement pairs for annotations that apply to the
46
+ * given scope node.
47
+ *
48
+ * For branch scopes we reuse the same comment-gathering helper used by
49
+ * the require-branch-annotation rule so that REQ-SCOPE-INHERITANCE
50
+ * aligns with existing behavior.
51
+ *
52
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SCOPE-ANALYSIS REQ-SCOPE-INHERITANCE
53
+ */
54
+ function getScopePairs(context, scopeNode, parent) {
55
+ const sourceCode = context.getSourceCode();
56
+ // Branch-style scope: use the branch helpers to collect comment text.
57
+ if (branch_annotation_helpers_1.DEFAULT_BRANCH_TYPES.includes(scopeNode.type)) {
58
+ const text = (0, branch_annotation_helpers_1.gatherBranchCommentText)(sourceCode, scopeNode, parent);
59
+ return (0, annotation_scope_analyzer_1.extractStoryReqPairsFromText)(text);
60
+ }
61
+ // Function-like scopes: collect from JSDoc and leading/before comments
62
+ const FUNCTION_LIKE_TYPES = new Set([
63
+ "FunctionDeclaration",
64
+ "FunctionExpression",
65
+ "ArrowFunctionExpression",
66
+ "MethodDefinition",
67
+ "TSDeclareFunction",
68
+ "TSMethodSignature",
69
+ ]);
70
+ const comments = [];
71
+ if (FUNCTION_LIKE_TYPES.has(scopeNode.type)) {
72
+ const jsdoc = sourceCode.getJSDocComment
73
+ ? sourceCode.getJSDocComment(scopeNode)
74
+ : null;
75
+ const before = sourceCode.getCommentsBefore
76
+ ? sourceCode.getCommentsBefore(scopeNode) || []
77
+ : [];
78
+ if (jsdoc) {
79
+ comments.push(jsdoc);
80
+ }
81
+ if (Array.isArray(scopeNode.leadingComments)) {
82
+ comments.push(...scopeNode.leadingComments);
83
+ }
84
+ comments.push(...before);
85
+ return (0, annotation_scope_analyzer_1.extractStoryReqPairsFromComments)(comments);
86
+ }
87
+ // Fallback: inspect JSDoc and leading comments around the scope node.
88
+ const jsdoc = sourceCode.getJSDocComment
89
+ ? sourceCode.getJSDocComment(scopeNode)
90
+ : null;
91
+ const before = sourceCode.getCommentsBefore
92
+ ? sourceCode.getCommentsBefore(scopeNode) || []
93
+ : [];
94
+ if (jsdoc) {
95
+ comments.push(jsdoc);
96
+ }
97
+ if (Array.isArray(scopeNode.leadingComments)) {
98
+ comments.push(...scopeNode.leadingComments);
99
+ }
100
+ comments.push(...before);
101
+ return (0, annotation_scope_analyzer_1.extractStoryReqPairsFromComments)(comments);
102
+ }
103
+ /**
104
+ * Collect the comments directly associated with a statement node.
105
+ *
106
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-STATEMENT-SIGNIFICANCE REQ-SCOPE-ANALYSIS
107
+ */
108
+ function getStatementComments(context, node) {
109
+ const sourceCode = context.getSourceCode();
110
+ const comments = [];
111
+ if (sourceCode.getCommentsBefore) {
112
+ comments.push(...(sourceCode.getCommentsBefore(node) || []));
113
+ }
114
+ if (Array.isArray(node.leadingComments)) {
115
+ comments.push(...node.leadingComments);
116
+ }
117
+ return comments;
118
+ }
119
+ /**
120
+ * Debug helper for logging scope-level pairs in TRACEABILITY_DEBUG mode.
121
+ *
122
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-REDUNDANCY-PATTERNS
123
+ */
124
+ function debugScopePairs(scopeNode, scopePairs) {
125
+ if (process.env.TRACEABILITY_DEBUG !== "1") {
126
+ return;
127
+ }
128
+ console.log("[no-redundant-annotation] Scope node type=%s pairs=%o", scopeNode && scopeNode.type, Array.from(scopePairs));
129
+ }
130
+ /**
131
+ * Walk up enclosing scopes starting from the given scope node and
132
+ * accumulate all story/requirement pairs, limited by maxScopeDepth.
133
+ *
134
+ * This keeps REQ-SCOPE-INHERITANCE and REQ-CONFIGURABLE-STRICTNESS
135
+ * aligned with the story's configuration model while delegating the
136
+ * actual comment parsing to getScopePairs.
137
+ *
138
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SCOPE-ANALYSIS REQ-SCOPE-INHERITANCE REQ-CONFIGURABLE-STRICTNESS
139
+ */
140
+ function collectScopePairs(context, startingScopeNode, maxScopeDepth) {
141
+ const result = new Set();
142
+ if (!startingScopeNode || maxScopeDepth <= 0) {
143
+ return result;
144
+ }
145
+ let current = startingScopeNode;
146
+ let depth = 0;
147
+ while (current && depth < maxScopeDepth) {
148
+ const parent = current.parent;
149
+ const pairs = getScopePairs(context, current, parent);
150
+ for (const key of pairs) {
151
+ result.add(key);
152
+ }
153
+ current = parent;
154
+ depth += 1;
155
+ }
156
+ return result;
157
+ }
158
+ /**
159
+ * Determine whether a statement is redundant relative to the provided
160
+ * scopePairs and options, and when so return the associated annotation
161
+ * comments. Returns null when the statement should not be treated as
162
+ * redundant.
163
+ *
164
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-REDUNDANCY-PATTERNS REQ-SAFE-REMOVAL REQ-STATEMENT-SIGNIFICANCE REQ-CONFIGURABLE-STRICTNESS
165
+ */
166
+ function getRedundantStatementContext(context, stmt, scopePairs, options) {
167
+ if (scopePairs.size === 0) {
168
+ return null;
169
+ }
170
+ if (!(0, annotation_scope_analyzer_1.isStatementEligibleForRedundancy)(stmt, options, branch_annotation_helpers_1.DEFAULT_BRANCH_TYPES)) {
171
+ return null;
172
+ }
173
+ const stmtComments = getStatementComments(context, stmt);
174
+ if (stmtComments.length === 0) {
175
+ return null;
176
+ }
177
+ const stmtPairs = (0, annotation_scope_analyzer_1.extractStoryReqPairsFromComments)(stmtComments);
178
+ if (process.env.TRACEABILITY_DEBUG === "1") {
179
+ console.log("[no-redundant-annotation] Statement type=%s eligible=%s commentCount=%d pairs=%o", stmt && stmt.type, (0, annotation_scope_analyzer_1.isStatementEligibleForRedundancy)(stmt, options, branch_annotation_helpers_1.DEFAULT_BRANCH_TYPES), stmtComments.length, Array.from(stmtPairs));
180
+ }
181
+ if (stmtPairs.size === 0) {
182
+ return null;
183
+ }
184
+ // When emphasis duplication is allowed, treat a single fully-covered
185
+ // pair as intentional emphasis and skip reporting.
186
+ if (options.allowEmphasisDuplication && stmtPairs.size === 1) {
187
+ if ((0, annotation_scope_analyzer_1.arePairsFullyCovered)(stmtPairs, scopePairs)) {
188
+ return null;
189
+ }
190
+ }
191
+ if (!(0, annotation_scope_analyzer_1.arePairsFullyCovered)(stmtPairs, scopePairs)) {
192
+ return null;
193
+ }
194
+ // At this point the statement-level annotations are fully
195
+ // covered by the parent/ancestor scopes and therefore redundant.
196
+ const annotationComments = stmtComments.filter((comment) => {
197
+ const commentText = typeof comment.value === "string" ? comment.value : "";
198
+ return /@story\b|@req\b|@supports\b/.test(commentText);
199
+ });
200
+ if (annotationComments.length === 0) {
201
+ return null;
202
+ }
203
+ return { comments: annotationComments };
204
+ }
205
+ /**
206
+ * Compute unique removal ranges for the given annotation comments.
207
+ *
208
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SAFE-REMOVAL
209
+ */
210
+ function getRemovalRangesForAnnotationComments(comments, sourceCode) {
211
+ const rangeMap = new Map();
212
+ for (const comment of comments) {
213
+ const [removalStart, removalEnd] = (0, annotation_scope_analyzer_1.getCommentRemovalRange)(comment, sourceCode);
214
+ const key = `${removalStart}:${removalEnd}`;
215
+ if (!rangeMap.has(key)) {
216
+ rangeMap.set(key, [removalStart, removalEnd]);
217
+ }
218
+ }
219
+ return Array.from(rangeMap.values()).sort((a, b) => b[0] - a[0]);
220
+ }
221
+ /**
222
+ * Analyze a block's statements and report redundant traceability annotations.
223
+ *
224
+ * This helper encapsulates the iteration and reporting logic so that the
225
+ * BlockStatement visitor remains small and focused on scope setup.
226
+ *
227
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-REDUNDANCY-PATTERNS REQ-SAFE-REMOVAL REQ-STATEMENT-SIGNIFICANCE
228
+ */
229
+ function reportRedundantAnnotationsInBlock(context, blockNode, scopePairs, options) {
230
+ const statements = Array.isArray(blockNode.body) ? blockNode.body : [];
231
+ if (statements.length === 0 || scopePairs.size === 0)
232
+ return;
233
+ const sourceCode = context.getSourceCode();
234
+ for (const stmt of statements) {
235
+ const info = getRedundantStatementContext(context, stmt, scopePairs, options);
236
+ if (!info) {
237
+ continue;
238
+ }
239
+ const ranges = getRemovalRangesForAnnotationComments(info.comments, sourceCode);
240
+ if (ranges.length === 0) {
241
+ continue;
242
+ }
243
+ context.report({
244
+ node: stmt,
245
+ messageId: "redundantAnnotation",
246
+ fix(fixer) {
247
+ return ranges.map(([start, end]) => fixer.removeRange([start, end]));
248
+ },
249
+ });
250
+ }
251
+ }
252
+ const rule = {
253
+ meta: {
254
+ type: "suggestion",
255
+ docs: {
256
+ description: "Detect and remove redundant traceability annotations already covered by containing scope",
257
+ recommended: false,
258
+ },
259
+ fixable: "code",
260
+ schema: [
261
+ {
262
+ type: "object",
263
+ properties: {
264
+ strictness: {
265
+ enum: ["strict", "moderate", "permissive"],
266
+ },
267
+ allowEmphasisDuplication: {
268
+ type: "boolean",
269
+ },
270
+ maxScopeDepth: {
271
+ type: "number",
272
+ minimum: 1,
273
+ },
274
+ alwaysCovered: {
275
+ type: "array",
276
+ items: { type: "string" },
277
+ uniqueItems: true,
278
+ },
279
+ },
280
+ additionalProperties: false,
281
+ },
282
+ ],
283
+ messages: {
284
+ /**
285
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-CLEAR-MESSAGES REQ-SAFE-REMOVAL
286
+ */
287
+ redundantAnnotation: "Annotation on this statement is redundant; it is already covered by its containing scope.",
288
+ },
289
+ },
290
+ create(context) {
291
+ const options = normalizeOptions(context.options[0]);
292
+ return {
293
+ // @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-REDUNDANCY-PATTERNS REQ-SAFE-REMOVAL
294
+ BlockStatement(node) {
295
+ const parent = node.parent;
296
+ if (process.env.TRACEABILITY_DEBUG === "1") {
297
+ console.log("[no-redundant-annotation] BlockStatement parent=%s statements=%d", parent && parent.type, Array.isArray(node.body) ? node.body.length : 0);
298
+ }
299
+ const scopePairs = collectScopePairs(context, parent, options.maxScopeDepth);
300
+ debugScopePairs(parent, scopePairs);
301
+ if (scopePairs.size === 0)
302
+ return;
303
+ reportRedundantAnnotationsInBlock(context, node, scopePairs, options);
304
+ },
305
+ };
306
+ },
307
+ };
308
+ exports.default = rule;
@@ -0,0 +1,107 @@
1
+ import type { Rule } from "eslint";
2
+ /**
3
+ * Shared types and helpers for redundant-annotation detection.
4
+ *
5
+ * These utilities focus on parsing traceability annotations from comment
6
+ * text and computing relationships between "scope" coverage and
7
+ * statement-level annotations. They are intentionally small, pure
8
+ * functions so that the ESLint rule can delegate most of its logic
9
+ * here while keeping its own create/visitor code shallow.
10
+ *
11
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SCOPE-ANALYSIS REQ-DUPLICATION-DETECTION
12
+ */
13
+ export declare const EXPECTED_RANGE_LENGTH = 2;
14
+ export type Strictness = "strict" | "moderate" | "permissive";
15
+ export interface RedundancyRuleOptions {
16
+ strictness: Strictness;
17
+ allowEmphasisDuplication: boolean;
18
+ maxScopeDepth: number;
19
+ alwaysCovered: readonly string[];
20
+ }
21
+ /**
22
+ * Canonical representation of a single story+requirement pair.
23
+ *
24
+ * The key form `"<story>|<req>"` lets us compare pairs across scopes
25
+ * without repeatedly allocating compound objects.
26
+ *
27
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-DUPLICATION-DETECTION REQ-DIFFERENT-REQUIREMENTS
28
+ */
29
+ export type StoryReqKey = string;
30
+ /**
31
+ * Build a canonical key for a story/requirement pair.
32
+ *
33
+ * Empty story or requirement components are normalized to the empty
34
+ * string so that comparisons remain stable even when some annotations
35
+ * omit one side (for example, malformed or story-less @req lines).
36
+ *
37
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-DUPLICATION-DETECTION REQ-DIFFERENT-REQUIREMENTS
38
+ */
39
+ export declare function toStoryReqKey(storyPath: string | null, reqId: string): StoryReqKey;
40
+ /**
41
+ * Extract story/requirement pairs from a snippet of comment text.
42
+ *
43
+ * Supported patterns:
44
+ * - `@story <path>` followed by one or more `@req <ID>` lines.
45
+ * - `@supports <path> <REQ-ID-1> <REQ-ID-2> ...` where each `REQ-*`
46
+ * token is treated as a separate pair bound to the same story path.
47
+ *
48
+ * The parser is intentionally conservative: it only creates pairs when
49
+ * it can confidently associate a requirement identifier with a story
50
+ * path. This avoids false positives in REQ-DIFFERENT-REQUIREMENTS by
51
+ * ensuring we never conflate different requirement IDs.
52
+ *
53
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SCOPE-ANALYSIS REQ-DUPLICATION-DETECTION REQ-DIFFERENT-REQUIREMENTS
54
+ */
55
+ export declare function extractStoryReqPairsFromText(text: string): Set<StoryReqKey>;
56
+ /**
57
+ * Extract story/requirement pairs from a list of ESLint comment nodes.
58
+ *
59
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SCOPE-ANALYSIS REQ-DUPLICATION-DETECTION
60
+ */
61
+ export declare function extractStoryReqPairsFromComments(comments: any[]): Set<StoryReqKey>;
62
+ /**
63
+ * Determine whether all story/requirement pairs in `child` are already
64
+ * covered by `parent`.
65
+ *
66
+ * This implements the core notion of redundancy: if a statement-level
67
+ * annotation only repeats the exact same story+requirement pairs that
68
+ * are already declared on its containing scope, it does not add any
69
+ * new traceability information.
70
+ *
71
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-DUPLICATION-DETECTION REQ-DIFFERENT-REQUIREMENTS REQ-SCOPE-INHERITANCE
72
+ */
73
+ export declare function arePairsFullyCovered(child: Set<StoryReqKey>, parent: Set<StoryReqKey>): boolean;
74
+ /**
75
+ * Decide whether a given statement node type should be considered
76
+ * "simple" or "significant" for redundancy detection, based on the
77
+ * configured strictness and alwaysCovered lists.
78
+ *
79
+ * - In `strict` mode, all non-branch statements are eligible.
80
+ * - In `moderate` mode (default), only statement types listed in
81
+ * `alwaysCovered` plus bare expression statements are treated as
82
+ * candidates for redundancy.
83
+ * - In `permissive` mode, only `alwaysCovered` types are considered.
84
+ *
85
+ * This keeps REQ-STATEMENT-SIGNIFICANCE and REQ-CONFIGURABLE-STRICTNESS
86
+ * aligned with the story's configuration model.
87
+ *
88
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-STATEMENT-SIGNIFICANCE REQ-CONFIGURABLE-STRICTNESS
89
+ */
90
+ export declare function isStatementEligibleForRedundancy(node: any, options: RedundancyRuleOptions, branchTypes: readonly string[]): boolean;
91
+ /**
92
+ * Compute the character range that should be removed when auto-fixing a
93
+ * redundant annotation comment.
94
+ *
95
+ * The implementation is conservative to satisfy REQ-SAFE-REMOVAL:
96
+ *
97
+ * - When the comment occupies its own line (only whitespace before the
98
+ * comment token), the removal range is expanded to include that
99
+ * leading whitespace and the trailing newline, so the entire line is
100
+ * removed.
101
+ * - When there is other code before the comment on the same line, only
102
+ * the comment text itself is removed, leaving surrounding code and
103
+ * whitespace intact.
104
+ *
105
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SAFE-REMOVAL
106
+ */
107
+ export declare function getCommentRemovalRange(comment: any, sourceCode: ReturnType<Rule.RuleContext["getSourceCode"]>): [number, number];
@@ -0,0 +1,233 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXPECTED_RANGE_LENGTH = void 0;
4
+ exports.toStoryReqKey = toStoryReqKey;
5
+ exports.extractStoryReqPairsFromText = extractStoryReqPairsFromText;
6
+ exports.extractStoryReqPairsFromComments = extractStoryReqPairsFromComments;
7
+ exports.arePairsFullyCovered = arePairsFullyCovered;
8
+ exports.isStatementEligibleForRedundancy = isStatementEligibleForRedundancy;
9
+ exports.getCommentRemovalRange = getCommentRemovalRange;
10
+ /**
11
+ * Shared types and helpers for redundant-annotation detection.
12
+ *
13
+ * These utilities focus on parsing traceability annotations from comment
14
+ * text and computing relationships between "scope" coverage and
15
+ * statement-level annotations. They are intentionally small, pure
16
+ * functions so that the ESLint rule can delegate most of its logic
17
+ * here while keeping its own create/visitor code shallow.
18
+ *
19
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SCOPE-ANALYSIS REQ-DUPLICATION-DETECTION
20
+ */
21
+ exports.EXPECTED_RANGE_LENGTH = 2; // [start, end]
22
+ /**
23
+ * Build a canonical key for a story/requirement pair.
24
+ *
25
+ * Empty story or requirement components are normalized to the empty
26
+ * string so that comparisons remain stable even when some annotations
27
+ * omit one side (for example, malformed or story-less @req lines).
28
+ *
29
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-DUPLICATION-DETECTION REQ-DIFFERENT-REQUIREMENTS
30
+ */
31
+ function toStoryReqKey(storyPath, reqId) {
32
+ const story = storyPath ?? "";
33
+ const req = reqId ?? "";
34
+ return `${story}|${req}`;
35
+ }
36
+ /**
37
+ * Extract story/requirement pairs from a snippet of comment text.
38
+ *
39
+ * Supported patterns:
40
+ * - `@story <path>` followed by one or more `@req <ID>` lines.
41
+ * - `@supports <path> <REQ-ID-1> <REQ-ID-2> ...` where each `REQ-*`
42
+ * token is treated as a separate pair bound to the same story path.
43
+ *
44
+ * The parser is intentionally conservative: it only creates pairs when
45
+ * it can confidently associate a requirement identifier with a story
46
+ * path. This avoids false positives in REQ-DIFFERENT-REQUIREMENTS by
47
+ * ensuring we never conflate different requirement IDs.
48
+ *
49
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SCOPE-ANALYSIS REQ-DUPLICATION-DETECTION REQ-DIFFERENT-REQUIREMENTS
50
+ */
51
+ function extractStoryReqPairsFromText(text) {
52
+ const pairs = new Set();
53
+ if (!text)
54
+ return pairs;
55
+ const lines = text.split(/\r?\n/);
56
+ let currentStory = null;
57
+ for (const rawLine of lines) {
58
+ const line = rawLine.trim();
59
+ if (!line)
60
+ continue;
61
+ // @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SCOPE-ANALYSIS
62
+ const storyMatch = line.match(/@story\s+(\S+)/);
63
+ if (storyMatch) {
64
+ currentStory = storyMatch[1];
65
+ }
66
+ // Handle explicit @req lines that follow the most recent @story.
67
+ const reqMatch = line.match(/@req\s+(\S+)/);
68
+ if (reqMatch && currentStory) {
69
+ pairs.add(toStoryReqKey(currentStory, reqMatch[1]));
70
+ }
71
+ // Handle consolidated @supports lines that encode both story and
72
+ // requirement identifiers on a single line.
73
+ const supportsMatch = line.match(/@supports\s+(\S+)\s+(.+)/);
74
+ if (supportsMatch) {
75
+ const storyPath = supportsMatch[1];
76
+ const tail = supportsMatch[2];
77
+ const tokens = tail
78
+ .split(/\s+/)
79
+ .filter((t) => /^REQ-[A-Z0-9-]+$/.test(t));
80
+ for (const reqId of tokens) {
81
+ pairs.add(toStoryReqKey(storyPath, reqId));
82
+ }
83
+ }
84
+ }
85
+ return pairs;
86
+ }
87
+ /**
88
+ * Extract story/requirement pairs from a list of ESLint comment nodes.
89
+ *
90
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SCOPE-ANALYSIS REQ-DUPLICATION-DETECTION
91
+ */
92
+ function extractStoryReqPairsFromComments(comments) {
93
+ const pairs = new Set();
94
+ if (!Array.isArray(comments) || comments.length === 0) {
95
+ return pairs;
96
+ }
97
+ const combinedText = comments
98
+ .filter((comment) => comment && typeof comment.value === "string")
99
+ .map((comment) => comment.value)
100
+ .join("\n");
101
+ const fromComments = extractStoryReqPairsFromText(combinedText);
102
+ for (const key of fromComments) {
103
+ pairs.add(key);
104
+ }
105
+ return pairs;
106
+ }
107
+ /**
108
+ * Determine whether all story/requirement pairs in `child` are already
109
+ * covered by `parent`.
110
+ *
111
+ * This implements the core notion of redundancy: if a statement-level
112
+ * annotation only repeats the exact same story+requirement pairs that
113
+ * are already declared on its containing scope, it does not add any
114
+ * new traceability information.
115
+ *
116
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-DUPLICATION-DETECTION REQ-DIFFERENT-REQUIREMENTS REQ-SCOPE-INHERITANCE
117
+ */
118
+ function arePairsFullyCovered(child, parent) {
119
+ if (child.size === 0)
120
+ return false;
121
+ if (parent.size === 0)
122
+ return false;
123
+ for (const key of child) {
124
+ if (!parent.has(key)) {
125
+ return false;
126
+ }
127
+ }
128
+ return true;
129
+ }
130
+ /**
131
+ * Decide whether a given statement node type should be considered
132
+ * "simple" or "significant" for redundancy detection, based on the
133
+ * configured strictness and alwaysCovered lists.
134
+ *
135
+ * - In `strict` mode, all non-branch statements are eligible.
136
+ * - In `moderate` mode (default), only statement types listed in
137
+ * `alwaysCovered` plus bare expression statements are treated as
138
+ * candidates for redundancy.
139
+ * - In `permissive` mode, only `alwaysCovered` types are considered.
140
+ *
141
+ * This keeps REQ-STATEMENT-SIGNIFICANCE and REQ-CONFIGURABLE-STRICTNESS
142
+ * aligned with the story's configuration model.
143
+ *
144
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-STATEMENT-SIGNIFICANCE REQ-CONFIGURABLE-STRICTNESS
145
+ */
146
+ function isStatementEligibleForRedundancy(node, options, branchTypes) {
147
+ if (!node || typeof node.type !== "string") {
148
+ return false;
149
+ }
150
+ // Never treat branch nodes themselves as "simple" statements; their
151
+ // annotations are typically intentional and should be preserved.
152
+ if (branchTypes.includes(node.type)) {
153
+ return false;
154
+ }
155
+ const alwaysCoveredSet = new Set(options.alwaysCovered);
156
+ if (alwaysCoveredSet.has(node.type)) {
157
+ return true;
158
+ }
159
+ if (options.strictness === "permissive") {
160
+ return false;
161
+ }
162
+ if (options.strictness === "moderate") {
163
+ // Treat side-effecting expression statements (e.g. assignments or
164
+ // simple calls) as eligible while still excluding more complex
165
+ // control-flow constructs.
166
+ return node.type === "ExpressionStatement";
167
+ }
168
+ // strict: any non-branch statement may be considered.
169
+ return true;
170
+ }
171
+ /**
172
+ * Compute the character range that should be removed when auto-fixing a
173
+ * redundant annotation comment.
174
+ *
175
+ * The implementation is conservative to satisfy REQ-SAFE-REMOVAL:
176
+ *
177
+ * - When the comment occupies its own line (only whitespace before the
178
+ * comment token), the removal range is expanded to include that
179
+ * leading whitespace and the trailing newline, so the entire line is
180
+ * removed.
181
+ * - When there is other code before the comment on the same line, only
182
+ * the comment text itself is removed, leaving surrounding code and
183
+ * whitespace intact.
184
+ *
185
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-SAFE-REMOVAL
186
+ */
187
+ function getCommentRemovalRange(comment, sourceCode) {
188
+ const fullText = sourceCode.getText();
189
+ const range = comment && comment.range;
190
+ if (!Array.isArray(range) || range.length !== exports.EXPECTED_RANGE_LENGTH) {
191
+ return [0, 0];
192
+ }
193
+ let [start, end] = range;
194
+ // Find the start of the current line.
195
+ let lineStart = start;
196
+ while (lineStart > 0) {
197
+ const ch = fullText.charAt(lineStart - 1);
198
+ if (ch === "\n" || ch === "\r")
199
+ break;
200
+ lineStart -= 1;
201
+ }
202
+ const leadingText = fullText.slice(lineStart, start);
203
+ const onlyWhitespaceBeforeComment = leadingText.trim().length === 0;
204
+ let removalStart = start;
205
+ let removalEnd = end;
206
+ if (onlyWhitespaceBeforeComment) {
207
+ removalStart = lineStart;
208
+ }
209
+ // Expand to consume trailing whitespace after the comment.
210
+ while (removalEnd < fullText.length) {
211
+ const ch = fullText.charAt(removalEnd);
212
+ if (ch === " " || ch === " ") {
213
+ removalEnd += 1;
214
+ }
215
+ else {
216
+ break;
217
+ }
218
+ }
219
+ // Optionally include the newline when the comment owns the line.
220
+ if (onlyWhitespaceBeforeComment && removalEnd < fullText.length) {
221
+ const ch = fullText.charAt(removalEnd);
222
+ if (ch === "\r") {
223
+ removalEnd += 1;
224
+ if (fullText.charAt(removalEnd) === "\n") {
225
+ removalEnd += 1;
226
+ }
227
+ }
228
+ else if (ch === "\n") {
229
+ removalEnd += 1;
230
+ }
231
+ }
232
+ return [removalStart, removalEnd];
233
+ }
@@ -417,12 +417,28 @@ function reportMissingReq(context, node, options) {
417
417
  * @supports REQ-ANNOTATION-PARSING
418
418
  * @supports REQ-DUAL-POSITION-DETECTION
419
419
  */
420
- function getBaseBranchIndentAndInsertPos(sourceCode, node) {
421
- let indent = sourceCode.lines[node.loc.start.line - 1].match(/^(\s*)/)?.[1] || "";
422
- let insertPos = sourceCode.getIndexFromLoc({
423
- line: node.loc.start.line,
420
+ /**
421
+ * Compute indentation and insert position for the start of a given 1-based line
422
+ * number. This keeps indentation and fixer insert positions consistent across
423
+ * branch helpers that need to align auto-inserted comments with existing
424
+ * source formatting.
425
+ * @supports docs/stories/004.0-DEV-BRANCH-ANNOTATIONS.story.md REQ-ANNOTATION-PARSING
426
+ */
427
+ function getIndentAndInsertPosForLine(sourceCode, line, fallbackIndent) {
428
+ const lines = sourceCode.lines;
429
+ let indent = fallbackIndent;
430
+ if (line >= 1 && line <= lines.length) {
431
+ const rawLine = lines[line - 1];
432
+ indent = rawLine.match(/^(\s*)/)?.[1] || fallbackIndent;
433
+ }
434
+ const insertPos = sourceCode.getIndexFromLoc({
435
+ line,
424
436
  column: 0,
425
437
  });
438
+ return { indent, insertPos };
439
+ }
440
+ function getBaseBranchIndentAndInsertPos(sourceCode, node) {
441
+ let { indent, insertPos } = getIndentAndInsertPosForLine(sourceCode, node.loc.start.line, "");
426
442
  if (node.type === "CatchClause" && node.body) {
427
443
  const bodyNode = node.body;
428
444
  const bodyStatements = Array.isArray(bodyNode.body)
@@ -433,22 +449,16 @@ function getBaseBranchIndentAndInsertPos(sourceCode, node) {
433
449
  : undefined;
434
450
  if (firstStatement && firstStatement.loc && firstStatement.loc.start) {
435
451
  const firstLine = firstStatement.loc.start.line;
436
- const innerIndent = sourceCode.lines[firstLine - 1].match(/^(\s*)/)?.[1] || "";
437
- indent = innerIndent;
438
- insertPos = sourceCode.getIndexFromLoc({
439
- line: firstLine,
440
- column: 0,
441
- });
452
+ const firstLineInfo = getIndentAndInsertPosForLine(sourceCode, firstLine, "");
453
+ indent = firstLineInfo.indent;
454
+ insertPos = firstLineInfo.insertPos;
442
455
  }
443
456
  else if (bodyNode.loc && bodyNode.loc.start) {
444
457
  const blockLine = bodyNode.loc.start.line;
445
- const blockIndent = sourceCode.lines[blockLine - 1].match(/^(\s*)/)?.[1] || "";
446
- const innerIndent = `${blockIndent} `;
458
+ const blockLineInfo = getIndentAndInsertPosForLine(sourceCode, blockLine, "");
459
+ const innerIndent = `${blockLineInfo.indent} `;
447
460
  indent = innerIndent;
448
- insertPos = sourceCode.getIndexFromLoc({
449
- line: blockLine,
450
- column: 0,
451
- });
461
+ insertPos = blockLineInfo.insertPos;
452
462
  }
453
463
  }
454
464
  return { indent, insertPos };
@@ -475,12 +485,9 @@ function getBranchAnnotationInfo(sourceCode, node, parent) {
475
485
  // For else-if blocks, align auto-fix comments with Prettier's tendency to place comments
476
486
  // inside the wrapped block body; non-block consequents intentionally keep the default behavior.
477
487
  const commentLine = node.consequent.loc.start.line + 1;
478
- const commentIndent = sourceCode.lines[commentLine - 1]?.match(/^(\s*)/)?.[1] || indent;
479
- indent = commentIndent;
480
- insertPos = sourceCode.getIndexFromLoc({
481
- line: commentLine,
482
- column: 0,
483
- });
488
+ const commentLineInfo = getIndentAndInsertPosForLine(sourceCode, commentLine, indent);
489
+ indent = commentLineInfo.indent;
490
+ insertPos = commentLineInfo.insertPos;
484
491
  }
485
492
  return { missingStory, missingReq, indent, insertPos };
486
493
  }
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ /**
7
+ * Integration tests for no-redundant-annotation rule across multiple files
8
+ * @supports docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md REQ-REDUNDANCY-PATTERNS REQ-SAFE-REMOVAL REQ-SCOPE-INHERITANCE
9
+ */
10
+ const use_at_your_own_risk_1 = require("eslint/use-at-your-own-risk");
11
+ const index_1 = __importDefault(require("../../src/index"));
12
+ async function lintTextWithConfig(text, filename, extraConfig = {}) {
13
+ const baseConfig = {
14
+ plugins: {
15
+ traceability: index_1.default,
16
+ },
17
+ rules: {},
18
+ };
19
+ const eslint = new use_at_your_own_risk_1.FlatESLint({
20
+ overrideConfig: [baseConfig, extraConfig],
21
+ overrideConfigFile: true,
22
+ ignore: false,
23
+ });
24
+ const [result] = await eslint.lintText(text, { filePath: filename });
25
+ return result;
26
+ }
27
+ describe("no-redundant-annotation integration (Story 027.0-DEV-REDUNDANT-ANNOTATION-DETECTION)", () => {
28
+ it("[REQ-REDUNDANCY-PATTERNS] cleans up redundant annotations in multiple files while preserving required ones", async () => {
29
+ const codeA = `// @story docs/stories/003.0-EXAMPLE.story.md
30
+ // @req REQ-INIT
31
+ function init() {
32
+ // @story docs/stories/003.0-EXAMPLE.story.md
33
+ // @req REQ-INIT
34
+ const config = loadConfig();
35
+ const validator = new Validator(config);
36
+ }`;
37
+ const codeB = `/**
38
+ * @story docs/stories/004.0-EXAMPLE.story.md
39
+ * @req REQ-PROCESS
40
+ */
41
+ function process(value) {
42
+ if (value) {
43
+ /* @story docs/stories/004.0-EXAMPLE.story.md
44
+ * @req REQ-PROCESS
45
+ */
46
+ return handle(value);
47
+ }
48
+ }`;
49
+ const config = {
50
+ rules: {
51
+ "traceability/no-redundant-annotation": ["warn"],
52
+ },
53
+ };
54
+ const [resultA, resultB] = await Promise.all([
55
+ lintTextWithConfig(codeA, "file-a.js", config),
56
+ lintTextWithConfig(codeB, "file-b.js", config),
57
+ ]);
58
+ expect(resultA.messages.map((m) => m.ruleId)).toContain("traceability/no-redundant-annotation");
59
+ expect(resultB.messages.map((m) => m.ruleId)).toContain("traceability/no-redundant-annotation");
60
+ const fixerConfig = {
61
+ rules: {
62
+ "traceability/no-redundant-annotation": ["warn"],
63
+ },
64
+ fix: true,
65
+ };
66
+ const eslintFix = new use_at_your_own_risk_1.FlatESLint({
67
+ overrideConfig: [
68
+ {
69
+ plugins: { traceability: index_1.default },
70
+ rules: fixerConfig.rules,
71
+ },
72
+ ],
73
+ overrideConfigFile: true,
74
+ ignore: false,
75
+ fix: true,
76
+ });
77
+ const [fixedA, fixedB] = await Promise.all([
78
+ (async () => {
79
+ const [result] = await eslintFix.lintText(codeA, {
80
+ filePath: "file-a.js",
81
+ });
82
+ return result;
83
+ })(),
84
+ (async () => {
85
+ const [result] = await eslintFix.lintText(codeB, {
86
+ filePath: "file-b.js",
87
+ });
88
+ return result;
89
+ })(),
90
+ ]);
91
+ expect(fixedA.output).toContain("// @story docs/stories/003.0-EXAMPLE.story.md");
92
+ expect(fixedA.output).toContain("// @req REQ-INIT");
93
+ expect(fixedA.output).not.toContain("// @req REQ-INIT\n const config");
94
+ expect(fixedB.output).toContain("@story docs/stories/004.0-EXAMPLE.story.md");
95
+ expect(fixedB.output).toContain("@req REQ-PROCESS");
96
+ expect(fixedB.output).not.toContain("@req REQ-PROCESS\n */\n return");
97
+ });
98
+ });
@@ -59,6 +59,7 @@ describe("Plugin Default Export and Configs (Story 001.0-DEV-PLUGIN-SETUP)", ()
59
59
  "valid-req-reference",
60
60
  "prefer-implements-annotation",
61
61
  "require-test-traceability",
62
+ "no-redundant-annotation",
62
63
  "prefer-supports-annotation",
63
64
  ];
64
65
  // Act: get actual rule names from plugin
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ /**
7
+ * Tests for: docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md
8
+ * @story docs/stories/027.0-DEV-REDUNDANT-ANNOTATION-DETECTION.story.md
9
+ * @req REQ-SCOPE-ANALYSIS - Verify that the rule understands scope coverage for branch and block annotations
10
+ * @req REQ-DUPLICATION-DETECTION - Verify detection of duplicate annotations within the same scope
11
+ * @req REQ-STATEMENT-SIGNIFICANCE - Verify that simple statements are treated as redundant when covered by scope
12
+ * @req REQ-SAFE-REMOVAL - Verify that auto-fix removes only redundant annotations and preserves code
13
+ * @req REQ-DIFFERENT-REQUIREMENTS - Verify that annotations with different requirement IDs are preserved
14
+ */
15
+ const eslint_1 = require("eslint");
16
+ const no_redundant_annotation_1 = __importDefault(require("../../src/rules/no-redundant-annotation"));
17
+ const ruleTester = new eslint_1.RuleTester({
18
+ languageOptions: { parserOptions: { ecmaVersion: 2020 } },
19
+ });
20
+ const runRule = (tests) => ruleTester.run("no-redundant-annotation", no_redundant_annotation_1.default, tests);
21
+ describe("no-redundant-annotation rule (Story 027.0-DEV-REDUNDANT-ANNOTATION-DETECTION)", () => {
22
+ runRule({
23
+ valid: [
24
+ {
25
+ name: "[REQ-DIFFERENT-REQUIREMENTS] preserves child annotation with different requirement ID",
26
+ code: `function example() {\n // @story docs/stories/002.0-EXAMPLE.story.md\n // @req REQ-EXAMPLE-PARENT\n if (flag) {\n // @story docs/stories/002.0-EXAMPLE.story.md\n // @req REQ-EXAMPLE-CHILD\n doWork();\n }\n}`,
27
+ },
28
+ {
29
+ name: "[REQ-STATEMENT-SIGNIFICANCE] preserves annotation on complex nested branch",
30
+ code: `function example() {\n // @story docs/stories/006.0-EXAMPLE.story.md\n // @req REQ-OUTER-CHECK\n if (enabled) {\n // @story docs/stories/006.0-EXAMPLE.story.md\n // @req REQ-INNER-VALIDATION\n if (validate) {\n validate(data);\n }\n }\n}`,
31
+ },
32
+ ],
33
+ invalid: [
34
+ {
35
+ name: "[REQ-SCOPE-ANALYSIS][REQ-STATEMENT-SIGNIFICANCE] flags redundant annotation on simple return inside annotated if",
36
+ code: `function example() {
37
+ // @story docs/stories/004.0-EXAMPLE.story.md
38
+ // @req REQ-PROCESS
39
+ if (condition) {
40
+ /* @story docs/stories/004.0-EXAMPLE.story.md\n * @req REQ-PROCESS
41
+ */
42
+ return value;
43
+ }
44
+ }`,
45
+ output: `function example() {
46
+ // @story docs/stories/004.0-EXAMPLE.story.md
47
+ // @req REQ-PROCESS
48
+ if (condition) {
49
+ return value;
50
+ }
51
+ }`,
52
+ errors: [
53
+ {
54
+ messageId: "redundantAnnotation",
55
+ },
56
+ ],
57
+ },
58
+ {
59
+ name: "[REQ-DUPLICATION-DETECTION] flags redundant annotations on sequential simple statements in same scope",
60
+ code: `// @story docs/stories/003.0-EXAMPLE.story.md\n// @req REQ-INIT\nfunction init() {\n // @story docs/stories/003.0-EXAMPLE.story.md\n // @req REQ-INIT\n const config = loadConfig();\n const validator = new Validator(config);\n}`,
61
+ output: `// @story docs/stories/003.0-EXAMPLE.story.md\n// @req REQ-INIT\nfunction init() {\n const config = loadConfig();\n const validator = new Validator(config);\n}`,
62
+ errors: [{ messageId: "redundantAnnotation" }],
63
+ },
64
+ {
65
+ name: "[REQ-SAFE-REMOVAL] removes full-line redundant comment without touching code on same line above",
66
+ code: `function example() {\n const keep = 1;\n // @story docs/stories/003.0-EXAMPLE.story.md\n // @req REQ-INIT\n if (flag) {\n // @story docs/stories/003.0-EXAMPLE.story.md\n // @req REQ-INIT\n const value = 1;\n }\n}`,
67
+ output: `function example() {\n const keep = 1;\n // @story docs/stories/003.0-EXAMPLE.story.md\n // @req REQ-INIT\n if (flag) {\n const value = 1;\n }\n}`,
68
+ errors: [{ messageId: "redundantAnnotation" }],
69
+ },
70
+ // TODO: rule implementation exists; full invalid-case behavior tests pending refinement
71
+ // {
72
+ // name: "[REQ-SCOPE-ANALYSIS][REQ-STATEMENT-SIGNIFICANCE] flags redundant annotation on simple return inside annotated if",
73
+ // code: `function example() {\n // @story docs/stories/004.0-EXAMPLE.story.md\n // @req REQ-PROCESS\n if (condition) {\n // @req REQ-PROCESS\n return value;\n }\n}`,
74
+ // output: `function example() {\n // @story docs/stories/004.0-EXAMPLE.story.md\n // @req REQ-PROCESS\n if (condition) {\n return value;\n }\n}`,
75
+ // errors: [
76
+ // {
77
+ // messageId: "redundantAnnotation",
78
+ // },
79
+ // ],
80
+ // },
81
+ // {
82
+ // name: "[REQ-DUPLICATION-DETECTION] flags redundant annotations on sequential simple statements in same scope",
83
+ // code: `// @story docs/stories/003.0-EXAMPLE.story.md\n// @req REQ-INIT\nfunction init() {\n // @req REQ-INIT\n const config = loadConfig();\n const validator = new Validator(config);\n}`,
84
+ // output: `// @story docs/stories/003.0-EXAMPLE.story.md\n// @req REQ-INIT\nfunction init() {\n const config = loadConfig();\n const validator = new Validator(config);\n}`,
85
+ // errors: [
86
+ // { messageId: "redundantAnnotation" },
87
+ // ],
88
+ // },
89
+ // {
90
+ // name: "[REQ-SAFE-REMOVAL] removes full-line redundant comment without touching code on same line above",
91
+ // code: `function example() {\n const keep = 1;\n // @story docs/stories/003.0-EXAMPLE.story.md\n // @req REQ-INIT\n if (flag) {\n // @req REQ-INIT\n const value = 1;\n }\n}`,
92
+ // output: `function example() {\n const keep = 1;\n // @story docs/stories/003.0-EXAMPLE.story.md\n // @req REQ-INIT\n if (flag) {\n const value = 1;\n }\n}`,
93
+ // errors: [
94
+ // { messageId: "redundantAnnotation" },
95
+ // ],
96
+ // },
97
+ ],
98
+ });
99
+ runRule({
100
+ valid: [
101
+ {
102
+ name: "[REQ-CONFIGURABLE-STRICTNESS] permissive mode does not flag expression statements as redundant",
103
+ options: [{ strictness: "permissive" }],
104
+ code: `function example() {\n // @story docs/stories/004.0-EXAMPLE.story.md\n // @req REQ-PROCESS\n if (condition) {\n // @story docs/stories/004.0-EXAMPLE.story.md\n // @req REQ-PROCESS\n doSomething();\n }\n}`,
105
+ },
106
+ {
107
+ name: "[REQ-CONFIGURABLE-STRICTNESS] allowEmphasisDuplication skips single covered pair",
108
+ options: [{ allowEmphasisDuplication: true }],
109
+ code: `function example() {\n // @story docs/stories/004.0-EXAMPLE.story.md\n // @req REQ-PROCESS\n if (condition) {\n // @story docs/stories/004.0-EXAMPLE.story.md\n // @req REQ-PROCESS\n return value;\n }\n}`,
110
+ },
111
+ {
112
+ name: "[REQ-SCOPE-INHERITANCE] maxScopeDepth=1 does not treat grandparent function annotations as covering nested block",
113
+ options: [{ maxScopeDepth: 1 }],
114
+ code: `/**\n * @story docs/stories/004.0-EXAMPLE.story.md\n * @req REQ-PROCESS\n */\nfunction example() {\n if (outer) {\n {\n // @story docs/stories/004.0-EXAMPLE.story.md\n // @req REQ-PROCESS\n const value = compute();\n }\n }\n}`,
115
+ },
116
+ ],
117
+ invalid: [
118
+ {
119
+ name: "[REQ-SCOPE-INHERITANCE] maxScopeDepth>1 treats function-level annotations as covering nested block statements",
120
+ options: [{ maxScopeDepth: 4 }],
121
+ code: `/**\n * @story docs/stories/004.0-EXAMPLE.story.md\n * @req REQ-PROCESS\n */\nfunction example() {\n if (outer) {\n {\n // @story docs/stories/004.0-EXAMPLE.story.md\n // @req REQ-PROCESS\n const value = compute();\n }\n }\n}`,
122
+ output: `/**\n * @story docs/stories/004.0-EXAMPLE.story.md\n * @req REQ-PROCESS\n */\nfunction example() {\n if (outer) {\n {\n const value = compute();\n }\n }\n}`,
123
+ errors: [{ messageId: "redundantAnnotation" }],
124
+ },
125
+ ],
126
+ });
127
+ });
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const annotation_scope_analyzer_1 = require("../../src/utils/annotation-scope-analyzer");
4
+ describe("annotation-scope-analyzer helpers (Story 027.0-DEV-REDUNDANT-ANNOTATION-DETECTION)", () => {
5
+ it("[REQ-DUPLICATION-DETECTION] builds stable story/req keys", () => {
6
+ const key = (0, annotation_scope_analyzer_1.toStoryReqKey)("docs/stories/001.story.md", "REQ-ONE");
7
+ expect(key).toBe("docs/stories/001.story.md|REQ-ONE");
8
+ });
9
+ it("[REQ-DUPLICATION-DETECTION] extracts pairs from @story/@req sequences", () => {
10
+ const text = `// @story docs/stories/001.story.md\n// @req REQ-ONE`;
11
+ const pairs = (0, annotation_scope_analyzer_1.extractStoryReqPairsFromText)(text);
12
+ expect(Array.from(pairs)).toEqual([
13
+ "docs/stories/001.story.md|REQ-ONE",
14
+ ]);
15
+ });
16
+ it("[REQ-SCOPE-ANALYSIS] extracts pairs from @supports lines", () => {
17
+ const text = `// @supports docs/stories/002.story.md REQ-A REQ-B OTHER`;
18
+ const pairs = (0, annotation_scope_analyzer_1.extractStoryReqPairsFromText)(text);
19
+ expect(pairs.has("docs/stories/002.story.md|REQ-A")).toBe(true);
20
+ expect(pairs.has("docs/stories/002.story.md|REQ-B")).toBe(true);
21
+ });
22
+ it("[REQ-DUPLICATION-DETECTION] aggregates pairs across comments", () => {
23
+ const comments = [
24
+ { value: "// @story docs/stories/001.story.md\n// @req REQ-ONE" },
25
+ { value: "// @supports docs/stories/002.story.md REQ-TWO" },
26
+ ];
27
+ const pairs = (0, annotation_scope_analyzer_1.extractStoryReqPairsFromComments)(comments);
28
+ expect(pairs.size).toBe(2);
29
+ });
30
+ it("[REQ-DUPLICATION-DETECTION] determines full coverage correctly", () => {
31
+ const parent = new Set([
32
+ "story|REQ-ONE",
33
+ "story|REQ-TWO",
34
+ ]);
35
+ const childCovered = new Set(["story|REQ-ONE"]);
36
+ const childNotCovered = new Set(["story|REQ-THREE"]);
37
+ expect((0, annotation_scope_analyzer_1.arePairsFullyCovered)(childCovered, parent)).toBe(true);
38
+ expect((0, annotation_scope_analyzer_1.arePairsFullyCovered)(childNotCovered, parent)).toBe(false);
39
+ });
40
+ it("[REQ-STATEMENT-SIGNIFICANCE] respects alwaysCovered and strictness levels", () => {
41
+ const base = {
42
+ strictness: "moderate",
43
+ allowEmphasisDuplication: false,
44
+ maxScopeDepth: 3,
45
+ alwaysCovered: ["ReturnStatement"],
46
+ };
47
+ const branchTypes = ["IfStatement"];
48
+ expect((0, annotation_scope_analyzer_1.isStatementEligibleForRedundancy)({ type: "ReturnStatement" }, base, branchTypes)).toBe(true);
49
+ expect((0, annotation_scope_analyzer_1.isStatementEligibleForRedundancy)({ type: "ExpressionStatement" }, base, branchTypes)).toBe(true);
50
+ expect((0, annotation_scope_analyzer_1.isStatementEligibleForRedundancy)({ type: "IfStatement" }, base, branchTypes)).toBe(false);
51
+ });
52
+ it("[REQ-SAFE-REMOVAL] computes removal range for full-line comment", () => {
53
+ const source = `const x = 1;\n// @story docs/stories/001.story.md\nconst y = 2;\n`;
54
+ const sourceCode = {
55
+ getText() {
56
+ return source;
57
+ },
58
+ };
59
+ const start = source.indexOf("// @story");
60
+ const end = start + "// @story docs/stories/001.story.md".length;
61
+ const comment = { range: [start, end] };
62
+ const [removalStart, removalEnd] = (0, annotation_scope_analyzer_1.getCommentRemovalRange)(comment, sourceCode);
63
+ const removed = source.slice(0, removalStart) + source.slice(removalEnd);
64
+ expect(removed).toBe("const x = 1;\nconst y = 2;\n");
65
+ });
66
+ it("[REQ-SAFE-REMOVAL] returns [0, 0] for comments with invalid range length (EXPECTS EXPECTED_RANGE_LENGTH usage)", () => {
67
+ const source = "const x = 1;";
68
+ const sourceCode = {
69
+ getText() {
70
+ return source;
71
+ },
72
+ };
73
+ const comment = { range: [0] };
74
+ const range = (0, annotation_scope_analyzer_1.getCommentRemovalRange)(comment, sourceCode);
75
+ expect(range).toEqual([0, 0]);
76
+ });
77
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-traceability",
3
- "version": "1.12.1",
3
+ "version": "1.13.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",
@@ -268,6 +268,42 @@ describe("Refunds flow docs/stories/010.0-PAYMENTS.story.md", () => {
268
268
  });
269
269
  ```
270
270
 
271
+ ### traceability/no-redundant-annotation
272
+
273
+ Description: Detects and optionally removes **redundant** traceability annotations on code that is already covered by an enclosing annotated scope. It focuses on simple, statement-level constructs—such as `return` statements, basic variable declarations, and other leaf statements—where repeating the same `@story` / `@req` / `@supports` information adds noise without improving coverage. When run with `--fix`, the rule offers safe auto-fixes that remove only the redundant comments while preserving all annotations that are required to maintain correct traceability.
274
+
275
+ The rule is designed to complement the core presence and validation rules: it never treats removing a redundant annotation as valid if doing so would leave the underlying requirement or story **uncovered** according to the plugin’s normal rules. It only targets comments whose traceability content is already implied by a surrounding function, method, or branch annotation.
276
+
277
+ Options:
278
+
279
+ The rule accepts an optional configuration object:
280
+
281
+ - `strictness` (`"strict" | "moderate" | "permissive"`, optional)  Controls how broadly statements are considered eligible for redundancy.
282
+ - `"strict"`  Treats any non-branch statement as a candidate for redundancy once it is covered by a containing annotated scope. This is the most aggressive mode and is useful in codebases that want to push almost all traceability down to function/branch level only.
283
+ - `"moderate"` (default)  Focuses on obviously leaf-like statements: anything in `alwaysCovered` **plus** bare `ExpressionStatement` nodes (for example, simple calls or assignments) that are not themselves branches. This mode balances redundancy cleanup with readability.
284
+ - `"permissive"`  Only treats AST node types listed in `alwaysCovered` as candidates. Other statements are ignored even when they are technically covered by an enclosing scope, which is useful when you prefer more explicit, local annotations.
285
+ - `allowEmphasisDuplication` (boolean, optional)  When `true`, allows a statement-level annotation that repeats a **single** fully-covered story/requirement pair from its parent scope purely for emphasis (for example, a guard clause with its own comment) and **does not** report it as redundant. When omitted or `false` (the default), even emphasis-only duplicates are treated as redundant when they add no new coverage.
286
+ - `maxScopeDepth` (number, optional)  Limits how far up the ancestor chain the rule searches for covering scopes when deciding whether a statements annotations are redundant. A value of `1` restricts checks to the immediate parent scope; larger values allow the rule to consider annotations on enclosing branches and functions further up the tree. The default is `3`, which is suitable for most common function and branch nesting patterns, but you can increase it (for example, to `4` or higher) in projects that use additional nested blocks inside annotated functions.
287
+ - `alwaysCovered` (string[], optional)  List of AST statement `node.type` strings that your project treats as "always covered" by their containing scope when that scope is annotated. By default, the rule treats `ReturnStatement` and `VariableDeclaration` as always-covered leaf statements. You can extend or override this list to tune which statement types are considered trivial enough to inherit coverage from their parent scopes.
288
+
289
+ Behavior notes:
290
+
291
+ - The rule only inspects comments that contain recognized traceability annotations (`@story`, `@req`, `@supports`) and are attached to simple statements (returns, expression statements, variable declarations, and similar leaf nodes). It intentionally does **not** attempt to de-duplicate annotations on functions, classes, or major branches, which remain the responsibility of the core rules. When a statement has multiple redundant traceability comments (for example, a small comment block that repeats both @story and @req lines), the rule reports a **single** diagnostic for that statement and, in fix mode, removes all of the redundant annotation comments associated with it in a single grouped fix.
292
+ - Auto-fix removes only the redundant traceability lines (and any now-empty comment delimiters when safe) while preserving surrounding non-traceability text in the same comment where possible.
293
+ - When no enclosing scope with compatible coverage is found within `maxScopeDepth`, the annotation is not considered redundant and is left unchanged.
294
+
295
+ Default Severity: `warn`
296
+
297
+ This rule is **not** enabled in the `recommended` or `strict` presets by default. To use it, add it explicitly to your ESLint configuration with an appropriate severity level:
298
+
299
+ ```jsonc
300
+ {
301
+ "rules": {
302
+ "traceability/no-redundant-annotation": "warn",
303
+ },
304
+ }
305
+ ```
306
+
271
307
  ### traceability/prefer-supports-annotation
272
308
 
273
309
  Description: An optional, opt-in migration helper that encourages converting legacy single‑story `@story` + `@req` JSDoc blocks into the newer multi‑story `@supports` format. The rule is **disabled by default** and is **not included in any built‑in preset**; you enable it explicitly and control its behavior entirely via ESLint severity (`"off" | "warn" | "error"`). It does not change what the core rules consider valid—it only adds migration recommendations and safe auto‑fixes on top of existing validation. The legacy rule key `traceability/prefer-implements-annotation` is still recognized as a **deprecated alias** for `traceability/prefer-supports-annotation` so that existing configurations continue to work unchanged.