@gobing-ai/ts-rule-engine 0.2.6 → 0.2.7

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 (75) hide show
  1. package/dist/config/extensions.d.ts +46 -0
  2. package/dist/config/extensions.d.ts.map +1 -0
  3. package/dist/config/extensions.js +63 -0
  4. package/dist/config/loader.js +13 -3
  5. package/dist/engine.d.ts +26 -1
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +79 -0
  8. package/dist/evaluators/exit-code-evaluator.d.ts +1 -1
  9. package/dist/evaluators/exit-code-evaluator.d.ts.map +1 -1
  10. package/dist/evaluators/exit-code-evaluator.js +22 -9
  11. package/dist/evaluators/forbidden-import-evaluator.d.ts +16 -3
  12. package/dist/evaluators/forbidden-import-evaluator.d.ts.map +1 -1
  13. package/dist/evaluators/forbidden-import-evaluator.js +71 -6
  14. package/dist/evaluators/import-boundary-evaluator.d.ts +19 -0
  15. package/dist/evaluators/import-boundary-evaluator.d.ts.map +1 -0
  16. package/dist/evaluators/import-boundary-evaluator.js +85 -0
  17. package/dist/evaluators/path-evaluator.d.ts +15 -2
  18. package/dist/evaluators/path-evaluator.d.ts.map +1 -1
  19. package/dist/evaluators/path-evaluator.js +49 -3
  20. package/dist/evaluators/regex-evaluator.d.ts.map +1 -1
  21. package/dist/evaluators/regex-evaluator.js +43 -8
  22. package/dist/evaluators/schema-artifact-evaluator.d.ts +21 -0
  23. package/dist/evaluators/schema-artifact-evaluator.d.ts.map +1 -0
  24. package/dist/evaluators/schema-artifact-evaluator.js +102 -0
  25. package/dist/evaluators/secrets-scanner-evaluator.d.ts +13 -2
  26. package/dist/evaluators/secrets-scanner-evaluator.d.ts.map +1 -1
  27. package/dist/evaluators/secrets-scanner-evaluator.js +72 -9
  28. package/dist/evaluators/sg-evaluator.d.ts +19 -0
  29. package/dist/evaluators/sg-evaluator.d.ts.map +1 -0
  30. package/dist/evaluators/sg-evaluator.js +112 -0
  31. package/dist/evaluators/test-location-evaluator.d.ts +14 -1
  32. package/dist/evaluators/test-location-evaluator.d.ts.map +1 -1
  33. package/dist/evaluators/test-location-evaluator.js +42 -22
  34. package/dist/evaluators/tsdoc-export-evaluator.js +19 -5
  35. package/dist/fixers/fixers.d.ts +86 -0
  36. package/dist/fixers/fixers.d.ts.map +1 -0
  37. package/dist/fixers/fixers.js +230 -0
  38. package/dist/fixers/test-stub-fixer.d.ts +49 -0
  39. package/dist/fixers/test-stub-fixer.d.ts.map +1 -0
  40. package/dist/fixers/test-stub-fixer.js +91 -0
  41. package/dist/host/builtins.d.ts.map +1 -1
  42. package/dist/host/builtins.js +12 -1
  43. package/dist/host/rule-engine-host.d.ts +3 -0
  44. package/dist/host/rule-engine-host.d.ts.map +1 -1
  45. package/dist/host/rule-engine-host.js +3 -0
  46. package/dist/index.d.ts +4 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +4 -0
  49. package/dist/resolvers/test-path-resolver.d.ts +72 -0
  50. package/dist/resolvers/test-path-resolver.d.ts.map +1 -0
  51. package/dist/resolvers/test-path-resolver.js +112 -0
  52. package/dist/types.d.ts +30 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/types.js +8 -0
  55. package/package.json +3 -3
  56. package/src/config/extensions.ts +108 -0
  57. package/src/config/loader.ts +13 -3
  58. package/src/engine.ts +99 -2
  59. package/src/evaluators/exit-code-evaluator.ts +27 -9
  60. package/src/evaluators/forbidden-import-evaluator.ts +101 -7
  61. package/src/evaluators/import-boundary-evaluator.ts +135 -0
  62. package/src/evaluators/path-evaluator.ts +66 -3
  63. package/src/evaluators/regex-evaluator.ts +53 -12
  64. package/src/evaluators/schema-artifact-evaluator.ts +134 -0
  65. package/src/evaluators/secrets-scanner-evaluator.ts +89 -11
  66. package/src/evaluators/sg-evaluator.ts +133 -0
  67. package/src/evaluators/test-location-evaluator.ts +47 -35
  68. package/src/evaluators/tsdoc-export-evaluator.ts +19 -5
  69. package/src/fixers/fixers.ts +294 -0
  70. package/src/fixers/test-stub-fixer.ts +118 -0
  71. package/src/host/builtins.ts +17 -1
  72. package/src/host/rule-engine-host.ts +4 -0
  73. package/src/index.ts +4 -0
  74. package/src/resolvers/test-path-resolver.ts +133 -0
  75. package/src/types.ts +34 -0
