@eduardbar/drift 1.2.0 → 1.4.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 (195) hide show
  1. package/.gga +50 -0
  2. package/.github/actions/drift-review/README.md +60 -0
  3. package/.github/actions/drift-review/action.yml +131 -0
  4. package/.github/actions/drift-scan/README.md +28 -32
  5. package/.github/actions/drift-scan/action.yml +78 -14
  6. package/.github/workflows/publish-vscode.yml +3 -3
  7. package/.github/workflows/publish.yml +3 -3
  8. package/.github/workflows/review-pr.yml +94 -9
  9. package/AGENTS.md +75 -245
  10. package/CHANGELOG.md +28 -0
  11. package/README.md +308 -51
  12. package/ROADMAP.md +6 -5
  13. package/dist/analyzer.d.ts +2 -2
  14. package/dist/analyzer.js +420 -159
  15. package/dist/benchmark.d.ts +2 -0
  16. package/dist/benchmark.js +204 -0
  17. package/dist/cli.js +693 -67
  18. package/dist/config.js +16 -2
  19. package/dist/diff.js +66 -10
  20. package/dist/doctor.d.ts +5 -0
  21. package/dist/doctor.js +133 -0
  22. package/dist/format.d.ts +17 -0
  23. package/dist/format.js +45 -0
  24. package/dist/git.js +12 -0
  25. package/dist/guard-types.d.ts +57 -0
  26. package/dist/guard-types.js +2 -0
  27. package/dist/guard.d.ts +14 -0
  28. package/dist/guard.js +239 -0
  29. package/dist/index.d.ts +12 -3
  30. package/dist/index.js +6 -1
  31. package/dist/init.d.ts +15 -0
  32. package/dist/init.js +273 -0
  33. package/dist/map-cycles.d.ts +2 -0
  34. package/dist/map-cycles.js +34 -0
  35. package/dist/map-svg.d.ts +19 -0
  36. package/dist/map-svg.js +97 -0
  37. package/dist/map.js +78 -138
  38. package/dist/metrics.js +70 -55
  39. package/dist/output-metadata.d.ts +13 -0
  40. package/dist/output-metadata.js +17 -0
  41. package/dist/plugins-capabilities.d.ts +4 -0
  42. package/dist/plugins-capabilities.js +21 -0
  43. package/dist/plugins-messages.d.ts +10 -0
  44. package/dist/plugins-messages.js +16 -0
  45. package/dist/plugins-rules.d.ts +9 -0
  46. package/dist/plugins-rules.js +137 -0
  47. package/dist/plugins.d.ts +2 -1
  48. package/dist/plugins.js +80 -28
  49. package/dist/printer.js +4 -0
  50. package/dist/reporter-constants.d.ts +16 -0
  51. package/dist/reporter-constants.js +39 -0
  52. package/dist/reporter.d.ts +3 -3
  53. package/dist/reporter.js +35 -55
  54. package/dist/review.d.ts +2 -1
  55. package/dist/review.js +4 -3
  56. package/dist/rules/comments.js +2 -2
  57. package/dist/rules/complexity.js +2 -7
  58. package/dist/rules/nesting.js +3 -13
  59. package/dist/rules/phase0-basic.js +10 -10
  60. package/dist/rules/phase3-configurable.js +23 -15
  61. package/dist/rules/shared.d.ts +2 -0
  62. package/dist/rules/shared.js +27 -3
  63. package/dist/saas/constants.d.ts +15 -0
  64. package/dist/saas/constants.js +48 -0
  65. package/dist/saas/dashboard.d.ts +8 -0
  66. package/dist/saas/dashboard.js +132 -0
  67. package/dist/saas/errors.d.ts +19 -0
  68. package/dist/saas/errors.js +37 -0
  69. package/dist/saas/helpers.d.ts +21 -0
  70. package/dist/saas/helpers.js +110 -0
  71. package/dist/saas/ingest.d.ts +3 -0
  72. package/dist/saas/ingest.js +249 -0
  73. package/dist/saas/organization.d.ts +5 -0
  74. package/dist/saas/organization.js +82 -0
  75. package/dist/saas/plan-change.d.ts +10 -0
  76. package/dist/saas/plan-change.js +15 -0
  77. package/dist/saas/store.d.ts +21 -0
  78. package/dist/saas/store.js +159 -0
  79. package/dist/saas/types.d.ts +191 -0
  80. package/dist/saas/types.js +2 -0
  81. package/dist/saas.d.ts +8 -82
  82. package/dist/saas.js +7 -320
  83. package/dist/sarif.d.ts +74 -0
  84. package/dist/sarif.js +122 -0
  85. package/dist/trust-advanced.d.ts +14 -0
  86. package/dist/trust-advanced.js +65 -0
  87. package/dist/trust-kpi-fs.d.ts +3 -0
  88. package/dist/trust-kpi-fs.js +141 -0
  89. package/dist/trust-kpi-parse.d.ts +7 -0
  90. package/dist/trust-kpi-parse.js +186 -0
  91. package/dist/trust-kpi-types.d.ts +16 -0
  92. package/dist/trust-kpi-types.js +2 -0
  93. package/dist/trust-kpi.d.ts +7 -0
  94. package/dist/trust-kpi.js +185 -0
  95. package/dist/trust-policy.d.ts +32 -0
  96. package/dist/trust-policy.js +160 -0
  97. package/dist/trust-render.d.ts +9 -0
  98. package/dist/trust-render.js +54 -0
  99. package/dist/trust-scoring.d.ts +9 -0
  100. package/dist/trust-scoring.js +208 -0
  101. package/dist/trust.d.ts +37 -0
  102. package/dist/trust.js +168 -0
  103. package/dist/types/app.d.ts +30 -0
  104. package/dist/types/app.js +2 -0
  105. package/dist/types/config.d.ts +25 -0
  106. package/dist/types/config.js +2 -0
  107. package/dist/types/core.d.ts +100 -0
  108. package/dist/types/core.js +2 -0
  109. package/dist/types/diff.d.ts +55 -0
  110. package/dist/types/diff.js +2 -0
  111. package/dist/types/plugin.d.ts +41 -0
  112. package/dist/types/plugin.js +2 -0
  113. package/dist/types/trust.d.ts +120 -0
  114. package/dist/types/trust.js +2 -0
  115. package/dist/types.d.ts +8 -211
  116. package/docs/PRD.md +187 -109
  117. package/docs/plugin-contract.md +61 -0
  118. package/docs/release-notes-draft.md +40 -0
  119. package/docs/rules-catalog.md +49 -0
  120. package/docs/trust-core-release-checklist.md +87 -0
  121. package/package.json +6 -3
  122. package/packages/vscode-drift/src/code-actions.ts +1 -1
  123. package/schemas/drift-ai-output.v1.json +162 -0
  124. package/schemas/drift-report.v1.json +151 -0
  125. package/schemas/drift-trust.v1.json +131 -0
  126. package/scripts/smoke-repo.mjs +394 -0
  127. package/src/analyzer.ts +484 -155
  128. package/src/benchmark.ts +266 -0
  129. package/src/cli.ts +840 -85
  130. package/src/config.ts +19 -2
  131. package/src/diff.ts +84 -10
  132. package/src/doctor.ts +173 -0
  133. package/src/format.ts +81 -0
  134. package/src/git.ts +16 -0
  135. package/src/guard-types.ts +64 -0
  136. package/src/guard.ts +324 -0
  137. package/src/index.ts +83 -0
  138. package/src/init.ts +298 -0
  139. package/src/map-cycles.ts +38 -0
  140. package/src/map-svg.ts +124 -0
  141. package/src/map.ts +111 -142
  142. package/src/metrics.ts +78 -59
  143. package/src/output-metadata.ts +30 -0
  144. package/src/plugins-capabilities.ts +36 -0
  145. package/src/plugins-messages.ts +35 -0
  146. package/src/plugins-rules.ts +296 -0
  147. package/src/plugins.ts +148 -27
  148. package/src/printer.ts +4 -0
  149. package/src/reporter-constants.ts +46 -0
  150. package/src/reporter.ts +64 -65
  151. package/src/review.ts +6 -4
  152. package/src/rules/comments.ts +2 -2
  153. package/src/rules/complexity.ts +2 -7
  154. package/src/rules/nesting.ts +3 -13
  155. package/src/rules/phase0-basic.ts +11 -12
  156. package/src/rules/phase3-configurable.ts +39 -26
  157. package/src/rules/shared.ts +31 -3
  158. package/src/saas/constants.ts +56 -0
  159. package/src/saas/dashboard.ts +172 -0
  160. package/src/saas/errors.ts +45 -0
  161. package/src/saas/helpers.ts +140 -0
  162. package/src/saas/ingest.ts +278 -0
  163. package/src/saas/organization.ts +99 -0
  164. package/src/saas/plan-change.ts +19 -0
  165. package/src/saas/store.ts +172 -0
  166. package/src/saas/types.ts +216 -0
  167. package/src/saas.ts +49 -433
  168. package/src/sarif.ts +232 -0
  169. package/src/trust-advanced.ts +99 -0
  170. package/src/trust-kpi-fs.ts +169 -0
  171. package/src/trust-kpi-parse.ts +219 -0
  172. package/src/trust-kpi-types.ts +19 -0
  173. package/src/trust-kpi.ts +210 -0
  174. package/src/trust-policy.ts +246 -0
  175. package/src/trust-render.ts +61 -0
  176. package/src/trust-scoring.ts +231 -0
  177. package/src/trust.ts +260 -0
  178. package/src/types/app.ts +30 -0
  179. package/src/types/config.ts +27 -0
  180. package/src/types/core.ts +105 -0
  181. package/src/types/diff.ts +61 -0
  182. package/src/types/plugin.ts +46 -0
  183. package/src/types/trust.ts +134 -0
  184. package/src/types.ts +78 -238
  185. package/tests/cli-sarif.test.ts +92 -0
  186. package/tests/diff.test.ts +124 -0
  187. package/tests/format.test.ts +157 -0
  188. package/tests/new-features.test.ts +80 -1
  189. package/tests/phase1-init-doctor-guard.test.ts +199 -0
  190. package/tests/plugins.test.ts +219 -0
  191. package/tests/rules.test.ts +23 -1
  192. package/tests/saas-foundation.test.ts +358 -1
  193. package/tests/sarif.test.ts +160 -0
  194. package/tests/trust-kpi.test.ts +147 -0
  195. package/tests/trust.test.ts +602 -0
