@jkwd/inbase 0.1.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 +76 -0
  2. package/apps/explorer/index.html +12 -0
  3. package/apps/explorer/package.json +28 -0
  4. package/apps/explorer/scripts/js-source.mjs +188 -0
  5. package/apps/explorer/scripts/patch-lib.d.ts +115 -0
  6. package/apps/explorer/scripts/patch-lib.mjs +472 -0
  7. package/apps/explorer/scripts/scan-target.mjs +188 -0
  8. package/apps/explorer/scripts/session-store.d.ts +156 -0
  9. package/apps/explorer/scripts/session-store.mjs +809 -0
  10. package/apps/explorer/scripts/target-config.d.ts +8 -0
  11. package/apps/explorer/scripts/target-config.mjs +42 -0
  12. package/apps/explorer/src/App.tsx +941 -0
  13. package/apps/explorer/src/agentIntent.ts +182 -0
  14. package/apps/explorer/src/codebase.ts +15 -0
  15. package/apps/explorer/src/index.css +632 -0
  16. package/apps/explorer/src/layout.ts +508 -0
  17. package/apps/explorer/src/main.tsx +16 -0
  18. package/apps/explorer/src/scene/BlockPlacer.tsx +87 -0
  19. package/apps/explorer/src/scene/Bridge.tsx +290 -0
  20. package/apps/explorer/src/scene/FileBlock.tsx +256 -0
  21. package/apps/explorer/src/scene/FolderArea.tsx +96 -0
  22. package/apps/explorer/src/scene/IslandPlacer.tsx +40 -0
  23. package/apps/explorer/src/scene/MapSelectBorder.tsx +57 -0
  24. package/apps/explorer/src/scene/MapView.tsx +247 -0
  25. package/apps/explorer/src/scene/Player.tsx +245 -0
  26. package/apps/explorer/src/scene/RelationLines.tsx +223 -0
  27. package/apps/explorer/src/scene/SelectionController.tsx +89 -0
  28. package/apps/explorer/src/scene/UserContextTracker.tsx +152 -0
  29. package/apps/explorer/src/scene/World.tsx +323 -0
  30. package/apps/explorer/src/theme.ts +111 -0
  31. package/apps/explorer/src/types.ts +245 -0
  32. package/apps/explorer/src/ui/CanvasErrorBoundary.tsx +38 -0
  33. package/apps/explorer/src/ui/HUD.tsx +1090 -0
  34. package/apps/explorer/src/ui/NameInput.tsx +45 -0
  35. package/apps/explorer/src/userContext.ts +73 -0
  36. package/apps/explorer/src/userCreated.ts +354 -0
  37. package/apps/explorer/src/vite-env.d.ts +1 -0
  38. package/apps/explorer/tsconfig.json +21 -0
  39. package/apps/explorer/vite.config.ts +295 -0
  40. package/bin/inbase.mjs +170 -0
  41. package/bin/project.mjs +94 -0
  42. package/bin/session.mjs +241 -0
  43. package/package.json +63 -0
  44. package/skill/inbase/SKILL.md +167 -0
