@getmikk/core 1.5.0 → 1.6.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.
package/README.md CHANGED
@@ -1,11 +1,15 @@
1
1
  # @getmikk/core
2
2
 
3
- > AST parsing, dependency graph construction, Merkle-tree hashing, contract management, and foundational utilities for the Mikk ecosystem.
3
+ > The foundation of the Mikk ecosystem — TypeScript AST parsing, dependency graph construction, Merkle-tree hashing, contract management, and lock file compilation.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@getmikk/core)](https://www.npmjs.com/package/@getmikk/core)
6
6
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](../../LICENSE)
7
7
 
8
- `@getmikk/core` is the foundation package that every other Mikk package depends on. It provides the complete pipeline for understanding a TypeScript codebase: parsing source files into structured ASTs, building a full dependency graph, computing Merkle-tree hashes for drift detection, and managing the `mikk.json` contract and `mikk.lock.json` lock file.
8
+ `@getmikk/core` is the foundation every other Mikk package builds on. It owns the complete pipeline for turning raw **TypeScript and Go** source into structured, queryable intelligence: parsing source files into real ASTs (TS Compiler API for TypeScript; regex + stateful scanning for Go; no external toolchain required), building a two-pass dependency graph with O(1) adjacency lookups, computing Merkle-tree SHA-256 hashes at function → file → module → root level, and compiling everything into a `mikk.lock.json` snapshot that every other package reads from.
9
+
10
+ Every AI context query, impact analysis, contract validation, and diagram generation ultimately runs on the graph and lock file produced here.
11
+
12
+ > Part of [Mikk](../../README.md) — the codebase nervous system for AI-assisted development.
9
13
 
10
14
  ---
11
15
 
@@ -22,11 +26,11 @@ bun add @getmikk/core
22
26
  ## Architecture Overview
23
27
 
24
28
  ```
25
- Source Files (.ts/.tsx)
29
+ Source Files (.ts/.tsx/.go)
26
30
 
27
31
 
28
32
  ┌─────────┐
29
- │ Parser │ ← TypeScriptParser + TypeScriptExtractor
33
+ │ Parser │ ← TypeScriptParser / GoParser
30
34
  └────┬────┘
31
35
  │ ParsedFile[]
32
36
 
@@ -96,6 +100,39 @@ const resolver = new TypeScriptResolver()
96
100
  const resolved = resolver.resolve(importDecl, fromFilePath, allProjectFiles)
97
101
  ```
98
102
 
103
+ #### Go Parser
104
+
105
+ Parses `.go` files without requiring the Go toolchain:
106
+
107
+ ```typescript
108
+ import { GoParser } from '@getmikk/core'
109
+
110
+ const parser = new GoParser()
111
+ const parsed = parser.parse('service.go', fileContent)
112
+
113
+ console.log(parsed.functions) // ParsedFunction[] — name, params with types, return type, calls[]
114
+ console.log(parsed.classes) // ParsedClass[] — receiver-based methods grouped by type name
115
+ console.log(parsed.imports) // ParsedImport[] — resolved against go.mod module path
116
+ console.log(parsed.exports) // ParsedExport[] — uppercase identifiers (Go convention)
117
+ console.log(parsed.routes) // ParsedRoute[] — Gin, Echo, Chi, Mux, net/http routes
118
+ ```
119
+
120
+ **Features**:
121
+ - Stateful line/character scanning (handles strings, comments, nested braces correctly)
122
+ - Receiver methods grouped with struct types as classes
123
+ - HTTP route detection (Gin/Echo/Chi/Mux/net.http/Fiber patterns)
124
+ - Error handling detection (`if err != nil` patterns)
125
+ - Function call extraction from bodies
126
+ - Grouped parameter expansion (`first, last string` → both typed as `string`)
127
+ - Import resolution via `go.mod` module path
128
+
129
+ #### Auto-detection by file extension
130
+
131
+ ```typescript
132
+ const parser = getParser('file.ts') // → TypeScriptParser
133
+ const parser = getParser('service.go') // → GoParser
134
+ ```
135
+
99
136
  ---
100
137
 
101
138
  ### 2. Graph — Dependency Graph Construction