package/src/map.ts CHANGED
@@ -4,6 +4,8 @@ import { Project } from 'ts-morph'
4
4
  import type { DriftConfig } from './types.js'
5
5
  import { detectLayerViolations } from './rules/phase3-arch.js'
6
6
  import { RULE_WEIGHTS } from './analyzer.js'
7
+ import { detectCycleEdges } from './map-cycles.js'
8
+ import { renderArchitectureSvg } from './map-svg.js'
7
9
 
8
10
  interface LayerNode {
9
11
  name: string
@@ -24,11 +26,68 @@ function detectLayer(relPath: string): string {
24
26
  return first
25
27
  }
26
28
 
27
- function esc(value: string): string {
28
- return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
29
+ function appendFileLayerContext(
30
+ filePath: string,
31
+ targetPath: string,
32
+ layers: Map<string, LayerNode>,
33
+ fileImportGraph: Map<string, Set<string>>,
34
+ ): { rel: string; layerName: string } {
35
+ const rel = relative(targetPath, filePath).replace(/\\/g, '/')
36
+ const layerName = detectLayer(rel)
37
+
38
+ if (!layers.has(layerName)) {
39
+ layers.set(layerName, { name: layerName, files: new Set() })
40
+ }
41
+
42
+ layers.get(layerName)!.files.add(rel)
43
+
44
+ if (!fileImportGraph.has(filePath)) {
45
+ fileImportGraph.set(filePath, new Set())
46
+ }
47
+
48
+ return { rel, layerName }
29
49
  }
30
50
 
31
- export function generateArchitectureSvg(targetPath: string, config?: DriftConfig): string {
51
+ function registerImportEdge(
52
+ layerName: string,
53
+ importedLayer: string,
54
+ edges: Map<string, number>,
55
+ layerAdjacency: Map<string, Set<string>>,
56
+ ): void {
57
+ if (importedLayer === layerName) return
58
+
59
+ const key = `${layerName}->${importedLayer}`
60
+ edges.set(key, (edges.get(key) ?? 0) + 1)
61
+
62
+ if (!layerAdjacency.has(layerName)) {
63
+ layerAdjacency.set(layerName, new Set())
64
+ }
65
+
66
+ layerAdjacency.get(layerName)!.add(importedLayer)
67
+ }
68
+
69
+ function buildEdgeList(edges: Map<string, number>, cycleEdges: Set<string>, violationEdges: Set<string>): MapEdge[] {
70
+ const edgeList: MapEdge[] = [...edges.entries()].map(([key, count]) => {
71
+ const [from, to] = key.split('->')
72
+ const kind = violationEdges.has(key)
73
+ ? 'violation'
74
+ : cycleEdges.has(key)
75
+ ? 'cycle'
76
+ : 'normal'
77
+ return { key, from, to, count, kind }
78
+ })
79
+
80
+ for (const key of violationEdges) {
81
+ if (edges.has(key)) continue
82
+ const [from, to] = key.split('->')
83
+ edgeList.push({ key, from, to, count: 1, kind: 'violation' })
84
+ }
85
+
86
+ return edgeList
87
+ }
88
+
89
+
90
+ function createArchitectureProject(targetPath: string): Project {
32
91
  const project = new Project({
33
92
  skipAddingFilesFromTsConfig: true,
34
93
  compilerOptions: { allowJs: true, jsx: 1 },
@@ -45,6 +104,18 @@ export function generateArchitectureSvg(targetPath: string, config?: DriftConfig
45
104
  `!${targetPath}/**/*.d.ts`,
46
105
  ])
47
106
 
107
+ return project
108
+ }
109
+
110
+ function collectArchitectureGraph(
111
+ project: Project,
112
+ targetPath: string,
113
+ ): {
114
+ layers: Map<string, LayerNode>
115
+ edges: Map<string, number>
116
+ layerAdjacency: Map<string, Set<string>>
117
+ fileImportGraph: Map<string, Set<string>>
118
+ } {
48
119
  const layers = new Map<string, LayerNode>()
49
120
  const edges = new Map<string, number>()
50
121
  const layerAdjacency = new Map<string, Set<string>>()
@@ -52,11 +123,7 @@ export function generateArchitectureSvg(targetPath: string, config?: DriftConfig
52
123
 
53
124
  for (const file of project.getSourceFiles()) {
54
125
  const filePath = file.getFilePath()
55
- const rel = relative(targetPath, filePath).replace(/\\/g, '/')
56
- const layerName = detectLayer(rel)
57
- if (!layers.has(layerName)) layers.set(layerName, { name: layerName, files: new Set() })
58
- layers.get(layerName)!.files.add(rel)
59
- if (!fileImportGraph.has(filePath)) fileImportGraph.set(filePath, new Set())
126
+ const { layerName } = appendFileLayerContext(filePath, targetPath, layers, fileImportGraph)
60
127
 
61
128
  for (const decl of file.getImportDeclarations()) {
62
129
  const imported = decl.getModuleSpecifierSourceFile()
@@ -64,150 +131,52 @@ export function generateArchitectureSvg(targetPath: string, config?: DriftConfig
64
131
  fileImportGraph.get(filePath)!.add(imported.getFilePath())
65
132
  const importedRel = relative(targetPath, imported.getFilePath()).replace(/\\/g, '/')
66
133
  const importedLayer = detectLayer(importedRel)
67
- if (importedLayer === layerName) continue
68
- const key = `${layerName}->${importedLayer}`
69
- edges.set(key, (edges.get(key) ?? 0) + 1)
70
- if (!layerAdjacency.has(layerName)) layerAdjacency.set(layerName, new Set())
71
- layerAdjacency.get(layerName)!.add(importedLayer)
134
+ registerImportEdge(layerName, importedLayer, edges, layerAdjacency)
72
135
  }
73
136
  }
74
137
 
75
- const cycleEdges = detectCycleEdges(layerAdjacency)
76
- const violationEdges = new Set<string>()
138
+ return { layers, edges, layerAdjacency, fileImportGraph }
139
+ }
77
140
 
78
- if (config?.layers && config.layers.length > 0) {
79
- const violations = detectLayerViolations(fileImportGraph, config.layers, targetPath, RULE_WEIGHTS)
80
- for (const issues of violations.values()) {
81
- for (const issue of issues) {
82
- const match = issue.message.match(/Layer '([^']+)' must not import from layer '([^']+)'/)
83
- if (!match) continue
84
- const from = match[1]
85
- const to = match[2]
86
- violationEdges.add(`${from}->${to}`)
87
- if (!layers.has(from)) layers.set(from, { name: from, files: new Set() })
88
- if (!layers.has(to)) layers.set(to, { name: to, files: new Set() })
89
- }
141
+ function collectViolationEdges(
142
+ config: DriftConfig | undefined,
143
+ fileImportGraph: Map<string, Set<string>>,
144
+ targetPath: string,
145
+ layers: Map<string, LayerNode>,
146
+ ): Set<string> {
147
+ const violationEdges = new Set<string>()
148
+ if (!config?.layers?.length) return violationEdges
149
+
150
+ const violations = detectLayerViolations(fileImportGraph, config.layers, targetPath, RULE_WEIGHTS)
151
+ for (const issues of violations.values()) {
152
+ for (const issue of issues) {
153
+ const match = issue.message.match(/Layer '([^']+)' must not import from layer '([^']+)'/)
154
+ if (!match) continue
155
+ const from = match[1]
156
+ const to = match[2]
157
+ violationEdges.add(`${from}->${to}`)
158
+ if (!layers.has(from)) layers.set(from, { name: from, files: new Set() })
159
+ if (!layers.has(to)) layers.set(to, { name: to, files: new Set() })
90
160
  }
91
161
  }
92
162
 
93
- const edgeList: MapEdge[] = [...edges.entries()].map(([key, count]) => {
94
- const [from, to] = key.split('->')
95
- const kind = violationEdges.has(key)
96
- ? 'violation'
97
- : cycleEdges.has(key)
98
- ? 'cycle'
99
- : 'normal'
100
- return { key, from, to, count, kind }
101
- })
102
-
103
- for (const key of violationEdges) {
104
- if (edges.has(key)) continue
105
- const [from, to] = key.split('->')
106
- edgeList.push({ key, from, to, count: 1, kind: 'violation' })
107
- }
108
-
109
- const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name))
110
- const width = 960
111
- const rowHeight = 90
112
- const height = Math.max(180, layerList.length * rowHeight + 120)
113
- const boxWidth = 240
114
- const boxHeight = 50
115
- const left = 100
116
-
117
- const boxes = layerList.map((layer, index) => {
118
- const y = 60 + index * rowHeight
119
- return {
120
- ...layer,
121
- x: left,
122
- y,
123
- }
124
- })
125
-
126
- const boxByName = new Map(boxes.map((box) => [box.name, box]))
127
-
128
- const lines = edgeList.map((edge) => {
129
- const a = boxByName.get(edge.from)
130
- const b = boxByName.get(edge.to)
131
- if (!a || !b) return ''
132
- const startX = a.x + boxWidth
133
- const startY = a.y + boxHeight / 2
134
- const endX = b.x
135
- const endY = b.y + boxHeight / 2
136
- const stroke = edge.kind === 'violation'
137
- ? '#ef4444'
138
- : edge.kind === 'cycle'
139
- ? '#f59e0b'
140
- : '#64748b'
141
- const widthPx = edge.kind === 'normal' ? 2 : 3
142
- return `
143
- <line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="${stroke}" stroke-width="${widthPx}" marker-end="url(#arrow)" data-edge="${esc(edge.key)}" data-kind="${edge.kind}" />
144
- <text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${edge.count}</text>`
145
- }).join('')
146
-
147
- const nodes = boxes.map((box) => `
148
- <g>
149
- <rect x="${box.x}" y="${box.y}" width="${boxWidth}" height="${boxHeight}" rx="8" fill="#0f172a" stroke="#334155" />
150
- <text x="${box.x + 12}" y="${box.y + 22}" fill="#e2e8f0" font-size="13" font-family="monospace">${esc(box.name)}</text>
151
- <text x="${box.x + 12}" y="${box.y + 38}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
152
- </g>`).join('')
153
-
154
- const cycleCount = edgeList.filter((edge) => edge.kind === 'cycle').length
155
- const violationCount = edgeList.filter((edge) => edge.kind === 'violation').length
156
-
157
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
158
- <defs>
159
- <marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
160
- <path d="M0,0 L0,6 L7,3 z" fill="#64748b"/>
161
- </marker>
162
- </defs>
163
- <rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
164
- <text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
165
- <text x="28" y="54" fill="#94a3b8" font-size="11" font-family="monospace">Layers inferred from top-level directories</text>
166
- <text x="28" y="72" fill="#94a3b8" font-size="11" font-family="monospace">Cycle edges: ${cycleCount} | Layer violations: ${violationCount}</text>
167
- <line x1="520" y1="66" x2="560" y2="66" stroke="#f59e0b" stroke-width="3" /><text x="567" y="69" fill="#94a3b8" font-size="11" font-family="monospace">cycle</text>
168
- <line x1="630" y1="66" x2="670" y2="66" stroke="#ef4444" stroke-width="3" /><text x="677" y="69" fill="#94a3b8" font-size="11" font-family="monospace">violation</text>
169
- ${lines}
170
- ${nodes}
171
- </svg>`
163
+ return violationEdges
172
164
  }
173
165
 
174
- function detectCycleEdges(adjacency: Map<string, Set<string>>): Set<string> {
175
- const visited = new Set<string>()
176
- const inStack = new Set<string>()
177
- const stack: string[] = []
178
- const cycleEdges = new Set<string>()
179
-
180
- function dfs(node: string): void {
181
- visited.add(node)
182
- inStack.add(node)
183
- stack.push(node)
184
-
185
- for (const neighbor of adjacency.get(node) ?? []) {
186
- if (!visited.has(neighbor)) {
187
- dfs(neighbor)
188
- continue
189
- }
190
-
191
- if (!inStack.has(neighbor)) continue
192
-
193
- const startIndex = stack.indexOf(neighbor)
194
- if (startIndex >= 0) {
195
- for (let i = startIndex; i < stack.length - 1; i++) {
196
- cycleEdges.add(`${stack[i]}->${stack[i + 1]}`)
197
- }
198
- }
199
- cycleEdges.add(`${node}->${neighbor}`)
200
- }
201
-
202
- stack.pop()
203
- inStack.delete(node)
204
- }
205
-
206
- for (const node of adjacency.keys()) {
207
- if (!visited.has(node)) dfs(node)
208
- }
166
+ export function generateArchitectureSvg(targetPath: string, config?: DriftConfig): string {
167
+ const project = createArchitectureProject(targetPath)
168
+ const { layers, edges, layerAdjacency, fileImportGraph } = collectArchitectureGraph(project, targetPath)
209
169
 
210
- return cycleEdges
170
+ const cycleEdges = detectCycleEdges(layerAdjacency)
171
+ const violationEdges = collectViolationEdges(config, fileImportGraph, targetPath, layers)
172
+
173
+ const edgeList = buildEdgeList(edges, cycleEdges, violationEdges)
174
+ return renderArchitectureSvg({
175
+ layers,
176
+ edgeList,
177
+ cycleCount: edgeList.filter((edge) => edge.kind === 'cycle').length,
178
+ violationCount: edgeList.filter((edge) => edge.kind === 'violation').length,
179
+ })
211
180
  }
212
181
 
213
182
  export function generateArchitectureMap(targetPath: string, outputFile = 'architecture.svg', config?: DriftConfig): string {
package/src/metrics.ts CHANGED
@@ -30,6 +30,20 @@ const AI_RULES = new Set([
30
30
  'ai-code-smell',
31
31
  ])
32
32
 
33
+ const ISSUE_WEIGHT_PER_FILE = 20
34
+ const DIMENSION_COUNT = 4
35
+ const MAX_COMPLEXITY_RISK = 40
36
+ const COMPLEXITY_RISK_PER_ISSUE = 10
37
+ const MISSING_TESTS_RISK = 25
38
+ const FREQUENT_CHANGE_THRESHOLD = 8
39
+ const FREQUENT_CHANGE_RISK = 20
40
+ const HIGH_DRIFT_THRESHOLD = 50
41
+ const HIGH_DRIFT_RISK = 15
42
+ const HOTSPOT_LIMIT = 10
43
+ const LEVEL_CRITICAL_THRESHOLD = 75
44
+ const LEVEL_HIGH_THRESHOLD = 55
45
+ const LEVEL_MEDIUM_THRESHOLD = 30
46
+
33
47
  function clamp(value: number, min: number, max: number): number {
34
48
  return Math.max(min, Math.min(max, value))
35
49
  }
@@ -38,20 +52,23 @@ function listFilesRecursively(root: string): string[] {
38
52
  if (!existsSync(root)) return []
39
53
  const out: string[] = []
40
54
  const stack = [root]
55
+
56
+ const shouldSkipDirectory = (name: string): boolean =>
57
+ name === 'node_modules' || name === 'dist' || name === '.git' || name === '.next' || name === 'build'
58
+
41
59
  while (stack.length > 0) {
42
60
  const current = stack.pop()!
43
61
  const entries = readdirSync(current)
44
62
  for (const entry of entries) {
45
63
  const full = join(current, entry)
46
64
  const stat = statSync(full)
47
- if (stat.isDirectory()) {
48
- if (entry === 'node_modules' || entry === 'dist' || entry === '.git' || entry === '.next' || entry === 'build') {
49
- continue
50
- }
51
- stack.push(full)
52
- } else {
65
+ if (!stat.isDirectory()) {
53
66
  out.push(full)
67
+ continue
54
68
  }
69
+
70
+ if (shouldSkipDirectory(entry)) continue
71
+ stack.push(full)
55
72
  }
56
73
  }
57
74
  return out
@@ -88,7 +105,55 @@ function getCommitTouchCount(targetPath: string, filePath: string): number {
88
105
  function qualityFromIssues(totalFiles: number, issues: DriftIssue[], rules: Set<string>): number {
89
106
  const count = issues.filter((issue) => rules.has(issue.rule)).length
90
107
  if (totalFiles === 0) return 100
91
- return clamp(100 - Math.round((count / totalFiles) * 20), 0, 100)
108
+ return clamp(100 - Math.round((count / totalFiles) * ISSUE_WEIGHT_PER_FILE), 0, 100)
109
+ }
110
+
111
+ function riskLevelFromScore(score: number): MaintenanceRiskMetrics['level'] {
112
+ if (score >= LEVEL_CRITICAL_THRESHOLD) return 'critical'
113
+ if (score >= LEVEL_HIGH_THRESHOLD) return 'high'
114
+ if (score >= LEVEL_MEDIUM_THRESHOLD) return 'medium'
115
+ return 'low'
116
+ }
117
+
118
+ function evaluateHotspot(targetPath: string, file: FileReport): RiskHotspot {
119
+ const complexityIssues = file.issues.filter((issue) =>
120
+ issue.rule === 'high-complexity' ||
121
+ issue.rule === 'deep-nesting' ||
122
+ issue.rule === 'large-function' ||
123
+ issue.rule === 'max-function-lines'
124
+ ).length
125
+
126
+ const changeFrequency = getCommitTouchCount(targetPath, file.path)
127
+ const hasTests = hasNearbyTest(targetPath, file.path)
128
+ const reasons: string[] = []
129
+ let risk = 0
130
+
131
+ if (complexityIssues > 0) {
132
+ risk += Math.min(MAX_COMPLEXITY_RISK, complexityIssues * COMPLEXITY_RISK_PER_ISSUE)
133
+ reasons.push('high complexity signals')
134
+ }
135
+ if (!hasTests) {
136
+ risk += MISSING_TESTS_RISK
137
+ reasons.push('no nearby tests')
138
+ }
139
+ if (changeFrequency >= FREQUENT_CHANGE_THRESHOLD) {
140
+ risk += FREQUENT_CHANGE_RISK
141
+ reasons.push('frequently changed file')
142
+ }
143
+ if (file.score >= HIGH_DRIFT_THRESHOLD) {
144
+ risk += HIGH_DRIFT_RISK
145
+ reasons.push('high drift score')
146
+ }
147
+
148
+ return {
149
+ file: file.path,
150
+ driftScore: file.score,
151
+ complexityIssues,
152
+ hasNearbyTests: hasTests,
153
+ changeFrequency,
154
+ risk: clamp(risk, 0, 100),
155
+ reasons,
156
+ }
92
157
  }
93
158
 
94
159
  export function computeRepoQuality(targetPath: string, files: FileReport[]): RepoQualityScore {
@@ -119,73 +184,27 @@ export function computeRepoQuality(targetPath: string, files: FileReport[]): Rep
119
184
  dimensions.complexity +
120
185
  dimensions['ai-patterns'] +
121
186
  dimensions.testing
122
- ) / 4)
187
+ ) / DIMENSION_COUNT)
123
188
 
124
189
  return { overall, dimensions }
125
190
  }
126
191
 
127
192
  export function computeMaintenanceRisk(report: DriftReport): MaintenanceRiskMetrics {
128
- const allFiles = report.files
129
- const hotspots: RiskHotspot[] = allFiles
130
- .map((file) => {
131
- const complexityIssues = file.issues.filter((issue) =>
132
- issue.rule === 'high-complexity' ||
133
- issue.rule === 'deep-nesting' ||
134
- issue.rule === 'large-function' ||
135
- issue.rule === 'max-function-lines'
136
- ).length
137
-
138
- const changeFrequency = getCommitTouchCount(report.targetPath, file.path)
139
- const hasTests = hasNearbyTest(report.targetPath, file.path)
140
- const reasons: string[] = []
141
- let risk = 0
142
-
143
- if (complexityIssues > 0) {
144
- risk += Math.min(40, complexityIssues * 10)
145
- reasons.push('high complexity signals')
146
- }
147
- if (!hasTests) {
148
- risk += 25
149
- reasons.push('no nearby tests')
150
- }
151
- if (changeFrequency >= 8) {
152
- risk += 20
153
- reasons.push('frequently changed file')
154
- }
155
- if (file.score >= 50) {
156
- risk += 15
157
- reasons.push('high drift score')
158
- }
159
-
160
- return {
161
- file: file.path,
162
- driftScore: file.score,
163
- complexityIssues,
164
- hasNearbyTests: hasTests,
165
- changeFrequency,
166
- risk: clamp(risk, 0, 100),
167
- reasons,
168
- }
169
- })
193
+ const hotspots: RiskHotspot[] = report.files
194
+ .map((file) => evaluateHotspot(report.targetPath, file))
170
195
  .filter((hotspot) => hotspot.risk > 0)
171
196
  .sort((a, b) => b.risk - a.risk)
172
- .slice(0, 10)
197
+ .slice(0, HOTSPOT_LIMIT)
173
198
 
174
199
  const highComplexityFiles = hotspots.filter((hotspot) => hotspot.complexityIssues > 0).length
175
200
  const filesWithoutNearbyTests = hotspots.filter((hotspot) => !hotspot.hasNearbyTests).length
176
- const frequentChangeFiles = hotspots.filter((hotspot) => hotspot.changeFrequency >= 8).length
201
+ const frequentChangeFiles = hotspots.filter((hotspot) => hotspot.changeFrequency >= FREQUENT_CHANGE_THRESHOLD).length
177
202
 
178
203
  const score = hotspots.length === 0
179
204
  ? 0
180
205
  : Math.round(hotspots.reduce((sum, hotspot) => sum + hotspot.risk, 0) / hotspots.length)
181
206
 
182
- const level = score >= 75
183
- ? 'critical'
184
- : score >= 55
185
- ? 'high'
186
- : score >= 30
187
- ? 'medium'
188
- : 'low'
207
+ const level = riskLevelFromScore(score)
189
208
 
190
209
  return {
191
210
  score,
@@ -0,0 +1,30 @@
1
+ import { createRequire } from 'node:module'
2
+
3
+ const require = createRequire(import.meta.url)
4
+ const { version } = require('../package.json') as { version: string }
5
+
6
+ const TOOL_VERSION = version
7
+
8
+ export const OUTPUT_SCHEMA = {
9
+ report: 'schemas/drift-report.v1.json',
10
+ trust: 'schemas/drift-trust.v1.json',
11
+ ai: 'schemas/drift-ai-output.v1.json',
12
+ } as const
13
+
14
+ type OutputMetadata = {
15
+ $schema: string
16
+ toolVersion: string
17
+ }
18
+
19
+ type JsonOutputWithMetadata<T extends object> = T & OutputMetadata
20
+
21
+ export function withOutputMetadata<T extends object>(
22
+ payload: T,
23
+ schema: string,
24
+ ): JsonOutputWithMetadata<T> {
25
+ return {
26
+ ...payload,
27
+ $schema: schema,
28
+ toolVersion: TOOL_VERSION,
29
+ }
30
+ }
@@ -0,0 +1,36 @@
1
+ import type { DriftPlugin } from './types.js'
2
+ import type { PluginValidationContext } from './plugins-rules.js'
3
+ import { pushError } from './plugins-messages.js'
4
+
5
+ export function validateCapabilities(
6
+ capabilitiesCandidate: unknown,
7
+ context: PluginValidationContext,
8
+ ): DriftPlugin['capabilities'] | undefined {
9
+ const { pluginId, pluginName, errors } = context
10
+ if (capabilitiesCandidate === undefined) return undefined
11
+ if (!capabilitiesCandidate || typeof capabilitiesCandidate !== 'object' || Array.isArray(capabilitiesCandidate)) {
12
+ pushError(
13
+ errors,
14
+ pluginId,
15
+ `Plugin '${pluginName}' has invalid capabilities metadata. Expected an object map like { "fixes": true } when provided.`,
16
+ { pluginName, code: 'plugin-capabilities-invalid' },
17
+ )
18
+ return undefined
19
+ }
20
+
21
+ const entries = Object.entries(capabilitiesCandidate as Record<string, unknown>)
22
+ for (const [capabilityKey, capabilityValue] of entries) {
23
+ const capabilityType = typeof capabilityValue
24
+ if (capabilityType !== 'string' && capabilityType !== 'number' && capabilityType !== 'boolean') {
25
+ pushError(
26
+ errors,
27
+ pluginId,
28
+ `Plugin '${pluginName}' capability '${capabilityKey}' has invalid value type '${capabilityType}'. Allowed: string | number | boolean.`,
29
+ { pluginName, code: 'plugin-capabilities-value-invalid' },
30
+ )
31
+ }
32
+ }
33
+
34
+ if (errors.length > 0) return undefined
35
+ return capabilitiesCandidate as DriftPlugin['capabilities']
36
+ }
@@ -0,0 +1,35 @@
1
+ import type { PluginLoadError, PluginLoadWarning } from './types.js'
2
+
3
+ type PluginMessageOptions = { pluginName?: string; ruleId?: string; code?: string }
4
+
5
+ function pushLoadMessage(
6
+ pluginId: string,
7
+ message: string,
8
+ options?: PluginMessageOptions,
9
+ ): PluginLoadError | PluginLoadWarning {
10
+ return {
11
+ pluginId,
12
+ pluginName: options?.pluginName,
13
+ ruleId: options?.ruleId,
14
+ code: options?.code,
15
+ message,
16
+ }
17
+ }
18
+
19
+ export function pushError(
20
+ errors: PluginLoadError[],
21
+ pluginId: string,
22
+ message: string,
23
+ options?: PluginMessageOptions,
24
+ ): void {
25
+ errors.push(pushLoadMessage(pluginId, message, options))
26
+ }
27
+
28
+ export function pushWarning(
29
+ warnings: PluginLoadWarning[],
30
+ pluginId: string,
31
+ message: string,
32
+ options?: PluginMessageOptions,
33
+ ): void {
34
+ warnings.push(pushLoadMessage(pluginId, message, options))
35
+ }