@@ -0,0 +1,472 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import {
4
+ collectImportSpecifiers,
5
+ extractImportBindings,
6
+ extractJsSymbols,
7
+ resolveSpecifierAgainst,
8
+ } from './js-source.mjs'
9
+ import { targetPathPrefix } from './target-config.mjs'
10
+
11
+ export function toFileId(input) {
12
+ if (!input || input === '/dev/null' || input === 'dev/null') return null
13
+ let value = input.trim().replaceAll('\\', '/')
14
+ if (
15
+ (value.startsWith('"') && value.endsWith('"')) ||
16
+ (value.startsWith("'") && value.endsWith("'"))
17
+ ) {
18
+ value = value.slice(1, -1)
19
+ }
20
+ if (value.startsWith('a/') || value.startsWith('b/')) value = value.slice(2)
21
+ if (targetPathPrefix) {
22
+ const index = value.indexOf(targetPathPrefix)
23
+ if (index >= 0) value = value.slice(index + targetPathPrefix.length)
24
+ }
25
+ if (value.startsWith('./')) value = value.slice(2)
26
+ return value || null
27
+ }
28
+
29
+ function splitLines(text) {
30
+ if (text === '') return []
31
+ const lines = text.split('\n')
32
+ if (text.endsWith('\n')) lines.pop()
33
+ return lines
34
+ }
35
+
36
+ function parseHunks(section) {
37
+ const hunks = []
38
+ const lines = splitLines(section)
39
+ let current = null
40
+
41
+ for (const line of lines) {
42
+ const header = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
43
+ if (header) {
44
+ if (current) hunks.push(current)
45
+ current = {
46
+ oldStart: Number(header[1]),
47
+ oldCount: Number(header[2] ?? '1'),
48
+ newStart: Number(header[3]),
49
+ newCount: Number(header[4] ?? '1'),
50
+ lines: [],
51
+ }
52
+ continue
53
+ }
54
+ if (!current) continue
55
+ if (line === '\') continue
56
+ if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) {
57
+ current.lines.push(line)
58
+ }
59
+ }
60
+ if (current) hunks.push(current)
61
+ return hunks
62
+ }
63
+
64
+ function fileKind(oldPath, newPath, section) {
65
+ if (!oldPath && newPath) return 'add'
66
+ if (oldPath && !newPath) return 'delete'
67
+ if (/^new file mode /m.test(section)) return 'add'
68
+ if (/^deleted file mode /m.test(section)) return 'delete'
69
+ return 'modify'
70
+ }
71
+
72
+ export function parseUnifiedPatch(patch) {
73
+ const text = patch.replaceAll('\r\n', '\n')
74
+ const files = []
75
+ const sections = text.split(/^(?=diff --git |--- )/m).filter((part) => part.trim())
76
+
77
+ for (const section of sections) {
78
+ const oldMatch = section.match(/^---\s+(\S+)/m)
79
+ const newMatch = section.match(/^\+\+\+\s+(\S+)/m)
80
+ if (!oldMatch || !newMatch) continue
81
+
82
+ const oldPath = toFileId(oldMatch[1])
83
+ const newPath = toFileId(newMatch[1])
84
+ const id = newPath || oldPath
85
+ if (!id) continue
86
+
87
+ const hunks = parseHunks(section)
88
+ const kind = fileKind(oldPath, newPath, section)
89
+ let addedLines = 0
90
+ for (const hunk of hunks) {
91
+ for (const line of hunk.lines) {
92
+ if (line.startsWith('+')) addedLines += 1
93
+ }
94
+ }
95
+
96
+ files.push({ id, kind, addedLines, hunks })
97
+ }
98
+
99
+ return {
100
+ files: files.filter((file) => file.kind === 'modify').map((file) => file.id),
101
+ creates: files.filter((file) => file.kind === 'add').map((file) => file.id),
102
+ deletes: files.filter((file) => file.kind === 'delete').map((file) => file.id),
103
+ createLines: Object.fromEntries(
104
+ files
105
+ .filter((file) => file.kind === 'add')
106
+ .map((file) => [file.id, Math.max(1, file.addedLines)]),
107
+ ),
108
+ entries: files,
109
+ }
110
+ }
111
+
112
+ export function folderOfFile(fileId) {
113
+ return fileId.includes('/') ? fileId.split('/').slice(0, -1).join('/') : '.'
114
+ }
115
+
116
+ export function folderParent(folderPath) {
117
+ if (!folderPath || folderPath === '.') return null
118
+ return folderPath.includes('/') ? folderPath.split('/').slice(0, -1).join('/') : '.'
119
+ }
120
+
121
+ export function foldersFromFileIds(ids = []) {
122
+ const folders = new Set(['.'])
123
+ for (const id of ids) {
124
+ let current = folderOfFile(id)
125
+ while (current && current !== '.') {
126
+ folders.add(current)
127
+ current = folderParent(current) ?? '.'
128
+ }
129
+ }
130
+ return folders
131
+ }
132
+
133
+ export function collectCreateFolders(creates = [], existingFolders = []) {
134
+ const known = existingFolders instanceof Set ? existingFolders : new Set(existingFolders)
135
+ const created = new Set()
136
+ for (const id of creates) {
137
+ let current = folderOfFile(id)
138
+ while (current && current !== '.') {
139
+ if (!known.has(current)) created.add(current)
140
+ current = folderParent(current) ?? '.'
141
+ }
142
+ }
143
+ return [...created].sort(
144
+ (left, right) =>
145
+ left.split('/').filter(Boolean).length - right.split('/').filter(Boolean).length ||
146
+ left.localeCompare(right),
147
+ )
148
+ }
149
+
150
+ function taggedSource(entry, tag) {
151
+ const chunks = []
152
+ for (const hunk of entry.hunks) {
153
+ for (const line of hunk.lines) {
154
+ if (line.startsWith(tag)) chunks.push(line.slice(1))
155
+ }
156
+ }
157
+ return chunks.join('\n')
158
+ }
159
+
160
+ function addedSource(entry) {
161
+ return taggedSource(entry, '+')
162
+ }
163
+
164
+ function removedSource(entry) {
165
+ return taggedSource(entry, '-')
166
+ }
167
+
168
+ function extractSymbols(source) {
169
+ // Patch previews only have function/variable buckets, so classes ride along
170
+ // as functions rather than disappearing from the HUD.
171
+ return extractJsSymbols(source).map((symbol) =>
172
+ symbol.kind === 'class' ? { ...symbol, kind: 'function' } : symbol,
173
+ )
174
+ }
175
+
176
+ function additionKey(file, name, extra = '') {
177
+ return extra ? `${file}:${name}:${extra}` : `${file}:${name}`
178
+ }
179
+
180
+ function emptyAdditions() {
181
+ return {
182
+ addedFunctions: [],
183
+ addedVariables: [],
184
+ addedImports: [],
185
+ }
186
+ }
187
+
188
+ function applyEntriesToAdditions(current, entries) {
189
+ const functions = new Map(
190
+ current.addedFunctions.map((item) => [additionKey(item.file, item.name), item]),
191
+ )
192
+ const variables = new Map(
193
+ current.addedVariables.map((item) => [additionKey(item.file, item.name), item]),
194
+ )
195
+ const imports = new Map(
196
+ current.addedImports.map((item) => [additionKey(item.file, item.name, item.from), item]),
197
+ )
198
+
199
+ const dropFile = (fileId) => {
200
+ for (const key of [...functions.keys()]) {
201
+ if (functions.get(key).file === fileId) functions.delete(key)
202
+ }
203
+ for (const key of [...variables.keys()]) {
204
+ if (variables.get(key).file === fileId) variables.delete(key)
205
+ }
206
+ for (const key of [...imports.keys()]) {
207
+ if (imports.get(key).file === fileId) imports.delete(key)
208
+ }
209
+ }
210
+
211
+ for (const entry of entries) {
212
+ if (entry.kind === 'delete') {
213
+ dropFile(entry.id)
214
+ continue
215
+ }
216
+
217
+ const removedSymbols = extractSymbols(removedSource(entry))
218
+ const addedSymbols = extractSymbols(addedSource(entry))
219
+ const keptSymbolKeys = new Set(
220
+ addedSymbols.map((symbol) => `${symbol.kind}:${symbol.name}`),
221
+ )
222
+ const previousSymbolKeys = new Set(
223
+ removedSymbols.map((symbol) => `${symbol.kind}:${symbol.name}`),
224
+ )
225
+
226
+ for (const symbol of removedSymbols) {
227
+ if (keptSymbolKeys.has(`${symbol.kind}:${symbol.name}`)) continue
228
+ if (symbol.kind === 'function') functions.delete(additionKey(entry.id, symbol.name))
229
+ else variables.delete(additionKey(entry.id, symbol.name))
230
+ }
231
+ for (const symbol of addedSymbols) {
232
+ if (previousSymbolKeys.has(`${symbol.kind}:${symbol.name}`)) continue
233
+ const item = { name: symbol.name, file: entry.id }
234
+ if (symbol.kind === 'function') functions.set(additionKey(entry.id, symbol.name), item)
235
+ else variables.set(additionKey(entry.id, symbol.name), item)
236
+ }
237
+
238
+ const removedBindings = extractImportBindings(removedSource(entry))
239
+ const addedBindings = extractImportBindings(addedSource(entry))
240
+ const keptImportKeys = new Set(
241
+ addedBindings.map((binding) => `${binding.name}\0${binding.from}`),
242
+ )
243
+ const previousImportKeys = new Set(
244
+ removedBindings.map((binding) => `${binding.name}\0${binding.from}`),
245
+ )
246
+
247
+ for (const binding of removedBindings) {
248
+ if (keptImportKeys.has(`${binding.name}\0${binding.from}`)) continue
249
+ imports.delete(additionKey(entry.id, binding.name, binding.from))
250
+ }
251
+ for (const binding of addedBindings) {
252
+ if (previousImportKeys.has(`${binding.name}\0${binding.from}`)) continue
253
+ imports.set(additionKey(entry.id, binding.name, binding.from), {
254
+ name: binding.name,
255
+ from: binding.from,
256
+ file: entry.id,
257
+ })
258
+ }
259
+ }
260
+
261
+ return {
262
+ addedFunctions: [...functions.values()],
263
+ addedVariables: [...variables.values()],
264
+ addedImports: [...imports.values()],
265
+ }
266
+ }
267
+
268
+ export function extractPatchAdditions(entries) {
269
+ return applyEntriesToAdditions(emptyAdditions(), entries)
270
+ }
271
+
272
+ export function accumulatePatchAdditions(patches = []) {
273
+ let additions = emptyAdditions()
274
+ for (const patch of patches) {
275
+ additions = applyEntriesToAdditions(additions, parseUnifiedPatch(patch).entries)
276
+ }
277
+ return additions
278
+ }
279
+
280
+ function folderOfFileId(fileId) {
281
+ const index = fileId.lastIndexOf('/')
282
+ return index === -1 ? '.' : fileId.slice(0, index)
283
+ }
284
+
285
+ function posixJoin(fromDir, specifier) {
286
+ const raw = fromDir === '.' ? specifier : `${fromDir}/${specifier}`
287
+ const parts = []
288
+ for (const part of raw.split('/')) {
289
+ if (!part || part === '.') continue
290
+ if (part === '..') {
291
+ parts.pop()
292
+ continue
293
+ }
294
+ parts.push(part)
295
+ }
296
+ return parts.join('/')
297
+ }
298
+
299
+ export function extractPatchImports(entries, knownFileIds = []) {
300
+ const known = new Set(knownFileIds)
301
+ for (const entry of entries) known.add(entry.id)
302
+ const edges = []
303
+ const seen = new Set()
304
+
305
+ for (const entry of entries) {
306
+ if (entry.kind === 'delete') continue
307
+ const fromDir = folderOfFileId(entry.id)
308
+ for (const specifier of collectImportSpecifiers(addedSource(entry))) {
309
+ if (!specifier.startsWith('.')) continue
310
+ const to = resolveSpecifierAgainst(posixJoin(fromDir, specifier), known)
311
+ if (!to || to === entry.id) continue
312
+ const key = `${entry.id}->${to}`
313
+ if (seen.has(key)) continue
314
+ seen.add(key)
315
+ edges.push({ from: entry.id, to })
316
+ }
317
+ }
318
+
319
+ return edges
320
+ }
321
+
322
+ function applyHunks(original, hunks) {
323
+ const lines = splitLines(original)
324
+ let delta = 0
325
+
326
+ for (const hunk of hunks) {
327
+ const start = hunk.oldStart === 0 ? 0 : hunk.oldStart - 1 + delta
328
+ const expected = []
329
+ const replacement = []
330
+ for (const line of hunk.lines) {
331
+ const tag = line[0]
332
+ const body = line.slice(1)
333
+ if (tag === ' ' || tag === '-') expected.push(body)
334
+ if (tag === ' ' || tag === '+') replacement.push(body)
335
+ }
336
+ const actual = lines.slice(start, start + expected.length)
337
+ if (actual.join('\n') !== expected.join('\n')) {
338
+ throw new Error(`Hunk does not apply at line ${hunk.oldStart}`)
339
+ }
340
+ lines.splice(start, expected.length, ...replacement)
341
+ delta += replacement.length - expected.length
342
+ }
343
+
344
+ if (lines.length === 0) return original.endsWith('\n') ? '\n' : ''
345
+ return `${lines.join('\n')}\n`
346
+ }
347
+
348
+ export function applyUnifiedPatch(patch, targetRoot) {
349
+ const parsed = parseUnifiedPatch(patch)
350
+ if (parsed.entries.length === 0) {
351
+ throw new Error('Patch did not contain any file changes')
352
+ }
353
+
354
+ for (const file of parsed.entries) {
355
+ const absolute = path.join(targetRoot, file.id)
356
+ if (file.kind === 'delete') {
357
+ if (fs.existsSync(absolute)) fs.rmSync(absolute)
358
+ continue
359
+ }
360
+
361
+ const original =
362
+ file.kind === 'add' || !fs.existsSync(absolute)
363
+ ? ''
364
+ : fs.readFileSync(absolute, 'utf8')
365
+
366
+ if (file.kind === 'modify' && original === '') {
367
+ throw new Error(`Cannot modify missing file ${file.id}`)
368
+ }
369
+
370
+ fs.mkdirSync(path.dirname(absolute), { recursive: true })
371
+ fs.writeFileSync(absolute, applyHunks(original, file.hunks))
372
+ }
373
+
374
+ return parsed
375
+ }
376
+
377
+ export const emptyIntent = {
378
+ updatedAt: null,
379
+ showMap: false,
380
+ status: 'idle',
381
+ feature: null,
382
+ steps: [],
383
+ step: null,
384
+ files: [],
385
+ creates: [],
386
+ deletes: [],
387
+ createFolders: [],
388
+ createLines: {},
389
+ imports: [],
390
+ addedFunctions: [],
391
+ addedVariables: [],
392
+ addedImports: [],
393
+ reason: null,
394
+ sessionId: null,
395
+ diffId: null,
396
+ parentDiffId: null,
397
+ chainIndex: null,
398
+ chain: [],
399
+ isActiveDiff: false,
400
+ preview: false,
401
+ phase: null,
402
+ working: false,
403
+ creationMode: false,
404
+ canEnterBlueprint: false,
405
+ blueprintSessionId: null,
406
+ userCreatedBlocks: [],
407
+ userCreatedIslands: [],
408
+ blueprintFunctions: [],
409
+ blueprintVariables: [],
410
+ blueprintImports: [],
411
+ }
412
+
413
+ export function isLastStep(intent) {
414
+ const steps = intent?.steps
415
+ if (!Array.isArray(steps) || steps.length === 0) return true
416
+ return typeof intent.step === 'number' && intent.step >= steps.length
417
+ }
418
+
419
+ export function overlayPatch(intent, patchText, knownFileIds = []) {
420
+ const base = { ...emptyIntent, ...intent }
421
+ const status =
422
+ base.status === 'approved' && isLastStep(base) ? 'finished' : base.status
423
+ const preview = status === 'pending' || status === 'extend'
424
+
425
+ if (!preview) {
426
+ return {
427
+ ...base,
428
+ status,
429
+ files: [],
430
+ creates: [],
431
+ deletes: [],
432
+ createFolders: [],
433
+ createLines: {},
434
+ imports: [],
435
+ addedFunctions: [],
436
+ addedVariables: [],
437
+ addedImports: [],
438
+ }
439
+ }
440
+
441
+ if (!patchText?.trim()) {
442
+ return {
443
+ ...base,
444
+ status,
445
+ files: base.files ?? [],
446
+ creates: base.creates ?? [],
447
+ deletes: base.deletes ?? [],
448
+ createFolders: base.createFolders ?? [],
449
+ createLines: base.createLines ?? {},
450
+ imports: base.imports ?? [],
451
+ addedFunctions: base.addedFunctions ?? [],
452
+ addedVariables: base.addedVariables ?? [],
453
+ addedImports: base.addedImports ?? [],
454
+ }
455
+ }
456
+
457
+ const parsed = parseUnifiedPatch(patchText)
458
+ return {
459
+ ...base,
460
+ status,
461
+ files: parsed.files,
462
+ creates: parsed.creates,
463
+ deletes: parsed.deletes,
464
+ createFolders: collectCreateFolders(
465
+ parsed.creates,
466
+ foldersFromFileIds(knownFileIds.filter((id) => !parsed.creates.includes(id))),
467
+ ),
468
+ createLines: parsed.createLines,
469
+ imports: extractPatchImports(parsed.entries, knownFileIds),
470
+ ...extractPatchAdditions(parsed.entries),
471
+ }
472
+ }
@@ -0,0 +1,188 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import {
5
+ FILE_EXTENSIONS,
6
+ SOURCE_EXTENSIONS,
7
+ RESOLVE_EXTENSIONS,
8
+ collectImportSpecifiers,
9
+ extractJsSymbols,
10
+ } from './js-source.mjs'
11
+ import {
12
+ dataDir as defaultDataDir,
13
+ targetName as defaultTargetName,
14
+ targetRoot as defaultTargetRoot,
15
+ } from './target-config.mjs'
16
+
17
+ const defaultOutPath = path.join(defaultDataDir, 'codebase.json')
18
+ const IGNORE_DIRS = new Set([
19
+ 'node_modules',
20
+ 'dist',
21
+ 'build',
22
+ 'out',
23
+ 'coverage',
24
+ '.git',
25
+ '.inbase',
26
+ ])
27
+ const IGNORE_FILES = new Set(['package-lock.json'])
28
+
29
+ function toPosix(filePath) {
30
+ return filePath.split(path.sep).join('/')
31
+ }
32
+
33
+ function walk(dir, acc = []) {
34
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
35
+ if (entry.name.startsWith('.')) continue
36
+ if (entry.isDirectory()) {
37
+ if (IGNORE_DIRS.has(entry.name)) continue
38
+ walk(path.join(dir, entry.name), acc)
39
+ continue
40
+ }
41
+ if (IGNORE_FILES.has(entry.name)) continue
42
+ if (!FILE_EXTENSIONS.has(path.extname(entry.name))) continue
43
+ acc.push(path.join(dir, entry.name))
44
+ }
45
+ return acc
46
+ }
47
+
48
+ function languageOf(filePath) {
49
+ const ext = path.extname(filePath).slice(1).toLowerCase()
50
+ return ext || 'txt'
51
+ }
52
+
53
+ function extractSymbols(source, ext) {
54
+ if (!SOURCE_EXTENSIONS.has(ext)) return []
55
+ return extractJsSymbols(source)
56
+ }
57
+
58
+ function resolveExisting(candidate) {
59
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
60
+ return candidate
61
+ }
62
+
63
+ for (const ext of RESOLVE_EXTENSIONS) {
64
+ const withExt = candidate + ext
65
+ if (fs.existsSync(withExt) && fs.statSync(withExt).isFile()) {
66
+ return withExt
67
+ }
68
+ }
69
+
70
+ const indexDir = candidate
71
+ if (fs.existsSync(indexDir) && fs.statSync(indexDir).isDirectory()) {
72
+ for (const ext of RESOLVE_EXTENSIONS) {
73
+ const indexFile = path.join(indexDir, `index${ext}`)
74
+ if (fs.existsSync(indexFile)) return indexFile
75
+ }
76
+ }
77
+
78
+ return null
79
+ }
80
+
81
+ function resolveImport(fromFile, specifier, root) {
82
+ if (!specifier.startsWith('.')) return null
83
+ const fromDir = path.dirname(fromFile)
84
+ const candidate = path.resolve(fromDir, specifier)
85
+ const resolved = resolveExisting(candidate)
86
+ if (!resolved) return null
87
+ const relative = toPosix(path.relative(root, resolved))
88
+ if (relative.startsWith('..')) return null
89
+ return relative
90
+ }
91
+
92
+ function ensureFolder(folders, folderPath, rootName) {
93
+ if (folders.has(folderPath)) return
94
+
95
+ const parent =
96
+ folderPath === '.'
97
+ ? null
98
+ : toPosix(path.posix.dirname(folderPath)) === '.'
99
+ ? '.'
100
+ : toPosix(path.posix.dirname(folderPath))
101
+
102
+ folders.set(folderPath, {
103
+ path: folderPath,
104
+ name: folderPath === '.' ? rootName : path.posix.basename(folderPath),
105
+ parent,
106
+ files: [],
107
+ children: [],
108
+ })
109
+
110
+ if (parent) ensureFolder(folders, parent, rootName)
111
+ }
112
+
113
+ export function scanTarget({
114
+ root = defaultTargetRoot,
115
+ name = path.basename(root),
116
+ dest = defaultOutPath,
117
+ } = {}) {
118
+ if (!fs.existsSync(root)) {
119
+ throw new Error(
120
+ `Target not found at ${root}. Set VISUAL_CODER_TARGET to the project you want to map.`,
121
+ )
122
+ }
123
+
124
+ const absoluteFiles = walk(root)
125
+ const folders = new Map()
126
+ ensureFolder(folders, '.', name)
127
+
128
+ const files = absoluteFiles.map((absolutePath) => {
129
+ const relative = toPosix(path.relative(root, absolutePath))
130
+ const ext = path.extname(relative)
131
+ const source = fs.readFileSync(absolutePath, 'utf8')
132
+ const folder = relative.includes('/') ? relative.split('/').slice(0, -1).join('/') : '.'
133
+
134
+ ensureFolder(folders, folder, name)
135
+
136
+ return {
137
+ id: relative,
138
+ name: path.posix.basename(relative),
139
+ path: relative,
140
+ folder,
141
+ lines: source.split(/\r?\n/).length,
142
+ language: languageOf(relative),
143
+ symbols: extractSymbols(source, ext),
144
+ imports: collectImportSpecifiers(source)
145
+ .map((specifier) => resolveImport(absolutePath, specifier, root))
146
+ .filter(Boolean),
147
+ }
148
+ })
149
+
150
+ const fileIds = new Set(files.map((file) => file.id))
151
+ for (const file of files) {
152
+ file.imports = [...new Set(file.imports.filter((id) => fileIds.has(id)))]
153
+ }
154
+
155
+ for (const file of files) {
156
+ folders.get(file.folder).files.push(file.id)
157
+ }
158
+
159
+ for (const folder of folders.values()) {
160
+ if (!folder.parent) continue
161
+ const parent = folders.get(folder.parent)
162
+ if (!parent.children.includes(folder.path)) {
163
+ parent.children.push(folder.path)
164
+ }
165
+ }
166
+
167
+ for (const folder of folders.values()) {
168
+ folder.files.sort((a, b) => a.localeCompare(b))
169
+ folder.children.sort((a, b) => a.localeCompare(b))
170
+ }
171
+
172
+ const graph = {
173
+ root: '.',
174
+ targetName: name,
175
+ files,
176
+ folders: [...folders.values()],
177
+ }
178
+
179
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
180
+ fs.writeFileSync(dest, `${JSON.stringify(graph, null, 2)}\n`)
181
+ console.log(`Scanned ${files.length} files -> ${path.relative(process.cwd(), dest)}`)
182
+ return graph
183
+ }
184
+
185
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : ''
186
+ if (invoked === fileURLToPath(import.meta.url)) {
187
+ scanTarget()
188
+ }