agentmap 0.8.0 → 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.
Files changed (83) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +24 -0
  3. package/dist/cli.js +37 -12
  4. package/dist/cli.js.map +1 -1
  5. package/dist/extract/definitions.js +12 -12
  6. package/dist/extract/definitions.js.map +1 -1
  7. package/dist/extract/definitions.test.js +30 -259
  8. package/dist/extract/definitions.test.js.map +1 -1
  9. package/dist/extract/git-status.d.ts +7 -2
  10. package/dist/extract/git-status.d.ts.map +1 -1
  11. package/dist/extract/git-status.js +12 -18
  12. package/dist/extract/git-status.js.map +1 -1
  13. package/dist/extract/markdown.js +1 -1
  14. package/dist/extract/markdown.test.js +3 -3
  15. package/dist/extract/markdown.test.js.map +1 -1
  16. package/dist/extract/marker.js +1 -1
  17. package/dist/extract/marker.test.js +4 -4
  18. package/dist/extract/marker.test.js.map +1 -1
  19. package/dist/index.d.ts +4 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -4
  22. package/dist/index.js.map +1 -1
  23. package/dist/logger.d.ts +10 -0
  24. package/dist/logger.d.ts.map +1 -0
  25. package/dist/logger.js +41 -0
  26. package/dist/logger.js.map +1 -0
  27. package/dist/map/builder.d.ts.map +1 -1
  28. package/dist/map/builder.js +23 -12
  29. package/dist/map/builder.js.map +1 -1
  30. package/dist/map/builder.test.d.ts +2 -0
  31. package/dist/map/builder.test.d.ts.map +1 -0
  32. package/dist/map/builder.test.js +66 -0
  33. package/dist/map/builder.test.js.map +1 -0
  34. package/dist/map/truncate.d.ts +7 -3
  35. package/dist/map/truncate.d.ts.map +1 -1
  36. package/dist/map/truncate.js +80 -11
  37. package/dist/map/truncate.js.map +1 -1
  38. package/dist/scanner.d.ts.map +1 -1
  39. package/dist/scanner.js +164 -65
  40. package/dist/scanner.js.map +1 -1
  41. package/dist/scanner.test.d.ts +2 -0
  42. package/dist/scanner.test.d.ts.map +1 -0
  43. package/dist/scanner.test.js +84 -0
  44. package/dist/scanner.test.js.map +1 -0
  45. package/dist/test-helpers/git-test-helpers.d.ts +13 -0
  46. package/dist/test-helpers/git-test-helpers.d.ts.map +1 -0
  47. package/dist/test-helpers/git-test-helpers.js +48 -0
  48. package/dist/test-helpers/git-test-helpers.js.map +1 -0
  49. package/dist/types.d.ts +15 -1
  50. package/dist/types.d.ts.map +1 -1
  51. package/package.json +15 -3
  52. package/src/cli.ts +164 -0
  53. package/src/extract/definitions.test.ts +2040 -0
  54. package/src/extract/definitions.ts +379 -0
  55. package/src/extract/git-status.test.ts +507 -0
  56. package/src/extract/git-status.ts +359 -0
  57. package/src/extract/markdown.test.ts +159 -0
  58. package/src/extract/markdown.ts +202 -0
  59. package/src/extract/marker.test.ts +566 -0
  60. package/src/extract/marker.ts +398 -0
  61. package/src/extract/submodules.test.ts +95 -0
  62. package/src/extract/submodules.ts +269 -0
  63. package/src/extract/utils.ts +27 -0
  64. package/src/index.ts +106 -0
  65. package/src/languages/cpp.ts +129 -0
  66. package/src/languages/go.ts +72 -0
  67. package/src/languages/index.ts +231 -0
  68. package/src/languages/javascript.ts +33 -0
  69. package/src/languages/python.ts +41 -0
  70. package/src/languages/rust.ts +72 -0
  71. package/src/languages/typescript.ts +74 -0
  72. package/src/languages/zig.ts +106 -0
  73. package/src/logger.ts +55 -0
  74. package/src/map/builder.test.ts +72 -0
  75. package/src/map/builder.ts +175 -0
  76. package/src/map/truncate.ts +188 -0
  77. package/src/map/yaml.ts +66 -0
  78. package/src/parser/index.ts +53 -0
  79. package/src/parser/languages.ts +64 -0
  80. package/src/scanner.test.ts +95 -0
  81. package/src/scanner.ts +364 -0
  82. package/src/test-helpers/git-test-helpers.ts +62 -0
  83. 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
+ }
@@ -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
+ })