@getmikk/core 1.2.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 (44) hide show
  1. package/README.md +431 -0
  2. package/package.json +6 -2
  3. package/src/contract/contract-generator.ts +85 -85
  4. package/src/contract/contract-reader.ts +28 -28
  5. package/src/contract/contract-writer.ts +114 -114
  6. package/src/contract/index.ts +12 -12
  7. package/src/contract/lock-compiler.ts +221 -221
  8. package/src/contract/lock-reader.ts +34 -34
  9. package/src/contract/schema.ts +147 -147
  10. package/src/graph/cluster-detector.ts +312 -312
  11. package/src/graph/graph-builder.ts +211 -211
  12. package/src/graph/impact-analyzer.ts +55 -55
  13. package/src/graph/index.ts +4 -4
  14. package/src/graph/types.ts +59 -59
  15. package/src/hash/file-hasher.ts +30 -30
  16. package/src/hash/hash-store.ts +119 -119
  17. package/src/hash/index.ts +3 -3
  18. package/src/hash/tree-hasher.ts +20 -20
  19. package/src/index.ts +12 -12
  20. package/src/parser/base-parser.ts +16 -16
  21. package/src/parser/boundary-checker.ts +211 -211
  22. package/src/parser/index.ts +46 -46
  23. package/src/parser/types.ts +90 -90
  24. package/src/parser/typescript/ts-extractor.ts +543 -543
  25. package/src/parser/typescript/ts-parser.ts +41 -41
  26. package/src/parser/typescript/ts-resolver.ts +86 -86
  27. package/src/utils/errors.ts +42 -42
  28. package/src/utils/fs.ts +75 -75
  29. package/src/utils/fuzzy-match.ts +186 -186
  30. package/src/utils/logger.ts +36 -36
  31. package/src/utils/minimatch.ts +19 -19
  32. package/tests/contract.test.ts +134 -134
  33. package/tests/fixtures/simple-api/package.json +5 -5
  34. package/tests/fixtures/simple-api/src/auth/middleware.ts +9 -9
  35. package/tests/fixtures/simple-api/src/auth/verify.ts +6 -6
  36. package/tests/fixtures/simple-api/src/index.ts +9 -9
  37. package/tests/fixtures/simple-api/src/utils/jwt.ts +3 -3
  38. package/tests/fixtures/simple-api/tsconfig.json +8 -8
  39. package/tests/fuzzy-match.test.ts +142 -142
  40. package/tests/graph.test.ts +169 -169
  41. package/tests/hash.test.ts +49 -49
  42. package/tests/helpers.ts +83 -83
  43. package/tests/parser.test.ts +218 -218
  44. package/tsconfig.json +15 -15
