@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.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/synapse +0 -0
- package/cmd/rubriceval/main.go +308 -0
- package/cmd/synapse/main.go +280 -0
- package/cmd/synapse/main_test.go +16 -0
- package/go.mod +7 -0
- package/go.sum +6 -0
- package/internal/adapter/adapter.go +1117 -0
- package/internal/adapter/adapter_test.go +518 -0
- package/internal/adapter/agy/adapter.go +193 -0
- package/internal/adapter/claudecode/adapter.go +415 -0
- package/internal/adapter/claudecode/adapter_test.go +260 -0
- package/internal/adapter/claudecode/agents.go +387 -0
- package/internal/adapter/claudecode/agents_test.go +480 -0
- package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
- package/internal/adapter/codex/adapter.go +919 -0
- package/internal/adapter/codex/adapter_test.go +920 -0
- package/internal/adapter/codex/agents.go +401 -0
- package/internal/adapter/codex/agents_test.go +610 -0
- package/internal/adapter/codex/summary_inputs_test.go +20 -0
- package/internal/adapter/pi/adapter.go +483 -0
- package/internal/adapter/pi/adapter_test.go +517 -0
- package/internal/citymap/builder.go +1124 -0
- package/internal/citymap/builder_test.go +818 -0
- package/internal/judge/cache.go +180 -0
- package/internal/judge/cli.go +240 -0
- package/internal/judge/cli_test.go +68 -0
- package/internal/judge/fresh_summary_test.go +37 -0
- package/internal/judge/input.go +233 -0
- package/internal/judge/judge.go +416 -0
- package/internal/judge/judge_test.go +288 -0
- package/internal/judge/prompt.go +129 -0
- package/internal/judge/rubric.go +275 -0
- package/internal/judge/rubric_test.go +642 -0
- package/internal/model/agent.go +63 -0
- package/internal/model/agent_schema_test.go +86 -0
- package/internal/model/agent_test.go +64 -0
- package/internal/model/model.go +175 -0
- package/internal/model/report.go +166 -0
- package/internal/model/stats.go +151 -0
- package/internal/model/stats_test.go +89 -0
- package/internal/model/trace_schema_test.go +67 -0
- package/internal/server/analyze.go +286 -0
- package/internal/server/analyze_test.go +297 -0
- package/internal/server/codex_index_test.go +40 -0
- package/internal/server/hardening_test.go +147 -0
- package/internal/server/reportindex.go +91 -0
- package/internal/server/reportindex_test.go +116 -0
- package/internal/server/server.go +1099 -0
- package/internal/server/server_test.go +1389 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
- package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
- package/internal/server/static/assets/index-C_adLrJr.js +3 -0
- package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
- package/internal/server/static/index.html +26 -0
- package/internal/server/tracestore.go +173 -0
- package/internal/server/tracestore_test.go +51 -0
- package/internal/textutil/truncate.go +30 -0
- package/internal/textutil/truncate_test.go +39 -0
- package/package.json +35 -0
- package/web/e2e/agent-lens.spec.ts +688 -0
- package/web/index.html +23 -0
- package/web/package-lock.json +1933 -0
- package/web/package.json +33 -0
- package/web/playwright.config.ts +24 -0
- package/web/src/App.tsx +876 -0
- package/web/src/api/client.ts +74 -0
- package/web/src/main.tsx +12 -0
- package/web/src/playback/recorder.ts +160 -0
- package/web/src/playback/reducer.ts +91 -0
- package/web/src/scene/CityScene.tsx +638 -0
- package/web/src/scene/TreeScene.tsx +656 -0
- package/web/src/scene/dirLabels.ts +145 -0
- package/web/src/scene/sceneUtils.ts +144 -0
- package/web/src/scene/textures.ts +60 -0
- package/web/src/scene/trail.ts +79 -0
- package/web/src/scene/treeLayout.ts +169 -0
- package/web/src/state/filters.ts +40 -0
- package/web/src/state/store.ts +83 -0
- package/web/src/styles.css +2565 -0
- package/web/src/types.ts +315 -0
- package/web/src/ui/AgentsPanel.tsx +376 -0
- package/web/src/ui/Dock.tsx +104 -0
- package/web/src/ui/Hud.tsx +335 -0
- package/web/src/ui/Inspector.tsx +107 -0
- package/web/src/ui/LogoMark.tsx +38 -0
- package/web/src/ui/ReportPanel.tsx +491 -0
- package/web/src/ui/SessionRail.tsx +316 -0
- package/web/src/ui/Timeline.tsx +458 -0
- package/web/src/ui/ViewPanel.tsx +45 -0
- package/web/src/ui/shortcuts.ts +4 -0
- package/web/tsconfig.json +21 -0
- 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
|
+
}
|