internal/projectlayout/layout.go
1
package projectlayout
3
import (
4
"bytes"
5
"crypto/sha256"
6
"errors"
7
"fmt"
8
"os"
9
"os/exec"
10
"path/filepath"
11
"sort"
12
"strings"
13
)
15
const ReferencePath = "docs/reference/repository-layout.md"
17
const zedLicensePath = "integrations/editors/zed/LICENSE"
18
const zedManifestPath = "integrations/editors/zed/Cargo.toml"
19
const zedGPLv3SHA256 = "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986"
21
type Area struct {
22
Path string
23
Purpose string
24
}
26
var Areas = []Area{
27
{Path: ".claude-plugin/", Purpose: "Claude marketplace discovery metadata"},
28
{Path: ".codex/", Purpose: "generated Codex repository adapter"},
29
{Path: ".cursor/", Purpose: "generated Cursor repository adapter"},
30
{Path: ".github/", Purpose: "GitHub workflows, templates and ownership"},
31
{Path: ".koment/", Purpose: "authoritative annotations and policy"},
32
{Path: ".mise/", Purpose: "pinned toolchain configuration"},
33
{Path: ".opencode/", Purpose: "generated OpenCode repository adapter"},
34
{Path: ".vscode/", Purpose: "VS Code workspace discovery and validation"},
35
{Path: "cmd/", Purpose: "Go binary entry points"},
36
{Path: "distribution/", Purpose: "delivery and deployment assets"},
37
{Path: "docs/", Purpose: "start, guide, reference and explanation documentation"},
38
{Path: "examples/", Purpose: "runnable and inspectable product examples"},
39
{Path: "integrations/", Purpose: "code installed into another product"},
40
{Path: "internal/", Purpose: "private Go product packages"},
41
{Path: "schema/", Purpose: "versioned public schemas"},
42
{Path: "scripts/", Purpose: "repository automation"},
43
{Path: "testdata/", Purpose: "repository-wide fixtures"},
44
}
46
var RootFiles = []string{
47
".gitignore",
48
".golangci.yml",
49
".lefthook.toml",
50
".mcp.json",
51
".release-please-manifest.json",
52
".renovaterc.json5",
53
"AGENTS.md",
54
"CHANGELOG.md",
55
"CLA.md",
56
"CLAUDE.md",
57
"CONTRIBUTING.md",
58
"DESIGN.md",
59
"Dockerfile",
60
"LICENSE",
61
"README.md",
62
"SECURITY.md",
63
"TRADEMARK.md",
64
"action.yml",
65
"go.mod",
66
"go.sum",
67
"opencode.json",
68
"release-please-config.json",
69
"server.json",
70
}
72
var ClosedChildren = map[string][]string{
73
"distribution": {"helm", "package-managers"},
74
"distribution/helm": {"chart_test.go", "koment"},
75
"distribution/package-managers": {"README.md", "homebrew", "naming_test.go", "registry_test.go", "scoop", "winget"},
76
"docs": {"README.md", "explanation", "guides", "reference", "start"},
77
"examples": {"annotated-workspace"},
78
"integrations": {"agent-plugins", "editors"},
79
"integrations/agent-plugins": {"README.md", "claude", "codex", "hermes", "opencode"},
80
"integrations/editors": {"vscode", "zed"},
81
}
83
var LegacyRoots = []string{"charts", "editors", "packaging", "plugins", "workspace"}
85
type referenceMigration struct {
86
Retired string
87
Replacement string
88
}
90
var referenceMigrations = []referenceMigration{
91
{Retired: "docs/" + "releasing.md", Replacement: "docs/guides/release-koment.md"},
92
}
94
func RepositoryRoot(start string) (string, error) {
95
root, err := filepath.Abs(start)
96
if err != nil {
97
return "", err
98
}
99
for {
100
if _, err := os.Stat(filepath.Join(root, "go.mod")); err == nil {
101
return root, nil
102
}
103
parent := filepath.Dir(root)
104
if parent == root {
105
return "", fmt.Errorf("no repository root above %s", start)
106
}
107
root = parent
108
}
109
}
111
func Check(root string) error {
112
repositoryRoot, err := os.OpenRoot(root)
113
if err != nil {
114
return err
115
}
116
checkError := checkRepository(root, repositoryRoot)
117
return errors.Join(checkError, repositoryRoot.Close())
118
}
120
func checkRepository(root string, repositoryRoot *os.Root) error {
121
paths, err := repositoryPaths(root, repositoryRoot)
122
if err != nil {
123
return err
124
}
125
violations := ValidatePaths(paths)
126
referenceViolations, err := validateRetiredReferences(repositoryRoot, paths)
127
if err != nil {
128
return err
129
}
130
violations = append(violations, referenceViolations...)
131
rootViolations, err := validateLegacyRoots(repositoryRoot)
132
if err != nil {
133
return err
134
}
135
violations = append(violations, rootViolations...)
136
licenseViolations, err := validateZedLicense(repositoryRoot)
137
if err != nil {
138
return err
139
}
140
violations = append(violations, licenseViolations...)
141
current, err := repositoryRoot.ReadFile(ReferencePath)
142
if err != nil {
143
violations = append(violations, fmt.Sprintf("%s: %v", ReferencePath, err))
144
} else if !bytes.Equal(current, Reference()) {
145
violations = append(violations, ReferencePath+": generated reference is stale; run mise run layout-render")
146
}
147
if len(violations) == 0 {
148
return nil
149
}
150
sort.Strings(violations)
151
return errors.New(strings.Join(violations, "\n"))
152
}
154
func validateZedLicense(repositoryRoot *os.Root) ([]string, error) {
155
license, err := repositoryRoot.ReadFile(zedLicensePath)
156
if err != nil {
157
return nil, fmt.Errorf("read %s: %w", zedLicensePath, err)
158
}
159
manifest, err := repositoryRoot.ReadFile(zedManifestPath)
160
if err != nil {
161
return nil, fmt.Errorf("read %s: %w", zedManifestPath, err)
162
}
163
violations := []string{}
164
if fmt.Sprintf("%x", sha256.Sum256(license)) != zedGPLv3SHA256 {
165
violations = append(violations, zedLicensePath+": expected the verbatim GPLv3 text required by ADR 0145")
166
}
167
if !bytes.Contains(manifest, []byte("license = \"GPL-3.0-or-later\"")) {
168
violations = append(violations, zedManifestPath+": expected license = \"GPL-3.0-or-later\" required by ADR 0145")
169
}
170
return violations, nil
171
}
173
func ValidatePaths(paths []string) []string {
174
allowedRoots := map[string]bool{}
175
for _, area := range Areas {
176
allowedRoots[strings.TrimSuffix(area.Path, "/")] = true
177
}
178
allowedFiles := map[string]bool{}
179
for _, file := range RootFiles {
180
allowedFiles[file] = true
181
}
182
legacy := map[string]bool{}
183
for _, name := range LegacyRoots {
184
legacy[name] = true
185
}
186
closed := map[string]map[string]bool{}
187
for parent, children := range ClosedChildren {
188
closed[parent] = map[string]bool{}
189
for _, child := range children {
190
closed[parent][child] = true
191
}
192
}
194
violations := []string{}
195
for _, repositoryPath := range paths {
196
parts := strings.Split(filepath.ToSlash(repositoryPath), "/")
197
if len(parts) == 1 {
198
if !allowedFiles[parts[0]] {
199
violations = append(violations, repositoryPath+": root file is outside the layout contract")
200
}
201
continue
202
}
203
if legacy[parts[0]] {
204
violations = append(violations, repositoryPath+": legacy root must be migrated")
205
continue
206
}
207
if !allowedRoots[parts[0]] {
208
violations = append(violations, repositoryPath+": root directory is outside the layout contract")
209
continue
210
}
211
for depth := 1; depth < len(parts); depth++ {
212
parent := strings.Join(parts[:depth], "/")
213
children, isClosed := closed[parent]
214
if isClosed && !children[parts[depth]] {
215
violations = append(violations, repositoryPath+": "+parts[depth]+" is not allowed under "+parent)
216
break
217
}
218
}
219
}
220
return violations
221
}
223
func Reference() []byte {
224
var output strings.Builder
225
output.WriteString("# Repository layout\n\n")
226
output.WriteString("Generated from `internal/projectlayout`; edit the executable specification, not this file.\n\n")
227
output.WriteString("The repository root is a closed contract. A tracked or non-ignored path outside the areas and exact root files below fails `mise run layout-check`.\n\n")
228
output.WriteString("The same check rejects repository-controlled references to paths retired by completed migrations. Historical path provenance under `.koment/` is excluded.\n\n")
229
output.WriteString("## Architectural areas\n\n")
230
output.WriteString("| Path | Owner |\n|---|---|\n")
231
for _, area := range Areas {
232
fmt.Fprintf(&output, "| `%s` | %s |\n", area.Path, area.Purpose)
233
}
234
output.WriteString("\n## Closed categories\n\n")
235
parents := make([]string, 0, len(ClosedChildren))
236
for parent := range ClosedChildren {
237
parents = append(parents, parent)
238
}
239
sort.Strings(parents)
240
for _, parent := range parents {
241
children := append([]string(nil), ClosedChildren[parent]...)
242
sort.Strings(children)
243
fmt.Fprintf(&output, "- `%s/`: `%s`\n", parent, strings.Join(children, "`, `"))
244
}
245
output.WriteString("\n## Exact root files\n\n")
246
files := append([]string(nil), RootFiles...)
247
sort.Strings(files)
248
for _, file := range files {
249
fmt.Fprintf(&output, "- `%s`\n", file)
250
}
251
output.WriteString("\n## Changing the contract\n\n")
252
output.WriteString("A boundary change must supersede ADR 0143, demonstrate why no existing area can own the capability, update `DESIGN.md` and this specification, regenerate this page, and migrate every path and reference in the same change. Convenience, file count, implementation language and symmetry are insufficient reasons.\n")
253
return []byte(output.String())
254
}
256
func WriteReference(root string) error {
257
repositoryRoot, err := os.OpenRoot(root)
258
if err != nil {
259
return err
260
}
261
writeError := repositoryRoot.WriteFile(ReferencePath, Reference(), 0o644)
262
return errors.Join(writeError, repositoryRoot.Close())
263
}
265
func repositoryPaths(root string, repositoryRoot *os.Root) ([]string, error) {
266
command := exec.Command("git", "ls-files", "--cached", "--others", "--exclude-standard", "-z")
267
command.Dir = root
268
content, err := command.Output()
269
if err != nil {
270
return nil, fmt.Errorf("list repository paths: %w", err)
271
}
272
paths := []string{}
273
for _, item := range bytes.Split(content, []byte{0}) {
274
if len(item) == 0 {
275
continue
276
}
277
path := string(item)
278
if _, err := repositoryRoot.Lstat(filepath.FromSlash(path)); err == nil {
279
paths = append(paths, path)
280
} else if !os.IsNotExist(err) {
281
return nil, fmt.Errorf("inspect %s: %w", path, err)
282
}
283
}
284
return paths, nil
285
}
287
func validateRetiredReferences(repositoryRoot *os.Root, paths []string) ([]string, error) {
288
violations := []string{}
289
for _, repositoryPath := range paths {
290
if strings.HasPrefix(filepath.ToSlash(repositoryPath), ".koment/") {
291
continue
292
}
293
content, err := repositoryRoot.ReadFile(filepath.FromSlash(repositoryPath))
294
if err != nil {
295
return nil, fmt.Errorf("inspect references in %s: %w", repositoryPath, err)
296
}
297
for _, migration := range referenceMigrations {
298
if bytes.Contains(content, []byte(migration.Retired)) {
299
violations = append(violations, fmt.Sprintf("%s: retired reference %q must be replaced with %q", repositoryPath, migration.Retired, migration.Replacement))
300
}
301
}
302
}
303
return violations, nil
304
}
306
func validateLegacyRoots(repositoryRoot *os.Root) ([]string, error) {
307
violations := []string{}
308
for _, name := range LegacyRoots {
309
_, err := repositoryRoot.Stat(name)
310
if os.IsNotExist(err) {
311
continue
312
}
313
if err != nil {
314
return nil, fmt.Errorf("inspect legacy root %s: %w", name, err)
315
}
316
violations = append(violations, name+"/: legacy root must be migrated")
317
}
318
return violations, nil
319
}