@@ -1,212 +1,212 @@
1
- import * as path from 'node:path'
2
- import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
3
-
4
- // ---------------------------------------------------------------------------
5
- // Types
6
- // ---------------------------------------------------------------------------
7
-
8
- export type ViolationSeverity = 'error' | 'warning'
9
-
10
- export interface BoundaryViolation {
11
- /** The function making the illegal import */
12
- from: {
13
- functionId: string
14
- functionName: string
15
- file: string
16
- moduleId: string
17
- moduleName: string
18
- }
19
- /** The function being illegally called */
20
- to: {
21
- functionId: string
22
- functionName: string
23
- file: string
24
- moduleId: string
25
- moduleName: string
26
- }
27
- rule: string
28
- severity: ViolationSeverity
29
- }
30
-
31
- export interface BoundaryCheckResult {
32
- pass: boolean
33
- violations: BoundaryViolation[]
34
- summary: string
35
- }
36
-
37
- // ---------------------------------------------------------------------------
38
- // Rule parsing
39
- // ---------------------------------------------------------------------------
40
-
41
- /**
42
- * Parse constraint strings into structured allow/deny rules.
43
- *
44
- * Supported syntax in mikk.json constraints:
45
- * "module:auth cannot import module:payments"
46
- * "module:cli cannot import module:db"
47
- * "module:core has no imports" → core is completely isolated
48
- * "module:api can only import module:core, module:utils"
49
- */
50
- interface ParsedRule {
51
- type: 'deny' | 'allow_only' | 'isolated'
52
- fromModuleId: string
53
- toModuleIds: string[] // For deny: what's forbidden. For allow_only: what's allowed.
54
- raw: string
55
- }
56
-
57
- function parseConstraint(constraint: string): ParsedRule | null {
58
- const c = constraint.trim().toLowerCase()
59
-
60
- // "module:X cannot import module:Y" or "module:X cannot import module:Y, module:Z"
61
- const denyMatch = c.match(/^module:(\S+)\s+cannot\s+import\s+(.+)$/)
62
- if (denyMatch) {
63
- const toModules = denyMatch[2]
64
- .split(',')
65
- .map(s => s.trim().replace('module:', ''))
66
- .filter(Boolean)
67
- return { type: 'deny', fromModuleId: denyMatch[1], toModuleIds: toModules, raw: constraint }
68
- }
69
-
70
- // "module:X can only import module:A, module:B"
71
- const allowOnlyMatch = c.match(/^module:(\S+)\s+can\s+only\s+import\s+(.+)$/)
72
- if (allowOnlyMatch) {
73
- const toModules = allowOnlyMatch[2]
74
- .split(',')
75
- .map(s => s.trim().replace('module:', ''))
76
- .filter(Boolean)
77
- return { type: 'allow_only', fromModuleId: allowOnlyMatch[1], toModuleIds: toModules, raw: constraint }
78
- }
79
-
80
- // "module:X has no imports" or "module:X is isolated"
81
- const isolatedMatch = c.match(/^module:(\S+)\s+(has\s+no\s+imports|is\s+isolated)$/)
82
- if (isolatedMatch) {
83
- return { type: 'isolated', fromModuleId: isolatedMatch[1], toModuleIds: [], raw: constraint }
84
- }
85
-
86
- return null // unrecognized constraint — skip silently
87
- }
88
-
89
- // ---------------------------------------------------------------------------
90
- // BoundaryChecker
91
- // ---------------------------------------------------------------------------
92
-
93
- /**
94
- * BoundaryChecker — walks the lock file's call graph and checks every
95
- * cross-module call against the rules declared in mikk.json constraints.
96
- *
97
- * This is the CI-ready enforcement layer.
98
- */
99
- export class BoundaryChecker {
100
- private rules: ParsedRule[]
101
- private moduleNames: Map<string, string> // id → name
102
-
103
- constructor(
104
- private contract: MikkContract,
105
- private lock: MikkLock
106
- ) {
107
- this.rules = contract.declared.constraints
108
- .map(parseConstraint)
109
- .filter((r): r is ParsedRule => r !== null)
110
-
111
- this.moduleNames = new Map(contract.declared.modules.map(m => [m.id, m.name]))
112
- }
113
-
114
- /** Run boundary check. Returns pass/fail + all violations. */
115
- check(): BoundaryCheckResult {
116
- const violations: BoundaryViolation[] = []
117
-
118
- // Collect all cross-module calls
119
- for (const fn of Object.values(this.lock.functions)) {
120
- for (const calleeId of fn.calls) {
121
- const callee = this.lock.functions[calleeId]
122
- if (!callee) continue
123
- if (fn.moduleId === callee.moduleId) continue // same module — fine
124
-
125
- // Check this cross-module call against all parsed rules
126
- const violation = this.checkCall(fn, callee)
127
- if (violation) violations.push(violation)
128
- }
129
- }
130
-
131
- const errorCount = violations.filter(v => v.severity === 'error').length
132
- const warnCount = violations.filter(v => v.severity === 'warning').length
133
-
134
- const summary = violations.length === 0
135
- ? `✓ All module boundaries respected (${Object.keys(this.lock.functions).length} functions checked)`
136
- : `✗ ${errorCount} boundary error(s), ${warnCount} warning(s) found`
137
-
138
- return {
139
- pass: errorCount === 0,
140
- violations,
141
- summary,
142
- }
143
- }
144
-
145
- /**
146
- * Check a single cross-module call against parsed rules.
147
- * Returns a violation if the call is forbidden, null if it's allowed.
148
- */
149
- private checkCall(
150
- caller: MikkLockFunction,
151
- callee: MikkLockFunction
152
- ): BoundaryViolation | null {
153
- for (const rule of this.rules) {
154
- if (rule.fromModuleId !== caller.moduleId) continue
155
-
156
- let forbidden = false
157
- let ruleDesc = rule.raw
158
-
159
- if (rule.type === 'isolated') {
160
- // Module may not call anything outside itself
161
- forbidden = true
162
- } else if (rule.type === 'deny') {
163
- // Module may not call into these specific modules
164
- forbidden = rule.toModuleIds.includes(callee.moduleId)
165
- } else if (rule.type === 'allow_only') {
166
- // Module may ONLY call into the listed modules (+ itself)
167
- forbidden = !rule.toModuleIds.includes(callee.moduleId)
168
- }
169
-
170
- if (forbidden) {
171
- return {
172
- from: {
173
- functionId: caller.id,
174
- functionName: caller.name,
175
- file: caller.file,
176
- moduleId: caller.moduleId,
177
- moduleName: this.moduleNames.get(caller.moduleId) ?? caller.moduleId,
178
- },
179
- to: {
180
- functionId: callee.id,
181
- functionName: callee.name,
182
- file: callee.file,
183
- moduleId: callee.moduleId,
184
- moduleName: this.moduleNames.get(callee.moduleId) ?? callee.moduleId,
185
- },
186
- rule: ruleDesc,
187
- severity: 'error',
188
- }
189
- }
190
- }
191
- return null
192
- }
193
-
194
- /** Return all cross-module call pairs (useful for generating allow rules) */
195
- allCrossModuleCalls(): { from: string; to: string; count: number }[] {
196
- const counts = new Map<string, number>()
197
- for (const fn of Object.values(this.lock.functions)) {
198
- for (const calleeId of fn.calls) {
199
- const callee = this.lock.functions[calleeId]
200
- if (!callee || fn.moduleId === callee.moduleId) continue
201
- const key = `${fn.moduleId}→${callee.moduleId}`
202
- counts.set(key, (counts.get(key) ?? 0) + 1)
203
- }
204
- }
205
- return [...counts.entries()]
206
- .map(([key, count]) => {
207
- const [from, to] = key.split('→')
208
- return { from, to, count }
209
- })
210
- .sort((a, b) => b.count - a.count)
211
- }
1
+ import * as path from 'node:path'
2
+ import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Types
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export type ViolationSeverity = 'error' | 'warning'
9
+
10
+ export interface BoundaryViolation {
11
+ /** The function making the illegal import */
12
+ from: {
13
+ functionId: string
14
+ functionName: string
15
+ file: string
16
+ moduleId: string
17
+ moduleName: string
18
+ }
19
+ /** The function being illegally called */
20
+ to: {
21
+ functionId: string
22
+ functionName: string
23
+ file: string
24
+ moduleId: string
25
+ moduleName: string
26
+ }
27
+ rule: string
28
+ severity: ViolationSeverity
29
+ }
30
+
31
+ export interface BoundaryCheckResult {
32
+ pass: boolean
33
+ violations: BoundaryViolation[]
34
+ summary: string
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Rule parsing
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Parse constraint strings into structured allow/deny rules.
43
+ *
44
+ * Supported syntax in mikk.json constraints:
45
+ * "module:auth cannot import module:payments"
46
+ * "module:cli cannot import module:db"
47
+ * "module:core has no imports" → core is completely isolated
48
+ * "module:api can only import module:core, module:utils"
49
+ */
50
+ interface ParsedRule {
51
+ type: 'deny' | 'allow_only' | 'isolated'
52
+ fromModuleId: string
53
+ toModuleIds: string[] // For deny: what's forbidden. For allow_only: what's allowed.
54
+ raw: string
55
+ }
56
+
57
+ function parseConstraint(constraint: string): ParsedRule | null {
58
+ const c = constraint.trim().toLowerCase()
59
+
60
+ // "module:X cannot import module:Y" or "module:X cannot import module:Y, module:Z"
61
+ const denyMatch = c.match(/^module:(\S+)\s+cannot\s+import\s+(.+)$/)
62
+ if (denyMatch) {
63
+ const toModules = denyMatch[2]
64
+ .split(',')
65
+ .map(s => s.trim().replace('module:', ''))
66
+ .filter(Boolean)
67
+ return { type: 'deny', fromModuleId: denyMatch[1], toModuleIds: toModules, raw: constraint }
68
+ }
69
+
70
+ // "module:X can only import module:A, module:B"
71
+ const allowOnlyMatch = c.match(/^module:(\S+)\s+can\s+only\s+import\s+(.+)$/)
72
+ if (allowOnlyMatch) {
73
+ const toModules = allowOnlyMatch[2]
74
+ .split(',')
75
+ .map(s => s.trim().replace('module:', ''))
76
+ .filter(Boolean)
77
+ return { type: 'allow_only', fromModuleId: allowOnlyMatch[1], toModuleIds: toModules, raw: constraint }
78
+ }
79
+
80
+ // "module:X has no imports" or "module:X is isolated"
81
+ const isolatedMatch = c.match(/^module:(\S+)\s+(has\s+no\s+imports|is\s+isolated)$/)
82
+ if (isolatedMatch) {
83
+ return { type: 'isolated', fromModuleId: isolatedMatch[1], toModuleIds: [], raw: constraint }
84
+ }
85
+
86
+ return null // unrecognized constraint — skip silently
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // BoundaryChecker
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * BoundaryChecker — walks the lock file's call graph and checks every
95
+ * cross-module call against the rules declared in mikk.json constraints.
96
+ *
97
+ * This is the CI-ready enforcement layer.
98
+ */
99
+ export class BoundaryChecker {
100
+ private rules: ParsedRule[]
101
+ private moduleNames: Map<string, string> // id → name
102
+
103
+ constructor(
104
+ private contract: MikkContract,
105
+ private lock: MikkLock
106
+ ) {
107
+ this.rules = contract.declared.constraints
108
+ .map(parseConstraint)
109
+ .filter((r): r is ParsedRule => r !== null)
110
+
111
+ this.moduleNames = new Map(contract.declared.modules.map(m => [m.id, m.name]))
112
+ }
113
+
114
+ /** Run boundary check. Returns pass/fail + all violations. */
115
+ check(): BoundaryCheckResult {
116
+ const violations: BoundaryViolation[] = []
117
+
118
+ // Collect all cross-module calls
119
+ for (const fn of Object.values(this.lock.functions)) {
120
+ for (const calleeId of fn.calls) {
121
+ const callee = this.lock.functions[calleeId]
122
+ if (!callee) continue
123
+ if (fn.moduleId === callee.moduleId) continue // same module — fine
124
+
125
+ // Check this cross-module call against all parsed rules
126
+ const violation = this.checkCall(fn, callee)
127
+ if (violation) violations.push(violation)
128
+ }
129
+ }
130
+
131
+ const errorCount = violations.filter(v => v.severity === 'error').length
132
+ const warnCount = violations.filter(v => v.severity === 'warning').length
133
+
134
+ const summary = violations.length === 0
135
+ ? `✓ All module boundaries respected (${Object.keys(this.lock.functions).length} functions checked)`
136
+ : `✗ ${errorCount} boundary error(s), ${warnCount} warning(s) found`
137
+
138
+ return {
139
+ pass: errorCount === 0,
140
+ violations,
141
+ summary,
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Check a single cross-module call against parsed rules.
147
+ * Returns a violation if the call is forbidden, null if it's allowed.
148
+ */
149
+ private checkCall(
150
+ caller: MikkLockFunction,
151
+ callee: MikkLockFunction
152
+ ): BoundaryViolation | null {
153
+ for (const rule of this.rules) {
154
+ if (rule.fromModuleId !== caller.moduleId) continue
155
+
156
+ let forbidden = false
157
+ let ruleDesc = rule.raw
158
+
159
+ if (rule.type === 'isolated') {
160
+ // Module may not call anything outside itself
161
+ forbidden = true
162
+ } else if (rule.type === 'deny') {
163
+ // Module may not call into these specific modules
164
+ forbidden = rule.toModuleIds.includes(callee.moduleId)
165
+ } else if (rule.type === 'allow_only') {
166
+ // Module may ONLY call into the listed modules (+ itself)
167
+ forbidden = !rule.toModuleIds.includes(callee.moduleId)
168
+ }
169
+
170
+ if (forbidden) {
171
+ return {
172
+ from: {
173
+ functionId: caller.id,
174
+ functionName: caller.name,
175
+ file: caller.file,
176
+ moduleId: caller.moduleId,
177
+ moduleName: this.moduleNames.get(caller.moduleId) ?? caller.moduleId,
178
+ },
179
+ to: {
180
+ functionId: callee.id,
181
+ functionName: callee.name,
182
+ file: callee.file,
183
+ moduleId: callee.moduleId,
184
+ moduleName: this.moduleNames.get(callee.moduleId) ?? callee.moduleId,
185
+ },
186
+ rule: ruleDesc,
187
+ severity: 'error',
188
+ }
189
+ }
190
+ }
191
+ return null
192
+ }
193
+
194
+ /** Return all cross-module call pairs (useful for generating allow rules) */
195
+ allCrossModuleCalls(): { from: string; to: string; count: number }[] {
196
+ const counts = new Map<string, number>()
197
+ for (const fn of Object.values(this.lock.functions)) {
198
+ for (const calleeId of fn.calls) {
199
+ const callee = this.lock.functions[calleeId]
200
+ if (!callee || fn.moduleId === callee.moduleId) continue
201
+ const key = `${fn.moduleId}→${callee.moduleId}`
202
+ counts.set(key, (counts.get(key) ?? 0) + 1)
203
+ }
204
+ }
205
+ return [...counts.entries()]
206
+ .map(([key, count]) => {
207
+ const [from, to] = key.split('→')
208
+ return { from, to, count }
209
+ })
210
+ .sort((a, b) => b.count - a.count)
211
+ }
212
212
  }
@@ -1,46 +1,46 @@
1
- import * as path from 'node:path'
2
- import { BaseParser } from './base-parser.js'
3
- import { TypeScriptParser } from './typescript/ts-parser.js'
4
- import { UnsupportedLanguageError } from '../utils/errors.js'
5
- import type { ParsedFile } from './types.js'
6
-
7
- export type { ParsedFile, ParsedFunction, ParsedImport, ParsedExport, ParsedClass, ParsedParam } from './types.js'
8
- export { BaseParser } from './base-parser.js'
9
- export { TypeScriptParser } from './typescript/ts-parser.js'
10
- export { TypeScriptExtractor } from './typescript/ts-extractor.js'
11
- export { TypeScriptResolver } from './typescript/ts-resolver.js'
12
- export { BoundaryChecker } from './boundary-checker.js'
13
-
14
- /** Get the appropriate parser for a file based on its extension */
15
- export function getParser(filePath: string): BaseParser {
16
- const ext = path.extname(filePath)
17
- switch (ext) {
18
- case '.ts':
19
- case '.tsx':
20
- return new TypeScriptParser()
21
- default:
22
- throw new UnsupportedLanguageError(ext)
23
- }
24
- }
25
-
26
- /** Parse multiple files and resolve imports across them */
27
- export async function parseFiles(
28
- filePaths: string[],
29
- projectRoot: string,
30
- readFile: (fp: string) => Promise<string>
31
- ): Promise<ParsedFile[]> {
32
- const tsParser = new TypeScriptParser()
33
- const files: ParsedFile[] = []
34
-
35
- for (const fp of filePaths) {
36
- const ext = path.extname(fp)
37
- if (ext === '.ts' || ext === '.tsx') {
38
- const content = await readFile(path.join(projectRoot, fp))
39
- const parsed = tsParser.parse(fp, content)
40
- files.push(parsed)
41
- }
42
- }
43
-
44
- // Resolve all imports after all files are parsed
45
- return tsParser.resolveImports(files, projectRoot)
46
- }
1
+ import * as path from 'node:path'
2
+ import { BaseParser } from './base-parser.js'
3
+ import { TypeScriptParser } from './typescript/ts-parser.js'
4
+ import { UnsupportedLanguageError } from '../utils/errors.js'
5
+ import type { ParsedFile } from './types.js'
6
+
7
+ export type { ParsedFile, ParsedFunction, ParsedImport, ParsedExport, ParsedClass, ParsedParam } from './types.js'
8
+ export { BaseParser } from './base-parser.js'
9
+ export { TypeScriptParser } from './typescript/ts-parser.js'
10
+ export { TypeScriptExtractor } from './typescript/ts-extractor.js'
11
+ export { TypeScriptResolver } from './typescript/ts-resolver.js'
12
+ export { BoundaryChecker } from './boundary-checker.js'
13
+
14
+ /** Get the appropriate parser for a file based on its extension */
15
+ export function getParser(filePath: string): BaseParser {
16
+ const ext = path.extname(filePath)
17
+ switch (ext) {
18
+ case '.ts':
19
+ case '.tsx':
20
+ return new TypeScriptParser()
21
+ default:
22
+ throw new UnsupportedLanguageError(ext)
23
+ }
24
+ }
25
+
26
+ /** Parse multiple files and resolve imports across them */
27
+ export async function parseFiles(
28
+ filePaths: string[],
29
+ projectRoot: string,
30
+ readFile: (fp: string) => Promise<string>
31
+ ): Promise<ParsedFile[]> {
32
+ const tsParser = new TypeScriptParser()
33
+ const files: ParsedFile[] = []
34
+
35
+ for (const fp of filePaths) {
36
+ const ext = path.extname(fp)
37
+ if (ext === '.ts' || ext === '.tsx') {
38
+ const content = await readFile(path.join(projectRoot, fp))
39
+ const parsed = tsParser.parse(fp, content)
40
+ files.push(parsed)
41
+ }
42
+ }
43
+
44
+ // Resolve all imports after all files are parsed
45
+ return tsParser.resolveImports(files, projectRoot)
46
+ }