@getmikk/core 1.5.1 → 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 +44 -6
- package/out.log +0 -0
- package/package.json +4 -3
- package/src/contract/adr-manager.ts +75 -0
- package/src/contract/index.ts +2 -0
- package/src/graph/dead-code-detector.ts +194 -0
- package/src/graph/graph-builder.ts +6 -2
- package/src/graph/impact-analyzer.ts +53 -2
- package/src/graph/index.ts +4 -1
- package/src/graph/types.ts +21 -0
- package/src/parser/go/go-extractor.ts +712 -0
- package/src/parser/go/go-parser.ts +41 -0
- package/src/parser/go/go-resolver.ts +70 -0
- package/src/parser/index.ts +27 -6
- package/src/parser/types.ts +1 -1
- package/src/parser/typescript/ts-extractor.ts +65 -18
- package/src/parser/typescript/ts-parser.ts +41 -1
- package/test-output.txt +0 -0
- package/tests/adr-manager.test.ts +97 -0
- package/tests/dead-code.test.ts +134 -0
- package/tests/go-parser.test.ts +366 -0
- package/tests/impact-classified.test.ts +78 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { BaseParser } from '../base-parser.js'
|
|
2
|
+
import { GoExtractor } from './go-extractor.js'
|
|
3
|
+
import { GoResolver } from './go-resolver.js'
|
|
4
|
+
import { hashContent } from '../../hash/file-hasher.js'
|
|
5
|
+
import type { ParsedFile } from '../types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GoParser — implements BaseParser for .go files.
|
|
9
|
+
* Uses GoExtractor (regex-based) to pull structured data from Go source
|
|
10
|
+
* without requiring the Go toolchain.
|
|
11
|
+
*/
|
|
12
|
+
export class GoParser extends BaseParser {
|
|
13
|
+
parse(filePath: string, content: string): ParsedFile {
|
|
14
|
+
const extractor = new GoExtractor(filePath, content)
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
path: filePath,
|
|
18
|
+
language: 'go',
|
|
19
|
+
functions: extractor.extractFunctions(),
|
|
20
|
+
classes: extractor.extractClasses(),
|
|
21
|
+
generics: [], // Go type aliases handled as classes/exports
|
|
22
|
+
imports: extractor.extractImports(),
|
|
23
|
+
exports: extractor.extractExports(),
|
|
24
|
+
routes: extractor.extractRoutes(),
|
|
25
|
+
hash: hashContent(content),
|
|
26
|
+
parsedAt: Date.now(),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
resolveImports(files: ParsedFile[], projectRoot: string): ParsedFile[] {
|
|
31
|
+
const resolver = new GoResolver(projectRoot)
|
|
32
|
+
return files.map(file => ({
|
|
33
|
+
...file,
|
|
34
|
+
imports: resolver.resolveAll(file.imports),
|
|
35
|
+
}))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getSupportedExtensions(): string[] {
|
|
39
|
+
return ['.go']
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import type { ParsedImport } from '../types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GoResolver — resolves Go import paths to project-relative file paths.
|
|
7
|
+
*
|
|
8
|
+
* Go import paths follow the module path declared in go.mod:
|
|
9
|
+
* module github.com/user/project
|
|
10
|
+
*
|
|
11
|
+
* An import "github.com/user/project/internal/auth" resolves to
|
|
12
|
+
* the directory internal/auth/ relative to the project root.
|
|
13
|
+
*
|
|
14
|
+
* Third-party imports (not matching the module path) are left unresolved.
|
|
15
|
+
*/
|
|
16
|
+
export class GoResolver {
|
|
17
|
+
private modulePath: string
|
|
18
|
+
|
|
19
|
+
constructor(private readonly projectRoot: string) {
|
|
20
|
+
this.modulePath = readModulePath(projectRoot)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resolve a list of imports for a single file */
|
|
24
|
+
resolveAll(imports: ParsedImport[]): ParsedImport[] {
|
|
25
|
+
return imports.map(imp => this.resolve(imp))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private resolve(imp: ParsedImport): ParsedImport {
|
|
29
|
+
const src = imp.source
|
|
30
|
+
|
|
31
|
+
// Third-party (doesn't start with our module path) → leave as-is
|
|
32
|
+
if (this.modulePath && !src.startsWith(this.modulePath)) {
|
|
33
|
+
return { ...imp, resolvedPath: '' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Internal import: strip module path prefix, map to relative dir
|
|
37
|
+
const relPath = this.modulePath
|
|
38
|
+
? src.slice(this.modulePath.length).replace(/^\//, '')
|
|
39
|
+
: src
|
|
40
|
+
|
|
41
|
+
// Try to find the entry file in the directory
|
|
42
|
+
const dirPath = relPath.replace(/\//g, path.sep)
|
|
43
|
+
const candidates = [
|
|
44
|
+
path.join(dirPath, path.basename(dirPath) + '.go'),
|
|
45
|
+
path.join(dirPath, 'index.go'),
|
|
46
|
+
dirPath + '.go',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
if (fs.existsSync(path.join(this.projectRoot, candidate))) {
|
|
51
|
+
return { ...imp, resolvedPath: candidate.replace(/\\/g, '/') }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fallback: point to directory (graph builder handles directories separately)
|
|
56
|
+
return { ...imp, resolvedPath: relPath.replace(/\\/g, '/') }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read module path from go.mod (e.g. "module github.com/user/myapp") */
|
|
61
|
+
function readModulePath(projectRoot: string): string {
|
|
62
|
+
const goModPath = path.join(projectRoot, 'go.mod')
|
|
63
|
+
try {
|
|
64
|
+
const content = fs.readFileSync(goModPath, 'utf-8')
|
|
65
|
+
const m = /^module\s+(\S+)/m.exec(content)
|
|
66
|
+
return m ? m[1] : ''
|
|
67
|
+
} catch {
|
|
68
|
+
return ''
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/parser/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as path from 'node:path'
|
|
2
2
|
import { BaseParser } from './base-parser.js'
|
|
3
3
|
import { TypeScriptParser } from './typescript/ts-parser.js'
|
|
4
|
+
import { GoParser } from './go/go-parser.js'
|
|
4
5
|
import { UnsupportedLanguageError } from '../utils/errors.js'
|
|
5
6
|
import type { ParsedFile } from './types.js'
|
|
6
7
|
|
|
@@ -9,6 +10,9 @@ export { BaseParser } from './base-parser.js'
|
|
|
9
10
|
export { TypeScriptParser } from './typescript/ts-parser.js'
|
|
10
11
|
export { TypeScriptExtractor } from './typescript/ts-extractor.js'
|
|
11
12
|
export { TypeScriptResolver } from './typescript/ts-resolver.js'
|
|
13
|
+
export { GoParser } from './go/go-parser.js'
|
|
14
|
+
export { GoExtractor } from './go/go-extractor.js'
|
|
15
|
+
export { GoResolver } from './go/go-resolver.js'
|
|
12
16
|
export { BoundaryChecker } from './boundary-checker.js'
|
|
13
17
|
|
|
14
18
|
/** Get the appropriate parser for a file based on its extension */
|
|
@@ -18,6 +22,8 @@ export function getParser(filePath: string): BaseParser {
|
|
|
18
22
|
case '.ts':
|
|
19
23
|
case '.tsx':
|
|
20
24
|
return new TypeScriptParser()
|
|
25
|
+
case '.go':
|
|
26
|
+
return new GoParser()
|
|
21
27
|
default:
|
|
22
28
|
throw new UnsupportedLanguageError(ext)
|
|
23
29
|
}
|
|
@@ -30,17 +36,32 @@ export async function parseFiles(
|
|
|
30
36
|
readFile: (fp: string) => Promise<string>
|
|
31
37
|
): Promise<ParsedFile[]> {
|
|
32
38
|
const tsParser = new TypeScriptParser()
|
|
33
|
-
const
|
|
39
|
+
const goParser = new GoParser()
|
|
40
|
+
const tsFiles: ParsedFile[] = []
|
|
41
|
+
const goFiles: ParsedFile[] = []
|
|
34
42
|
|
|
35
43
|
for (const fp of filePaths) {
|
|
36
44
|
const ext = path.extname(fp)
|
|
37
45
|
if (ext === '.ts' || ext === '.tsx') {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
try {
|
|
47
|
+
const content = await readFile(path.join(projectRoot, fp))
|
|
48
|
+
tsFiles.push(tsParser.parse(fp, content))
|
|
49
|
+
} catch {
|
|
50
|
+
// Skip unreadable files (permissions, binary, etc.) — don't abort the whole parse
|
|
51
|
+
}
|
|
52
|
+
} else if (ext === '.go') {
|
|
53
|
+
try {
|
|
54
|
+
const content = await readFile(path.join(projectRoot, fp))
|
|
55
|
+
goFiles.push(goParser.parse(fp, content))
|
|
56
|
+
} catch {
|
|
57
|
+
// Skip unreadable files
|
|
58
|
+
}
|
|
41
59
|
}
|
|
42
60
|
}
|
|
43
61
|
|
|
44
|
-
// Resolve
|
|
45
|
-
|
|
62
|
+
// Resolve imports per language after all files of that language are parsed
|
|
63
|
+
const resolvedTs = tsParser.resolveImports(tsFiles, projectRoot)
|
|
64
|
+
const resolvedGo = goParser.resolveImports(goFiles, projectRoot)
|
|
65
|
+
|
|
66
|
+
return [...resolvedTs, ...resolvedGo]
|
|
46
67
|
}
|
package/src/parser/types.ts
CHANGED
|
@@ -89,7 +89,7 @@ export interface ParsedGeneric {
|
|
|
89
89
|
/** Everything extracted from a single file */
|
|
90
90
|
export interface ParsedFile {
|
|
91
91
|
path: string // "src/auth/verify.ts"
|
|
92
|
-
language: 'typescript' | 'python'
|
|
92
|
+
language: 'typescript' | 'python' | 'go'
|
|
93
93
|
functions: ParsedFunction[]
|
|
94
94
|
classes: ParsedClass[]
|
|
95
95
|
generics: ParsedGeneric[]
|
|
@@ -530,31 +530,53 @@ export class TypeScriptExtractor {
|
|
|
530
530
|
return [...new Set(calls)] // deduplicate
|
|
531
531
|
}
|
|
532
532
|
|
|
533
|
-
/** Extract the purpose from JSDoc comments or preceding single-line comments
|
|
533
|
+
/** Extract the purpose from JSDoc comments or preceding single-line comments.
|
|
534
|
+
* Falls back to deriving a human-readable sentence from the function name. */
|
|
534
535
|
private extractPurpose(node: ts.Node): string {
|
|
535
536
|
const fullText = this.sourceFile.getFullText()
|
|
536
537
|
const commentRanges = ts.getLeadingCommentRanges(fullText, node.getFullStart())
|
|
537
|
-
if (
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
}
|
|
538
|
+
if (commentRanges && commentRanges.length > 0) {
|
|
539
|
+
const meaningfulLines: string[] = []
|
|
540
|
+
for (const range of commentRanges) {
|
|
541
|
+
const comment = fullText.slice(range.pos, range.end)
|
|
542
|
+
let clean = ''
|
|
543
|
+
if (comment.startsWith('/**') || comment.startsWith('/*')) {
|
|
544
|
+
clean = comment.replace(/[\/\*]/g, '').trim()
|
|
545
|
+
} else if (comment.startsWith('//')) {
|
|
546
|
+
clean = comment.replace(/\/\//g, '').trim()
|
|
547
|
+
}
|
|
548
548
|
|
|
549
|
-
|
|
550
|
-
|
|
549
|
+
// Skip divider lines (lines with 3+ repeated special characters)
|
|
550
|
+
if (/^[─\-_=\*]{3,}$/.test(clean)) continue
|
|
551
|
+
|
|
552
|
+
if (clean) meaningfulLines.push(clean)
|
|
553
|
+
}
|
|
551
554
|
|
|
552
|
-
|
|
555
|
+
// Return the first meaningful line — in JSDoc, the first line is the summary.
|
|
556
|
+
const fromComment = meaningfulLines.length > 0 ? meaningfulLines[0].split('\n')[0].trim() : ''
|
|
557
|
+
if (fromComment) return fromComment
|
|
553
558
|
}
|
|
554
559
|
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
return
|
|
560
|
+
// Fallback: derive a human-readable sentence from the function/identifier name
|
|
561
|
+
const name = this.getNodeName(node)
|
|
562
|
+
return name ? derivePurposeFromName(name) : ''
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Get the identifier name from common declaration node types */
|
|
566
|
+
private getNodeName(node: ts.Node): string {
|
|
567
|
+
if (ts.isFunctionDeclaration(node) && node.name) return node.name.text
|
|
568
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
569
|
+
const parent = node.parent
|
|
570
|
+
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
|
|
571
|
+
return parent.name.text
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) return node.name.text
|
|
575
|
+
if (ts.isConstructorDeclaration(node)) return 'constructor'
|
|
576
|
+
if ((ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
|
|
577
|
+
return (node as any).name.text
|
|
578
|
+
}
|
|
579
|
+
return ''
|
|
558
580
|
}
|
|
559
581
|
|
|
560
582
|
/** Extract edge cases handled (if statements returning early) */
|
|
@@ -679,3 +701,28 @@ export class TypeScriptExtractor {
|
|
|
679
701
|
})
|
|
680
702
|
}
|
|
681
703
|
}
|
|
704
|
+
|
|
705
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Derive a human-readable purpose sentence from a camelCase/PascalCase identifier.
|
|
709
|
+
* Examples:
|
|
710
|
+
* validateJwtToken → "Validate jwt token"
|
|
711
|
+
* buildGraphFromLock → "Build graph from lock"
|
|
712
|
+
* UserRepository → "User repository"
|
|
713
|
+
* parseFiles → "Parse files"
|
|
714
|
+
*/
|
|
715
|
+
function derivePurposeFromName(name: string): string {
|
|
716
|
+
if (!name || name === 'constructor') return ''
|
|
717
|
+
// Split on camelCase/PascalCase boundaries and underscores
|
|
718
|
+
const words = name
|
|
719
|
+
.replace(/_+/g, ' ')
|
|
720
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
721
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
722
|
+
.toLowerCase()
|
|
723
|
+
.split(/\s+/)
|
|
724
|
+
.filter(Boolean)
|
|
725
|
+
if (words.length === 0) return ''
|
|
726
|
+
words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1)
|
|
727
|
+
return words.join(' ')
|
|
728
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
1
3
|
import { BaseParser } from '../base-parser.js'
|
|
2
4
|
import { TypeScriptExtractor } from './ts-extractor.js'
|
|
3
5
|
import { TypeScriptResolver } from './ts-resolver.js'
|
|
@@ -54,7 +56,8 @@ export class TypeScriptParser extends BaseParser {
|
|
|
54
56
|
|
|
55
57
|
/** Resolve all import paths in parsed files to absolute project paths */
|
|
56
58
|
resolveImports(files: ParsedFile[], projectRoot: string): ParsedFile[] {
|
|
57
|
-
const
|
|
59
|
+
const tsConfigPaths = loadTsConfigPaths(projectRoot)
|
|
60
|
+
const resolver = new TypeScriptResolver(projectRoot, tsConfigPaths)
|
|
58
61
|
const allFilePaths = files.map(f => f.path)
|
|
59
62
|
return files.map(file => ({
|
|
60
63
|
...file,
|
|
@@ -66,3 +69,40 @@ export class TypeScriptParser extends BaseParser {
|
|
|
66
69
|
return ['.ts', '.tsx']
|
|
67
70
|
}
|
|
68
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read compilerOptions.paths from the nearest tsconfig.json in projectRoot.
|
|
75
|
+
* Handles baseUrl prefix so aliases like "@/*" → ["src/*"] resolve correctly.
|
|
76
|
+
*/
|
|
77
|
+
function loadTsConfigPaths(projectRoot: string): Record<string, string[]> {
|
|
78
|
+
const candidates = ['tsconfig.json', 'tsconfig.base.json']
|
|
79
|
+
for (const name of candidates) {
|
|
80
|
+
const tsConfigPath = path.join(projectRoot, name)
|
|
81
|
+
try {
|
|
82
|
+
const raw = fs.readFileSync(tsConfigPath, 'utf-8')
|
|
83
|
+
// Strip line comments before JSON.parse (tsconfig allows them)
|
|
84
|
+
const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
85
|
+
let tsConfig: any
|
|
86
|
+
try {
|
|
87
|
+
tsConfig = JSON.parse(stripped)
|
|
88
|
+
} catch {
|
|
89
|
+
// Stripping may have broken a URL (e.g. "https://...") — fall back to raw
|
|
90
|
+
tsConfig = JSON.parse(raw)
|
|
91
|
+
}
|
|
92
|
+
const options = tsConfig.compilerOptions ?? {}
|
|
93
|
+
const rawPaths: Record<string, string[]> = options.paths ?? {}
|
|
94
|
+
if (Object.keys(rawPaths).length === 0) continue
|
|
95
|
+
|
|
96
|
+
const baseUrl: string = options.baseUrl ?? '.'
|
|
97
|
+
// Prefix each target with baseUrl so relative resolution works
|
|
98
|
+
const resolved: Record<string, string[]> = {}
|
|
99
|
+
for (const [alias, targets] of Object.entries(rawPaths)) {
|
|
100
|
+
resolved[alias] = (targets as string[]).map(t =>
|
|
101
|
+
t.startsWith('.') ? path.posix.join(baseUrl, t) : t
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
return resolved
|
|
105
|
+
} catch { /* tsconfig not found or invalid — continue */ }
|
|
106
|
+
}
|
|
107
|
+
return {}
|
|
108
|
+
}
|
package/test-output.txt
ADDED
|
Binary file
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'
|
|
2
|
+
import { AdrManager } from '../src/contract/adr-manager'
|
|
3
|
+
import * as fs from 'node:fs/promises'
|
|
4
|
+
import * as path from 'node:path'
|
|
5
|
+
|
|
6
|
+
describe('AdrManager', () => {
|
|
7
|
+
const TEMP_DIR = path.join(process.cwd(), '.test-temp')
|
|
8
|
+
const CONTRACT_PATH = path.join(TEMP_DIR, 'mikk.json')
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
await fs.mkdir(TEMP_DIR, { recursive: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
await fs.rm(TEMP_DIR, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
// Create a fresh mikk.json for each test
|
|
20
|
+
const initialContract = {
|
|
21
|
+
version: '1.0.0',
|
|
22
|
+
project: { name: 'test', description: 'test', language: 'ts', entryPoints: [] },
|
|
23
|
+
declared: {
|
|
24
|
+
modules: [],
|
|
25
|
+
decisions: [
|
|
26
|
+
{ id: 'ADR-1', title: 'First ADR', reason: 'Because', date: '2024-01-01' }
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
overwrite: { mode: 'never' as const }
|
|
30
|
+
}
|
|
31
|
+
await fs.writeFile(CONTRACT_PATH, JSON.stringify(initialContract))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('lists decisions', async () => {
|
|
35
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
36
|
+
const decisions = await manager.list()
|
|
37
|
+
expect(decisions).toHaveLength(1)
|
|
38
|
+
expect(decisions[0].id).toBe('ADR-1')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('gets a decision by id', async () => {
|
|
42
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
43
|
+
const decision = await manager.get('ADR-1')
|
|
44
|
+
expect(decision?.title).toBe('First ADR')
|
|
45
|
+
|
|
46
|
+
const missing = await manager.get('NOT-FOUND')
|
|
47
|
+
expect(missing).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('adds a new decision', async () => {
|
|
51
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
52
|
+
await manager.add({
|
|
53
|
+
id: 'ADR-2',
|
|
54
|
+
title: 'Second ADR',
|
|
55
|
+
reason: 'Why not',
|
|
56
|
+
date: '2024-01-02'
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const decisions = await manager.list()
|
|
60
|
+
expect(decisions).toHaveLength(2)
|
|
61
|
+
expect(decisions[1].id).toBe('ADR-2')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('fails to add if id already exists', async () => {
|
|
65
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
66
|
+
await expect(manager.add({
|
|
67
|
+
id: 'ADR-1',
|
|
68
|
+
title: 'Duplicate',
|
|
69
|
+
reason: 'Will fail',
|
|
70
|
+
date: '2024-01-03'
|
|
71
|
+
})).rejects.toThrow(/already exists/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('updates an existing decision', async () => {
|
|
75
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
76
|
+
await manager.update('ADR-1', { title: 'Updated Title' })
|
|
77
|
+
|
|
78
|
+
const decision = await manager.get('ADR-1')
|
|
79
|
+
expect(decision?.title).toBe('Updated Title')
|
|
80
|
+
expect(decision?.reason).toBe('Because') // Preserved
|
|
81
|
+
expect(decision?.id).toBe('ADR-1')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('fails to update missing decision', async () => {
|
|
85
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
86
|
+
await expect(manager.update('ADR-99', { title: 'Nope' })).rejects.toThrow(/not found/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('removes a decision', async () => {
|
|
90
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
91
|
+
const success = await manager.remove('ADR-1')
|
|
92
|
+
expect(success).toBe(true)
|
|
93
|
+
|
|
94
|
+
const decisions = await manager.list()
|
|
95
|
+
expect(decisions).toHaveLength(0)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { DeadCodeDetector } from '../src/graph/dead-code-detector'
|
|
3
|
+
import { buildTestGraph, mockFunction } from './helpers'
|
|
4
|
+
import { GraphBuilder } from '../src/graph/graph-builder'
|
|
5
|
+
import type { MikkLock } from '../src/contract/schema'
|
|
6
|
+
|
|
7
|
+
/** Helper to generate a dummy lock file from graph nodes for the detector */
|
|
8
|
+
function generateDummyLock(graphNodes: Map<string, any>): MikkLock {
|
|
9
|
+
const lock: MikkLock = {
|
|
10
|
+
version: '1.0.0',
|
|
11
|
+
generatedAt: new Date().toISOString(),
|
|
12
|
+
generatorVersion: '1.0.0',
|
|
13
|
+
projectRoot: '/test',
|
|
14
|
+
syncState: {
|
|
15
|
+
status: 'clean',
|
|
16
|
+
lastSyncAt: new Date().toISOString(),
|
|
17
|
+
lockHash: 'x',
|
|
18
|
+
contractHash: 'x',
|
|
19
|
+
},
|
|
20
|
+
graph: {
|
|
21
|
+
nodes: 0,
|
|
22
|
+
edges: 0,
|
|
23
|
+
rootHash: 'x',
|
|
24
|
+
},
|
|
25
|
+
functions: {},
|
|
26
|
+
classes: {},
|
|
27
|
+
files: {},
|
|
28
|
+
modules: {},
|
|
29
|
+
routes: [],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const [id, node] of graphNodes.entries()) {
|
|
33
|
+
if (node.type === 'function') {
|
|
34
|
+
const name = node.label
|
|
35
|
+
const file = node.file
|
|
36
|
+
// We need to populate `calledBy` for transitive liveness checks
|
|
37
|
+
|
|
38
|
+
lock.functions[id] = {
|
|
39
|
+
id,
|
|
40
|
+
name,
|
|
41
|
+
file,
|
|
42
|
+
moduleId: node.moduleId ?? 'unknown',
|
|
43
|
+
startLine: 1, endLine: 10, hash: 'x',
|
|
44
|
+
calls: [],
|
|
45
|
+
calledBy: [],
|
|
46
|
+
isExported: node.metadata?.isExported ?? false,
|
|
47
|
+
isAsync: false,
|
|
48
|
+
params: [],
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return lock
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('DeadCodeDetector', () => {
|
|
56
|
+
|
|
57
|
+
it('detects uncalled functions', () => {
|
|
58
|
+
// A calls B. C is isolated.
|
|
59
|
+
const graph = buildTestGraph([
|
|
60
|
+
['A', 'B'],
|
|
61
|
+
['B', 'nothing'],
|
|
62
|
+
['C', 'nothing']
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
const lock = generateDummyLock(graph.nodes)
|
|
66
|
+
|
|
67
|
+
// Add calledBy relationships since GraphBuilder test helper doesn't do reverse
|
|
68
|
+
// lookups for the lock structure (it only does it for the graph)
|
|
69
|
+
Object.values(lock.functions).forEach(fn => {
|
|
70
|
+
const inEdges = graph.inEdges.get(fn.id) ?? []
|
|
71
|
+
fn.calledBy = inEdges.filter(e => e.type === 'calls').map(e => e.source)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const detector = new DeadCodeDetector(graph, lock)
|
|
75
|
+
const result = detector.detect()
|
|
76
|
+
|
|
77
|
+
expect(result.deadFunctions.map(f => f.name)).toContain('C')
|
|
78
|
+
expect(result.deadFunctions.map(f => f.name)).toContain('A') // A has no callers, so it is dead code
|
|
79
|
+
expect(result.deadFunctions.map(f => f.name)).not.toContain('B') // B has a caller
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('exempts exported functions', () => {
|
|
83
|
+
const graph = buildTestGraph([
|
|
84
|
+
['D', 'nothing']
|
|
85
|
+
])
|
|
86
|
+
graph.nodes.get('fn:src/D.ts:D')!.metadata!.isExported = true
|
|
87
|
+
|
|
88
|
+
const lock = generateDummyLock(graph.nodes)
|
|
89
|
+
|
|
90
|
+
const detector = new DeadCodeDetector(graph, lock)
|
|
91
|
+
const result = detector.detect()
|
|
92
|
+
|
|
93
|
+
expect(result.deadFunctions).toHaveLength(0) // D is exported
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('exempts entry point name patterns', () => {
|
|
97
|
+
const graph = buildTestGraph([
|
|
98
|
+
['main', 'nothing'],
|
|
99
|
+
['loginHandler', 'nothing'],
|
|
100
|
+
['useAuth', 'nothing'] // React hook
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
const lock = generateDummyLock(graph.nodes)
|
|
104
|
+
|
|
105
|
+
const detector = new DeadCodeDetector(graph, lock)
|
|
106
|
+
const result = detector.detect()
|
|
107
|
+
|
|
108
|
+
expect(result.deadFunctions).toHaveLength(0) // All match exempt patterns
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('exempts functions called by exported functions in the same file', () => {
|
|
112
|
+
const graph = buildTestGraph([
|
|
113
|
+
['ExportedFn', 'InternalHelper'],
|
|
114
|
+
['InternalHelper', 'nothing']
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
// Both in same file by default from buildTestGraph
|
|
118
|
+
graph.nodes.get('fn:src/ExportedFn.ts:ExportedFn')!.metadata!.isExported = true
|
|
119
|
+
|
|
120
|
+
// In the lock, we must place them in the SAME file manually because buildTestGraph
|
|
121
|
+
// puts them in separate files based on name
|
|
122
|
+
const lock = generateDummyLock(graph.nodes)
|
|
123
|
+
lock.functions['fn:src/ExportedFn.ts:ExportedFn'].file = 'src/shared.ts'
|
|
124
|
+
lock.functions['fn:src/InternalHelper.ts:InternalHelper'].file = 'src/shared.ts'
|
|
125
|
+
|
|
126
|
+
// Set up the calledBy relation
|
|
127
|
+
lock.functions['fn:src/InternalHelper.ts:InternalHelper'].calledBy = ['fn:src/ExportedFn.ts:ExportedFn']
|
|
128
|
+
|
|
129
|
+
const detector = new DeadCodeDetector(graph, lock)
|
|
130
|
+
const result = detector.detect()
|
|
131
|
+
|
|
132
|
+
expect(result.deadFunctions).toHaveLength(0) // InternalHelper is called by exported fn in same file
|
|
133
|
+
})
|
|
134
|
+
})
|