@@ -7,28 +7,106 @@ import {
7
7
  } from '../types';
8
8
  import { discoverFiles, readWorkdirFile } from './file-utils';
9
9
 
10
- /** Scans text files for high-confidence secret-like tokens. */
10
+ /** Built-in secret category names. */
11
+ export type SecretsCategory = 'api-key' | 'private-key' | 'password' | 'token' | 'connection-string';
12
+
13
+ // Patterns are assembled from a keyword group plus a value-shape suffix rather
14
+ // than written as literal `keyword: "value"` lines, so this source file does not
15
+ // trip the secrets-scanner when it scans itself.
16
+ const ASSIGN = '\\s*[=:]\\s*';
17
+ const QUOTED = (body: string) => `["'\`]${body}["'\`]`;
18
+ const keyworded = (keywords: string, value: string) => `(?i)(${keywords})${ASSIGN}${QUOTED(value)}`;
19
+
20
+ /** Built-in category → regex source. A leading `(?i)` is folded into the JS `i` flag. */
21
+ const BUILTIN_PATTERNS: Record<SecretsCategory, string> = {
22
+ 'api-key': `${keyworded('api[_-]?key|apikey|api_secret|access[_-]?token|auth[_-]?token|secret[_-]?key', '[A-Za-z0-9_/+=.-]{8,}')}|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}`,
23
+ 'private-key': 'PRIVATE[_-]?KEY|-----BEGIN\\s+(?:RSA |OPENSSH |EC )?PRIVATE\\s+KEY',
24
+ password: keyworded('passw[o]rd|passwd|pwd|pass', '[^"\'`]{4,}'),
25
+ token: keyworded('bearer[_-]?token|refresh[_-]?token|session[_-]?token', '[A-Za-z0-9_\\-./+=]{8,}'),
26
+ 'connection-string': '(?i)(mongodb|postgres|mysql|redis)://[^\\s"\'`]{8,}',
27
+ };
28
+
29
+ const ALL_CATEGORIES = Object.keys(BUILTIN_PATTERNS) as SecretsCategory[];
30
+
31
+ /** A compiled scanner pattern with its reporting label. */
32
+ interface ScanPattern {
33
+ regex: RegExp;
34
+ label: string;
35
+ }
36
+
37
+ /**
38
+ * Scans text files for secret-like tokens.
39
+ *
40
+ * Config (`evaluator.config`, all optional):
41
+ * - `categories`: subset of built-in categories to scan (default: all five —
42
+ * `api-key`, `private-key`, `password`, `token`, `connection-string`).
43
+ * - `customPatterns`: extra `{ name, pattern }` entries to scan for.
44
+ * - `scope`: `{ include, exclude }` globs; falls back to the rule's `include` /
45
+ * `exclude` when omitted.
46
+ */
11
47
  export class SecretsScannerEvaluator implements RuleEvaluator {
12
48
  constructor() {}
13
49
 
14
- /** Evaluate source files against bundled secret patterns. */
50
+ /** Evaluate files against the selected secret categories and custom patterns. */
15
51
  async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
16
- const files = await discoverFiles({ workdir: context.workdir, include: rule.include, exclude: rule.exclude });
52
+ const config = rule.evaluator.config ?? {};
53
+ const patterns = buildPatterns(config);
54
+ const scope = config.scope as { include?: unknown; exclude?: unknown } | undefined;
55
+ const include = stringArray(scope?.include) ?? rule.include;
56
+ const exclude = stringArray(scope?.exclude) ?? rule.exclude;
57
+ const files = await discoverFiles({ workdir: context.workdir, include, exclude });
58
+
17
59
  const findings = [];
18
- const secretPattern = /(sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY)/;
19
60
  for (const file of files) {
20
61
  const lines = (await readWorkdirFile(context.workdir, file)).split('\n');
21
62
  for (const [index, line] of lines.entries()) {
22
- if (secretPattern.test(line)) {
23
- findings.push(
24
- createFinding(rule, 'Potential secret token found', file, {
25
- line: index + 1,
26
- code: 'secret:token',
27
- }),
28
- );
63
+ for (const pattern of patterns) {
64
+ pattern.regex.lastIndex = 0;
65
+ if (pattern.regex.test(line)) {
66
+ findings.push(
67
+ createFinding(rule, `potential secret (${pattern.label})`, file, {
68
+ line: index + 1,
69
+ code: `secret:${pattern.label}`,
70
+ }),
71
+ );
72
+ break;
73
+ }
29
74
  }
30
75
  }
31
76
  }
32
77
  return { findings, fixes: [] };
33
78
  }
34
79
  }
