eslint-plugin-traceability 1.16.1 → 1.17.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.
Files changed (29) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/lib/src/index.js +53 -33
  3. package/lib/src/maintenance/commands.d.ts +4 -0
  4. package/lib/src/maintenance/commands.js +4 -0
  5. package/lib/src/maintenance/index.d.ts +1 -0
  6. package/lib/src/maintenance/index.js +1 -0
  7. package/lib/src/maintenance/report.js +2 -2
  8. package/lib/src/maintenance/update.js +4 -2
  9. package/lib/src/rules/helpers/require-story-helpers.d.ts +5 -11
  10. package/lib/src/rules/helpers/require-story-helpers.js +7 -74
  11. package/lib/src/rules/helpers/test-callback-exclusion.d.ts +43 -0
  12. package/lib/src/rules/helpers/test-callback-exclusion.js +100 -0
  13. package/lib/src/rules/helpers/valid-annotation-format-validators.js +8 -2
  14. package/lib/src/rules/no-redundant-annotation.js +4 -0
  15. package/lib/src/rules/prefer-implements-annotation.js +25 -20
  16. package/lib/src/rules/require-story-annotation.js +14 -1
  17. package/lib/src/rules/valid-annotation-format.js +62 -42
  18. package/lib/tests/integration/no-redundant-annotation.integration.test.js +31 -0
  19. package/lib/tests/integration/require-traceability-test-callbacks.integration.test.d.ts +1 -0
  20. package/lib/tests/integration/require-traceability-test-callbacks.integration.test.js +148 -0
  21. package/lib/tests/maintenance/detect-isolated.test.js +22 -14
  22. package/lib/tests/perf/maintenance-cli-large-workspace.test.js +145 -64
  23. package/lib/tests/perf/maintenance-large-workspace.test.js +65 -46
  24. package/lib/tests/rules/no-redundant-annotation.test.js +5 -0
  25. package/lib/tests/rules/require-story-annotation.test.js +21 -0
  26. package/lib/tests/rules/require-story-helpers.test.js +69 -0
  27. package/lib/tests/utils/{annotation-checker-branches.test.d.ts → annotation-checker-autofix-behavior.test.d.ts} +1 -1
  28. package/lib/tests/utils/{annotation-checker-branches.test.js → annotation-checker-autofix-behavior.test.js} +2 -2
  29. package/package.json +2 -2
