@hybridlabor-api/bdb-synapse 1.0.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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/synapse +0 -0
  4. package/cmd/rubriceval/main.go +308 -0
  5. package/cmd/synapse/main.go +280 -0
  6. package/cmd/synapse/main_test.go +16 -0
  7. package/go.mod +7 -0
  8. package/go.sum +6 -0
  9. package/internal/adapter/adapter.go +1117 -0
  10. package/internal/adapter/adapter_test.go +518 -0
  11. package/internal/adapter/agy/adapter.go +193 -0
  12. package/internal/adapter/claudecode/adapter.go +415 -0
  13. package/internal/adapter/claudecode/adapter_test.go +260 -0
  14. package/internal/adapter/claudecode/agents.go +387 -0
  15. package/internal/adapter/claudecode/agents_test.go +480 -0
  16. package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
  17. package/internal/adapter/codex/adapter.go +919 -0
  18. package/internal/adapter/codex/adapter_test.go +920 -0
  19. package/internal/adapter/codex/agents.go +401 -0
  20. package/internal/adapter/codex/agents_test.go +610 -0
  21. package/internal/adapter/codex/summary_inputs_test.go +20 -0
  22. package/internal/adapter/pi/adapter.go +483 -0
  23. package/internal/adapter/pi/adapter_test.go +517 -0
  24. package/internal/citymap/builder.go +1124 -0
  25. package/internal/citymap/builder_test.go +818 -0
  26. package/internal/judge/cache.go +180 -0
  27. package/internal/judge/cli.go +240 -0
  28. package/internal/judge/cli_test.go +68 -0
  29. package/internal/judge/fresh_summary_test.go +37 -0
  30. package/internal/judge/input.go +233 -0
  31. package/internal/judge/judge.go +416 -0
  32. package/internal/judge/judge_test.go +288 -0
  33. package/internal/judge/prompt.go +129 -0
  34. package/internal/judge/rubric.go +275 -0
  35. package/internal/judge/rubric_test.go +642 -0
  36. package/internal/model/agent.go +63 -0
  37. package/internal/model/agent_schema_test.go +86 -0
  38. package/internal/model/agent_test.go +64 -0
  39. package/internal/model/model.go +175 -0
  40. package/internal/model/report.go +166 -0
  41. package/internal/model/stats.go +151 -0
  42. package/internal/model/stats_test.go +89 -0
  43. package/internal/model/trace_schema_test.go +67 -0
  44. package/internal/server/analyze.go +286 -0
  45. package/internal/server/analyze_test.go +297 -0
  46. package/internal/server/codex_index_test.go +40 -0
  47. package/internal/server/hardening_test.go +147 -0
  48. package/internal/server/reportindex.go +91 -0
  49. package/internal/server/reportindex_test.go +116 -0
  50. package/internal/server/server.go +1099 -0
  51. package/internal/server/server_test.go +1389 -0
  52. package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
  53. package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
  54. package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
  55. package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
  56. package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
  57. package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
  58. package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
  59. package/internal/server/static/assets/index-C_adLrJr.js +3 -0
  60. package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
  61. package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  62. package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  63. package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
  64. package/internal/server/static/index.html +26 -0
  65. package/internal/server/tracestore.go +173 -0
  66. package/internal/server/tracestore_test.go +51 -0
  67. package/internal/textutil/truncate.go +30 -0
  68. package/internal/textutil/truncate_test.go +39 -0
  69. package/package.json +35 -0
  70. package/web/e2e/agent-lens.spec.ts +688 -0
  71. package/web/index.html +23 -0
  72. package/web/package-lock.json +1933 -0
  73. package/web/package.json +33 -0
  74. package/web/playwright.config.ts +24 -0
  75. package/web/src/App.tsx +876 -0
  76. package/web/src/api/client.ts +74 -0
  77. package/web/src/main.tsx +12 -0
  78. package/web/src/playback/recorder.ts +160 -0
  79. package/web/src/playback/reducer.ts +91 -0
  80. package/web/src/scene/CityScene.tsx +638 -0
  81. package/web/src/scene/TreeScene.tsx +656 -0
  82. package/web/src/scene/dirLabels.ts +145 -0
  83. package/web/src/scene/sceneUtils.ts +144 -0
  84. package/web/src/scene/textures.ts +60 -0
  85. package/web/src/scene/trail.ts +79 -0
  86. package/web/src/scene/treeLayout.ts +169 -0
  87. package/web/src/state/filters.ts +40 -0
  88. package/web/src/state/store.ts +83 -0
  89. package/web/src/styles.css +2565 -0
  90. package/web/src/types.ts +315 -0
  91. package/web/src/ui/AgentsPanel.tsx +376 -0
  92. package/web/src/ui/Dock.tsx +104 -0
  93. package/web/src/ui/Hud.tsx +335 -0
  94. package/web/src/ui/Inspector.tsx +107 -0
  95. package/web/src/ui/LogoMark.tsx +38 -0
  96. package/web/src/ui/ReportPanel.tsx +491 -0
  97. package/web/src/ui/SessionRail.tsx +316 -0
  98. package/web/src/ui/Timeline.tsx +458 -0
  99. package/web/src/ui/ViewPanel.tsx +45 -0
  100. package/web/src/ui/shortcuts.ts +4 -0
  101. package/web/tsconfig.json +21 -0
  102. package/web/vite.config.ts +28 -0
