v0.4.0 21d1792

business_logic.go RawHistoryFolder

1package analyzer
2
3import (
4	"fmt"
5	"go/ast"
6	"go/token"
7	"go/types"
8	"strings"
9
10	"github.com/bnema/hexcheck/config"
11)
12
13type businessLogicConfidence string
14
15const (
16	businessLogicConfidenceNone   businessLogicConfidence = "none"
17	businessLogicConfidenceLow    businessLogicConfidence = "low"
18	businessLogicConfidenceMedium businessLogicConfidence = "medium"
19	businessLogicConfidenceHigh   businessLogicConfidence = "high"
20)
21
22type businessLogicSignal struct {
23	kind   string
24	strong bool
25}
26
27func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match) {
28	if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint {
29		return
30	}
31	if r.cfg.Heuristics.ExcludeTestFiles != nil && *r.cfg.Heuristics.ExcludeTestFiles && strings.HasSuffix(filePath, "_test.go") {
32		return
33	}
34
35	for _, decl := range file.Decls {
36		fn, ok := decl.(*ast.FuncDecl)
37		if !ok || fn.Body == nil {
38			continue
39		}
40		r.isSuspiciousBusinessFunction(fn, file, filePath, current.Role)
41	}
42}
43
44func (r runner) isSuspiciousBusinessFunction(fn *ast.FuncDecl, file *ast.File, filePath string, fileRole config.Role) bool {
45	facts := r.collectBusinessFunctionFacts(fn, fileRole, file)
46	if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
47		return false
48	}
49	strong, weak := facts.classify()
50	if facts.isTechnicalDetectionOnly() {
51		return false
52	}
53	confidence := facts.businessLogicConfidence(strong, weak)
54	if confidenceMeets(confidence, effectiveBusinessLogicMinConfidence(r.cfg)) {
55		return r.report(fn.Name.Pos(), "suspicious-business-logic-in-adapter", filePath, "%s function %s has business-logic evidence: confidence=%s strong=%v weak=%v", facts.componentRole(), fn.Name.Name, confidence, signalKinds(strong), signalKinds(weak))
56	}
57	return false
58}
59
60func (r runner) collectBusinessFunctionFacts(fn *ast.FuncDecl, fileRole config.Role, file ...*ast.File) businessFunctionFacts {
61	if fileRole == "" {
62		fileRole = config.RoleAdapter
63	}
64	facts := businessFunctionFacts{runner: r, fn: fn, fileRole: fileRole, imports: map[string]bool{}}
65	if len(file) > 0 && file[0] != nil {
66		for _, imp := range file[0].Imports {
67			facts.imports[strings.Trim(imp.Path.Value, "\"")] = true
68		}
69	}
70	// Reject oversized bodies before type queries or secondary traversals.
71	ast.Inspect(fn.Body, func(n ast.Node) bool {
72		if n == nil {
73			return false
74		}
75		facts.nodeCount++
76		return facts.nodeCount <= *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes
77	})
78	if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
79		return facts
80	}
81	facts.collectLocals()
82	ast.Inspect(fn.Body, facts.inspect)
83	return facts
84}
85
86type businessFunctionFacts struct {
87	runner   runner
88	fn       *ast.FuncDecl
89	fileRole config.Role
90
91	nodeCount             int
92	branchCount           int
93	keyword               bool
94	domainTypeUse         bool
95	domainMutation        bool
96	domainDecision        bool
97	zeroDecision          bool
98	guardedDomainError    bool
99	domainMethodCall      bool
100	domainConstructorCall bool
101	domainErrorReturn     bool
102	policyConstant        bool
103	usecaseCalls          map[string]bool
104	imports               map[string]bool
105	usesTechnicalPackage  bool
106	constructedLocals     map[types.Object]bool
107}
108
109func (f *businessFunctionFacts) inspect(n ast.Node) bool {
110	if n == nil {
111		return true
112	}
113
114	if f.exprUsesRole(exprFromNode(n), config.RoleCore) || f.exprUsesRole(exprFromNode(n), config.RoleUsecase) {
115		f.domainTypeUse = true
116	}
117
118	switch node := n.(type) {
119	case *ast.IfStmt:
120		f.branchCount++
121		f.inspectDecision(node.Cond)
122		f.inspectMembershipInit(node)
123		f.inspectPolicyError(node)
124	case *ast.SwitchStmt:
125		f.branchCount++
126		f.inspectDecision(node.Tag)
127	case *ast.CaseClause:
128		for _, expr := range node.List {
129			f.inspectDecision(expr)
130		}
131	case *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt:
132		f.branchCount++
133	case *ast.AssignStmt:
134		for _, lhs := range node.Lhs {
135			if f.selectorReceiverUsesRole(lhs, config.RoleCore, config.RoleUsecase) && !f.isConstructedLocal(lhs) {
136				f.domainMutation = true
137			}
138		}
139	case *ast.CallExpr:
140		f.inspectCall(node)
141	case *ast.BasicLit:
142		if node.Kind == token.INT || node.Kind == token.FLOAT || node.Kind == token.STRING {
143			f.policyConstant = true
144		}
145	case *ast.ReturnStmt:
146		for _, result := range node.Results {
147			if f.exprUsesRole(result, config.RoleCore) || f.exprUsesRole(result, config.RoleUsecase) || f.isDomainError(result) {
148				f.domainErrorReturn = true
149			}
150		}
151	}
152	return true
153}
154
155// inspectDecision distinguishes domain decisions from merely transporting domain
156// values. Formatting calls do not become policy evidence in a comparison.
157func (f *businessFunctionFacts) inspectDecision(expr ast.Expr) {
158	if expr == nil {
159		return
160	}
161	ast.Inspect(expr, func(n ast.Node) bool {
162		switch node := n.(type) {
163		case *ast.IndexExpr:
164			// A domain-derived key deciding membership is policy, unlike
165			// formatting a key for a port call.
166			ast.Inspect(node.Index, func(n ast.Node) bool {
167				if call, ok := n.(*ast.CallExpr); ok {
168					if sel, ok := call.Fun.(*ast.SelectorExpr); ok && f.exprUsesRole(sel.X, config.RoleCore) {
169						f.domainDecision = true
170					}
171				}
172				return true
173			})
174		case *ast.CallExpr:
175			if fn, ok := f.calleeObject(node.Fun).(*types.Func); ok && f.coreObject(fn) {
176				sig := fn.Type().(*types.Signature)
177				if sig.Recv() == nil && sig.Results().Len() == 1 && types.Identical(sig.Results().At(0).Type(), types.Typ[types.Bool]) {
178					f.domainDecision = true
179				}
180			}
181			if sel, ok := node.Fun.(*ast.SelectorExpr); ok && f.exprUsesRole(sel.X, config.RoleCore) {
182				switch sel.Sel.Name {
183				case "String", "Error":
184				case "IsZero":
185					f.zeroDecision = true
186				default:
187					f.domainDecision = true
188				}
189			}
190			return false
191		case *ast.BinaryExpr:
192			switch node.Op {
193			case token.LSS, token.LEQ, token.GTR, token.GEQ:
194				if f.numericDomainOperand(node.X) || f.numericDomainOperand(node.Y) {
195					f.domainDecision = true
196				}
197			}
198		}
199		return true
200	})
201}
202
203func (f *businessFunctionFacts) isDomainError(expr ast.Expr) bool {
204	sel, ok := expr.(*ast.SelectorExpr)
205	if !ok || !strings.HasPrefix(sel.Sel.Name, "Err") {
206		return false
207	}
208	obj := f.runner.pass.TypesInfo.ObjectOf(sel.Sel)
209	if obj == nil || obj.Pkg() == nil {
210		return false
211	}
212	match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path()))
213	return ok && match.Role == config.RoleCore
214}
215
216func (f *businessFunctionFacts) calleeObject(expr ast.Expr) types.Object {
217	switch expr := expr.(type) {
218	case *ast.Ident:
219		return f.runner.pass.TypesInfo.ObjectOf(expr)
220	case *ast.SelectorExpr:
221		return f.runner.pass.TypesInfo.ObjectOf(expr.Sel)
222	}
223	return nil
224}
225
226func (f *businessFunctionFacts) coreObject(obj types.Object) bool {
227	if obj == nil || obj.Pkg() == nil {
228		return false
229	}
230	match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path()))
231	return ok && match.Role == config.RoleCore
232}
233
234func (f *businessFunctionFacts) numericDomainOperand(expr ast.Expr) bool {
235	t := f.runner.pass.TypesInfo.TypeOf(expr)
236	if t == nil {
237		return false
238	}
239	basic, ok := t.Underlying().(*types.Basic)
240	if !ok || basic.Info()&(types.IsInteger|types.IsFloat) == 0 {
241		return false
242	}
243	switch expr := expr.(type) {
244	case *ast.ParenExpr:
245		return f.numericDomainOperand(expr.X)
246	case *ast.SelectorExpr:
247		return f.exprUsesRole(expr.X, config.RoleCore) || f.coreObject(f.runner.pass.TypesInfo.ObjectOf(expr.Sel))
248	case *ast.Ident:
249		return f.coreObject(f.runner.pass.TypesInfo.ObjectOf(expr))
250	}
251	return false
252}
253
254func (f *businessFunctionFacts) inspectMembershipInit(stmt *ast.IfStmt) {
255	assign, ok := stmt.Init.(*ast.AssignStmt)
256	if !ok || len(assign.Lhs) != 2 || len(assign.Rhs) != 1 {
257		return
258	}
259	index, ok := assign.Rhs[0].(*ast.IndexExpr)
260	if !ok {
261		return
262	}
263	t := f.runner.pass.TypesInfo.TypeOf(index.X)
264	if t == nil {
265		return
266	}
267	if _, ok := t.Underlying().(*types.Map); !ok {
268		return
269	}
270	id, ok := assign.Lhs[1].(*ast.Ident)
271	if !ok || id.Name == "_" {
272		return
273	}
274	obj := f.runner.pass.TypesInfo.ObjectOf(id)
275	// Only follow the comma-ok boolean when it participates in the condition.
276	ast.Inspect(stmt.Cond, func(n ast.Node) bool {
277		if use, ok := n.(*ast.Ident); ok && f.runner.pass.TypesInfo.ObjectOf(use) == obj {
278			f.inspectDecision(index)
279		}
280		return true
281	})
282}
283
284// A guarded domain error is policy evidence unless it is a conventional
285// storage/validation sentinel. Those are routinely translated by adapters.
286func (f *businessFunctionFacts) inspectPolicyError(stmt *ast.IfStmt) {
287	for _, s := range stmt.Body.List {
288		ret, ok := s.(*ast.ReturnStmt)
289		if !ok {
290			continue
291		}
292		for _, result := range ret.Results {
293			sel, ok := result.(*ast.SelectorExpr)
294			if !ok || !strings.HasPrefix(sel.Sel.Name, "Err") {
295				continue
296			}
297			switch sel.Sel.Name {
298			case "ErrNotFound", "ErrConflict", "ErrInvalid", "ErrAlreadyExists":
299				continue
300			}
301			if f.isDomainError(result) {
302				f.domainDecision = true
303				f.guardedDomainError = true
304			}
305		}
306	}
307}
308
309// isConstructedLocal excludes field-by-field decoding into a fresh local
310// value. Values obtained from calls (including ports) remain mutation evidence.
311func (f *businessFunctionFacts) isConstructedLocal(expr ast.Expr) bool {
312	for {
313		sel, ok := expr.(*ast.SelectorExpr)
314		if !ok {
315			break
316		}
317		expr = sel.X
318	}
319	id, ok := expr.(*ast.Ident)
320	if !ok {
321		return false
322	}
323	return f.constructedLocals[f.runner.pass.TypesInfo.ObjectOf(id)]
324}
325
326func (f *businessFunctionFacts) collectLocals() {
327	f.constructedLocals = map[types.Object]bool{}
328	reassigned := map[types.Object]bool{}
329	info := f.runner.pass.TypesInfo
330	ast.Inspect(f.fn.Body, func(n ast.Node) bool {
331		switch node := n.(type) {
332		case *ast.ValueSpec:
333			for i, name := range node.Names {
334				if obj := info.Defs[name]; obj != nil {
335					f.constructedLocals[obj] = len(node.Values) == 0 || (i < len(node.Values) && isFreshValue(node.Values[i]))
336				}
337			}
338		case *ast.AssignStmt:
339			for i, lhs := range node.Lhs {
340				if name, ok := lhs.(*ast.Ident); ok {
341					obj := info.ObjectOf(name)
342					if obj == nil {
343						continue
344					}
345					if info.Defs[name] == obj {
346						f.constructedLocals[obj] = i < len(node.Rhs) && isFreshValue(node.Rhs[i])
347					} else {
348						reassigned[obj] = true
349					}
350				}
351			}
352		}
353		return true
354	})
355	for obj := range reassigned {
356		delete(f.constructedLocals, obj)
357	}
358}
359
360func isFreshValue(expr ast.Expr) bool {
361	if unary, ok := expr.(*ast.UnaryExpr); ok && unary.Op == token.AND {
362		expr = unary.X
363	}
364	_, ok := expr.(*ast.CompositeLit)
365	return ok
366}
367
368func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
369	f.inspectTechnicalCall(call)
370	calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun)
371	if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) {
372		f.domainConstructorCall = true
373	}
374	if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
375		if f.exprUsesRole(sel.X, config.RoleCore) {
376			f.domainMethodCall = true
377		}
378		if f.exprUsesRole(sel.X, config.RolePorts) || f.exprUsesRole(sel.X, config.RoleUsecase) {
379			if f.usecaseCalls == nil {
380				f.usecaseCalls = map[string]bool{}
381			}
382			f.usecaseCalls[collaboratorKey(f.runner.pass.TypesInfo, sel.X)] = true
383		}
384	}
385	for _, arg := range call.Args {
386		if f.exprUsesRole(arg, config.RoleCore) || f.exprUsesRole(arg, config.RoleUsecase) {
387			f.domainTypeUse = true
388		}
389	}
390}
391
392func (f *businessFunctionFacts) inspectTechnicalCall(call *ast.CallExpr) {
393	sel, ok := call.Fun.(*ast.SelectorExpr)
394	if !ok {
395		return
396	}
397	ident, ok := sel.X.(*ast.Ident)
398	if !ok || f.runner.pass == nil || f.runner.pass.TypesInfo == nil {
399		return
400	}
401	pkgName, ok := f.runner.pass.TypesInfo.ObjectOf(ident).(*types.PkgName)
402	if !ok || pkgName.Imported() == nil {
403		return
404	}
405	path := pkgName.Imported().Path()
406	if technicalImportPaths[path] && f.imports[path] {
407		f.usesTechnicalPackage = true
408	}
409}
410
411var technicalImportPaths = map[string]bool{
412	"os":            true,
413	"path/filepath": true,
414	"strconv":       true,
415	"bufio":         true,
416}
417
418func collaboratorKey(info *types.Info, expr ast.Expr) string {
419	if ident, ok := expr.(*ast.Ident); ok {
420		if obj := info.ObjectOf(ident); obj != nil {
421			return obj.Pkg().Path() + "." + obj.Name()
422		}
423	}
424	return fmt.Sprintf("%p", expr)
425}
426
427func (f *businessFunctionFacts) businessLogicConfidence(strong, weak []businessLogicSignal) businessLogicConfidence {
428	if len(strong) >= *f.runner.cfg.Heuristics.BusinessLogicMinStrongSignals {
429		return businessLogicConfidenceHigh
430	}
431	if len(strong) >= 1 && len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals {
432		return businessLogicConfidenceMedium
433	}
434	if len(strong) >= 1 || len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals {
435		return businessLogicConfidenceLow
436	}
437	return businessLogicConfidenceNone
438}
439
440func effectiveBusinessLogicMinConfidence(cfg *config.Config) string {
441	min := cfg.Heuristics.BusinessLogicMinConfidence
442	if cfg.Heuristics.BusinessLogicMode == "ci" && confidenceMeets(businessLogicConfidenceHigh, min) {
443		return string(businessLogicConfidenceHigh)
444	}
445	return min
446}
447
448func confidenceMeets(got businessLogicConfidence, min string) bool {
449	levels := map[businessLogicConfidence]int{
450		businessLogicConfidenceNone:   0,
451		businessLogicConfidenceLow:    1,
452		businessLogicConfidenceMedium: 2,
453		businessLogicConfidenceHigh:   3,
454	}
455	want, ok := levels[businessLogicConfidence(min)]
456	if !ok {
457		want = levels[businessLogicConfidenceLow]
458	}
459	return levels[got] >= want
460}
461
462func (f *businessFunctionFacts) isTechnicalDetectionOnly() bool {
463	if !containsWord(f.fn.Name.Name, "Detect") {
464		return false
465	}
466	if !f.usesTechnicalPackage {
467		return false
468	}
469	return !f.domainTypeUse && !f.domainMutation && !f.domainMethodCall && !f.domainConstructorCall && !f.domainErrorReturn && len(f.usecaseCalls) == 0
470}
471
472func (f *businessFunctionFacts) componentRole() config.Role {
473	return f.fileRole
474}
475
476func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) {
477	strong := []businessLogicSignal{}
478	weak := []businessLogicSignal{}
479	if len(f.fn.Type.Params.List) > 0 {
480		for _, field := range f.fn.Type.Params.List {
481			if f.typeHasAnyRole(f.runner.pass.TypesInfo.TypeOf(field.Type), config.RoleCore, config.RoleUsecase) {
482				f.domainTypeUse = true
483			}
484		}
485	}
486	for _, keyword := range f.runner.cfg.Heuristics.BusinessKeywords {
487		if containsWord(f.fn.Name.Name, keyword) {
488			f.keyword = true
489			break
490		}
491	}
492
493	if f.zeroDecision && f.domainMutation {
494		f.domainDecision = true
495	}
496	if f.domainMutation {
497		weak = append(weak, businessLogicSignal{kind: "domain mutation"})
498	}
499	if f.domainMethodCall {
500		weak = append(weak, businessLogicSignal{kind: "domain method call"})
501	}
502	if f.guardedDomainError && f.branchCount >= 2 {
503		weak = append(weak, businessLogicSignal{kind: "guarded domain error"})
504	}
505	if f.domainDecision && f.branchCount >= 2 {
506		strong = append(strong, businessLogicSignal{kind: "domain-driven control flow", strong: true})
507	}
508	if f.keyword && len(f.usecaseCalls) >= 2 {
509		strong = append(strong, businessLogicSignal{kind: "business keyword with multiple port/usecase collaborators", strong: true})
510	}
511	if f.keyword && f.branchCount >= 3 && f.policyConstant && policyKeyword(f.fn.Name.Name) {
512		strong = append(strong, businessLogicSignal{kind: "business keyword with policy constants and branching", strong: true})
513	}
514	if f.keyword && (f.domainDecision || (f.domainMutation && containsWord(f.fn.Name.Name, "Validate")) || (f.domainTypeUse && (containsWord(f.fn.Name.Name, "Compute") || containsWord(f.fn.Name.Name, "Calculate") || containsWord(f.fn.Name.Name, "Score")))) {
515		strong = append(strong, businessLogicSignal{kind: "business keyword with domain context", strong: true})
516	}
517
518	if f.keyword {
519		weak = append(weak, businessLogicSignal{kind: "business keyword", strong: false})
520	}
521	if f.policyConstant {
522		weak = append(weak, businessLogicSignal{kind: "policy constants", strong: false})
523	}
524	if f.domainErrorReturn {
525		weak = append(weak, businessLogicSignal{kind: "domain result return", strong: false})
526	}
527	if len(f.usecaseCalls) >= 2 {
528		weak = append(weak, businessLogicSignal{kind: "multiple port/usecase collaborators", strong: false})
529	}
530	if f.branchCount >= *f.runner.cfg.Heuristics.BusinessLogicThreshold {
531		weak = append(weak, businessLogicSignal{kind: fmt.Sprintf("branch count %d", f.branchCount), strong: false})
532	}
533	return strong, weak
534}
535
536func policyKeyword(name string) bool {
537	for _, word := range []string{"Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Performance", "Selected"} {
538		if containsWord(name, word) {
539			return true
540		}
541	}
542	return false
543}
544
545func (f *businessFunctionFacts) exprUsesRole(expr ast.Expr, roles ...config.Role) bool {
546	if expr == nil {
547		return false
548	}
549	return f.typeHasAnyRole(f.runner.pass.TypesInfo.TypeOf(expr), roles...)
550}
551
552func (f *businessFunctionFacts) selectorReceiverUsesRole(expr ast.Expr, roles ...config.Role) bool {
553	sel, ok := expr.(*ast.SelectorExpr)
554	if !ok {
555		return false
556	}
557	return f.exprUsesRole(sel.X, roles...)
558}
559
560func (f *businessFunctionFacts) typeHasAnyRole(t types.Type, roles ...config.Role) bool {
561	for _, role := range roles {
562		if f.typeHasRole(t, role) {
563			return true
564		}
565	}
566	return false
567}
568
569func (f *businessFunctionFacts) typeHasRole(t types.Type, role config.Role) bool {
570	if t == nil {
571		return false
572	}
573	switch tt := t.(type) {
574	case *types.Pointer:
575		return f.typeHasRole(tt.Elem(), role)
576	case *types.Slice:
577		return f.typeHasRole(tt.Elem(), role)
578	case *types.Array:
579		return f.typeHasRole(tt.Elem(), role)
580	case *types.Map:
581		return f.typeHasRole(tt.Key(), role) || f.typeHasRole(tt.Elem(), role)
582	case *types.Signature:
583		return f.typeHasRole(tt.Results(), role)
584	case *types.Tuple:
585		for v := range tt.Variables() {
586			if f.typeHasRole(v.Type(), role) {
587				return true
588			}
589		}
590		return false
591	case *types.Named:
592		obj := tt.Obj()
593		if obj == nil || obj.Pkg() == nil {
594			return false
595		}
596		match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path()))
597		if ok && match.Role == role {
598			return true
599		}
600		return f.typeHasRole(tt.Underlying(), role)
601	default:
602		return false
603	}
604}
605
606func exprFromNode(n ast.Node) ast.Expr {
607	expr, _ := n.(ast.Expr)
608	return expr
609}
610
611func signalKinds(signals []businessLogicSignal) []string {
612	out := make([]string, 0, len(signals))
613	for _, signal := range signals {
614		out = append(out, signal.kind)
615	}
616	return out
617}
618
619func containsWord(name, word string) bool {
620	if word == "" || len(word) > len(name) {
621		return false
622	}
623	for i := 0; i <= len(name)-len(word); i++ {
624		if !strings.EqualFold(name[i:i+len(word)], word) {
625			continue
626		}
627		beforeOK := i == 0 || isBoundaryRune(rune(name[i-1]), rune(name[i]))
628		after := i + len(word)
629		afterOK := after == len(name) || isBoundaryRune(rune(name[after-1]), rune(name[after]))
630		if beforeOK && afterOK {
631			return true
632		}
633	}
634	return false
635}
636
637func isBoundaryRune(prev, next rune) bool {
638	return prev == '_' || next == '_' || (prev >= 'a' && prev <= 'z' && next >= 'A' && next <= 'Z')
639}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 5

↑↓ moveenter openesc close