@@ -135,7 +172,8 @@ const impact = analyzer.analyze(['src/utils/math.ts::calculateTotal'])
135
172
  console.log(impact.changed) // string[] — directly changed node IDs
136
173
  console.log(impact.impacted) // string[] — transitively affected nodes
137
174
  console.log(impact.depth) // number — max propagation depth
138
- console.log(impact.confidence) // number 0-1 confidence score
175
+ console.log(impact.confidence) // 'high' | 'medium' | 'low'
176
+ console.log(impact.classified) // { critical: [], high: [], medium: [], low: [] }
139
177
  ```
140
178
 
141
179
  #### ClusterDetector
@@ -414,7 +452,7 @@ import type {
414
452
  // Parser
415
453
  ParsedFile, ParsedFunction, ParsedClass, ParsedImport, ParsedExport, ParsedParam, ParsedGeneric,
416
454
  // Graph
417
- DependencyGraph, GraphNode, GraphEdge, ImpactResult, NodeType, EdgeType, ModuleCluster,
455
+ DependencyGraph, GraphNode, GraphEdge, ImpactResult, NodeType, EdgeType, ModuleCluster, ClassifiedImpact, RiskLevel,
418
456
  // Contract
419
457
  MikkContract, MikkLock, MikkModule, MikkDecision, MikkLockFunction, MikkLockModule, MikkLockFile,
420
458
  // Boundary
package/out.log ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmikk/core",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,7 +27,8 @@
27
27
  "zod": "^3.22.0"
28
28
  },
29
29
  "devDependencies": {
30
- "typescript": "^5.7.0",
31
- "@types/node": "^22.0.0"
30
+ "@types/bun": "^1.3.10",
31
+ "@types/node": "^22.0.0",
32
+ "typescript": "^5.7.0"
32
33
  }
33
34
  }
@@ -0,0 +1,75 @@
1
+ import * as fs from 'node:fs/promises'
2
+ import type { MikkContract, MikkDecision } from './schema.js'
3
+ import { MikkContractSchema } from './schema.js'
4
+
5
+ /**
6
+ * AdrManager — CRUD operations on Architectural Decision Records
7
+ * stored in the `declared.decisions` array of mikk.json.
8
+ *
9
+ * Each ADR has: id, title, reason, date.
10
+ * Exposed as an MCP tool so AI assistants can read and update ADRs.
11
+ */
12
+ export class AdrManager {
13
+ constructor(private contractPath: string) { }
14
+
15
+ // ─── Read ──────────────────────────────────────────────────────
16
+
17
+ async list(): Promise<MikkDecision[]> {
18
+ const contract = await this.readContract()
19
+ return contract.declared.decisions ?? []
20
+ }
21
+
22
+ async get(id: string): Promise<MikkDecision | null> {
23
+ const decisions = await this.list()
24
+ return decisions.find(d => d.id === id) ?? null
25
+ }
26
+
27
+ // ─── Write ─────────────────────────────────────────────────────
28
+
29
+ async add(decision: MikkDecision): Promise<void> {
30
+ const contract = await this.readContract()
31
+ if (!contract.declared.decisions) {
32
+ contract.declared.decisions = []
33
+ }
34
+ // Check for duplicate ID
35
+ if (contract.declared.decisions.some(d => d.id === decision.id)) {
36
+ throw new Error(`ADR with id "${decision.id}" already exists. Use update() instead.`)
37
+ }
38
+ contract.declared.decisions.push(decision)
39
+ await this.writeContract(contract)
40
+ }
41
+
42
+ async update(id: string, fields: Partial<Omit<MikkDecision, 'id'>>): Promise<void> {
43
+ const contract = await this.readContract()
44
+ const decisions = contract.declared.decisions ?? []
45
+ const idx = decisions.findIndex(d => d.id === id)
46
+ if (idx === -1) {
47
+ throw new Error(`ADR "${id}" not found. Use add() to create a new decision.`)
48
+ }
49
+ decisions[idx] = { ...decisions[idx], ...fields, id } // preserve id
50
+ contract.declared.decisions = decisions
51
+ await this.writeContract(contract)
52
+ }
53
+
54
+ async remove(id: string): Promise<boolean> {
55
+ const contract = await this.readContract()
56
+ const decisions = contract.declared.decisions ?? []
57
+ const idx = decisions.findIndex(d => d.id === id)
58
+ if (idx === -1) return false
59
+ decisions.splice(idx, 1)
60
+ contract.declared.decisions = decisions
61
+ await this.writeContract(contract)
62
+ return true
63
+ }
64
+
65
+ // ─── Helpers ───────────────────────────────────────────────────
66
+
67
+ private async readContract(): Promise<MikkContract> {
68
+ const raw = await fs.readFile(this.contractPath, 'utf-8')
69
+ return MikkContractSchema.parse(JSON.parse(raw))
70
+ }
71
+
72
+ private async writeContract(contract: MikkContract): Promise<void> {
73
+ await fs.writeFile(this.contractPath, JSON.stringify(contract, null, 2), 'utf-8')
74
+ }
75
+ }
@@ -10,3 +10,5 @@ export { ContractWriter, type UpdateResult } from './contract-writer.js'
10
10
  export { ContractReader } from './contract-reader.js'
11
11
  export { LockReader } from './lock-reader.js'
12
12
  export { ContractGenerator } from './contract-generator.js'
13
+ export { AdrManager } from './adr-manager.js'
14
+
@@ -0,0 +1,194 @@
1
+ import type { DependencyGraph } from './types.js'
2
+ import type { MikkLock } from '../contract/schema.js'
3
+
4
+ // ─── Types ──────────────────────────────────────────────────────────
5
+
6
+ export interface DeadCodeEntry {
7
+ id: string
8
+ name: string
9
+ file: string
10
+ moduleId?: string
11
+ type: 'function' | 'class'
12
+ reason: string
13
+ }
14
+
15
+ export interface DeadCodeResult {
16
+ deadFunctions: DeadCodeEntry[]
17
+ totalFunctions: number
18
+ deadCount: number
19
+ deadPercentage: number
20
+ byModule: Record<string, { dead: number; total: number; items: DeadCodeEntry[] }>
21
+ }
22
+
23
+ // ─── Exemption patterns ────────────────────────────────────────────
24
+
25
+ /** Common entry-point function names that are never "dead" even with 0 callers */
26
+ const ENTRY_POINT_PATTERNS = [
27
+ /^(main|bootstrap|start|init|setup|configure|register|mount)$/i,
28
+ /^(app|server|index|mod|program)$/i,
29
+ /Handler$/i, // Express/Koa/Hono handlers
30
+ /Middleware$/i,
31
+ /Controller$/i,
32
+ /^use[A-Z]/, // React hooks
33
+ /^handle[A-Z]/, // Event handlers
34
+ /^on[A-Z]/, // Event listeners
35
+ ]
36
+
37
+ /** Common test function patterns */
38
+ const TEST_PATTERNS = [
39
+ /^(it|describe|test|beforeAll|afterAll|beforeEach|afterEach)$/,
40
+ /\.test\./,
41
+ /\.spec\./,
42
+ /__test__/,
43
+ ]
44
+
45
+ // ─── Detector ──────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * DeadCodeDetector — walks the dependency graph and finds functions
49
+ * with zero incoming `calls` edges after applying multi-pass exemptions.
50
+ *
51
+ * Exemptions:
52
+ * 1. Exported symbols (may be consumed externally)
53
+ * 2. Entry point patterns (main, handler, middleware, hooks, etc.)
54
+ * 3. Route handlers (detected HTTP routes)
55
+ * 4. Test functions (describe, it, test, etc.)
56
+ * 5. Decorated classes/functions (typically framework-managed)
57
+ * 6. Constructor methods (called implicitly)
58
+ */
59
+ export class DeadCodeDetector {
60
+ private routeHandlers: Set<string>
61
+
62
+ constructor(
63
+ private graph: DependencyGraph,
64
+ private lock: MikkLock,
65
+ ) {
66
+ // Build a set of handler function names from detected routes
67
+ this.routeHandlers = new Set(
68
+ (lock.routes ?? []).map(r => r.handler).filter(Boolean),
69
+ )
70
+ }
71
+
72
+ detect(): DeadCodeResult {
73
+ const dead: DeadCodeEntry[] = []
74
+ let totalFunctions = 0
75
+ const byModule: DeadCodeResult['byModule'] = {}
76
+
77
+ for (const [id, fn] of Object.entries(this.lock.functions)) {
78
+ totalFunctions++
79
+ const moduleId = fn.moduleId ?? 'unknown'
80
+
81
+ // Initialize module bucket
82
+ if (!byModule[moduleId]) {
83
+ byModule[moduleId] = { dead: 0, total: 0, items: [] }
84
+ }
85
+ byModule[moduleId].total++
86
+
87
+ // Check if this function has any incoming call edges
88
+ const inEdges = this.graph.inEdges.get(id) || []
89
+ const hasCallers = inEdges.some(e => e.type === 'calls')
90
+
91
+ if (hasCallers) continue // Not dead
92
+
93
+ // Apply exemptions
94
+ if (this.isExempt(fn, id)) continue
95
+
96
+ const entry: DeadCodeEntry = {
97
+ id,
98
+ name: fn.name,
99
+ file: fn.file,
100
+ moduleId,
101
+ type: 'function',
102
+ reason: this.inferReason(fn, id),
103
+ }
104
+ dead.push(entry)
105
+ byModule[moduleId].dead++
106
+ byModule[moduleId].items.push(entry)
107
+ }
108
+
109
+ // Also check classes (if present in lock)
110
+ if (this.lock.classes) {
111
+ for (const [id, cls] of Object.entries(this.lock.classes)) {
112
+ const moduleId = cls.moduleId ?? 'unknown'
113
+ if (!byModule[moduleId]) {
114
+ byModule[moduleId] = { dead: 0, total: 0, items: [] }
115
+ }
116
+
117
+ const inEdges = this.graph.inEdges.get(id) || []
118
+ const hasCallers = inEdges.some(e => e.type === 'calls' || e.type === 'imports')
119
+
120
+ if (hasCallers) continue
121
+ if (cls.isExported) continue // Exported classes are exempt
122
+
123
+ const entry: DeadCodeEntry = {
124
+ id,
125
+ name: cls.name,
126
+ file: cls.file,
127
+ moduleId,
128
+ type: 'class',
129
+ reason: 'Class has no callers or importers and is not exported',
130
+ }
131
+ dead.push(entry)
132
+ byModule[moduleId].dead++
133
+ byModule[moduleId].items.push(entry)
134
+ }
135
+ }
136
+
137
+ return {
138
+ deadFunctions: dead,
139
+ totalFunctions,
140
+ deadCount: dead.length,
141
+ deadPercentage: totalFunctions > 0
142
+ ? Math.round((dead.length / totalFunctions) * 1000) / 10
143
+ : 0,
144
+ byModule,
145
+ }
146
+ }
147
+
148
+ // ─── Exemption checks ──────────────────────────────────────────
149
+
150
+ private isExempt(fn: MikkLock['functions'][string], id: string): boolean {
151
+ // 1. Exported functions — may be consumed by external packages
152
+ if (fn.isExported) return true
153
+
154
+ // 2. Entry point patterns
155
+ if (ENTRY_POINT_PATTERNS.some(p => p.test(fn.name))) return true
156
+
157
+ // 3. Route handlers
158
+ if (this.routeHandlers.has(fn.name)) return true
159
+
160
+ // 4. Test functions or in test files
161
+ if (TEST_PATTERNS.some(p => p.test(fn.name) || p.test(fn.file))) return true
162
+
163
+ // 5. Constructor methods
164
+ if (fn.name === 'constructor' || fn.name === '__init__') return true
165
+
166
+ // 6. Functions called by exported functions in the same file
167
+ // (transitive liveness — if an exported fn calls this, it's alive)
168
+ if (this.isCalledByExportedInSameFile(fn, id)) return true
169
+
170
+ return false
171
+ }
172
+
173
+ private isCalledByExportedInSameFile(
174
+ fn: MikkLock['functions'][string],
175
+ fnId: string,
176
+ ): boolean {
177
+ // Check calledBy — if any caller is exported and in the same file, exempt
178
+ for (const callerId of fn.calledBy) {
179
+ const caller = this.lock.functions[callerId]
180
+ if (caller && caller.isExported && caller.file === fn.file) {
181
+ return true
182
+ }
183
+ }
184
+ return false
185
+ }
186
+
187
+ private inferReason(fn: MikkLock['functions'][string], id: string): string {
188
+ if (fn.calledBy.length === 0) {
189
+ return 'No callers found anywhere in the codebase'
190
+ }
191
+ // calledBy has entries but they didn't resolve to graph edges
192
+ return `${fn.calledBy.length} references exist but none resolved to active call edges`
193
+ }
194
+ }
@@ -121,6 +121,7 @@ export class GraphBuilder {
121
121
  source: file.path,
122
122
  target: imp.resolvedPath,
123
123
  type: 'imports',
124
+ confidence: 1.0, // Import edges are always deterministic from AST
124
125
  })
125
126
  }
126
127
  }
@@ -145,24 +146,27 @@ export class GraphBuilder {
145
146
  // Try to resolve: first check imported names, then local functions
146
147
  const simpleName = call.includes('.') ? call.split('.').pop()! : call
147
148
 
148
- // Check if it's an imported function
149
+ // Check if it's an imported function (confidence 0.8 — resolved via import names)
149
150
  const importedId = importedNames.get(simpleName) || importedNames.get(call)
150
151
  if (importedId && graph.nodes.has(importedId)) {
151
152
  graph.edges.push({
152
153
  source: fn.id,
153
154
  target: importedId,
154
155
  type: 'calls',
156
+ confidence: 0.8, // Resolved through import names, not direct AST binding
155
157
  })
156
158
  continue
157
159
  }
158
160
 
159
- // Check if it's a local function in the same file
161
+ // Check if it's a local function in the same file (confidence 1.0 for exact, 0.5 for fuzzy)
160
162
  const localId = `fn:${file.path}:${simpleName}`
161
163
  if (graph.nodes.has(localId) && localId !== fn.id) {
164
+ // Direct local match — high confidence
162
165
  graph.edges.push({
163
166
  source: fn.id,
164
167
  target: localId,
165
168
  type: 'calls',
169
+ confidence: simpleName === call ? 1.0 : 0.5, // exact name = 1.0, dot-access strip = 0.5
166
170
  })
167
171
  }
168
172
  }
@@ -1,9 +1,15 @@
1
- import type { DependencyGraph, ImpactResult } from './types.js'
1
+ import type { DependencyGraph, ImpactResult, ClassifiedImpact, RiskLevel } from './types.js'
2
2
 
3
3
  /**
4
4
  * ImpactAnalyzer — Given changed nodes, walks the graph backwards (BFS)
5
5
  * to find everything that depends on them.
6
6
  * Powers "what breaks if I change X?"
7
+ *
8
+ * Now includes risk classification:
9
+ * CRITICAL = direct caller (depth 1) that crosses a module boundary
10
+ * HIGH = direct caller (depth 1) within the same module
11
+ * MEDIUM = depth 2
12
+ * LOW = depth 3+
7
13
  */
8
14
  export class ImpactAnalyzer {
9
15
  constructor(private graph: DependencyGraph) { }
@@ -11,13 +17,24 @@ export class ImpactAnalyzer {
11
17
  /** Given a list of changed node IDs, find everything impacted */
12
18
  analyze(changedNodeIds: string[]): ImpactResult {
13
19
  const visited = new Set<string>()
20
+ const depthMap = new Map<string, number>()
14
21
  const queue: { id: string; depth: number }[] = changedNodeIds.map(id => ({ id, depth: 0 }))
15
22
  let maxDepth = 0
16
23
 
24
+ const changedSet = new Set(changedNodeIds)
25
+
26
+ // Collect module IDs of the changed nodes
27
+ const changedModules = new Set<string | undefined>()
28
+ for (const id of changedNodeIds) {
29
+ const node = this.graph.nodes.get(id)
30
+ if (node) changedModules.add(node.moduleId)
31
+ }
32
+
17
33
  while (queue.length > 0) {
18
34
  const { id: current, depth } = queue.shift()!
19
35
  if (visited.has(current)) continue
20
36
  visited.add(current)
37
+ depthMap.set(current, depth)
21
38
  maxDepth = Math.max(maxDepth, depth)
22
39
 
23
40
  // Find everything that depends on current (incoming edges)
@@ -29,13 +46,47 @@ export class ImpactAnalyzer {
29
46
  }
30
47
  }
31
48
 
32
- const impacted = [...visited].filter(id => !changedNodeIds.includes(id))
49
+ const impacted = [...visited].filter(id => !changedSet.has(id))
50
+
51
+ // Classify each impacted node by risk level
52
+ const classified: ImpactResult['classified'] = {
53
+ critical: [],
54
+ high: [],
55
+ medium: [],
56
+ low: [],
57
+ }
58
+
59
+ for (const id of impacted) {
60
+ const node = this.graph.nodes.get(id)
61
+ if (!node) continue
62
+
63
+ const depth = depthMap.get(id) ?? 999
64
+ const crossesBoundary = !changedModules.has(node.moduleId)
65
+
66
+ const risk: RiskLevel =
67
+ depth === 1 && crossesBoundary ? 'critical' :
68
+ depth === 1 ? 'high' :
69
+ depth === 2 ? 'medium' :
70
+ 'low'
71
+
72
+ const entry: ClassifiedImpact = {
73
+ nodeId: id,
74
+ label: node.label,
75
+ file: node.file,
76
+ moduleId: node.moduleId,
77
+ risk,
78
+ depth,
79
+ }
80
+
81
+ classified[risk].push(entry)
82
+ }
33
83
 
34
84
  return {
35
85
  changed: changedNodeIds,
36
86
  impacted,
37
87
  depth: maxDepth,
38
88
  confidence: this.computeConfidence(impacted.length, maxDepth),
89
+ classified,
39
90
  }
40
91
  }
41
92
 
@@ -1,4 +1,7 @@
1
- export type { DependencyGraph, GraphNode, GraphEdge, ImpactResult, NodeType, EdgeType, ModuleCluster } from './types.js'
1
+ export type { DependencyGraph, GraphNode, GraphEdge, ImpactResult, NodeType, EdgeType, ModuleCluster, RiskLevel, ClassifiedImpact } from './types.js'
2
2
  export { GraphBuilder } from './graph-builder.js'
3
3
  export { ImpactAnalyzer } from './impact-analyzer.js'
4
4
  export { ClusterDetector } from './cluster-detector.js'
5
+ export { DeadCodeDetector } from './dead-code-detector.js'
6
+ export type { DeadCodeResult, DeadCodeEntry } from './dead-code-detector.js'
7
+
@@ -33,6 +33,7 @@ export interface GraphEdge {
33
33
  target: string // "fn:src/utils/jwt.ts:jwtDecode"
34
34
  type: EdgeType
35
35
  weight?: number // How often this call happens (for coupling metrics)
36
+ confidence?: number // 0.0–1.0: 1.0 = direct AST call, 0.8 = via interface, 0.5 = fuzzy/inferred
36
37
  }
37
38
 
38
39
  /** The full dependency graph */
@@ -43,12 +44,32 @@ export interface DependencyGraph {
43
44
  inEdges: Map<string, GraphEdge[]> // node → [edges coming in]
44
45
  }
45
46
 
47
+ /** Risk level for an impacted node */
48
+ export type RiskLevel = 'critical' | 'high' | 'medium' | 'low'
49
+
50
+ /** A single node in the classified impact result */
51
+ export interface ClassifiedImpact {
52
+ nodeId: string
53
+ label: string
54
+ file: string
55
+ moduleId?: string
56
+ risk: RiskLevel
57
+ depth: number // hops from change
58
+ }
59
+
46
60
  /** Result of impact analysis */
47
61
  export interface ImpactResult {
48
62
  changed: string[] // The directly changed nodes
49
63
  impacted: string[] // Everything that depends on changed nodes
50
64
  depth: number // How many hops from change to furthest impact
51
65
  confidence: 'high' | 'medium' | 'low'
66
+ /** Risk-classified breakdown of impacted nodes */
67
+ classified: {
68
+ critical: ClassifiedImpact[]
69
+ high: ClassifiedImpact[]
70
+ medium: ClassifiedImpact[]
71
+ low: ClassifiedImpact[]
72
+ }
52
73
  }
53
74
 
54
75
  /** A cluster of files that naturally belong together */