@@ -286,17 +286,8 @@ function tryBuildInlineAutoFix(context, comments, storyIndex, reqIndices) {
286
286
  * @story docs/stories/010.3-DEV-MIGRATE-TO-SUPPORTS.story.md
287
287
  * @req REQ-MIGRATE-INLINE
288
288
  */
289
- function handleInlineStorySequence(context, group, startIndex) {
289
+ function collectReqIndicesAfterStory(group, startIndex) {
290
290
  const n = group.length;
291
- const current = group[startIndex];
292
- const normalized = (0, valid_annotation_format_internal_1.normalizeCommentLine)(current.value || "");
293
- if (!normalized || !/^@story\b/.test(normalized)) {
294
- return startIndex + 1;
295
- }
296
- if (/^@supports\b/.test(normalized)) {
297
- return startIndex + 1;
298
- }
299
- const storyIndex = startIndex;
300
291
  const reqIndices = [];
301
292
  let j = startIndex + 1;
302
293
  while (j < n) {
@@ -312,6 +303,19 @@ function handleInlineStorySequence(context, group, startIndex) {
312
303
  }
313
304
  break;
314
305
  }
306
+ return { reqIndices, nextIndex: j };
307
+ }
308
+ function handleInlineStorySequence(context, group, startIndex) {
309
+ const current = group[startIndex];
310
+ const normalized = (0, valid_annotation_format_internal_1.normalizeCommentLine)(current.value || "");
311
+ if (!normalized || !/^@story\b/.test(normalized)) {
312
+ return startIndex + 1;
313
+ }
314
+ if (/^@supports\b/.test(normalized)) {
315
+ return startIndex + 1;
316
+ }
317
+ const storyIndex = startIndex;
318
+ const { reqIndices, nextIndex } = collectReqIndicesAfterStory(group, startIndex);
315
319
  if (reqIndices.length === 0) {
316
320
  context.report({
317
321
  node: current,
@@ -333,7 +337,7 @@ function handleInlineStorySequence(context, group, startIndex) {
333
337
  messageId: "preferImplements",
334
338
  });
335
339
  }
336
- return reqIndices[reqIndices.length - 1] + 1;
340
+ return nextIndex;
337
341
  }
338
342
  /**
339
343
  * Process a contiguous group of inline line comments, identifying legacy
@@ -343,19 +347,20 @@ function handleInlineStorySequence(context, group, startIndex) {
343
347
  * @story docs/stories/010.3-DEV-MIGRATE-TO-SUPPORTS.story.md
344
348
  * @req REQ-MIGRATE-INLINE
345
349
  */
350
+ function advanceInlineGroupIndex(context, group, currentIndex) {
351
+ const current = group[currentIndex];
352
+ const normalized = (0, valid_annotation_format_internal_1.normalizeCommentLine)(current.value || "");
353
+ if (!normalized || !/^@story\b/.test(normalized)) {
354
+ return currentIndex + 1;
355
+ }
356
+ return handleInlineStorySequence(context, group, currentIndex);
357
+ }
346
358
  function processInlineGroup(context, group) {
347
359
  if (group.length === 0)
348
360
  return;
349
- const n = group.length;
350
361
  let i = 0;
351
- while (i < n) {
352
- const current = group[i];
353
- const normalized = (0, valid_annotation_format_internal_1.normalizeCommentLine)(current.value || "");
354
- if (!normalized || !/^@story\b/.test(normalized)) {
355
- i += 1;
356
- continue;
357
- }
358
- i = handleInlineStorySequence(context, group, i);
362
+ while (i < group.length) {
363
+ i = advanceInlineGroupIndex(context, group, i);
359
364
  }
360
365
  }
361
366
  /**
@@ -49,6 +49,11 @@ const rule = {
49
49
  methodAnnotationTemplate: { type: "string" },
50
50
  autoFix: { type: "boolean" },
51
51
  excludeTestCallbacks: { type: "boolean" },
52
+ additionalTestHelperNames: {
53
+ type: "array",
54
+ items: { type: "string" },
55
+ uniqueItems: true,
56
+ },
52
57
  },
53
58
  additionalProperties: false,
54
59
  },
@@ -79,6 +84,10 @@ const rule = {
79
84
  const excludeTestCallbacks = typeof opts.excludeTestCallbacks === "boolean"
80
85
  ? opts.excludeTestCallbacks
81
86
  : true;
87
+ const additionalTestHelperNames = Array.isArray(opts.additionalTestHelperNames) &&
88
+ opts.additionalTestHelperNames.every((name) => typeof name === "string")
89
+ ? opts.additionalTestHelperNames
90
+ : undefined;
82
91
  /**
83
92
  * Optional debug logging for troubleshooting this rule.
84
93
  * Developers can temporarily uncomment the block below to log when the rule
@@ -94,7 +103,10 @@ const rule = {
94
103
  // : "<unknown>",
95
104
  // );
96
105
  // Local closure that binds configured scope and export priority to the helper.
97
- const should = (node) => (0, require_story_helpers_1.shouldProcessNode)(node, scope, exportPriority, { excludeTestCallbacks });
106
+ const should = (node) => (0, require_story_helpers_1.shouldProcessNode)(node, scope, exportPriority, {
107
+ excludeTestCallbacks,
108
+ additionalTestHelperNames,
109
+ });
98
110
  // Delegate visitor construction to helper to keep this file concise.
99
111
  return (0, require_story_visitors_1.buildVisitors)(context, sourceCode, {
100
112
  shouldProcessNode: should,
@@ -104,6 +116,7 @@ const rule = {
104
116
  methodAnnotationTemplate,
105
117
  autoFix,
106
118
  excludeTestCallbacks,
119
+ additionalTestHelperNames,
107
120
  });
108
121
  },
109
122
  };
@@ -3,6 +3,49 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const valid_annotation_options_1 = require("./helpers/valid-annotation-options");
4
4
  const valid_annotation_format_internal_1 = require("./helpers/valid-annotation-format-internal");
5
5
  const valid_annotation_format_validators_1 = require("./helpers/valid-annotation-format-validators");
6
+ function handleImplementsLine(normalized, pending, deps) {
7
+ const { context, comment, options } = deps;
8
+ const isImplements = /@supports\b/.test(normalized);
9
+ if (!isImplements) {
10
+ return pending;
11
+ }
12
+ const implementsValue = normalized.replace(/^@supports\b/, "").trim();
13
+ (0, valid_annotation_format_validators_1.validateImplementsAnnotation)(context, comment, implementsValue, options);
14
+ return pending;
15
+ }
16
+ function handleStoryOrReqLine(normalized, pending, deps) {
17
+ const { context, comment, options } = deps;
18
+ const isStory = /@story\b/.test(normalized);
19
+ const isReq = /@req\b/.test(normalized);
20
+ if (!isStory && !isReq) {
21
+ return pending;
22
+ }
23
+ (0, valid_annotation_format_validators_1.finalizePendingAnnotation)(context, comment, options, pending);
24
+ const rawValue = normalized.replace(/^@story\b|^@req\b/, "");
25
+ const trimmedValue = rawValue.trim();
26
+ return {
27
+ type: isStory ? "story" : "req",
28
+ value: trimmedValue,
29
+ hasValue: trimmedValue.length > 0,
30
+ };
31
+ }
32
+ function extendPendingAnnotation(normalized, pending) {
33
+ if (!pending) {
34
+ return pending;
35
+ }
36
+ const continuation = normalized.trim();
37
+ if (!continuation) {
38
+ return pending;
39
+ }
40
+ const updatedValue = pending.value
41
+ ? `${pending.value} ${continuation}`
42
+ : continuation;
43
+ return {
44
+ ...pending,
45
+ value: updatedValue,
46
+ hasValue: pending.hasValue || continuation.length > 0,
47
+ };
48
+ }
6
49
  /**
7
50
  * Process a single normalized comment line and update the pending annotation state.
8
51
  *
@@ -22,31 +65,21 @@ function processCommentLine({ normalized, pending, context, comment, options, })
22
65
  if (!normalized) {
23
66
  return pending;
24
67
  }
25
- const isStory = /@story\b/.test(normalized);
26
- const isReq = /@req\b/.test(normalized);
27
- const isImplements = /@supports\b/.test(normalized);
28
- // Handle @supports as an immediate, single-line annotation
29
- // @story docs/stories/010.2-DEV-MULTI-STORY-SUPPORT.story.md
30
- // @req REQ-IMPLEMENTS-PARSE - Immediately validate @supports without starting multi-line state
31
- if (isImplements) {
32
- const implementsValue = normalized.replace(/^@supports\b/, "").trim();
33
- (0, valid_annotation_format_validators_1.validateImplementsAnnotation)(context, comment, implementsValue, options);
34
- return pending;
68
+ const afterImplements = handleImplementsLine(normalized, pending, {
69
+ context,
70
+ comment,
71
+ options,
72
+ });
73
+ if (afterImplements !== pending) {
74
+ return afterImplements;
35
75
  }
36
- // @story docs/stories/005.0-DEV-ANNOTATION-VALIDATION.story.md
37
- // @story docs/stories/008.0-DEV-AUTO-FIX.story.md
38
- // @story docs/stories/010.2-DEV-MULTI-STORY-SUPPORT.story.md
39
- // @req REQ-SYNTAX-VALIDATION - Start new pending annotation when a tag is found
40
- // @req REQ-AUTOFIX-FORMAT - Provide safe, minimal automatic fixes for common format issues
41
- // @req REQ-MIXED-SUPPORT - Support mixed @story/@req/@implements usage in comments
42
- if (isStory || isReq) {
43
- (0, valid_annotation_format_validators_1.finalizePendingAnnotation)(context, comment, options, pending);
44
- const value = normalized.replace(/^@story\b|^@req\b/, "").trim();
45
- return {
46
- type: isStory ? "story" : "req",
47
- value,
48
- hasValue: value.trim().length > 0,
49
- };
76
+ const afterStoryOrReq = handleStoryOrReqLine(normalized, pending, {
77
+ context,
78
+ comment,
79
+ options,
80
+ });
81
+ if (afterStoryOrReq !== pending) {
82
+ return afterStoryOrReq;
50
83
  }
51
84
  // Implement JSDoc tag coexistence behavior: terminate @story/@req values when a new non-traceability JSDoc tag line (e.g., @param, @returns) is encountered.
52
85
  // @supports docs/stories/022.0-DEV-JSDOC-COEXISTENCE.story.md REQ-ANNOTATION-TERMINATION REQ-CONTINUATION-LOGIC
@@ -60,23 +93,7 @@ function processCommentLine({ normalized, pending, context, comment, options, })
60
93
  // @req REQ-MULTILINE-SUPPORT - Extend value of existing pending annotation across lines
61
94
  // @req REQ-AUTOFIX-FORMAT - Maintain complete logical value for downstream validation and fixes
62
95
  // @req REQ-MIXED-SUPPORT - Leave non-annotation lines untouched when no pending state exists
63
- if (pending) {
64
- const continuation = normalized.trim();
65
- // @story docs/stories/005.0-DEV-ANNOTATION-VALIDATION.story.md
66
- // @req REQ-MULTILINE-SUPPORT - Skip blank continuation lines without altering pending annotation
67
- if (!continuation) {
68
- return pending;
69
- }
70
- const updatedValue = pending.value
71
- ? `${pending.value} ${continuation}`
72
- : continuation;
73
- return {
74
- ...pending,
75
- value: updatedValue,
76
- hasValue: pending.hasValue || continuation.length > 0,
77
- };
78
- }
79
- return pending;
96
+ return extendPendingAnnotation(normalized, pending);
80
97
  }
81
98
  /**
82
99
  * Process a single comment node and validate any @story/@req/@supports annotations it contains.
@@ -98,7 +115,7 @@ function processCommentLine({ normalized, pending, context, comment, options, })
98
115
  * @req REQ-FORMAT-VALIDATION - Validate @implements story path and requirement IDs
99
116
  * @req REQ-MIXED-SUPPORT - Support mixed @story/@req/@implements usage in comments
100
117
  */
101
- function processComment(context, comment, options) {
118
+ function processCommentLines({ context, comment, options, }) {
102
119
  const rawLines = (comment.value || "").split(/\r?\n/);
103
120
  let pending = null;
104
121
  rawLines.forEach((rawLine) => {
@@ -113,6 +130,9 @@ function processComment(context, comment, options) {
113
130
  });
114
131
  (0, valid_annotation_format_validators_1.finalizePendingAnnotation)(context, comment, options, pending);
115
132
  }
133
+ function processComment(context, comment, options) {
134
+ processCommentLines({ context, comment, options });
135
+ }
116
136
  exports.default = {
117
137
  meta: {
118
138
  type: "problem",
@@ -95,4 +95,35 @@ function process(value) {
95
95
  expect(fixedB.output).toContain("@req REQ-PROCESS");
96
96
  expect(fixedB.output).not.toContain("@req REQ-PROCESS\n */\n return");
97
97
  });
98
+ it("[REQ-CATCH-BLOCK-HANDLING] does not report redundant annotations for try/if/else-if/catch pattern from story 027.0 (regression from issue #6)", async () => {
99
+ const code = `// @supports prompts/004.0-DEV-FILTER-VULNERABLE-VERSIONS.md
100
+ // @req REQ-SAFE-ONLY
101
+ async function filterVulnerableVersions(versionInfo, safeVersions) {
102
+ try {
103
+ // @supports prompts/004.0-DEV-FILTER-VULNERABLE-VERSIONS.md
104
+ // @req REQ-SAFE-ONLY
105
+ if (!versionInfo) {
106
+ return [];
107
+ } else if (!safeVersions || safeVersions.length === 0) {
108
+ return versionInfo;
109
+ }
110
+
111
+ // @supports prompts/004.0-DEV-FILTER-VULNERABLE-VERSIONS.md
112
+ // @req REQ-SAFE-ONLY
113
+ return versionInfo.filter(v => safeVersions.includes(v));
114
+ } catch (error) {
115
+ // @supports prompts/004.0-DEV-FILTER-VULNERABLE-VERSIONS.md
116
+ // @req REQ-SAFE-ONLY
117
+ return [];
118
+ }
119
+ }
120
+ `;
121
+ const config = {
122
+ rules: {
123
+ "traceability/no-redundant-annotation": ["warn"],
124
+ },
125
+ };
126
+ const result = await lintTextWithConfig(code, "filter-vulnerable-versions.js", config);
127
+ expect(result.messages.filter((m) => m.ruleId === "traceability/no-redundant-annotation").length).toBe(0);
128
+ });
98
129
  });
@@ -0,0 +1,148 @@
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 require-traceability with configurable test callback exclusion.
8
+ *
9
+ * @supports docs/stories/010.4-DEV-UNIFIED-FUNCTION-RULE-AND-ALIASES.story.md REQ-UNIFIED-ALIAS-ENGINE
10
+ * @supports docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md REQ-ANNOTATION-REQUIRED REQ-FUNCTION-DETECTION
11
+ * @supports docs/stories/013-exclude-test-framework-callbacks.proposed.md REQ-TEST-CALLBACK-EXCLUSION
12
+ */
13
+ const use_at_your_own_risk_1 = require("eslint/use-at-your-own-risk");
14
+ const index_1 = __importDefault(require("../../src/index"));
15
+ async function lintTextWithConfig(text, filename, extraConfig) {
16
+ const baseConfig = {
17
+ plugins: {
18
+ traceability: index_1.default,
19
+ },
20
+ };
21
+ const eslint = new use_at_your_own_risk_1.FlatESLint({
22
+ overrideConfig: [baseConfig, ...extraConfig],
23
+ overrideConfigFile: true,
24
+ ignore: false,
25
+ });
26
+ const [result] = await eslint.lintText(text, { filePath: filename });
27
+ return result;
28
+ }
29
+ describe("Unified require-traceability with configurable test callback exclusion (Story 013-exclude-test-framework-callbacks)", () => {
30
+ const baseHeader = `/**\n * @supports docs/stories/003.0-DEV-FUNCTION-ANNOTATIONS.story.md REQ-ANNOTATION-REQUIRED\n */`;
31
+ const jsTestCallback = `${baseHeader}\n
32
+ describe('suite', () => {\n it('does something', () => {\n const value = 1;\n });\n});`;
33
+ const tsTestCallback = `${baseHeader}\n
34
+ import { describe, it } from 'vitest';
35
+
36
+ describe('suite', () => {\n it('does something', () => {\n const value = 1;\n });\n});`;
37
+ const jsBenchCallback = `${baseHeader}\n
38
+ import { bench } from 'vitest';
39
+
40
+ bench('bench case', () => {\n function helper() {}\n helper();\n});`;
41
+ const jsCustomHelperCallback = `${baseHeader}\n
42
+ function helperWrapper(fn) {\n return fn;\n}
43
+
44
+ helperWrapper(() => {\n function helper() {}\n helper();\n});`;
45
+ async function getRuleMessages(code, filename, extraConfig) {
46
+ const result = await lintTextWithConfig(code, filename, extraConfig);
47
+ return result.messages.filter((m) => m.ruleId === "traceability/require-traceability" ||
48
+ m.ruleId === "traceability/require-story-annotation");
49
+ }
50
+ it("[REQ-TEST-CALLBACK-EXCLUSION] excludes callbacks under known test helpers when configured", async () => {
51
+ const config = [
52
+ {
53
+ rules: {
54
+ "traceability/require-traceability": ["error"],
55
+ "traceability/require-story-annotation": [
56
+ "error",
57
+ {
58
+ excludeTestCallbacks: true,
59
+ },
60
+ ],
61
+ },
62
+ },
63
+ ];
64
+ const messagesJs = await getRuleMessages(jsTestCallback, "example.test.js", config);
65
+ const messagesTs = await getRuleMessages(tsTestCallback, "example.test.ts", config);
66
+ expect(messagesJs).toHaveLength(0);
67
+ expect(messagesTs).toHaveLength(0);
68
+ });
69
+ it("[REQ-TEST-CALLBACK-EXCLUSION] never excludes Vitest bench callbacks via test-callback exclusion, even when exclusion is enabled", async () => {
70
+ const baseConfig = [
71
+ {
72
+ rules: {
73
+ "traceability/require-traceability": ["error"],
74
+ "traceability/require-story-annotation": [
75
+ "error",
76
+ {
77
+ excludeTestCallbacks: true,
78
+ },
79
+ ],
80
+ },
81
+ },
82
+ ];
83
+ const withBenchAsHelperConfig = [
84
+ {
85
+ rules: {
86
+ "traceability/require-traceability": ["error"],
87
+ "traceability/require-story-annotation": [
88
+ "error",
89
+ {
90
+ excludeTestCallbacks: true,
91
+ additionalTestHelperNames: ["bench"],
92
+ },
93
+ ],
94
+ },
95
+ },
96
+ ];
97
+ const baseResult = await lintTextWithConfig(jsBenchCallback, "bench.test.ts", baseConfig);
98
+ const withBenchHelperResult = await lintTextWithConfig(jsBenchCallback, "bench.test.ts", withBenchAsHelperConfig);
99
+ const baseMessages = baseResult.messages.filter((m) => m.ruleId === "traceability/require-traceability" ||
100
+ m.ruleId === "traceability/require-story-annotation");
101
+ const withBenchHelperMessages = withBenchHelperResult.messages.filter((m) => m.ruleId === "traceability/require-traceability" ||
102
+ m.ruleId === "traceability/require-story-annotation");
103
+ expect(withBenchHelperMessages.length).toBeGreaterThanOrEqual(baseMessages.length);
104
+ });
105
+ it("[REQ-TEST-CALLBACK-EXCLUSION] respects additionalTestHelperNames for custom helpers but not for bench callbacks", async () => {
106
+ const baseConfig = [
107
+ {
108
+ rules: {
109
+ "traceability/require-traceability": ["error"],
110
+ "traceability/require-story-annotation": [
111
+ "error",
112
+ {
113
+ excludeTestCallbacks: true,
114
+ },
115
+ ],
116
+ },
117
+ },
118
+ ];
119
+ const withAdditionalHelpersConfig = [
120
+ {
121
+ rules: {
122
+ "traceability/require-traceability": ["error"],
123
+ "traceability/require-story-annotation": [
124
+ "error",
125
+ {
126
+ excludeTestCallbacks: true,
127
+ additionalTestHelperNames: ["helperWrapper", "bench"],
128
+ },
129
+ ],
130
+ },
131
+ },
132
+ ];
133
+ const wrapperBaseResult = await lintTextWithConfig(jsCustomHelperCallback, "helper-wrapper.test.ts", baseConfig);
134
+ const wrapperWithHelpersResult = await lintTextWithConfig(jsCustomHelperCallback, "helper-wrapper.test.ts", withAdditionalHelpersConfig);
135
+ const benchBaseResult = await lintTextWithConfig(jsBenchCallback, "bench.test.ts", baseConfig);
136
+ const benchWithHelpersResult = await lintTextWithConfig(jsBenchCallback, "bench.test.ts", withAdditionalHelpersConfig);
137
+ const wrapperBaseMessages = wrapperBaseResult.messages.filter((m) => m.ruleId === "traceability/require-traceability" ||
138
+ m.ruleId === "traceability/require-story-annotation");
139
+ const wrapperWithHelpersMessages = wrapperWithHelpersResult.messages.filter((m) => m.ruleId === "traceability/require-traceability" ||
140
+ m.ruleId === "traceability/require-story-annotation");
141
+ const benchBaseMessages = benchBaseResult.messages.filter((m) => m.ruleId === "traceability/require-traceability" ||
142
+ m.ruleId === "traceability/require-story-annotation");
143
+ const benchWithHelpersMessages = benchWithHelpersResult.messages.filter((m) => m.ruleId === "traceability/require-traceability" ||
144
+ m.ruleId === "traceability/require-story-annotation");
145
+ expect(wrapperWithHelpersMessages.length).toBeLessThanOrEqual(wrapperBaseMessages.length);
146
+ expect(benchWithHelpersMessages.length).toBeGreaterThanOrEqual(benchBaseMessages.length);
147
+ });
148
+ });
@@ -77,8 +77,8 @@ describe("detectStaleAnnotations isolated (Story 009.0-DEV-MAINTENANCE-TOOLS)",
77
77
  }
78
78
  });
79
79
  it("[REQ-MAINT-DETECT] handles permission denied errors by returning an empty result", () => {
80
- const tmpDir2 = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-perm-"));
81
- const dir = path.join(tmpDir2, "subdir");
80
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tmp-perm-"));
81
+ const dir = path.join(tmpDir, "subdir");
82
82
  fs.mkdirSync(dir);
83
83
  const filePath = path.join(dir, "file.ts");
84
84
  const content = `
@@ -87,24 +87,32 @@ describe("detectStaleAnnotations isolated (Story 009.0-DEV-MAINTENANCE-TOOLS)",
87
87
  */
88
88
  `;
89
89
  fs.writeFileSync(filePath, content, "utf8");
90
- // Remove read permission
90
+ const originalReadFileSync = fs.readFileSync;
91
+ const readSpy = jest
92
+ .spyOn(fs, "readFileSync")
93
+ .mockImplementation((p, ...args) => {
94
+ const strPath = typeof p === "string" ? p : p.toString();
95
+ if (strPath === filePath) {
96
+ const err = new Error("EACCES: permission denied, open");
97
+ err.code = "EACCES";
98
+ throw err;
99
+ }
100
+ // Delegate to original implementation for all other paths
101
+ // to keep behavior realistic.
102
+ // @ts-ignore
103
+ return originalReadFileSync(p, ...args);
104
+ });
91
105
  try {
92
- fs.chmodSync(dir, 0o000);
93
- expect(() => (0, detect_1.detectStaleAnnotations)(tmpDir2)).toThrow();
106
+ const result = (0, detect_1.detectStaleAnnotations)(tmpDir);
107
+ expect(result).toEqual([]);
94
108
  }
95
109
  finally {
96
- // Restore permissions and cleanup temporary directory, ignoring errors during cleanup
97
- try {
98
- fs.chmodSync(dir, 0o700);
99
- }
100
- catch {
101
- // ignore
102
- }
110
+ readSpy.mockRestore();
103
111
  try {
104
- fs.rmSync(tmpDir2, { recursive: true, force: true });
112
+ fs.rmSync(tmpDir, { recursive: true, force: true });
105
113
  }
106
114
  catch {
107
- // ignore
115
+ // ignore cleanup errors
108
116
  }
109
117
  }
110
118
  });