agentmap 0.7.1 → 0.9.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/CHANGELOG.md +96 -0
- package/README.md +24 -0
- package/dist/cli.js +44 -12
- package/dist/cli.js.map +1 -1
- package/dist/extract/definitions.js +12 -12
- package/dist/extract/definitions.js.map +1 -1
- package/dist/extract/definitions.test.js +30 -259
- package/dist/extract/definitions.test.js.map +1 -1
- package/dist/extract/git-status.d.ts +11 -4
- package/dist/extract/git-status.d.ts.map +1 -1
- package/dist/extract/git-status.js +21 -16
- package/dist/extract/git-status.js.map +1 -1
- package/dist/extract/markdown.js +1 -1
- package/dist/extract/markdown.test.js +3 -3
- package/dist/extract/markdown.test.js.map +1 -1
- package/dist/extract/marker.js +1 -1
- package/dist/extract/marker.test.js +4 -4
- package/dist/extract/marker.test.js.map +1 -1
- package/dist/extract/submodules.d.ts +12 -0
- package/dist/extract/submodules.d.ts.map +1 -0
- package/dist/extract/submodules.js +234 -0
- package/dist/extract/submodules.js.map +1 -0
- package/dist/extract/submodules.test.d.ts +2 -0
- package/dist/extract/submodules.test.d.ts.map +1 -0
- package/dist/extract/submodules.test.js +84 -0
- package/dist/extract/submodules.test.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -9
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +10 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +41 -0
- package/dist/logger.js.map +1 -0
- package/dist/map/builder.d.ts +3 -3
- package/dist/map/builder.d.ts.map +1 -1
- package/dist/map/builder.js +59 -9
- package/dist/map/builder.js.map +1 -1
- package/dist/map/builder.test.d.ts +2 -0
- package/dist/map/builder.test.d.ts.map +1 -0
- package/dist/map/builder.test.js +66 -0
- package/dist/map/builder.test.js.map +1 -0
- package/dist/map/truncate.d.ts +7 -3
- package/dist/map/truncate.d.ts.map +1 -1
- package/dist/map/truncate.js +90 -9
- package/dist/map/truncate.js.map +1 -1
- package/dist/map/yaml.d.ts.map +1 -1
- package/dist/map/yaml.js +13 -3
- package/dist/map/yaml.js.map +1 -1
- package/dist/scanner.d.ts +9 -2
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +172 -49
- package/dist/scanner.js.map +1 -1
- package/dist/scanner.test.d.ts +2 -0
- package/dist/scanner.test.d.ts.map +1 -0
- package/dist/scanner.test.js +84 -0
- package/dist/scanner.test.js.map +1 -0
- package/dist/test-helpers/git-test-helpers.d.ts +13 -0
- package/dist/test-helpers/git-test-helpers.d.ts.map +1 -0
- package/dist/test-helpers/git-test-helpers.js +48 -0
- package/dist/test-helpers/git-test-helpers.js.map +1 -0
- package/dist/types.d.ts +42 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -3
- package/src/cli.ts +164 -0
- package/src/extract/definitions.test.ts +2040 -0
- package/src/extract/definitions.ts +379 -0
- package/src/extract/git-status.test.ts +507 -0
- package/src/extract/git-status.ts +359 -0
- package/src/extract/markdown.test.ts +159 -0
- package/src/extract/markdown.ts +202 -0
- package/src/extract/marker.test.ts +566 -0
- package/src/extract/marker.ts +398 -0
- package/src/extract/submodules.test.ts +95 -0
- package/src/extract/submodules.ts +269 -0
- package/src/extract/utils.ts +27 -0
- package/src/index.ts +106 -0
- package/src/languages/cpp.ts +129 -0
- package/src/languages/go.ts +72 -0
- package/src/languages/index.ts +231 -0
- package/src/languages/javascript.ts +33 -0
- package/src/languages/python.ts +41 -0
- package/src/languages/rust.ts +72 -0
- package/src/languages/typescript.ts +74 -0
- package/src/languages/zig.ts +106 -0
- package/src/logger.ts +55 -0
- package/src/map/builder.test.ts +72 -0
- package/src/map/builder.ts +175 -0
- package/src/map/truncate.ts +188 -0
- package/src/map/yaml.ts +66 -0
- package/src/parser/index.ts +53 -0
- package/src/parser/languages.ts +64 -0
- package/src/scanner.test.ts +95 -0
- package/src/scanner.ts +364 -0
- package/src/test-helpers/git-test-helpers.ts +62 -0
- package/src/types.ts +191 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Build the nested map object from file results.
|
|
2
|
+
|
|
3
|
+
import { basename } from 'path'
|
|
4
|
+
import type { Definition, FileEntry, FileResult, FileDiffStats, MapNode, SubmoduleInfo, SubmoduleNode } from '../types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a nested map object from file results and submodule info
|
|
8
|
+
*/
|
|
9
|
+
export function buildMap(results: FileResult[], rootName: string, submodules?: SubmoduleInfo[]): MapNode {
|
|
10
|
+
const root: MapNode = {}
|
|
11
|
+
|
|
12
|
+
// Insert submodule entries
|
|
13
|
+
if (submodules) {
|
|
14
|
+
for (const sub of submodules) {
|
|
15
|
+
insertSubmodule(root, sub)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const result of results) {
|
|
20
|
+
insertFile(root, result)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Wrap in root name
|
|
24
|
+
return { [rootName]: root }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Format file diff stats as a string like "+15-3" or "+15" or "-3"
|
|
29
|
+
*/
|
|
30
|
+
function formatFileDiff(diff: FileDiffStats): string {
|
|
31
|
+
const parts: string[] = []
|
|
32
|
+
if (diff.added > 0) {
|
|
33
|
+
parts.push(`+${diff.added}`)
|
|
34
|
+
}
|
|
35
|
+
if (diff.deleted > 0) {
|
|
36
|
+
parts.push(`-${diff.deleted}`)
|
|
37
|
+
}
|
|
38
|
+
return parts.join('')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Format a definition as a string like "exported fn updated (+5-2)"
|
|
43
|
+
* No commas, exported/extern before the type, "fn" instead of "function".
|
|
44
|
+
*/
|
|
45
|
+
function formatDefinition(def: Definition): string {
|
|
46
|
+
const parts: string[] = []
|
|
47
|
+
|
|
48
|
+
if (def.exported) {
|
|
49
|
+
parts.push('exported')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (def.extern) {
|
|
53
|
+
parts.push('extern')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
parts.push(def.type === 'function' ? 'fn' : def.type)
|
|
57
|
+
|
|
58
|
+
// Add diff info if present
|
|
59
|
+
if (def.diff) {
|
|
60
|
+
const diffParts: string[] = []
|
|
61
|
+
if (def.diff.added > 0) {
|
|
62
|
+
diffParts.push(`+${def.diff.added}`)
|
|
63
|
+
}
|
|
64
|
+
if (def.diff.deleted > 0) {
|
|
65
|
+
diffParts.push(`-${def.diff.deleted}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (diffParts.length > 0) {
|
|
69
|
+
parts.push(`${def.diff.status} (${diffParts.join('')})`)
|
|
70
|
+
} else {
|
|
71
|
+
parts.push(def.diff.status)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return parts.join(' ')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Insert a file result into the map at its path location
|
|
80
|
+
*/
|
|
81
|
+
function insertFile(root: MapNode, result: FileResult): void {
|
|
82
|
+
const parts = result.relativePath.split('/')
|
|
83
|
+
let current = root
|
|
84
|
+
|
|
85
|
+
// Navigate/create directory structure
|
|
86
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
87
|
+
const dir = parts[i]
|
|
88
|
+
if (!current[dir]) {
|
|
89
|
+
current[dir] = {}
|
|
90
|
+
}
|
|
91
|
+
current = current[dir] as MapNode
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Create file entry
|
|
95
|
+
const filename = parts[parts.length - 1]
|
|
96
|
+
|
|
97
|
+
// Duplicate files: just point to the original, no defs
|
|
98
|
+
if (result.duplicateOf) {
|
|
99
|
+
current[filename] = { description: `duplicate of ${result.duplicateOf}` }
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const entry: FileEntry = {}
|
|
104
|
+
|
|
105
|
+
if (result.description) {
|
|
106
|
+
entry.description = result.description
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (result.diff) {
|
|
110
|
+
entry.diff = formatFileDiff(result.diff)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (result.definitions.length > 0) {
|
|
114
|
+
entry.defs = {}
|
|
115
|
+
for (const def of result.definitions) {
|
|
116
|
+
entry.defs[def.name] = formatDefinition(def)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
current[filename] = entry
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Insert a submodule entry into the map at its path location.
|
|
125
|
+
* Format: "branch @ sha" or "detached @ sha" or "uninitialized @ sha"
|
|
126
|
+
*/
|
|
127
|
+
function insertSubmodule(root: MapNode, sub: SubmoduleInfo): void {
|
|
128
|
+
const parts = sub.path.split('/')
|
|
129
|
+
let current = root
|
|
130
|
+
|
|
131
|
+
// Navigate/create directory structure for nested submodule paths
|
|
132
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
133
|
+
const dir = parts[i]
|
|
134
|
+
if (!current[dir]) {
|
|
135
|
+
current[dir] = {}
|
|
136
|
+
}
|
|
137
|
+
current = current[dir] as MapNode
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Build the submodule label
|
|
141
|
+
let label: string
|
|
142
|
+
if (!sub.initialized) {
|
|
143
|
+
label = `uninitialized @ ${sub.commit}`
|
|
144
|
+
} else if (sub.branch) {
|
|
145
|
+
label = `${sub.branch} @ ${sub.commit}`
|
|
146
|
+
} else {
|
|
147
|
+
label = `detached @ ${sub.commit}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const name = parts[parts.length - 1]
|
|
151
|
+
const existing = current[name]
|
|
152
|
+
const entry: SubmoduleNode = existing && typeof existing === 'object'
|
|
153
|
+
? existing as SubmoduleNode
|
|
154
|
+
: { submodule: label }
|
|
155
|
+
|
|
156
|
+
entry.submodule = label
|
|
157
|
+
if (sub.dirty) {
|
|
158
|
+
entry.dirty = 'modified'
|
|
159
|
+
} else {
|
|
160
|
+
delete entry.dirty
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
current[name] = entry
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get the root name from a directory path
|
|
168
|
+
*/
|
|
169
|
+
export function getRootName(dir: string): string {
|
|
170
|
+
// Handle trailing slashes
|
|
171
|
+
const cleaned = dir.replace(/\/+$/, '')
|
|
172
|
+
// Get basename, or use 'root' for current directory
|
|
173
|
+
const name = basename(cleaned)
|
|
174
|
+
return name === '.' || name === '' ? 'root' : name
|
|
175
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Truncate definitions and descriptions in map to limit context size.
|
|
2
|
+
|
|
3
|
+
import type { DefEntry, FileEntry, MapNode, SubmoduleEntry, SubmoduleNode } from '../types.js'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_MAX_DEFS = 25
|
|
6
|
+
const DEFAULT_MAX_DESC_CHARS = 300
|
|
7
|
+
|
|
8
|
+
export interface TruncateOptions {
|
|
9
|
+
maxDefs?: number
|
|
10
|
+
maxDescChars?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if a def value indicates exported or extern
|
|
15
|
+
*/
|
|
16
|
+
function isExportedDef(value: string): boolean {
|
|
17
|
+
return value.includes('exported') || value.includes('extern')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Truncate description by character count, rounding up to include the full line
|
|
22
|
+
* that crosses the limit (in excess).
|
|
23
|
+
*/
|
|
24
|
+
function truncateDescriptionByChars(description: string, maxChars: number): string {
|
|
25
|
+
if (description.length <= maxChars) return description
|
|
26
|
+
|
|
27
|
+
const lines = description.split('\n')
|
|
28
|
+
|
|
29
|
+
// Single-line fallback: hard-truncate at maxChars
|
|
30
|
+
if (lines.length === 1) {
|
|
31
|
+
return description.slice(0, maxChars) + '...'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let charCount = 0
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < lines.length; i++) {
|
|
37
|
+
// +1 for the newline separator (except first line)
|
|
38
|
+
charCount += lines[i].length + (i > 0 ? 1 : 0)
|
|
39
|
+
if (charCount >= maxChars) {
|
|
40
|
+
// Include this line (in excess), then stop
|
|
41
|
+
const kept = lines.slice(0, i + 1)
|
|
42
|
+
const remaining = lines.length - kept.length
|
|
43
|
+
if (remaining > 0) {
|
|
44
|
+
kept.push(`... and ${remaining} more lines`)
|
|
45
|
+
}
|
|
46
|
+
return kept.join('\n')
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return description
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if a value is a FileEntry (has description or defs)
|
|
55
|
+
*/
|
|
56
|
+
function isFileEntry(value: unknown): value is FileEntry {
|
|
57
|
+
if (!value || typeof value !== 'object') return false
|
|
58
|
+
const obj = value as Record<string, unknown>
|
|
59
|
+
return 'description' in obj || 'defs' in obj
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if a value is a SubmoduleEntry (has submodule key)
|
|
64
|
+
*/
|
|
65
|
+
function isSubmoduleEntry(value: unknown): value is SubmoduleEntry {
|
|
66
|
+
if (!value || typeof value !== 'object') return false
|
|
67
|
+
return 'submodule' in (value as Record<string, unknown>)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Recursively truncate a submodule node while preserving its metadata keys.
|
|
72
|
+
*/
|
|
73
|
+
function truncateSubmoduleNode(entry: SubmoduleNode, options: TruncateOptions): SubmoduleNode {
|
|
74
|
+
const result: SubmoduleNode = { submodule: entry.submodule }
|
|
75
|
+
|
|
76
|
+
if (entry.dirty) {
|
|
77
|
+
result.dirty = entry.dirty
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
81
|
+
if (key === 'submodule' || key === 'dirty') {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (isFileEntry(value)) {
|
|
86
|
+
result[key] = truncateFileEntry(value, options)
|
|
87
|
+
} else if (isSubmoduleEntry(value)) {
|
|
88
|
+
result[key] = truncateSubmoduleNode(value as SubmoduleNode, options)
|
|
89
|
+
} else if (value && typeof value === 'object') {
|
|
90
|
+
result[key] = truncateMap(value as MapNode, options)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Truncate a file entry: cap description by chars and defs by count.
|
|
99
|
+
*/
|
|
100
|
+
function truncateFileEntry(entry: FileEntry, options: TruncateOptions): FileEntry {
|
|
101
|
+
let result = entry
|
|
102
|
+
|
|
103
|
+
// Truncate description by character count
|
|
104
|
+
const maxDescChars = options.maxDescChars ?? DEFAULT_MAX_DESC_CHARS
|
|
105
|
+
if (result.description && result.description.length > maxDescChars) {
|
|
106
|
+
result = { ...result, description: truncateDescriptionByChars(result.description, maxDescChars) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Truncate defs
|
|
110
|
+
result = truncateDefs(result, options.maxDefs)
|
|
111
|
+
return result
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Truncate definitions in a file entry to maxDefs
|
|
116
|
+
* If file has exported symbols, shows only exports field instead
|
|
117
|
+
* Otherwise uses current truncation behavior
|
|
118
|
+
*/
|
|
119
|
+
export function truncateDefs(entry: FileEntry, maxDefsOrOptions?: number | TruncateOptions): FileEntry {
|
|
120
|
+
const maxDefs = typeof maxDefsOrOptions === 'number'
|
|
121
|
+
? maxDefsOrOptions
|
|
122
|
+
: (maxDefsOrOptions?.maxDefs ?? DEFAULT_MAX_DEFS)
|
|
123
|
+
|
|
124
|
+
if (!entry.defs) return entry
|
|
125
|
+
|
|
126
|
+
const defNames = Object.keys(entry.defs)
|
|
127
|
+
if (defNames.length <= maxDefs) return entry
|
|
128
|
+
|
|
129
|
+
// Filter to only exported/extern definitions
|
|
130
|
+
const exportedNames = defNames.filter(name => isExportedDef(entry.defs![name]))
|
|
131
|
+
|
|
132
|
+
// If we have exports, use exports field instead of defs
|
|
133
|
+
if (exportedNames.length > 0) {
|
|
134
|
+
const exports: DefEntry = {}
|
|
135
|
+
const maxExports = Math.min(exportedNames.length, maxDefs)
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < maxExports; i++) {
|
|
138
|
+
const name = exportedNames[i]
|
|
139
|
+
exports[name] = entry.defs[name]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Add marker if exports were also truncated
|
|
143
|
+
if (exportedNames.length > maxDefs) {
|
|
144
|
+
const remaining = exportedNames.length - maxDefs
|
|
145
|
+
exports[`__more_${remaining}__`] = `${remaining} more exports`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Return with exports instead of defs
|
|
149
|
+
const { defs, ...rest } = entry
|
|
150
|
+
return { ...rest, exports }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// No exports found - use current truncation behavior
|
|
154
|
+
const truncated: DefEntry = {}
|
|
155
|
+
for (let i = 0; i < maxDefs; i++) {
|
|
156
|
+
const name = defNames[i]
|
|
157
|
+
truncated[name] = entry.defs[name]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const remaining = defNames.length - maxDefs
|
|
161
|
+
// Add marker that will be converted to comment
|
|
162
|
+
truncated[`__more_${remaining}__`] = `${remaining} more definitions`
|
|
163
|
+
|
|
164
|
+
return { ...entry, defs: truncated }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Recursively truncate defs and descriptions in all files in the map
|
|
169
|
+
*/
|
|
170
|
+
export function truncateMap(node: MapNode, maxDefsOrOptions?: number | TruncateOptions): MapNode {
|
|
171
|
+
const options: TruncateOptions = typeof maxDefsOrOptions === 'number'
|
|
172
|
+
? { maxDefs: maxDefsOrOptions }
|
|
173
|
+
: (maxDefsOrOptions ?? {})
|
|
174
|
+
|
|
175
|
+
const result: MapNode = {}
|
|
176
|
+
|
|
177
|
+
for (const [key, value] of Object.entries(node)) {
|
|
178
|
+
if (isFileEntry(value)) {
|
|
179
|
+
result[key] = truncateFileEntry(value, options)
|
|
180
|
+
} else if (isSubmoduleEntry(value)) {
|
|
181
|
+
result[key] = truncateSubmoduleNode(value as SubmoduleNode, options)
|
|
182
|
+
} else if (value && typeof value === 'object') {
|
|
183
|
+
result[key] = truncateMap(value as MapNode, options)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return result
|
|
188
|
+
}
|
package/src/map/yaml.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Format map object to YAML string.
|
|
2
|
+
|
|
3
|
+
import yaml from 'js-yaml'
|
|
4
|
+
import type { MapNode } from '../types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if a key is a README file (case-insensitive)
|
|
8
|
+
*/
|
|
9
|
+
function isReadme(key: string): boolean {
|
|
10
|
+
const lower = key.toLowerCase()
|
|
11
|
+
return lower === 'readme.md' || lower === 'readme'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Custom key sorter: description first, then submodule/dirty, then diff, then defs/exports, then README files, then alphabetical
|
|
16
|
+
*/
|
|
17
|
+
function sortKeys(a: string, b: string): number {
|
|
18
|
+
// description always first
|
|
19
|
+
if (a === 'description') return -1
|
|
20
|
+
if (b === 'description') return 1
|
|
21
|
+
// submodule second (for submodule entries)
|
|
22
|
+
if (a === 'submodule') return -1
|
|
23
|
+
if (b === 'submodule') return 1
|
|
24
|
+
// dirty third (for submodule entries)
|
|
25
|
+
if (a === 'dirty') return -1
|
|
26
|
+
if (b === 'dirty') return 1
|
|
27
|
+
// diff fourth
|
|
28
|
+
if (a === 'diff') return -1
|
|
29
|
+
if (b === 'diff') return 1
|
|
30
|
+
// defs/exports fifth
|
|
31
|
+
if (a === 'defs' || a === 'exports') return -1
|
|
32
|
+
if (b === 'defs' || b === 'exports') return 1
|
|
33
|
+
// README files come before other files
|
|
34
|
+
const aIsReadme = isReadme(a)
|
|
35
|
+
const bIsReadme = isReadme(b)
|
|
36
|
+
if (aIsReadme && !bIsReadme) return -1
|
|
37
|
+
if (bIsReadme && !aIsReadme) return 1
|
|
38
|
+
// alphabetical for everything else
|
|
39
|
+
return a.localeCompare(b)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Convert __more_N__ markers to YAML comments
|
|
44
|
+
*/
|
|
45
|
+
function markersToComments(yamlStr: string): string {
|
|
46
|
+
return yamlStr.replace(
|
|
47
|
+
/^(\s*)__more_(\d+)__: (\d+ more (?:definitions|exports))$/gm,
|
|
48
|
+
'$1# ... $3'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Convert map object to YAML string
|
|
54
|
+
* Automatically converts truncation markers to comments
|
|
55
|
+
*/
|
|
56
|
+
export function toYaml(map: MapNode): string {
|
|
57
|
+
const yamlStr = yaml.dump(map, {
|
|
58
|
+
indent: 2,
|
|
59
|
+
lineWidth: -1, // Don't wrap lines
|
|
60
|
+
noRefs: true, // Don't use YAML references
|
|
61
|
+
sortKeys, // Custom ordering: description first
|
|
62
|
+
quotingType: '"',
|
|
63
|
+
forceQuotes: false,
|
|
64
|
+
})
|
|
65
|
+
return markersToComments(yamlStr)
|
|
66
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Tree-sitter parser initialization and code parsing.
|
|
2
|
+
|
|
3
|
+
import Parser from 'web-tree-sitter'
|
|
4
|
+
import type { Language, SyntaxTree } from '../types.js'
|
|
5
|
+
import { loadGrammar } from './languages.js'
|
|
6
|
+
|
|
7
|
+
let initialized = false
|
|
8
|
+
let sharedParser: Parser | null = null
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Initialize the tree-sitter parser
|
|
12
|
+
*/
|
|
13
|
+
export async function initParser(): Promise<void> {
|
|
14
|
+
if (initialized) return
|
|
15
|
+
await Parser.init()
|
|
16
|
+
initialized = true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get the shared parser instance
|
|
21
|
+
*/
|
|
22
|
+
async function getParser(): Promise<Parser> {
|
|
23
|
+
if (sharedParser) return sharedParser
|
|
24
|
+
await initParser()
|
|
25
|
+
sharedParser = new Parser()
|
|
26
|
+
return sharedParser
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse source code and return the syntax tree
|
|
31
|
+
*/
|
|
32
|
+
export async function parseCode(
|
|
33
|
+
code: string,
|
|
34
|
+
language: Language
|
|
35
|
+
): Promise<SyntaxTree> {
|
|
36
|
+
const parser = await getParser()
|
|
37
|
+
const grammar = await loadGrammar(language)
|
|
38
|
+
parser.setLanguage(grammar)
|
|
39
|
+
return parser.parse(code)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reset the parser (for testing)
|
|
44
|
+
*/
|
|
45
|
+
export function resetParser(): void {
|
|
46
|
+
if (sharedParser) {
|
|
47
|
+
sharedParser.delete()
|
|
48
|
+
sharedParser = null
|
|
49
|
+
}
|
|
50
|
+
initialized = false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { detectLanguage, loadGrammar, LANGUAGE_EXTENSIONS } from './languages.js'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Language detection and grammar loading for tree-sitter.
|
|
2
|
+
|
|
3
|
+
import Parser from 'web-tree-sitter'
|
|
4
|
+
import type { Language } from '../types.js'
|
|
5
|
+
import { createRequire } from 'module'
|
|
6
|
+
import { LANGUAGE_EXTENSIONS, GRAMMAR_PATHS } from '../languages/index.js'
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url)
|
|
9
|
+
|
|
10
|
+
// Re-export for backwards compatibility
|
|
11
|
+
export { LANGUAGE_EXTENSIONS }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Detect language from file path extension
|
|
15
|
+
*/
|
|
16
|
+
export function detectLanguage(filepath: string): Language | null {
|
|
17
|
+
const ext = filepath.slice(filepath.lastIndexOf('.'))
|
|
18
|
+
return LANGUAGE_EXTENSIONS[ext] ?? null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the WASM grammar path for a language
|
|
23
|
+
*/
|
|
24
|
+
function getGrammarPath(language: Language): string {
|
|
25
|
+
return require.resolve(GRAMMAR_PATHS[language])
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cache for loaded grammars
|
|
30
|
+
*/
|
|
31
|
+
const grammarCache = new Map<Language, Parser.Language>()
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ensure Parser is initialized before loading grammars
|
|
35
|
+
*/
|
|
36
|
+
let parserInitialized = false
|
|
37
|
+
async function ensureParserInit(): Promise<void> {
|
|
38
|
+
if (!parserInitialized) {
|
|
39
|
+
await Parser.init()
|
|
40
|
+
parserInitialized = true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Load a tree-sitter grammar for the given language
|
|
46
|
+
*/
|
|
47
|
+
export async function loadGrammar(language: Language): Promise<Parser.Language> {
|
|
48
|
+
await ensureParserInit()
|
|
49
|
+
|
|
50
|
+
const cached = grammarCache.get(language)
|
|
51
|
+
if (cached) return cached
|
|
52
|
+
|
|
53
|
+
const path = getGrammarPath(language)
|
|
54
|
+
const grammar = await Parser.Language.load(path)
|
|
55
|
+
grammarCache.set(language, grammar)
|
|
56
|
+
return grammar
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Clear grammar cache (for testing)
|
|
61
|
+
*/
|
|
62
|
+
export function clearGrammarCache(): void {
|
|
63
|
+
grammarCache.clear()
|
|
64
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Tests for recursive scanning across initialized git submodules.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test'
|
|
4
|
+
import { scanDirectory } from './scanner.js'
|
|
5
|
+
import { addSubmodule, commitAll, createRepo, deinitSubmodule, updateSubmodulesRecursive, writeTrackedFile } from './test-helpers/git-test-helpers.js'
|
|
6
|
+
|
|
7
|
+
describe('scanDirectory submodule recursion', () => {
|
|
8
|
+
test('includes files from initialized nested submodules under prefixed paths', async () => {
|
|
9
|
+
const nestedRepo = createRepo('agentmap-submodule-nested-')
|
|
10
|
+
writeTrackedFile(nestedRepo, 'README.md', '# Nested repo\n\nNested submodule README.')
|
|
11
|
+
commitAll(nestedRepo, 'Add nested repo README')
|
|
12
|
+
|
|
13
|
+
const childRepo = createRepo('agentmap-submodule-child-')
|
|
14
|
+
writeTrackedFile(childRepo, 'README.md', '# Child repo\n\nChild submodule README.')
|
|
15
|
+
commitAll(childRepo, 'Add child repo README')
|
|
16
|
+
addSubmodule(childRepo, nestedRepo.dir, 'deps/nested-lib')
|
|
17
|
+
commitAll(childRepo, 'Add nested submodule')
|
|
18
|
+
|
|
19
|
+
const rootRepo = createRepo('agentmap-submodule-root-')
|
|
20
|
+
writeTrackedFile(rootRepo, 'README.md', '# Root repo\n\nRoot repo README.')
|
|
21
|
+
commitAll(rootRepo, 'Add root README')
|
|
22
|
+
addSubmodule(rootRepo, childRepo.dir, 'vendor/child-lib')
|
|
23
|
+
commitAll(rootRepo, 'Add child submodule')
|
|
24
|
+
updateSubmodulesRecursive(rootRepo)
|
|
25
|
+
|
|
26
|
+
const result = await scanDirectory({ dir: rootRepo.dir })
|
|
27
|
+
|
|
28
|
+
expect(result.files.map(file => file.relativePath).sort()).toMatchInlineSnapshot(`
|
|
29
|
+
[
|
|
30
|
+
"README.md",
|
|
31
|
+
"vendor/child-lib/README.md",
|
|
32
|
+
"vendor/child-lib/deps/nested-lib/README.md",
|
|
33
|
+
]
|
|
34
|
+
`)
|
|
35
|
+
|
|
36
|
+
expect(result.submodules.map(submodule => ({
|
|
37
|
+
path: submodule.path,
|
|
38
|
+
initialized: submodule.initialized,
|
|
39
|
+
dirty: submodule.dirty,
|
|
40
|
+
commitLength: submodule.commit.length,
|
|
41
|
+
}))).toMatchInlineSnapshot(`
|
|
42
|
+
[
|
|
43
|
+
{
|
|
44
|
+
"commitLength": 7,
|
|
45
|
+
"dirty": false,
|
|
46
|
+
"initialized": true,
|
|
47
|
+
"path": "vendor/child-lib",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"commitLength": 7,
|
|
51
|
+
"dirty": false,
|
|
52
|
+
"initialized": true,
|
|
53
|
+
"path": "vendor/child-lib/deps/nested-lib",
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
`)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('keeps uninitialized submodules as metadata-only nodes', async () => {
|
|
60
|
+
const childRepo = createRepo('agentmap-submodule-uninit-child-')
|
|
61
|
+
writeTrackedFile(childRepo, 'README.md', '# Child repo\n\nChild submodule README.')
|
|
62
|
+
commitAll(childRepo, 'Add child repo README')
|
|
63
|
+
|
|
64
|
+
const rootRepo = createRepo('agentmap-submodule-uninit-root-')
|
|
65
|
+
writeTrackedFile(rootRepo, 'README.md', '# Root repo\n\nRoot repo README.')
|
|
66
|
+
commitAll(rootRepo, 'Add root README')
|
|
67
|
+
addSubmodule(rootRepo, childRepo.dir, 'vendor/child-lib')
|
|
68
|
+
commitAll(rootRepo, 'Add child submodule')
|
|
69
|
+
deinitSubmodule(rootRepo, 'vendor/child-lib')
|
|
70
|
+
|
|
71
|
+
const result = await scanDirectory({ dir: rootRepo.dir })
|
|
72
|
+
|
|
73
|
+
expect(result.files.map(file => file.relativePath).sort()).toMatchInlineSnapshot(`
|
|
74
|
+
[
|
|
75
|
+
"README.md",
|
|
76
|
+
]
|
|
77
|
+
`)
|
|
78
|
+
|
|
79
|
+
expect(result.submodules.map(submodule => ({
|
|
80
|
+
path: submodule.path,
|
|
81
|
+
initialized: submodule.initialized,
|
|
82
|
+
dirty: submodule.dirty,
|
|
83
|
+
commitLength: submodule.commit.length,
|
|
84
|
+
}))).toMatchInlineSnapshot(`
|
|
85
|
+
[
|
|
86
|
+
{
|
|
87
|
+
"commitLength": 7,
|
|
88
|
+
"dirty": false,
|
|
89
|
+
"initialized": false,
|
|
90
|
+
"path": "vendor/child-lib",
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
`)
|
|
94
|
+
})
|
|
95
|
+
})
|