80
+
81
+ /** Compile the selected built-in categories plus any custom patterns. */
82
+ function buildPatterns(config: Record<string, unknown>): ScanPattern[] {
83
+ const categories = stringArray(config.categories) ?? ALL_CATEGORIES;
84
+ const patterns: ScanPattern[] = [];
85
+ for (const category of categories) {
86
+ const source = BUILTIN_PATTERNS[category as SecretsCategory];
87
+ if (source !== undefined) patterns.push({ regex: compile(source), label: category });
88
+ }
89
+ const custom = config.customPatterns;
90
+ if (Array.isArray(custom)) {
91
+ for (const entry of custom as { name?: unknown; pattern?: unknown }[]) {
92
+ if (typeof entry.name === 'string' && typeof entry.pattern === 'string') {
93
+ patterns.push({ regex: compile(entry.pattern), label: entry.name });
94
+ }
95
+ }
96
+ }
97
+ return patterns;
98
+ }
99
+
100
+ /** Compile a pattern, folding a leading `(?i)` group into the JS `i` flag. */
101
+ function compile(source: string): RegExp {
102
+ const inline = /^\(\?([a-z]+)\)/.exec(source);
103
+ if (inline) {
104
+ const flags = [...(inline[1] ?? '')].filter((flag) => 'imsu'.includes(flag)).join('');
105
+ return new RegExp(source.slice(inline[0].length), flags);
106
+ }
107
+ return new RegExp(source);
108
+ }
109
+
110
+ function stringArray(value: unknown): string[] | undefined {
111
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : undefined;
112
+ }
@@ -0,0 +1,133 @@
1
+ import { NodeProcessExecutor, type ProcessExecutor } from '@gobing-ai/ts-runtime';
2
+ import {
3
+ type ConstraintRule,
4
+ createFinding,
5
+ type RuleContext,
6
+ type RuleEvaluationResult,
7
+ type RuleEvaluator,
8
+ } from '../types';
9
+ import { matchesGlob } from './file-utils';
10
+
11
+ /**
12
+ * Evaluates source code against an ast-grep pattern using the `sg` CLI.
13
+ *
14
+ * ## Options (in `evaluator.config`)
15
+ * - `pattern` — ast-grep pattern to search for (required).
16
+ * - `language` — language for ast-grep parsing (default: `typescript`).
17
+ *
18
+ * Include globs from the rule are forwarded to sg via `--glob` arguments.
19
+ * Exclude globs from the rule are applied in-process after parsing sg output.
20
+ */
21
+ export class SgEvaluator implements RuleEvaluator {
22
+ private readonly executor: ProcessExecutor;
23
+
24
+ constructor(executor: ProcessExecutor = new NodeProcessExecutor()) {
25
+ this.executor = executor;
26
+ }
27
+
28
+ /** Run sg and emit one finding per matched AST node. */
29
+ async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
30
+ const config = rule.evaluator.config ?? {};
31
+ const pattern = config.pattern;
32
+ if (typeof pattern !== 'string' || pattern.length === 0) {
33
+ throw new Error('sg evaluator requires string config "pattern"');
34
+ }
35
+
36
+ const language = typeof config.language === 'string' ? config.language : 'typescript';
37
+ const include = rule.include ?? [];
38
+ const exclude = rule.exclude ?? [];
39
+
40
+ const args: string[] = ['run', '--pattern', pattern, '--lang', language, '--json'];
41
+ for (const glob of include) {
42
+ args.push(`--glob=${glob}`);
43
+ }
44
+
45
+ const result = await this.executor.run({
46
+ command: 'sg',
47
+ args,
48
+ cwd: context.workdir,
49
+ timeout: 60_000,
50
+ rejectOnError: false,
51
+ label: 'sg',
52
+ });
53
+
54
+ const stdout = result.stdout.trim();
55
+
56
+ if (stdout.length === 0) {
57
+ if (result.exitCode !== 0 && result.exitCode !== null && result.stderr.trim().length > 0) {
58
+ throw new Error(`sg failed: ${result.stderr.trim()}`);
59
+ }
60
+ return { findings: [], fixes: [] };
61
+ }
62
+
63
+ const matches = parseSgJson(stdout);
64
+ const findings = [];
65
+ for (const match of matches) {
66
+ if (exclude.some((glob) => matchesGlob(match.file, glob))) continue;
67
+ findings.push(
68
+ createFinding(rule, 'matched sg pattern', match.file, {
69
+ line: match.line,
70
+ code: 'sg:match',
71
+ }),
72
+ );
73
+ }
74
+
75
+ return { findings, fixes: [] };
76
+ }
77
+ }
78
+
79
+ /** Parsed sg match entry. */
80
+ interface SgMatch {
81
+ file: string;
82
+ line: number;
83
+ text: string;
84
+ }
85
+
86
+ /**
87
+ * Parse `sg --json` output.
88
+ *
89
+ * Handles both a JSON array (sg >= 0.40) and newline-delimited JSON objects
90
+ * (older sg or `--json=stream`). Returns workdir-relative file paths as-is
91
+ * since sg emits paths relative to cwd.
92
+ */
93
+ function parseSgJson(stdout: string): SgMatch[] {
94
+ // Try JSON array first (newer sg).
95
+ try {
96
+ const parsed = JSON.parse(stdout);
97
+ if (Array.isArray(parsed)) {
98
+ return parsed.flatMap((event) => {
99
+ const file = typeof event.file === 'string' ? event.file : '';
100
+ if (!file) return [];
101
+ return [
102
+ {
103
+ file,
104
+ line: (event.range?.start?.line ?? 0) + 1,
105
+ text: typeof event.text === 'string' ? event.text.trim() : '',
106
+ },
107
+ ];
108
+ });
109
+ }
110
+ } catch {
111
+ // Fall through to line-delimited parsing.
112
+ }
113
+
114
+ // Fallback: newline-delimited JSON.
115
+ const results: SgMatch[] = [];
116
+ for (const line of stdout.split('\n')) {
117
+ const trimmed = line.trim();
118
+ if (!trimmed) continue;
119
+ try {
120
+ const event = JSON.parse(trimmed);
121
+ const file = typeof event.file === 'string' ? event.file : '';
122
+ if (!file) continue;
123
+ results.push({
124
+ file,
125
+ line: (event.range?.start?.line ?? 0) + 1,
126
+ text: typeof event.text === 'string' ? event.text.trim() : '',
127
+ });
128
+ } catch {
129
+ // Skip unparseable lines.
130
+ }
131
+ }
132
+ return results;
133
+ }
@@ -1,3 +1,5 @@
1
+ import type { CapabilityRegistry } from '../host/capability-registry';
2
+ import type { TestPathResolver } from '../resolvers/test-path-resolver';
1
3
  import {
2
4
  type ConstraintRule,
3
5
  createFinding,
@@ -12,6 +14,7 @@ interface TestLocationConfig {
12
14
  expected?: string;
13
15
  forbid?: string[];
14
16
  requireCorrespondingTest?: boolean;
17
+ resolver?: string;
15
18
  }
16
19
 
17
20
  /**
@@ -22,12 +25,16 @@ interface TestLocationConfig {
22
25
  * - `expected`: glob the test files must match (required)
23
26
  * - `forbid`: globs where tests must not live (e.g. `**\/__tests__/**`)
24
27
  * - `requireCorrespondingTest`: when true, flags source files (from `rule.include`)
25
- * that lack a test at the TypeScript-conventional path
28
+ * that lack a test at the resolver's conventional path
29
+ * - `resolver`: language resolver name (`typescript` default, or `python`/`go`/`rust`)
26
30
  *
27
31
  * Discovery walks the workdir and applies `**` globs precisely, so it stays
28
32
  * self-contained (no `rg --files` shell-out).
29
33
  */
30
34
  export class TestLocationEvaluator implements RuleEvaluator {
35
+ /** Optional resolver registry; when absent, the TypeScript convention is used. */
36
+ constructor(private readonly resolvers?: CapabilityRegistry<TestPathResolver>) {}
37
+
31
38
  /** Evaluate test-file placement and optional coverage of source files. */
32
39
  async evaluate(rule: ConstraintRule, context: RuleContext): Promise<RuleEvaluationResult> {
33
40
  const config = (rule.evaluator.config ?? {}) as TestLocationConfig;
@@ -49,20 +56,15 @@ export class TestLocationEvaluator implements RuleEvaluator {
49
56
  const violated = forbid.find((pattern) => matchesGlob(file, pattern));
50
57
  if (violated !== undefined) {
51
58
  findings.push(
52
- createFinding(
53
- rule,
54
- `Test file "${file}" is in a forbidden location (matches "${violated}")`,
55
- file,
56
- {
57
- code: 'test-location:forbidden',
58
- },
59
- ),
59
+ createFinding(rule, `test file in forbidden location (matches "${violated}")`, file, {
60
+ code: 'test-location:forbidden',
61
+ }),
60
62
  );
61
63
  continue;
62
64
  }
63
65
  if (!matchesGlob(file, expected)) {
64
66
  findings.push(
65
- createFinding(rule, `Test file "${file}" does not match expected pattern "${expected}"`, file, {
67
+ createFinding(rule, `test file outside expected location (expected "${expected}")`, file, {
66
68
  code: 'test-location:unexpected',
67
69
  }),
68
70
  );
@@ -70,23 +72,19 @@ export class TestLocationEvaluator implements RuleEvaluator {
70
72
  }
71
73
 
72
74
  if (config.requireCorrespondingTest) {
75
+ const resolver = this.selectResolver(config.resolver);
73
76
  const srcPatterns = rule.include ?? ['**/*.ts', '**/*.tsx'];
74
77
  const testSet = new Set(testFiles);
75
78
  for (const srcFile of allFiles) {
76
79
  if (!srcPatterns.some((pattern) => matchesGlob(srcFile, pattern))) continue;
77
80
  if (exclude.some((pattern) => matchesGlob(srcFile, pattern))) continue;
78
- const testPath = resolveTestPath(srcFile);
81
+ const testPath = resolver.resolveTestPath(srcFile);
79
82
  if (testPath === srcFile) continue;
80
83
  if (!testSet.has(testPath)) {
81
84
  findings.push(
82
- createFinding(
83
- rule,
84
- `Source file "${srcFile}" has no corresponding test → ${testPath}`,
85
- srcFile,
86
- {
87
- code: 'test-location:missing',
88
- },
89
- ),
85
+ createFinding(rule, `no corresponding test → ${testPath}`, srcFile, {
86
+ code: 'test-location:missing',
87
+ }),
90
88
  );
91
89
  }
92
90
  }
@@ -94,22 +92,36 @@ export class TestLocationEvaluator implements RuleEvaluator {
94
92
 
95
93
  return { findings, fixes: [] };
96
94
  }
97
- }
98
95
 
99
- /**
100
- * Map a TypeScript source path to its conventional test path.
101
- *
102
- * packages/core/src/foo/bar.ts packages/core/tests/foo/bar.test.ts
103
- * src/foo/bar.ts → tests/foo/bar.test.ts
104
- */
105
- function resolveTestPath(srcRelPath: string): string {
106
- if (srcRelPath.includes('.test.') || srcRelPath.includes('.spec.')) return srcRelPath;
107
- const srcIdx = srcRelPath.indexOf('/src/');
108
- if (srcIdx !== -1) {
109
- const pkg = srcRelPath.slice(0, srcIdx);
110
- const rel = srcRelPath.slice(srcIdx + '/src/'.length).replace(/\.(ts|tsx|js|jsx)$/, '.test.ts');
111
- return `${pkg}/tests/${rel}`;
96
+ /**
97
+ * Pick the resolver named in config (default `typescript`).
98
+ *
99
+ * Falls back to the built-in TypeScript convention when no registry was
100
+ * injected; throws if a named resolver is requested but not registered.
101
+ */
102
+ private selectResolver(name = 'typescript'): TestPathResolver {
103
+ if (this.resolvers === undefined) {
104
+ if (name !== 'typescript') {
105
+ throw new Error(`test-location resolver "${name}" requested but no resolver registry is available`);
106
+ }
107
+ return TYPESCRIPT_FALLBACK;
108
+ }
109
+ return this.resolvers.get(name);
112
110
  }
113
- const withoutExt = srcRelPath.replace(/\.(ts|tsx|js|jsx)$/, '');
114
- return `tests/${withoutExt.replace(/^src\//, '')}.test.ts`;
115
111
  }
112
+
113
+ /** Built-in TypeScript convention used when no resolver registry is injected. */
114
+ const TYPESCRIPT_FALLBACK: TestPathResolver = {
115
+ name: 'typescript',
116
+ resolveTestPath(srcRelPath: string): string {
117
+ if (srcRelPath.includes('.test.') || srcRelPath.includes('.spec.')) return srcRelPath;
118
+ const srcIdx = srcRelPath.indexOf('/src/');
119
+ if (srcIdx !== -1) {
120
+ const pkg = srcRelPath.slice(0, srcIdx);
121
+ const rel = srcRelPath.slice(srcIdx + '/src/'.length).replace(/\.(ts|tsx|js|jsx)$/, '.test.ts');
122
+ return `${pkg}/tests/${rel}`;
123
+ }
124
+ const withoutExt = srcRelPath.replace(/\.(ts|tsx|js|jsx)$/, '');
125
+ return `tests/${withoutExt.replace(/^src\//, '')}.test.ts`;
126
+ },
127
+ };
@@ -20,8 +20,8 @@ interface ExportSite {
20
20
  }
21
21
 
22
22
  const KIND_PATTERN: Record<ExportKind, RegExp> = {
23
- function: /^export\s+(?:async\s+)?function\s+([A-Za-z0-9_$]+)/,
24
- class: /^export\s+(?:abstract\s+)?class\s+([A-Za-z0-9_$]+)/,
23
+ function: /^export\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s+([A-Za-z0-9_$]+)/,
24
+ class: /^export\s+(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z0-9_$]+)/,
25
25
  interface: /^export\s+interface\s+([A-Za-z0-9_$]+)/,
26
26
  type: /^export\s+type\s+([A-Za-z0-9_$]+)/,
27
27
  const: /^export\s+const\s+([A-Za-z0-9_$]+)/,
@@ -90,8 +90,22 @@ function findExports(lines: string[], requested: ReadonlySet<ExportKind>): Expor
90
90
  return sites;
91
91
  }
92
92
 
93
- /** True when the line immediately above a declaration closes a JSDoc block. */
93
+ /**
94
+ * True when a JSDoc block precedes a declaration.
95
+ *
96
+ * Skips decorator lines (`@Component(...)`) so a documented but decorated class
97
+ * is not falsely flagged, then checks whether the nearest preceding non-decorator
98
+ * line closes (or is) a JSDoc comment.
99
+ */
94
100
  function precededByJsdoc(lines: string[], declarationLine: number): boolean {
95
- const prev = lines[declarationLine - 2]?.trim();
96
- return prev !== undefined && (prev.endsWith('*/') || prev.startsWith('/**'));
101
+ let cursor = declarationLine - 2; // zero-based line above the declaration
102
+ while (cursor >= 0) {
103
+ const prev = lines[cursor]?.trim() ?? '';
104
+ if (prev.startsWith('@')) {
105
+ cursor -= 1; // decorator — keep walking up
106
+ continue;
107
+ }
108
+ return prev.endsWith('*/') || prev.startsWith('/**');
109
+ }
110
+ return false;
97
111
  }