@eduardbar/drift 1.1.0 → 1.3.0

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 (66) hide show
  1. package/.github/workflows/publish-vscode.yml +3 -3
  2. package/.github/workflows/publish.yml +3 -3
  3. package/.github/workflows/review-pr.yml +153 -0
  4. package/AGENTS.md +6 -0
  5. package/README.md +192 -4
  6. package/ROADMAP.md +6 -5
  7. package/dist/analyzer.d.ts +2 -2
  8. package/dist/analyzer.js +420 -159
  9. package/dist/benchmark.d.ts +2 -0
  10. package/dist/benchmark.js +185 -0
  11. package/dist/cli.js +509 -23
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.js +3 -0
  16. package/dist/map.d.ts +3 -2
  17. package/dist/map.js +98 -10
  18. package/dist/plugins.d.ts +2 -1
  19. package/dist/plugins.js +177 -28
  20. package/dist/printer.js +4 -0
  21. package/dist/review.js +2 -2
  22. package/dist/rules/comments.js +2 -2
  23. package/dist/rules/complexity.js +2 -7
  24. package/dist/rules/nesting.js +3 -13
  25. package/dist/rules/phase0-basic.js +10 -10
  26. package/dist/rules/shared.d.ts +2 -0
  27. package/dist/rules/shared.js +27 -3
  28. package/dist/saas.d.ts +219 -0
  29. package/dist/saas.js +762 -0
  30. package/dist/trust-kpi.d.ts +9 -0
  31. package/dist/trust-kpi.js +445 -0
  32. package/dist/trust.d.ts +65 -0
  33. package/dist/trust.js +571 -0
  34. package/dist/types.d.ts +160 -0
  35. package/docs/PRD.md +199 -172
  36. package/docs/plugin-contract.md +61 -0
  37. package/docs/trust-core-release-checklist.md +55 -0
  38. package/package.json +5 -3
  39. package/packages/vscode-drift/src/code-actions.ts +53 -0
  40. package/packages/vscode-drift/src/extension.ts +11 -0
  41. package/src/analyzer.ts +484 -155
  42. package/src/benchmark.ts +244 -0
  43. package/src/cli.ts +628 -36
  44. package/src/diff.ts +75 -10
  45. package/src/git.ts +16 -0
  46. package/src/index.ts +63 -0
  47. package/src/map.ts +112 -10
  48. package/src/plugins.ts +354 -26
  49. package/src/printer.ts +4 -0
  50. package/src/review.ts +2 -2
  51. package/src/rules/comments.ts +2 -2
  52. package/src/rules/complexity.ts +2 -7
  53. package/src/rules/nesting.ts +3 -13
  54. package/src/rules/phase0-basic.ts +11 -12
  55. package/src/rules/shared.ts +31 -3
  56. package/src/saas.ts +1031 -0
  57. package/src/trust-kpi.ts +518 -0
  58. package/src/trust.ts +774 -0
  59. package/src/types.ts +177 -0
  60. package/tests/diff.test.ts +124 -0
  61. package/tests/new-features.test.ts +98 -0
  62. package/tests/plugins.test.ts +219 -0
  63. package/tests/rules.test.ts +23 -1
  64. package/tests/saas-foundation.test.ts +464 -0
  65. package/tests/trust-kpi.test.ts +120 -0
  66. package/tests/trust.test.ts +584 -0
package/src/plugins.ts CHANGED
@@ -1,30 +1,353 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { isAbsolute, resolve } from 'node:path'
3
3
  import { createRequire } from 'node:module'
4
- import type { DriftPlugin, PluginLoadError, LoadedPlugin } from './types.js'
4
+ import type { DriftIssue, DriftPlugin, DriftPluginRule, PluginLoadError, PluginLoadWarning, LoadedPlugin } from './types.js'
5
5
 
6
6
  const require = createRequire(import.meta.url)