@@ -0,0 +1,1124 @@
1
+ package citymap
2
+
3
+ import (
4
+ "bufio"
5
+ "bytes"
6
+ "context"
7
+ "errors"
8
+ "io"
9
+ "io/fs"
10
+ "math"
11
+ "os"
12
+ "os/exec"
13
+ "path"
14
+ "path/filepath"
15
+ "sort"
16
+ "strings"
17
+ "syscall"
18
+ "time"
19
+
20
+ "github.com/hybridlabor-api/bdb-synapse/internal/model"
21
+ )
22
+
23
+ type Builder struct{}
24
+
25
+ // Scan limits. Package-level vars so tests can shrink them; production code
26
+ // treats them as constants.
27
+ var (
28
+ // maxMapFiles caps how many files a citymap holds — past this the
29
+ // browser scene degrades, so the tree is expanded level by level and
30
+ // cut at the budget.
31
+ maxMapFiles = 10000
32
+ // maxGitListPaths bounds the streamed git ls-files read; a git repo at
33
+ // $HOME (dotfiles setups) lists every untracked file beneath it.
34
+ maxGitListPaths = 100000
35
+ // maxLineCountBytes caps line counting; larger files fall back to
36
+ // byte-based weight instead of being read end to end.
37
+ maxLineCountBytes int64 = 2 << 20
38
+ // readDirTimeout bounds a single directory read. A healthy filesystem
39
+ // answers in microseconds; only TCC-gated folders, dead mounts, and
40
+ // cloud placeholders hit this, and losing their subtree beats hanging.
41
+ readDirTimeout = 5 * time.Second
42
+ // inspectTimeout bounds a single file inspection (stat + line count)
43
+ // the same way — cloud placeholder files materialize on open.
44
+ inspectTimeout = 5 * time.Second
45
+ // gitTimeout bounds every git subprocess — ls-files and the repo-state
46
+ // probes alike; git status walks the whole tree and hangs where walks
47
+ // hang.
48
+ gitTimeout = 30 * time.Second
49
+ )
50
+
51
+ const (
52
+ // probeDepth is how deep workspace mode looks for project roots.
53
+ probeDepth = 4
54
+ // maxScanRoots bounds how many project roots a workspace walk fans out to.
55
+ maxScanRoots = 256
56
+ // maxWalkDepth is a safety rail against pathologically deep trees.
57
+ maxWalkDepth = 32
58
+ )
59
+
60
+ // junkDirNames are never entered during walks: generated trees that would
61
+ // drown the map (node_modules) and, at $HOME scale, the macOS Library.
62
+ var junkDirNames = map[string]bool{
63
+ "node_modules": true,
64
+ "dist": true,
65
+ "build": true,
66
+ "vendor": true,
67
+ "target": true,
68
+ "__pycache__": true,
69
+ "Library": true,
70
+ }
71
+
72
+ // junkBundleSuffixes are macOS package directories walks never enter —
73
+ // opaque app/media bundles, some of them TCC-protected.
74
+ var junkBundleSuffixes = []string{".app", ".photoslibrary", ".musiclibrary", ".tvlibrary", ".fcpbundle", ".framework"}
75
+
76
+ // protectedDirNames are the macOS folders under TCC consent — reading them
77
+ // (or, for the media homes, their library subfolders) can block on a
78
+ // permission decision that a background process never gets to answer.
79
+ // Workspace mode enters them only when the trace touched something inside —
80
+ // a touch means the grant almost certainly already exists.
81
+ var protectedDirNames = map[string]bool{
82
+ "Desktop": true,
83
+ "Documents": true,
84
+ "Downloads": true,
85
+ "Movies": true,
86
+ "Music": true,
87
+ "Pictures": true,
88
+ }
89
+
90
+ // projectMarkerNames identify a directory as a project root during
91
+ // workspace-mode probing.
92
+ var projectMarkerNames = map[string]bool{
93
+ ".git": true,
94
+ "go.mod": true,
95
+ "package.json": true,
96
+ "Cargo.toml": true,
97
+ "pyproject.toml": true,
98
+ "setup.py": true,
99
+ "requirements.txt": true,
100
+ "Gemfile": true,
101
+ "pom.xml": true,
102
+ "build.gradle": true,
103
+ "build.gradle.kts": true,
104
+ "CMakeLists.txt": true,
105
+ "Makefile": true,
106
+ "mix.exs": true,
107
+ "composer.json": true,
108
+ "Package.swift": true,
109
+ "deno.json": true,
110
+ "pubspec.yaml": true,
111
+ }
112
+
113
+ func (Builder) Build(repoRoot string, trace *model.Trace) (*model.CityMap, error) {
114
+ root, err := filepath.Abs(repoRoot)
115
+ if err != nil {
116
+ return nil, err
117
+ }
118
+ touched := touchedPaths(trace)
119
+ candidates, truncated, err := listFiles(root, touched)
120
+ if err != nil {
121
+ return nil, err
122
+ }
123
+
124
+ // The map budget is spent on entries that earn their slot, in three
125
+ // tiers: trace-touched files that exist come first (a budget cut must
126
+ // never drop them or mislabel them ghost), then ghosts for touched
127
+ // files that are genuinely gone, then everything else the scan found.
128
+ // Inspection failures (deleted-but-tracked paths, unreadable entries)
129
+ // consume no budget, and nothing — ghosts included — may push the map
130
+ // past maxMapFiles.
131
+ seen := map[string]bool{}
132
+ cityFiles := make([]model.CityFile, 0, min(len(candidates), maxMapFiles))
133
+ if trace != nil {
134
+ // Aggregate targets before seating anything: dedup by canonical
135
+ // path in first-appearance order (deterministic), with strength
136
+ // upgraded if any occurrence is strong — a weak miss must not mask
137
+ // a later strong touch of the same path. Seating then runs in two
138
+ // passes, existing files before any ghost, so the tier order holds
139
+ // even when the budget runs out mid-trace.
140
+ type touchedTarget struct {
141
+ raw string
142
+ rel string
143
+ strong bool
144
+ }
145
+ var targets []touchedTarget
146
+ index := map[string]int{}
147
+ for _, event := range trace.Events {
148
+ for _, target := range event.Targets {
149
+ raw := target.Path
150
+ if raw == "" {
151
+ continue
152
+ }
153
+ rel := cleanRel(raw)
154
+ key := raw
155
+ if rel != "" {
156
+ key = rel
157
+ }
158
+ if i, ok := index[key]; ok {
159
+ if !target.Weak {
160
+ targets[i].strong = true
161
+ }
162
+ continue
163
+ }
164
+ index[key] = len(targets)
165
+ targets = append(targets, touchedTarget{raw: raw, rel: rel, strong: !target.Weak})
166
+ }
167
+ }
168
+ ghosts := make([]touchedTarget, 0, len(targets))
169
+ for _, tt := range targets {
170
+ if tt.rel != "" {
171
+ meta, err := inspectFileBounded(root, tt.rel)
172
+ if err == nil {
173
+ seen[tt.rel] = true
174
+ seen[tt.raw] = true
175
+ if len(cityFiles) >= maxMapFiles {
176
+ truncated = true
177
+ continue
178
+ }
179
+ cityFiles = append(cityFiles, meta)
180
+ continue
181
+ }
182
+ if errors.Is(err, errNotRegular) {
183
+ // exists but is not city material — policy skip
184
+ continue
185
+ }
186
+ if !benignReadError(err) {
187
+ // timeout, permission — the file is not proven gone,
188
+ // so a ghost would lie; the map is just missing
189
+ // something it wanted to show.
190
+ truncated = true
191
+ continue
192
+ }
193
+ }
194
+ if tt.strong {
195
+ ghosts = append(ghosts, tt)
196
+ }
197
+ }
198
+ for _, tt := range ghosts {
199
+ if len(cityFiles) >= maxMapFiles {
200
+ truncated = true
201
+ break
202
+ }
203
+ seen[tt.raw] = true
204
+ if tt.rel != "" {
205
+ seen[tt.rel] = true
206
+ }
207
+ cityFiles = append(cityFiles, model.CityFile{
208
+ Path: tt.raw,
209
+ Dir: filepath.ToSlash(filepath.Dir(tt.raw)),
210
+ Lang: langForPath(tt.raw),
211
+ Ghost: true,
212
+ })
213
+ }
214
+ }
215
+ for _, rel := range candidates {
216
+ if seen[rel] {
217
+ continue
218
+ }
219
+ meta, err := inspectFileBounded(root, rel)
220
+ if err != nil {
221
+ // vanished files and policy-skipped non-regular entries never
222
+ // truncate the map; anything else — timeout, permission — is
223
+ // real content the map lost
224
+ if !benignReadError(err) && !errors.Is(err, errNotRegular) {
225
+ truncated = true
226
+ }
227
+ continue
228
+ }
229
+ if len(cityFiles) >= maxMapFiles {
230
+ // a genuinely mappable new file cannot fit
231
+ truncated = true
232
+ break
233
+ }
234
+ seen[rel] = true
235
+ cityFiles = append(cityFiles, meta)
236
+ }
237
+
238
+ sort.Slice(cityFiles, func(i, j int) bool {
239
+ return cityFiles[i].Path < cityFiles[j].Path
240
+ })
241
+ for i := range cityFiles {
242
+ cityFiles[i].ID = i
243
+ }
244
+
245
+ rootNode := buildTree(cityFiles)
246
+ dirs := make([]model.CityDir, 0)
247
+ layoutNode(rootNode, model.Rect{X: 0, Z: 0, W: 120, D: 120}, &cityFiles, &dirs)
248
+
249
+ commit, dirty := repoState(root)
250
+ return &model.CityMap{
251
+ Version: 1,
252
+ Repo: model.RepoMeta{
253
+ Root: root,
254
+ Commit: commit,
255
+ Dirty: dirty,
256
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
257
+ Truncated: truncated,
258
+ },
259
+ Files: cityFiles,
260
+ Dirs: dirs,
261
+ Layout: model.LayoutMeta{
262
+ Algorithm: "squarified-treemap-v1",
263
+ Weight: "sqrt(max(lines, bytes/4096, 16))",
264
+ },
265
+ }, nil
266
+ }
267
+
268
+ // touchedPaths collects every trace target path. The set steers scanning: a
269
+ // touched file is the last to be cut, and a touched directory is worth
270
+ // entering even where the scan would otherwise not look.
271
+ func touchedPaths(trace *model.Trace) map[string]bool {
272
+ if trace == nil {
273
+ return nil
274
+ }
275
+ touched := map[string]bool{}
276
+ for _, event := range trace.Events {
277
+ for _, target := range event.Targets {
278
+ if target.Path != "" {
279
+ touched[target.Path] = true
280
+ }
281
+ }
282
+ }
283
+ return touched
284
+ }
285
+
286
+ // listFiles enumerates candidate files for the citymap, bounded so a huge
287
+ // root (a $HOME session) cannot stall the build. Three modes, picked from
288
+ // the shape of root:
289
+ //
290
+ // - repo: root is a git repo — stream git ls-files
291
+ // - project: root has a project marker but no git — walk the whole tree
292
+ // - workspace: neither — walk only subtrees that look like projects
293
+ //
294
+ // The result is in budget-fill order but deliberately not capped: the
295
+ // maxMapFiles budget is applied by Build, which charges it only for files
296
+ // that actually exist. truncated reports scan-budget cuts (git overflow,
297
+ // walk level stop, root cap) — Build adds its own when the fill overflows.
298
+ func listFiles(root string, touched map[string]bool) ([]string, bool, error) {
299
+ files, overflow, err := gitListFiles(root)
300
+ if err == nil && len(files) > 0 {
301
+ return orderCandidates(files), overflow, nil
302
+ }
303
+
304
+ workspace := !hasProjectMarker(root)
305
+ var scanRoots []string
306
+ probeTruncated := false
307
+ if workspace {
308
+ scanRoots, probeTruncated = probeProjectRoots(root, touched)
309
+ }
310
+ files, walkTruncated := walkFiles(root, scanRoots, workspace)
311
+ if len(files) == 0 {
312
+ return nil, false, errors.New("no files found")
313
+ }
314
+ return orderCandidates(files), probeTruncated || walkTruncated, nil
315
+ }
316
+
317
+ // gitListFiles streams git ls-files so a repo spanning a huge tree (a
318
+ // dotfiles repo at $HOME lists every untracked file beneath it via -o)
319
+ // cannot buffer gigabytes; reading stops at maxGitListPaths.
320
+ func gitListFiles(root string) ([]string, bool, error) {
321
+ ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
322
+ defer cancel()
323
+ cmd := exec.CommandContext(ctx, "git", "-C", root, "ls-files", "-co", "--exclude-standard", "-z")
324
+ cmd.WaitDelay = time.Second
325
+ stdout, err := cmd.StdoutPipe()
326
+ if err != nil {
327
+ return nil, false, err
328
+ }
329
+ if err := cmd.Start(); err != nil {
330
+ return nil, false, err
331
+ }
332
+ scanner := bufio.NewScanner(stdout)
333
+ scanner.Buffer(make([]byte, 64*1024), 1024*1024)
334
+ scanner.Split(splitNul)
335
+ var files []string
336
+ overflow := false
337
+ for scanner.Scan() {
338
+ if len(files) >= maxGitListPaths {
339
+ overflow = true
340
+ break
341
+ }
342
+ if p := scanner.Text(); p != "" {
343
+ files = append(files, filepath.ToSlash(p))
344
+ }
345
+ }
346
+ if overflow {
347
+ _ = cmd.Process.Kill()
348
+ _ = cmd.Wait()
349
+ return files, true, nil
350
+ }
351
+ if err := scanner.Err(); err != nil {
352
+ _ = cmd.Process.Kill()
353
+ _ = cmd.Wait()
354
+ return nil, false, err
355
+ }
356
+ if err := cmd.Wait(); err != nil {
357
+ return nil, false, err
358
+ }
359
+ return files, false, nil
360
+ }
361
+
362
+ func splitNul(data []byte, atEOF bool) (int, []byte, error) {
363
+ if i := bytes.IndexByte(data, 0); i >= 0 {
364
+ return i + 1, data[:i], nil
365
+ }
366
+ if atEOF && len(data) > 0 {
367
+ return len(data), data, nil
368
+ }
369
+ return 0, nil, nil
370
+ }
371
+
372
+ func hasProjectMarker(root string) bool {
373
+ entries, err := readDirBounded(root)
374
+ if err != nil {
375
+ return false
376
+ }
377
+ for _, entry := range entries {
378
+ if projectMarkerNames[entry.Name()] {
379
+ return true
380
+ }
381
+ }
382
+ return false
383
+ }
384
+
385
+ type walkEntry struct {
386
+ rel string
387
+ depth int
388
+ }
389
+
390
+ // skipWalkDir reports whether a walk should stay out of a directory:
391
+ // dot-prefixed trees, generated junk, and macOS bundles.
392
+ func skipWalkDir(name string) bool {
393
+ if strings.HasPrefix(name, ".") || junkDirNames[name] {
394
+ return true
395
+ }
396
+ lower := strings.ToLower(name)
397
+ for _, suffix := range junkBundleSuffixes {
398
+ if strings.HasSuffix(lower, suffix) {
399
+ return true
400
+ }
401
+ }
402
+ return false
403
+ }
404
+
405
+ // probeProjectRoots shallow-scans a workspace root (a directory that is
406
+ // neither a git repo nor a project itself, $HOME being the canonical case)
407
+ // for subdirectories that look like project roots. It reads at most
408
+ // probeDepth levels, never enters hidden or junk directories, and enters
409
+ // the TCC-protected macOS folders only when the trace touched them. The
410
+ // parent directory of every touched file is seeded in as a root too —
411
+ // unverified, since the walk's bounded read validates it and a stat here
412
+ // would be an unbounded syscall. The second return reports partial
413
+ // discovery: a root cap or a non-benign read failure.
414
+ func probeProjectRoots(root string, touched map[string]bool) ([]string, bool) {
415
+ rootsSet := map[string]bool{}
416
+ for p := range touched {
417
+ rel := cleanRel(p)
418
+ if rel == "" || !strings.Contains(rel, "/") {
419
+ continue
420
+ }
421
+ rootsSet[path.Dir(rel)] = true
422
+ }
423
+
424
+ partial := false
425
+ touchedTop := touchedTopDirs(touched)
426
+ queue := []walkEntry{{}}
427
+ for len(queue) > 0 {
428
+ cur := queue[0]
429
+ queue = queue[1:]
430
+ entries, err := readDirBounded(filepath.Join(root, filepath.FromSlash(cur.rel)))
431
+ if err != nil {
432
+ if !benignReadError(err) {
433
+ // an unreadable directory may hide whole projects
434
+ partial = true
435
+ }
436
+ continue
437
+ }
438
+ if cur.rel != "" {
439
+ marker := false
440
+ for _, entry := range entries {
441
+ if projectMarkerNames[entry.Name()] {
442
+ marker = true
443
+ break
444
+ }
445
+ }
446
+ if marker {
447
+ // a project root is scanned by the walk, not the probe
448
+ rootsSet[cur.rel] = true
449
+ continue
450
+ }
451
+ }
452
+ if cur.depth >= probeDepth {
453
+ continue
454
+ }
455
+ for _, entry := range entries {
456
+ if !entry.IsDir() {
457
+ continue
458
+ }
459
+ name := entry.Name()
460
+ if skipWalkDir(name) {
461
+ continue
462
+ }
463
+ if cur.rel == "" && protectedDirNames[name] && !touchedTop[name] {
464
+ continue
465
+ }
466
+ queue = append(queue, walkEntry{rel: joinRel(cur.rel, name), depth: cur.depth + 1})
467
+ }
468
+ }
469
+
470
+ roots := make([]string, 0, len(rootsSet))
471
+ for rel := range rootsSet {
472
+ roots = append(roots, rel)
473
+ }
474
+ sort.Strings(roots)
475
+ kept := make([]string, 0, len(roots))
476
+ isRoot := map[string]bool{}
477
+ for _, rel := range roots {
478
+ if underAny(rel, isRoot) {
479
+ continue
480
+ }
481
+ isRoot[rel] = true
482
+ kept = append(kept, rel)
483
+ }
484
+ if len(kept) > maxScanRoots {
485
+ kept = kept[:maxScanRoots]
486
+ partial = true
487
+ }
488
+ return kept, partial
489
+ }
490
+
491
+ // underAny reports whether any ancestor directory of rel is in set.
492
+ func underAny(rel string, set map[string]bool) bool {
493
+ for i := strings.IndexByte(rel, '/'); i > 0; {
494
+ if set[rel[:i]] {
495
+ return true
496
+ }
497
+ next := strings.IndexByte(rel[i+1:], '/')
498
+ if next < 0 {
499
+ return false
500
+ }
501
+ i += 1 + next
502
+ }
503
+ return false
504
+ }
505
+
506
+ // walkFiles enumerates files breadth-first, one level at a time, so the
507
+ // maxMapFiles budget fills with shallow entries before deep ones and
508
+ // enumeration stops at the first level boundary past the budget — deeper
509
+ // levels are never even read. In project mode (workspace=false) the whole
510
+ // tree is walked. In workspace mode only the root's loose files and the
511
+ // discovered scanRoots subtrees are read; no roots means no subtree gets
512
+ // walked — that is the only-project-subtrees policy at work, not
513
+ // truncation. Unreadable directories are skipped, never fatal.
514
+ func walkFiles(root string, scanRoots []string, workspace bool) ([]string, bool) {
515
+ var files []string
516
+ truncated := false
517
+
518
+ var queue []walkEntry
519
+ if !workspace {
520
+ queue = []walkEntry{{}}
521
+ } else {
522
+ for _, rel := range scanRoots {
523
+ queue = append(queue, walkEntry{rel: rel, depth: strings.Count(rel, "/") + 1})
524
+ }
525
+ if entries, err := readDirBounded(root); err == nil {
526
+ for _, entry := range entries {
527
+ if mappableFile(entry) {
528
+ files = append(files, entry.Name())
529
+ }
530
+ }
531
+ } else if !benignReadError(err) {
532
+ truncated = true
533
+ }
534
+ }
535
+
536
+ for len(queue) > 0 {
537
+ sort.Slice(queue, func(i, j int) bool {
538
+ if queue[i].depth != queue[j].depth {
539
+ return queue[i].depth < queue[j].depth
540
+ }
541
+ return queue[i].rel < queue[j].rel
542
+ })
543
+ depth := queue[0].depth
544
+ wave := 0
545
+ for wave < len(queue) && queue[wave].depth == depth {
546
+ wave++
547
+ }
548
+ if len(files) >= maxMapFiles || depth > maxWalkDepth {
549
+ truncated = true
550
+ break
551
+ }
552
+ level := queue[:wave]
553
+ queue = queue[wave:]
554
+ for _, cur := range level {
555
+ entries, err := readDirBounded(filepath.Join(root, filepath.FromSlash(cur.rel)))
556
+ if err != nil {
557
+ // a subtree the walk chose but could not read is data
558
+ // loss, unlike the policy skips below; a vanished or
559
+ // never-a-directory seed loses nothing
560
+ if !benignReadError(err) {
561
+ truncated = true
562
+ }
563
+ continue
564
+ }
565
+ for _, entry := range entries {
566
+ name := entry.Name()
567
+ if entry.IsDir() {
568
+ if skipWalkDir(name) {
569
+ continue
570
+ }
571
+ // protected-dir gating lives in probeProjectRoots: a
572
+ // workspace walk only ever starts inside scan roots
573
+ queue = append(queue, walkEntry{rel: joinRel(cur.rel, name), depth: depth + 1})
574
+ } else if mappableFile(entry) {
575
+ files = append(files, joinRel(cur.rel, name))
576
+ }
577
+ }
578
+ }
579
+ }
580
+ return files, truncated
581
+ }
582
+
583
+ // orderCandidates puts the candidate list in budget-fill order: plain
584
+ // alphabetical when everything fits, otherwise level by level with non-dot
585
+ // paths ahead of dot-prefixed ones within a level — the shape the walk
586
+ // enumerates in — so the budget cut lands on the deepest, most hidden
587
+ // corners.
588
+ func orderCandidates(files []string) []string {
589
+ if len(files) <= maxMapFiles {
590
+ sort.Strings(files)
591
+ return files
592
+ }
593
+ type ranked struct {
594
+ path string
595
+ dot bool
596
+ depth int
597
+ }
598
+ items := make([]ranked, len(files))
599
+ for i, p := range files {
600
+ items[i] = ranked{
601
+ path: p,
602
+ depth: strings.Count(p, "/"),
603
+ dot: strings.HasPrefix(p, ".") || strings.Contains(p, "/."),
604
+ }
605
+ }
606
+ sort.Slice(items, func(i, j int) bool {
607
+ a, b := items[i], items[j]
608
+ if a.depth != b.depth {
609
+ return a.depth < b.depth
610
+ }
611
+ if a.dot != b.dot {
612
+ return b.dot
613
+ }
614
+ return a.path < b.path
615
+ })
616
+ for i := range items {
617
+ files[i] = items[i].path
618
+ }
619
+ return files
620
+ }
621
+
622
+ // cleanRel normalizes a trace target path into a root-relative slash path,
623
+ // or "" when it is absolute or escapes the root.
624
+ func cleanRel(p string) string {
625
+ p = path.Clean(filepath.ToSlash(p))
626
+ if p == "" || p == "." || p == ".." || strings.HasPrefix(p, "/") || strings.HasPrefix(p, "../") {
627
+ return ""
628
+ }
629
+ return p
630
+ }
631
+
632
+ func joinRel(dir, name string) string {
633
+ if dir == "" {
634
+ return name
635
+ }
636
+ return dir + "/" + name
637
+ }
638
+
639
+ // errNotRegular marks an entry that exists but is never city material —
640
+ // FIFOs, sockets, devices, symlinks resolving to directories. Skipping one
641
+ // is policy, not data loss, and it is not proven gone either: no ghost, no
642
+ // truncation.
643
+ var errNotRegular = errors.New("not a regular file")
644
+
645
+ // benignReadError reports read failures that lose nothing mappable: the
646
+ // path is gone, or was never a directory (a stale trace seed). Everything
647
+ // else — timeout, permission, IO — is content the map failed to show and
648
+ // must surface as truncated.
649
+ func benignReadError(err error) bool {
650
+ return errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR)
651
+ }
652
+
653
+ // readDirBounded lists a directory but gives up after readDirTimeout. A
654
+ // directory open can block indefinitely — TCC-gated folders wait on a
655
+ // consent dialog a background process never sees, dead network mounts and
656
+ // cloud placeholders wait on IO — and a hung build is worse than a missing
657
+ // subtree. The abandoned goroutine (at most one per hung directory) is the
658
+ // price of surviving; the timeout is generous enough that healthy
659
+ // filesystems never trip it, keeping the map deterministic in practice.
660
+ func readDirBounded(dir string) ([]os.DirEntry, error) {
661
+ type result struct {
662
+ entries []os.DirEntry
663
+ err error
664
+ }
665
+ ch := make(chan result, 1)
666
+ go func() {
667
+ entries, err := os.ReadDir(dir)
668
+ ch <- result{entries, err}
669
+ }()
670
+ select {
671
+ case r := <-ch:
672
+ return r.entries, r.err
673
+ case <-time.After(readDirTimeout):
674
+ return nil, errors.New("directory read timed out")
675
+ }
676
+ }
677
+
678
+ // mappableFile reports whether a directory entry can go on the map: regular
679
+ // files and symlinks only. FIFOs and sockets must never be collected — a
680
+ // later open() on a writerless FIFO blocks forever.
681
+ func mappableFile(entry os.DirEntry) bool {
682
+ t := entry.Type()
683
+ return t.IsRegular() || t&os.ModeSymlink != 0
684
+ }
685
+
686
+ // touchedTopDirs lists the first path component of every touched file — the
687
+ // key that lets a walk enter an otherwise-gated protected directory.
688
+ func touchedTopDirs(touched map[string]bool) map[string]bool {
689
+ out := map[string]bool{}
690
+ for p := range touched {
691
+ rel := cleanRel(p)
692
+ if i := strings.Index(rel, "/"); i > 0 {
693
+ out[rel[:i]] = true
694
+ }
695
+ }
696
+ return out
697
+ }
698
+
699
+ // inspectFileBounded runs inspectFile with a deadline — opening a cloud
700
+ // placeholder file can block on materialization the way a gated directory
701
+ // read blocks on consent. A file that cannot be inspected in time is
702
+ // dropped from the map.
703
+ func inspectFileBounded(root, rel string) (model.CityFile, error) {
704
+ type result struct {
705
+ meta model.CityFile
706
+ err error
707
+ }
708
+ ch := make(chan result, 1)
709
+ go func() {
710
+ meta, err := inspectFile(root, rel)
711
+ ch <- result{meta, err}
712
+ }()
713
+ select {
714
+ case r := <-ch:
715
+ return r.meta, r.err
716
+ case <-time.After(inspectTimeout):
717
+ return model.CityFile{}, errors.New("file inspection timed out")
718
+ }
719
+ }
720
+
721
+ func inspectFile(root, rel string) (model.CityFile, error) {
722
+ abs := filepath.Join(root, filepath.FromSlash(rel))
723
+ info, err := os.Stat(abs)
724
+ if err != nil {
725
+ return model.CityFile{}, err
726
+ }
727
+ if !info.Mode().IsRegular() {
728
+ // FIFOs, sockets, devices — opening these can block forever
729
+ return model.CityFile{}, errNotRegular
730
+ }
731
+ lines := 1
732
+ if info.Size() > maxLineCountBytes {
733
+ // too big to read end to end; fileWeight falls back to bytes
734
+ lines = 0
735
+ } else if !isBinaryLike(abs, rel) {
736
+ var err error
737
+ lines, err = countLines(abs)
738
+ if err != nil {
739
+ lines = 0
740
+ }
741
+ }
742
+ dir := filepath.ToSlash(filepath.Dir(rel))
743
+ if dir == "." {
744
+ dir = ""
745
+ }
746
+ return model.CityFile{
747
+ Path: filepath.ToSlash(rel),
748
+ Dir: dir,
749
+ Lines: lines,
750
+ Bytes: info.Size(),
751
+ Lang: langForPath(rel),
752
+ }, nil
753
+ }
754
+
755
+ func isBinaryLike(abs, path string) bool {
756
+ ext := strings.ToLower(filepath.Ext(path))
757
+ switch ext {
758
+ case ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".zip", ".gz", ".tgz", ".mp4", ".mov", ".mp3", ".woff", ".woff2", ".ttf", ".otf":
759
+ return true
760
+ }
761
+ f, err := os.Open(abs)
762
+ if err != nil {
763
+ return false
764
+ }
765
+ defer f.Close()
766
+ buf := make([]byte, 8192)
767
+ n, err := f.Read(buf)
768
+ if err != nil && n == 0 {
769
+ return false
770
+ }
771
+ return bytes.Contains(buf[:n], []byte{0})
772
+ }
773
+
774
+ func countLines(path string) (int, error) {
775
+ f, err := os.Open(path)
776
+ if err != nil {
777
+ return 0, err
778
+ }
779
+ defer f.Close()
780
+
781
+ lines := 0
782
+ hasBytes := false
783
+ var last byte
784
+ buf := make([]byte, 64*1024)
785
+ for {
786
+ n, err := f.Read(buf)
787
+ if n > 0 {
788
+ hasBytes = true
789
+ last = buf[n-1]
790
+ lines += bytes.Count(buf[:n], []byte{'\n'})
791
+ }
792
+ if err == io.EOF {
793
+ break
794
+ }
795
+ if err != nil {
796
+ return lines, err
797
+ }
798
+ }
799
+ if hasBytes && last != '\n' {
800
+ lines++
801
+ }
802
+ return lines, nil
803
+ }
804
+
805
+ func langForPath(path string) string {
806
+ ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
807
+ switch ext {
808
+ case "go":
809
+ return "go"
810
+ case "ts", "tsx":
811
+ return "typescript"
812
+ case "js", "jsx":
813
+ return "javascript"
814
+ case "md", "mdx":
815
+ return "markdown"
816
+ case "py":
817
+ return "python"
818
+ case "json":
819
+ return "json"
820
+ case "yaml", "yml":
821
+ return "yaml"
822
+ case "css":
823
+ return "css"
824
+ case "html":
825
+ return "html"
826
+ case "":
827
+ return "text"
828
+ default:
829
+ return ext
830
+ }
831
+ }
832
+
833
+ // gitOutput runs one git subcommand against root under the shared deadline —
834
+ // no git call may hang the build. WaitDelay forces the pipes shut if a
835
+ // grandchild survives the kill and holds them open.
836
+ func gitOutput(root string, args ...string) ([]byte, error) {
837
+ ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
838
+ defer cancel()
839
+ cmd := exec.CommandContext(ctx, "git", append([]string{"-C", root}, args...)...)
840
+ cmd.WaitDelay = time.Second
841
+ return cmd.Output()
842
+ }
843
+
844
+ func repoState(root string) (string, bool) {
845
+ commit := ""
846
+ if commitBytes, err := gitOutput(root, "rev-parse", "--short", "HEAD"); err == nil {
847
+ commit = strings.TrimSpace(string(commitBytes))
848
+ }
849
+
850
+ statusBytes, err := gitOutput(root, "status", "--porcelain")
851
+ dirty := err == nil && len(bytes.TrimSpace(statusBytes)) > 0
852
+ return commit, dirty
853
+ }
854
+
855
+ type node struct {
856
+ name string
857
+ path string
858
+ children map[string]*node
859
+ files []int
860
+ weight float64
861
+ rect model.Rect
862
+ fileCount int
863
+ lines int
864
+ }
865
+
866
+ func buildTree(files []model.CityFile) *node {
867
+ root := &node{name: "", children: map[string]*node{}}
868
+ for i, file := range files {
869
+ parts := splitPath(file.Path)
870
+ cur := root
871
+ for _, part := range parts[:len(parts)-1] {
872
+ next := cur.children[part]
873
+ if next == nil {
874
+ nextPath := part
875
+ if cur.path != "" {
876
+ nextPath = cur.path + "/" + part
877
+ }
878
+ next = &node{name: part, path: nextPath, children: map[string]*node{}}
879
+ cur.children[part] = next
880
+ }
881
+ cur = next
882
+ }
883
+ cur.files = append(cur.files, i)
884
+ }
885
+ computeWeight(root, files)
886
+ return root
887
+ }
888
+
889
+ func splitPath(path string) []string {
890
+ parts := strings.Split(filepath.ToSlash(path), "/")
891
+ out := parts[:0]
892
+ for _, part := range parts {
893
+ if part != "" && part != "." {
894
+ out = append(out, part)
895
+ }
896
+ }
897
+ if len(out) == 0 {
898
+ return []string{path}
899
+ }
900
+ return out
901
+ }
902
+
903
+ func computeWeight(n *node, files []model.CityFile) float64 {
904
+ n.weight = 0
905
+ for _, idx := range n.files {
906
+ n.weight += fileWeight(files[idx])
907
+ n.fileCount++
908
+ n.lines += files[idx].Lines
909
+ }
910
+ for _, child := range sortedChildren(n.children) {
911
+ n.weight += computeWeight(child, files)
912
+ n.fileCount += child.fileCount
913
+ n.lines += child.lines
914
+ }
915
+ if n.weight <= 0 {
916
+ n.weight = 1
917
+ }
918
+ return n.weight
919
+ }
920
+
921
+ type layoutItem struct {
922
+ name string
923
+ kind string
924
+ idx int
925
+ node *node
926
+ weight float64
927
+ area float64
928
+ }
929
+
930
+ func layoutNode(n *node, rect model.Rect, files *[]model.CityFile, dirs *[]model.CityDir) {
931
+ n.rect = rect
932
+ if n.path != "" {
933
+ *dirs = append(*dirs, model.CityDir{
934
+ Path: n.path,
935
+ Depth: len(strings.Split(n.path, "/")),
936
+ Rect: n.rect,
937
+ FileCount: n.fileCount,
938
+ Lines: n.lines,
939
+ })
940
+ }
941
+
942
+ items := make([]layoutItem, 0, len(n.children)+len(n.files))
943
+ for _, child := range sortedChildren(n.children) {
944
+ items = append(items, layoutItem{name: child.name, kind: "dir", node: child, weight: child.weight})
945
+ }
946
+ for _, idx := range n.files {
947
+ items = append(items, layoutItem{name: (*files)[idx].Path, kind: "file", idx: idx, weight: fileWeight((*files)[idx])})
948
+ }
949
+ sort.Slice(items, func(i, j int) bool {
950
+ if items[i].weight == items[j].weight {
951
+ return items[i].name < items[j].name
952
+ }
953
+ return items[i].weight > items[j].weight
954
+ })
955
+
956
+ total := 0.0
957
+ for _, item := range items {
958
+ total += item.weight
959
+ }
960
+ if total <= 0 {
961
+ return
962
+ }
963
+
964
+ scale := rect.W * rect.D / total
965
+ for i := range items {
966
+ items[i].area = items[i].weight * scale
967
+ }
968
+ for _, placed := range squarify(rect, items) {
969
+ childRect := capAspect(inset(placed.rect, 0.08), 40)
970
+ item := placed.item
971
+ if item.kind == "dir" {
972
+ layoutNode(item.node, childRect, files, dirs)
973
+ } else {
974
+ (*files)[item.idx].Rect = childRect
975
+ }
976
+ }
977
+ }
978
+
979
+ func sortedChildren(children map[string]*node) []*node {
980
+ names := make([]string, 0, len(children))
981
+ for name := range children {
982
+ names = append(names, name)
983
+ }
984
+ sort.Strings(names)
985
+ out := make([]*node, 0, len(names))
986
+ for _, name := range names {
987
+ out = append(out, children[name])
988
+ }
989
+ return out
990
+ }
991
+
992
+ func capAspect(rect model.Rect, maxRatio float64) model.Rect {
993
+ if rect.W <= 0 || rect.D <= 0 || maxRatio <= 1 {
994
+ return rect
995
+ }
996
+ if rect.W/rect.D > maxRatio {
997
+ newW := rect.D * maxRatio
998
+ rect.X += (rect.W - newW) / 2
999
+ rect.W = newW
1000
+ } else if rect.D/rect.W > maxRatio {
1001
+ newD := rect.W * maxRatio
1002
+ rect.Z += (rect.D - newD) / 2
1003
+ rect.D = newD
1004
+ }
1005
+ return rect
1006
+ }
1007
+
1008
+ func fileWeight(file model.CityFile) float64 {
1009
+ units := float64(file.Lines)
1010
+ if byteUnits := float64(file.Bytes) / 4096.0; byteUnits > units {
1011
+ units = byteUnits
1012
+ }
1013
+ if units < 16 {
1014
+ units = 16
1015
+ }
1016
+ return math.Sqrt(units)
1017
+ }
1018
+
1019
+ type placedItem struct {
1020
+ item layoutItem
1021
+ rect model.Rect
1022
+ }
1023
+
1024
+ func squarify(rect model.Rect, items []layoutItem) []placedItem {
1025
+ remaining := rect
1026
+ var placed []placedItem
1027
+ var row []layoutItem
1028
+ for idx, item := range items {
1029
+ if item.area <= 0 {
1030
+ continue
1031
+ }
1032
+ side := math.Min(remaining.W, remaining.D)
1033
+ if len(row) < 2 || idx == len(items)-1 || worstAspect(append(row, item), side) <= worstAspect(row, side) {
1034
+ row = append(row, item)
1035
+ continue
1036
+ }
1037
+ var rowPlaced []placedItem
1038
+ rowPlaced, remaining = layoutRow(remaining, row)
1039
+ placed = append(placed, rowPlaced...)
1040
+ row = []layoutItem{item}
1041
+ }
1042
+ if len(row) > 0 {
1043
+ rowPlaced, _ := layoutRow(remaining, row)
1044
+ placed = append(placed, rowPlaced...)
1045
+ }
1046
+ return placed
1047
+ }
1048
+
1049
+ func worstAspect(row []layoutItem, side float64) float64 {
1050
+ if len(row) == 0 || side <= 0 {
1051
+ return math.Inf(1)
1052
+ }
1053
+ sum := 0.0
1054
+ minArea := math.Inf(1)
1055
+ maxArea := 0.0
1056
+ for _, item := range row {
1057
+ sum += item.area
1058
+ minArea = math.Min(minArea, item.area)
1059
+ maxArea = math.Max(maxArea, item.area)
1060
+ }
1061
+ if sum <= 0 || minArea <= 0 {
1062
+ return math.Inf(1)
1063
+ }
1064
+ side2 := side * side
1065
+ sum2 := sum * sum
1066
+ return math.Max(side2*maxArea/sum2, sum2/(side2*minArea))
1067
+ }
1068
+
1069
+ func layoutRow(rect model.Rect, row []layoutItem) ([]placedItem, model.Rect) {
1070
+ sum := 0.0
1071
+ for _, item := range row {
1072
+ sum += item.area
1073
+ }
1074
+ if sum <= 0 {
1075
+ return nil, rect
1076
+ }
1077
+ placed := make([]placedItem, 0, len(row))
1078
+ if rect.W >= rect.D {
1079
+ rowD := sum / rect.W
1080
+ x := rect.X
1081
+ for i, item := range row {
1082
+ w := item.area / rowD
1083
+ if i == len(row)-1 {
1084
+ w = rect.X + rect.W - x
1085
+ }
1086
+ placed = append(placed, placedItem{item: item, rect: model.Rect{X: x, Z: rect.Z, W: w, D: rowD}})
1087
+ x += w
1088
+ }
1089
+ rect.Z += rowD
1090
+ rect.D -= rowD
1091
+ } else {
1092
+ rowW := sum / rect.D
1093
+ z := rect.Z
1094
+ for i, item := range row {
1095
+ d := item.area / rowW
1096
+ if i == len(row)-1 {
1097
+ d = rect.Z + rect.D - z
1098
+ }
1099
+ placed = append(placed, placedItem{item: item, rect: model.Rect{X: rect.X, Z: z, W: rowW, D: d}})
1100
+ z += d
1101
+ }
1102
+ rect.X += rowW
1103
+ rect.W -= rowW
1104
+ }
1105
+ if rect.W < 0 {
1106
+ rect.W = 0
1107
+ }
1108
+ if rect.D < 0 {
1109
+ rect.D = 0
1110
+ }
1111
+ return placed, rect
1112
+ }
1113
+
1114
+ func inset(rect model.Rect, pad float64) model.Rect {
1115
+ if rect.W > pad*2 {
1116
+ rect.X += pad
1117
+ rect.W -= pad * 2
1118
+ }
1119
+ if rect.D > pad*2 {
1120
+ rect.Z += pad
1121
+ rect.D -= pad * 2
1122
+ }
1123
+ return rect
1124
+ }