7
+ const VALID_SEVERITIES: DriftIssue['severity'][] = ['error', 'warning', 'info']
8
+ const SUPPORTED_PLUGIN_API_VERSION = 1
9
+ const RULE_ID_REQUIRED = /^[a-z][a-z0-9]*(?:[-_/][a-z0-9]+)*$/
7
10
 
8
- function isPluginShape(value: unknown): value is DriftPlugin {
9
- if (!value || typeof value !== 'object') return false
10
- const candidate = value as Partial<DriftPlugin>
11
- if (typeof candidate.name !== 'string') return false
12
- if (!Array.isArray(candidate.rules)) return false
13
- return candidate.rules.every((rule) =>
14
- rule &&
15
- typeof rule === 'object' &&
16
- typeof rule.name === 'string' &&
17
- typeof rule.detect === 'function'
18
- )
11
+ type PluginCandidate = {
12
+ name?: unknown
13
+ apiVersion?: unknown
14
+ capabilities?: unknown
15
+ rules?: unknown
19
16
  }
20
17
 
21
- function normalizePluginExport(mod: unknown): DriftPlugin | undefined {
22
- if (isPluginShape(mod)) return mod
18
+ type RuleCandidate = {
19
+ id?: unknown
20
+ name?: unknown
21
+ severity?: unknown
22
+ weight?: unknown
23
+ detect?: unknown
24
+ fix?: unknown
25
+ }
26
+
27
+ function normalizePluginExport(mod: unknown): unknown {
23
28
  if (mod && typeof mod === 'object' && 'default' in mod) {
24
- const maybeDefault = (mod as { default?: unknown }).default
25
- if (isPluginShape(maybeDefault)) return maybeDefault
29
+ return (mod as { default?: unknown }).default ?? mod
30
+ }
31
+ return mod
32
+ }
33
+
34
+ function pushError(
35
+ errors: PluginLoadError[],
36
+ pluginId: string,
37
+ message: string,
38
+ options?: { pluginName?: string; ruleId?: string; code?: string },
39
+ ): void {
40
+ errors.push({
41
+ pluginId,
42
+ pluginName: options?.pluginName,
43
+ ruleId: options?.ruleId,
44
+ code: options?.code,
45
+ message,
46
+ })
47
+ }
48
+
49
+ function pushWarning(
50
+ warnings: PluginLoadWarning[],
51
+ pluginId: string,
52
+ message: string,
53
+ options?: { pluginName?: string; ruleId?: string; code?: string },
54
+ ): void {
55
+ warnings.push({
56
+ pluginId,
57
+ pluginName: options?.pluginName,
58
+ ruleId: options?.ruleId,
59
+ code: options?.code,
60
+ message,
61
+ })
62
+ }
63
+
64
+ function normalizeRule(
65
+ pluginId: string,
66
+ pluginName: string,
67
+ rawRule: RuleCandidate,
68
+ ruleIndex: number,
69
+ options: { strictRuleId: boolean },
70
+ errors: PluginLoadError[],
71
+ warnings: PluginLoadWarning[],
72
+ ): DriftPluginRule | undefined {
73
+ const rawRuleId = typeof rawRule.id === 'string'
74
+ ? rawRule.id.trim()
75
+ : typeof rawRule.name === 'string'
76
+ ? rawRule.name.trim()
77
+ : ''
78
+
79
+ const ruleLabel = rawRuleId || `rule#${ruleIndex + 1}`
80
+
81
+ if (!rawRuleId) {
82
+ pushError(
83
+ errors,
84
+ pluginId,
85
+ `Invalid rule at index ${ruleIndex}. Expected 'id' or 'name' as a non-empty string.`,
86
+ { pluginName, code: 'plugin-rule-id-missing' },
87
+ )
88
+ return undefined
89
+ }
90
+
91
+ if (typeof rawRule.detect !== 'function') {
92
+ pushError(
93
+ errors,
94
+ pluginId,
95
+ `Rule '${rawRuleId}' is invalid. Expected 'detect(file, context)' function.`,
96
+ { pluginName, ruleId: rawRuleId, code: 'plugin-rule-detect-invalid' },
97
+ )
98
+ return undefined
99
+ }
100
+
101
+ if (rawRule.detect.length > 2) {
102
+ pushWarning(
103
+ warnings,
104
+ pluginId,
105
+ `Rule '${rawRuleId}' detect() declares ${rawRule.detect.length} parameters. Expected 1-2 parameters (file, context).`,
106
+ { pluginName, ruleId: rawRuleId, code: 'plugin-rule-detect-arity' },
107
+ )
108
+ }
109
+
110
+ if (!RULE_ID_REQUIRED.test(rawRuleId)) {
111
+ if (options.strictRuleId) {
112
+ pushError(
113
+ errors,
114
+ pluginId,
115
+ `Rule id '${ruleLabel}' is invalid. Use lowercase letters, numbers, and separators (-, _, /), starting with a letter.`,
116
+ { pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-invalid' },
117
+ )
118
+ return undefined
119
+ }
120
+
121
+ pushWarning(
122
+ warnings,
123
+ pluginId,
124
+ `Rule id '${ruleLabel}' uses a legacy format. For forward compatibility, migrate to lowercase kebab-case and set apiVersion: ${SUPPORTED_PLUGIN_API_VERSION}.`,
125
+ { pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-format-legacy' },
126
+ )
127
+ }
128
+
129
+ let severity: DriftIssue['severity'] | undefined
130
+ if (rawRule.severity !== undefined) {
131
+ if (typeof rawRule.severity === 'string' && VALID_SEVERITIES.includes(rawRule.severity as DriftIssue['severity'])) {
132
+ severity = rawRule.severity as DriftIssue['severity']
133
+ } else {
134
+ pushError(
135
+ errors,
136
+ pluginId,
137
+ `Rule '${rawRuleId}' has invalid severity '${String(rawRule.severity)}'. Allowed: error, warning, info.`,
138
+ { pluginName, ruleId: rawRuleId, code: 'plugin-rule-severity-invalid' },
139
+ )
140
+ }
141
+ }
142
+
143
+ let weight: number | undefined
144
+ if (rawRule.weight !== undefined) {
145
+ if (typeof rawRule.weight === 'number' && Number.isFinite(rawRule.weight) && rawRule.weight >= 0 && rawRule.weight <= 100) {
146
+ weight = rawRule.weight
147
+ } else {
148
+ pushError(
149
+ errors,
150
+ pluginId,
151
+ `Rule '${rawRuleId}' has invalid weight '${String(rawRule.weight)}'. Expected a finite number between 0 and 100.`,
152
+ { pluginName, ruleId: rawRuleId, code: 'plugin-rule-weight-invalid' },
153
+ )
154
+ }
155
+ }
156
+
157
+ let fix: DriftPluginRule['fix'] | undefined
158
+ if (rawRule.fix !== undefined) {
159
+ if (typeof rawRule.fix === 'function') {
160
+ fix = rawRule.fix as DriftPluginRule['fix']
161
+ if (rawRule.fix.length > 3) {
162
+ pushWarning(
163
+ warnings,
164
+ pluginId,
165
+ `Rule '${rawRuleId}' fix() declares ${rawRule.fix.length} parameters. Expected up to 3 (issue, file, context).`,
166
+ { pluginName, ruleId: rawRuleId, code: 'plugin-rule-fix-arity' },
167
+ )
168
+ }
169
+ } else {
170
+ pushError(
171
+ errors,
172
+ pluginId,
173
+ `Rule '${rawRuleId}' has invalid fix. Expected a function when provided.`,
174
+ { pluginName, ruleId: rawRuleId, code: 'plugin-rule-fix-invalid' },
175
+ )
176
+ }
177
+ }
178
+
179
+ return {
180
+ id: rawRuleId,
181
+ name: rawRuleId,
182
+ detect: rawRule.detect as DriftPluginRule['detect'],
183
+ severity,
184
+ weight,
185
+ fix,
186
+ }
187
+ }
188
+
189
+ function validatePluginContract(
190
+ pluginId: string,
191
+ candidate: unknown,
192
+ ): {
193
+ plugin?: DriftPlugin
194
+ errors: PluginLoadError[]
195
+ warnings: PluginLoadWarning[]
196
+ } {
197
+ const errors: PluginLoadError[] = []
198
+ const warnings: PluginLoadWarning[] = []
199
+
200
+ if (!candidate || typeof candidate !== 'object') {
201
+ pushError(
202
+ errors,
203
+ pluginId,
204
+ `Invalid plugin contract in '${pluginId}'. Expected an object export with shape { name, rules[] }`,
205
+ { code: 'plugin-shape-invalid' },
206
+ )
207
+ return { errors, warnings }
208
+ }
209
+
210
+ const plugin = candidate as PluginCandidate
211
+ const pluginName = typeof plugin.name === 'string' ? plugin.name.trim() : ''
212
+ if (!pluginName) {
213
+ pushError(
214
+ errors,
215
+ pluginId,
216
+ `Invalid plugin contract in '${pluginId}'. Expected 'name' as a non-empty string.`,
217
+ { code: 'plugin-name-missing' },
218
+ )
219
+ return { errors, warnings }
220
+ }
221
+
222
+ const hasExplicitApiVersion = plugin.apiVersion !== undefined
223
+ const isLegacyPlugin = !hasExplicitApiVersion
224
+
225
+ if (isLegacyPlugin) {
226
+ pushWarning(
227
+ warnings,
228
+ pluginId,
229
+ `Plugin '${pluginName}' does not declare 'apiVersion'. Assuming ${SUPPORTED_PLUGIN_API_VERSION} for backward compatibility; please add apiVersion: ${SUPPORTED_PLUGIN_API_VERSION}.`,
230
+ { pluginName, code: 'plugin-api-version-implicit' },
231
+ )
232
+ } else if (typeof plugin.apiVersion !== 'number' || !Number.isInteger(plugin.apiVersion) || plugin.apiVersion <= 0) {
233
+ pushError(
234
+ errors,
235
+ pluginId,
236
+ `Plugin '${pluginName}' has invalid apiVersion '${String(plugin.apiVersion)}'. Expected a positive integer (for example: ${SUPPORTED_PLUGIN_API_VERSION}).`,
237
+ { pluginName, code: 'plugin-api-version-invalid' },
238
+ )
239
+ return { errors, warnings }
240
+ } else if (plugin.apiVersion !== SUPPORTED_PLUGIN_API_VERSION) {
241
+ pushError(
242
+ errors,
243
+ pluginId,
244
+ `Plugin '${pluginName}' targets apiVersion ${plugin.apiVersion}, but this drift build supports apiVersion ${SUPPORTED_PLUGIN_API_VERSION}.`,
245
+ { pluginName, code: 'plugin-api-version-unsupported' },
246
+ )
247
+ return { errors, warnings }
248
+ }
249
+
250
+ let capabilities: DriftPlugin['capabilities'] | undefined
251
+ if (plugin.capabilities !== undefined) {
252
+ if (!plugin.capabilities || typeof plugin.capabilities !== 'object' || Array.isArray(plugin.capabilities)) {
253
+ pushError(
254
+ errors,
255
+ pluginId,
256
+ `Plugin '${pluginName}' has invalid capabilities metadata. Expected an object map like { "fixes": true } when provided.`,
257
+ { pluginName, code: 'plugin-capabilities-invalid' },
258
+ )
259
+ return { errors, warnings }
260
+ }
261
+
262
+ const entries = Object.entries(plugin.capabilities as Record<string, unknown>)
263
+ for (const [capabilityKey, capabilityValue] of entries) {
264
+ const capabilityType = typeof capabilityValue
265
+ if (capabilityType !== 'string' && capabilityType !== 'number' && capabilityType !== 'boolean') {
266
+ pushError(
267
+ errors,
268
+ pluginId,
269
+ `Plugin '${pluginName}' capability '${capabilityKey}' has invalid value type '${capabilityType}'. Allowed: string | number | boolean.`,
270
+ { pluginName, code: 'plugin-capabilities-value-invalid' },
271
+ )
272
+ }
273
+ }
274
+
275
+ if (errors.length > 0) {
276
+ return { errors, warnings }
277
+ }
278
+
279
+ capabilities = plugin.capabilities as DriftPlugin['capabilities']
280
+ }
281
+
282
+ if (!Array.isArray(plugin.rules)) {
283
+ pushError(
284
+ errors,
285
+ pluginId,
286
+ `Invalid plugin '${pluginName}'. Expected 'rules' to be an array.`,
287
+ { pluginName, code: 'plugin-rules-not-array' },
288
+ )
289
+ return { errors, warnings }
290
+ }
291
+
292
+ const normalizedRules: DriftPluginRule[] = []
293
+ const seenRuleIds = new Set<string>()
294
+
295
+ for (const [ruleIndex, rawRule] of plugin.rules.entries()) {
296
+ if (!rawRule || typeof rawRule !== 'object') {
297
+ pushError(
298
+ errors,
299
+ pluginId,
300
+ `Invalid rule at index ${ruleIndex} in plugin '${pluginName}'. Expected an object.`,
301
+ { pluginName, code: 'plugin-rule-shape-invalid' },
302
+ )
303
+ continue
304
+ }
305
+
306
+ const normalized = normalizeRule(
307
+ pluginId,
308
+ pluginName,
309
+ rawRule as RuleCandidate,
310
+ ruleIndex,
311
+ { strictRuleId: !isLegacyPlugin },
312
+ errors,
313
+ warnings,
314
+ )
315
+ if (normalized) {
316
+ if (seenRuleIds.has(normalized.id ?? normalized.name)) {
317
+ pushError(
318
+ errors,
319
+ pluginId,
320
+ `Plugin '${pluginName}' defines duplicate rule id '${normalized.id ?? normalized.name}'. Rule ids must be unique within a plugin.`,
321
+ { pluginName, ruleId: normalized.id ?? normalized.name, code: 'plugin-rule-id-duplicate' },
322
+ )
323
+ continue
324
+ }
325
+
326
+ seenRuleIds.add(normalized.id ?? normalized.name)
327
+ normalizedRules.push(normalized)
328
+ }
329
+ }
330
+
331
+ if (normalizedRules.length === 0) {
332
+ pushError(
333
+ errors,
334
+ pluginId,
335
+ `Plugin '${pluginName}' has no valid rules after validation.`,
336
+ { pluginName, code: 'plugin-rules-empty' },
337
+ )
338
+ return { errors, warnings }
339
+ }
340
+
341
+ return {
342
+ plugin: {
343
+ name: pluginName,
344
+ apiVersion: hasExplicitApiVersion ? plugin.apiVersion as number : SUPPORTED_PLUGIN_API_VERSION,
345
+ capabilities,
346
+ rules: normalizedRules,
347
+ },
348
+ errors,
349
+ warnings,
26
350
  }
27
- return undefined
28
351
  }
29
352
 
30
353
  function resolvePluginSpecifier(projectRoot: string, pluginId: string): string {
@@ -43,34 +366,39 @@ function resolvePluginSpecifier(projectRoot: string, pluginId: string): string {
43
366
  export function loadPlugins(projectRoot: string, pluginIds: string[] | undefined): {
44
367
  plugins: LoadedPlugin[]
45
368
  errors: PluginLoadError[]
369
+ warnings: PluginLoadWarning[]
46
370
  } {
47
371
  if (!pluginIds || pluginIds.length === 0) {
48
- return { plugins: [], errors: [] }
372
+ return { plugins: [], errors: [], warnings: [] }
49
373
  }
50
374
 
51
375
  const loaded: LoadedPlugin[] = []
52
376
  const errors: PluginLoadError[] = []
377
+ const warnings: PluginLoadWarning[] = []
53
378
 
54
379
  for (const pluginId of pluginIds) {
55
380
  const resolved = resolvePluginSpecifier(projectRoot, pluginId)
56
381
  try {
57
382
  const mod = require(resolved)
58
- const plugin = normalizePluginExport(mod)
59
- if (!plugin) {
60
- errors.push({
61
- pluginId,
62
- message: `Invalid plugin contract in '${pluginId}'. Expected: { name, rules[] }`,
63
- })
383
+ const normalized = normalizePluginExport(mod)
384
+ const validation = validatePluginContract(pluginId, normalized)
385
+
386
+ errors.push(...validation.errors)
387
+ warnings.push(...validation.warnings)
388
+
389
+ if (!validation.plugin) {
64
390
  continue
65
391
  }
66
- loaded.push({ id: pluginId, plugin })
392
+
393
+ loaded.push({ id: pluginId, plugin: validation.plugin })
67
394
  } catch (error) {
68
395
  errors.push({
69
396
  pluginId,
397
+ code: 'plugin-load-failed',
70
398
  message: error instanceof Error ? error.message : String(error),
71
399
  })
72
400
  }
73
401
  }
74
402
 
75
- return { plugins: loaded, errors }
403
+ return { plugins: loaded, errors, warnings }
76
404
  }
package/src/printer.ts CHANGED
@@ -133,6 +133,10 @@ function formatFixSuggestion(issue: DriftIssue): string[] {
133
133
  'Fix or remove the failing plugin in drift.config.*',
134
134
  'Validate plugin contract: export { name, rules[] } and detector functions',
135
135
  ],
136
+ 'plugin-warning': [
137
+ 'Review plugin validation warnings and align with the recommended contract',
138
+ 'Use explicit rule ids and valid severity/weight values',
139
+ ],
136
140
  }
137
141
  return suggestions[issue.rule] ?? ['Review and fix manually']
138
142
  }
package/src/review.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { resolve } from 'node:path'
1
+ import { relative, resolve } from 'node:path'
2
2
  import { analyzeProject } from './analyzer.js'
3
3
  import { loadConfig } from './config.js'
4
4
  import { buildReport } from './reporter.js'
@@ -66,7 +66,7 @@ export async function generateReview(projectPath: string, baseRef: string): Prom
66
66
  ...baseReport,
67
67
  files: baseReport.files.map((file) => ({
68
68
  ...file,
69
- path: file.path.replace(tempDir!, resolvedPath),
69
+ path: resolve(resolvedPath, relative(tempDir!, file.path)),
70
70
  })),
71
71
  }
72
72
 
@@ -1,6 +1,6 @@
1
1
  import { SourceFile } from 'ts-morph'
2
2
  import type { DriftIssue } from '../types.js'
3
- import { hasIgnoreComment } from './shared.js'
3
+ import { hasIgnoreComment, getFileLines } from './shared.js'
4
4
 
5
5
  const TRIVIAL_COMMENT_PATTERNS = [
6
6
  { comment: /\/\/\s*return\b/i, code: /^\s*return\b/ },
@@ -41,7 +41,7 @@ function checkLineForContradiction(
41
41
 
42
42
  export function detectCommentContradiction(file: SourceFile): DriftIssue[] {
43
43
  const issues: DriftIssue[] = []
44
- const lines = file.getFullText().split('\n')
44
+ const lines = getFileLines(file)
45
45
 
46
46
  for (let i = 0; i < lines.length - 1; i++) {
47
47
  const commentLine = lines[i].trim()
@@ -1,6 +1,6 @@
1
1
  import { SourceFile, SyntaxKind } from 'ts-morph'
2
2
  import type { DriftIssue } from '../types.js'
3
- import { hasIgnoreComment, getSnippet, type FunctionLike } from './shared.js'
3
+ import { hasIgnoreComment, getSnippet, collectFunctionLikes, type FunctionLike } from './shared.js'
4
4
 
5
5
  const COMPLEXITY_THRESHOLD = 10
6
6
 
@@ -31,12 +31,7 @@ function getCyclomaticComplexity(fn: FunctionLike): number {
31
31
 
32
32
  export function detectHighComplexity(file: SourceFile): DriftIssue[] {
33
33
  const issues: DriftIssue[] = []
34
- const fns: FunctionLike[] = [
35
- ...file.getFunctions(),
36
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
37
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
38
- ...file.getClasses().flatMap((c) => c.getMethods()),
39
- ]
34
+ const fns: FunctionLike[] = collectFunctionLikes(file)
40
35
 
41
36
  for (const fn of fns) {
42
37
  const complexity = getCyclomaticComplexity(fn)
@@ -1,6 +1,6 @@
1
1
  import { SourceFile, SyntaxKind, Node } from 'ts-morph'
2
2
  import type { DriftIssue } from '../types.js'
3
- import { hasIgnoreComment, getSnippet, type FunctionLike } from './shared.js'
3
+ import { hasIgnoreComment, getSnippet, collectFunctionLikes, type FunctionLike } from './shared.js'
4
4
 
5
5
  const NESTING_THRESHOLD = 3
6
6
  const PARAMS_THRESHOLD = 4
@@ -35,12 +35,7 @@ function getMaxNestingDepth(fn: FunctionLike): number {
35
35
 
36
36
  export function detectDeepNesting(file: SourceFile): DriftIssue[] {
37
37
  const issues: DriftIssue[] = []
38
- const fns: FunctionLike[] = [
39
- ...file.getFunctions(),
40
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
41
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
42
- ...file.getClasses().flatMap((c) => c.getMethods()),
43
- ]
38
+ const fns: FunctionLike[] = collectFunctionLikes(file)
44
39
 
45
40
  for (const fn of fns) {
46
41
  const depth = getMaxNestingDepth(fn)
@@ -62,12 +57,7 @@ export function detectDeepNesting(file: SourceFile): DriftIssue[] {
62
57
 
63
58
  export function detectTooManyParams(file: SourceFile): DriftIssue[] {
64
59
  const issues: DriftIssue[] = []
65
- const fns: FunctionLike[] = [
66
- ...file.getFunctions(),
67
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
68
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
69
- ...file.getClasses().flatMap((c) => c.getMethods()),
70
- ]
60
+ const fns: FunctionLike[] = collectFunctionLikes(file)
71
61
 
72
62
  for (const fn of fns) {
73
63
  const paramCount = fn.getParameters().length
@@ -1,6 +1,6 @@
1
1
  import { SourceFile, SyntaxKind } from 'ts-morph'
2
2
  import type { DriftIssue } from '../types.js'
3
- import { hasIgnoreComment, getSnippet, getFunctionLikeLines, type FunctionLike } from './shared.js'
3
+ import { hasIgnoreComment, getSnippet, getFunctionLikeLines, collectFunctionLikes, getFileLines } from './shared.js'
4
4
 
5
5
  const LARGE_FILE_THRESHOLD = 300
6
6
  const LARGE_FUNCTION_THRESHOLD = 50
@@ -26,12 +26,7 @@ export function detectLargeFile(file: SourceFile): DriftIssue[] {
26
26
 
27
27
  export function detectLargeFunctions(file: SourceFile): DriftIssue[] {
28
28
  const issues: DriftIssue[] = []
29
- const fns: FunctionLike[] = [
30
- ...file.getFunctions(),
31
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
32
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
33
- ...file.getClasses().flatMap((c) => c.getMethods()),
34
- ]
29
+ const fns = collectFunctionLikes(file)
35
30
 
36
31
  for (const fn of fns) {
37
32
  const lines = getFunctionLikeLines(fn)
@@ -70,7 +65,7 @@ export function detectDebugLeftovers(file: SourceFile): DriftIssue[] {
70
65
  }
71
66
  }
72
67
 
73
- const lines = file.getFullText().split('\n')
68
+ const lines = getFileLines(file)
74
69
  lines.forEach((lineContent, i) => {
75
70
  if (/\/\/\s*(TODO|FIXME|HACK|XXX|TEMP)\b/i.test(lineContent)) {
76
71
  if (hasIgnoreComment(file, i + 1)) return
@@ -90,14 +85,18 @@ export function detectDebugLeftovers(file: SourceFile): DriftIssue[] {
90
85
 
91
86
  export function detectDeadCode(file: SourceFile): DriftIssue[] {
92
87
  const issues: DriftIssue[] = []
88
+ const identifierCounts = new Map<string, number>()
89
+
90
+ for (const id of file.getDescendantsOfKind(SyntaxKind.Identifier)) {
91
+ const text = id.getText()
92
+ identifierCounts.set(text, (identifierCounts.get(text) ?? 0) + 1)
93
+ }
93
94
 
94
95
  for (const imp of file.getImportDeclarations()) {
95
96
  for (const named of imp.getNamedImports()) {
96
97
  const name = named.getName()
97
- const refs = file.getDescendantsOfKind(SyntaxKind.Identifier).filter(
98
- (id) => id.getText() === name && id !== named.getNameNode()
99
- )
100
- if (refs.length === 0) {
98
+ const refsCount = Math.max(0, (identifierCounts.get(name) ?? 0) - 1)
99
+ if (refsCount === 0) {
101
100
  issues.push({
102
101
  rule: 'dead-code',
103
102
  severity: 'warning',
@@ -5,12 +5,40 @@ import {
5
5
  ArrowFunction,
6
6
  FunctionExpression,
7
7
  MethodDeclaration,
8
+ SyntaxKind,
8
9
  } from 'ts-morph'
9
10
 
10
11
  export type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
11
12
 
12
- export function hasIgnoreComment(file: SourceFile, line: number): boolean {
13
+ const fileLinesCache = new WeakMap<SourceFile, string[]>()
14
+ const functionLikesCache = new WeakMap<SourceFile, FunctionLike[]>()
15
+
16
+ export function getFileLines(file: SourceFile): string[] {
17
+ const cached = fileLinesCache.get(file)
18
+ if (cached) return cached
19
+
13
20
  const lines = file.getFullText().split('\n')
21
+ fileLinesCache.set(file, lines)
22
+ return lines
23
+ }
24
+
25
+ export function collectFunctionLikes(file: SourceFile): FunctionLike[] {
26
+ const cached = functionLikesCache.get(file)
27
+ if (cached) return cached
28
+
29
+ const fns: FunctionLike[] = [
30
+ ...file.getFunctions(),
31
+ ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
32
+ ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
33
+ ...file.getClasses().flatMap((c) => c.getMethods()),
34
+ ]
35
+
36
+ functionLikesCache.set(file, fns)
37
+ return fns
38
+ }
39
+
40
+ export function hasIgnoreComment(file: SourceFile, line: number): boolean {
41
+ const lines = getFileLines(file)
14
42
  const currentLine = lines[line - 1] ?? ''
15
43
  const prevLine = lines[line - 2] ?? ''
16
44
 
@@ -20,13 +48,13 @@ export function hasIgnoreComment(file: SourceFile, line: number): boolean {
20
48
  }
21
49
 
22
50
  export function isFileIgnored(file: SourceFile): boolean {
23
- const firstLines = file.getFullText().split('\n').slice(0, 10).join('\n') // drift-ignore
51
+ const firstLines = getFileLines(file).slice(0, 10).join('\n') // drift-ignore
24
52
  return /\/\/\s*drift-ignore-file\b/.test(firstLines)
25
53
  }
26
54
 
27
55
  export function getSnippet(node: Node, file: SourceFile): string {
28
56
  const startLine = node.getStartLineNumber()
29
- const lines = file.getFullText().split('\n')
57
+ const lines = getFileLines(file)
30
58
  return lines
31
59
  .slice(Math.max(0, startLine - 1), startLine + 1)
32
60
  .join('\n')