module-spec 0.0.0-stage → 0.2.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/CHANGELOG.md +97 -0
- package/README.md +132 -2
- package/bin/module-spec.js +193 -0
- package/lib/affected.js +54 -0
- package/lib/baseline.js +34 -0
- package/lib/branch.js +26 -0
- package/lib/check.js +53 -0
- package/lib/checks/api.js +36 -0
- package/lib/checks/clones.js +33 -0
- package/lib/checks/imports.js +101 -0
- package/lib/checks/manifests.js +52 -0
- package/lib/checks/ports.js +32 -0
- package/lib/checks/tables.js +37 -0
- package/lib/config.js +56 -0
- package/lib/files.js +56 -0
- package/lib/languages/go/go.mod +5 -0
- package/lib/languages/go/index.js +37 -0
- package/lib/languages/go/mutate.js +56 -0
- package/lib/languages/go/read.js +40 -0
- package/lib/languages/go/read_project.go +433 -0
- package/lib/languages/go/scaffold.js +28 -0
- package/lib/languages/go/templates/module/internal/shell/create.go +14 -0
- package/lib/languages/go/templates/module/{{pkg}}.go +15 -0
- package/lib/languages/go/templates/module/{{pkg}}_test.go +13 -0
- package/lib/languages/go/templates/port/internal/adapters/in_memory_{{portSnake}}.go +10 -0
- package/lib/languages/go/templates/port/internal/ports/{{portSnake}}.go +5 -0
- package/lib/languages/go/templates/port/{{portSnake}}_contract_test.go +20 -0
- package/lib/languages/index.js +66 -0
- package/lib/languages/python/environment.js +15 -0
- package/lib/languages/python/index.js +35 -0
- package/lib/languages/python/mutate.js +72 -0
- package/lib/languages/python/package.js +16 -0
- package/lib/languages/python/read.js +81 -0
- package/lib/languages/python/read_project.py +145 -0
- package/lib/languages/python/scaffold.js +19 -0
- package/lib/languages/python/templates/module/__init__.py +3 -0
- package/lib/languages/python/templates/module/shell/__init__.py +0 -0
- package/lib/languages/python/templates/module/shell/create.py +16 -0
- package/lib/languages/python/templates/module/tests/__init__.py +0 -0
- package/lib/languages/python/templates/module/tests/test_{{snake}}.py +5 -0
- package/lib/languages/python/templates/port/adapters/in_memory_{{portSnake}}.py +9 -0
- package/lib/languages/python/templates/port/ports/{{portSnake}}.py +5 -0
- package/lib/languages/python/templates/port/tests/test_{{portSnake}}_contract.py +11 -0
- package/lib/languages/typescript/index.js +37 -0
- package/lib/languages/typescript/mutate.js +95 -0
- package/lib/languages/typescript/project.js +24 -0
- package/lib/languages/typescript/read.js +220 -0
- package/lib/languages/typescript/scaffold.js +60 -0
- package/lib/languages/typescript/templates/module/index.ts +2 -0
- package/lib/languages/typescript/templates/module/shell/create{{Name}}.ts +8 -0
- package/lib/languages/typescript/templates/module-test.node.ts +7 -0
- package/lib/languages/typescript/templates/module-test.vitest.ts +6 -0
- package/lib/languages/typescript/templates/port/adapters/inMemory{{Port}}.ts +5 -0
- package/lib/languages/typescript/templates/port/ports/{{Port}}.ts +1 -0
- package/lib/languages/typescript/templates/port-test.node.ts +12 -0
- package/lib/languages/typescript/templates/port-test.vitest.ts +12 -0
- package/lib/languages/typescript/templates/view-module/__tests__/{{name}}.test.tsx +9 -0
- package/lib/languages/typescript/templates/view-module/core/model.ts +25 -0
- package/lib/languages/typescript/templates/view-module/index.ts +4 -0
- package/lib/languages/typescript/templates/view-module/shell/create{{Name}}Model.ts +20 -0
- package/lib/languages/typescript/templates/view-module/view/{{Name}}Page.tsx +14 -0
- package/lib/modules.js +104 -0
- package/lib/mutation.js +124 -0
- package/lib/place.js +25 -0
- package/lib/report.js +42 -0
- package/lib/rules.js +145 -0
- package/lib/scaffold.js +57 -0
- package/lib/scans.js +62 -0
- package/lib/templating.js +28 -0
- package/lib/tools.js +17 -0
- package/manifest.schema.json +35 -0
- package/package.json +38 -4
- package/spec/adopting.md +70 -0
- package/spec/backend.md +176 -0
- package/spec/common.md +163 -0
- package/spec/frontend.md +181 -0
- package/spec/roles.md +93 -0
- package/spec/tests.md +146 -0
- package/spec/workflow.md +50 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// Command read_project reads a Go project for module-spec with Go's own parser: import edges down to
|
|
2
|
+
// the file that declares each name used, what the scan plan's scans see, test package clauses and what
|
|
3
|
+
// each door package exports.
|
|
4
|
+
//
|
|
5
|
+
// It takes one JSON request on stdin and prints one JSON answer on stdout.
|
|
6
|
+
package main
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"bufio"
|
|
10
|
+
"bytes"
|
|
11
|
+
"encoding/json"
|
|
12
|
+
"fmt"
|
|
13
|
+
"go/ast"
|
|
14
|
+
"go/parser"
|
|
15
|
+
"go/printer"
|
|
16
|
+
"go/token"
|
|
17
|
+
"os"
|
|
18
|
+
"path"
|
|
19
|
+
"path/filepath"
|
|
20
|
+
"sort"
|
|
21
|
+
"strings"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// plan is the kit's scan plan (lib/scans.js): the files each scan searches.
|
|
25
|
+
type plan struct {
|
|
26
|
+
Wait []string `json:"wait"`
|
|
27
|
+
IO []string `json:"io"`
|
|
28
|
+
Env []string `json:"env"`
|
|
29
|
+
Complexity []string `json:"complexity"`
|
|
30
|
+
MaxComplexity int `json:"maxComplexity"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type request struct {
|
|
34
|
+
ProjectRoot string `json:"projectRoot"`
|
|
35
|
+
Files []string `json:"files"`
|
|
36
|
+
TestFiles []string `json:"testFiles"`
|
|
37
|
+
DoorDirs []string `json:"doorDirs"`
|
|
38
|
+
Plan plan `json:"plan"`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type edge struct {
|
|
42
|
+
From string `json:"from"`
|
|
43
|
+
Line int `json:"line"`
|
|
44
|
+
To string `json:"to,omitempty"`
|
|
45
|
+
External string `json:"external,omitempty"`
|
|
46
|
+
Std bool `json:"std,omitempty"`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type found struct {
|
|
50
|
+
Scan string `json:"scan,omitempty"`
|
|
51
|
+
File string `json:"file"`
|
|
52
|
+
Line int `json:"line"`
|
|
53
|
+
Detail string `json:"detail"`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type answer struct {
|
|
57
|
+
Edges []edge `json:"edges"`
|
|
58
|
+
Sightings []found `json:"sightings"`
|
|
59
|
+
WhiteBox []found `json:"whiteBox"`
|
|
60
|
+
Exports map[string][]string `json:"exports"`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// saw records each of seen as a sighting of scan.
|
|
64
|
+
func (a *answer) saw(scan string, seen []found) {
|
|
65
|
+
for _, sighting := range seen {
|
|
66
|
+
sighting.Scan = scan
|
|
67
|
+
a.Sightings = append(a.Sightings, sighting)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// pkg is one directory's package: its name, the file that declares each top-level name, and its files.
|
|
72
|
+
type pkg struct {
|
|
73
|
+
name string
|
|
74
|
+
declared map[string]string
|
|
75
|
+
files []string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type project struct {
|
|
79
|
+
root string
|
|
80
|
+
modulePath string
|
|
81
|
+
fset *token.FileSet
|
|
82
|
+
parsed map[string]*ast.File
|
|
83
|
+
packages map[string]*pkg
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
var ioBuiltins = map[string]bool{"print": true, "println": true}
|
|
87
|
+
var envReads = map[string]bool{"Getenv": true, "LookupEnv": true, "Environ": true, "ExpandEnv": true}
|
|
88
|
+
|
|
89
|
+
func main() {
|
|
90
|
+
var req request
|
|
91
|
+
if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
|
|
92
|
+
fail("reading the request: %v", err)
|
|
93
|
+
}
|
|
94
|
+
p := &project{root: req.ProjectRoot, fset: token.NewFileSet(), parsed: map[string]*ast.File{}, packages: map[string]*pkg{}}
|
|
95
|
+
p.modulePath = readModulePath(filepath.Join(req.ProjectRoot, "go.mod"))
|
|
96
|
+
out := answer{
|
|
97
|
+
Edges: p.edges(req.Files),
|
|
98
|
+
Sightings: []found{},
|
|
99
|
+
WhiteBox: p.whiteBox(req.TestFiles),
|
|
100
|
+
Exports: map[string][]string{},
|
|
101
|
+
}
|
|
102
|
+
out.saw("wait", p.scan(req.Plan.Wait, waits))
|
|
103
|
+
out.saw("io", p.scan(req.Plan.IO, usesIOBuiltin))
|
|
104
|
+
out.saw("env", p.envReads(req.Plan.Env))
|
|
105
|
+
out.saw("complexity", p.complexity(req.Plan.Complexity, req.Plan.MaxComplexity))
|
|
106
|
+
for _, dir := range req.DoorDirs {
|
|
107
|
+
out.Exports[dir] = p.exports(dir)
|
|
108
|
+
}
|
|
109
|
+
if err := json.NewEncoder(os.Stdout).Encode(out); err != nil {
|
|
110
|
+
fail("writing the answer: %v", err)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func fail(format string, args ...any) {
|
|
115
|
+
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
|
116
|
+
os.Exit(1)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func readModulePath(goMod string) string {
|
|
120
|
+
file, err := os.Open(goMod)
|
|
121
|
+
if err != nil {
|
|
122
|
+
fail("a Go project needs go.mod at its root: %v", err)
|
|
123
|
+
}
|
|
124
|
+
defer file.Close()
|
|
125
|
+
lines := bufio.NewScanner(file)
|
|
126
|
+
for lines.Scan() {
|
|
127
|
+
if fields := strings.Fields(lines.Text()); len(fields) == 2 && fields[0] == "module" {
|
|
128
|
+
return strings.Trim(fields[1], `"`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
fail("go.mod names no module")
|
|
132
|
+
return ""
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func (p *project) parse(file string) *ast.File {
|
|
136
|
+
if parsed, ok := p.parsed[file]; ok {
|
|
137
|
+
return parsed
|
|
138
|
+
}
|
|
139
|
+
parsed, err := parser.ParseFile(p.fset, filepath.Join(p.root, file), nil, parser.SkipObjectResolution)
|
|
140
|
+
if err != nil {
|
|
141
|
+
fail("parsing %s: %v", file, err)
|
|
142
|
+
}
|
|
143
|
+
p.parsed[file] = parsed
|
|
144
|
+
return parsed
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func (p *project) line(node ast.Node) int {
|
|
148
|
+
return p.fset.Position(node.Pos()).Line
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// pkgAt reads the package in a project directory, test files left out.
|
|
152
|
+
func (p *project) pkgAt(dir string) *pkg {
|
|
153
|
+
if known, ok := p.packages[dir]; ok {
|
|
154
|
+
return known
|
|
155
|
+
}
|
|
156
|
+
found := &pkg{declared: map[string]string{}}
|
|
157
|
+
entries, _ := os.ReadDir(filepath.Join(p.root, dir))
|
|
158
|
+
for _, entry := range entries {
|
|
159
|
+
name := entry.Name()
|
|
160
|
+
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
file := path.Join(dir, name)
|
|
164
|
+
parsed := p.parse(file)
|
|
165
|
+
found.name = parsed.Name.Name
|
|
166
|
+
found.files = append(found.files, file)
|
|
167
|
+
for _, name := range topLevelNames(parsed) {
|
|
168
|
+
if _, taken := found.declared[name]; !taken {
|
|
169
|
+
found.declared[name] = file
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
sort.Strings(found.files)
|
|
174
|
+
p.packages[dir] = found
|
|
175
|
+
return found
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func topLevelNames(file *ast.File) []string {
|
|
179
|
+
var names []string
|
|
180
|
+
for _, decl := range file.Decls {
|
|
181
|
+
switch decl := decl.(type) {
|
|
182
|
+
case *ast.FuncDecl:
|
|
183
|
+
if decl.Recv == nil {
|
|
184
|
+
names = append(names, decl.Name.Name)
|
|
185
|
+
}
|
|
186
|
+
case *ast.GenDecl:
|
|
187
|
+
for _, spec := range decl.Specs {
|
|
188
|
+
switch spec := spec.(type) {
|
|
189
|
+
case *ast.TypeSpec:
|
|
190
|
+
names = append(names, spec.Name.Name)
|
|
191
|
+
case *ast.ValueSpec:
|
|
192
|
+
for _, name := range spec.Names {
|
|
193
|
+
names = append(names, name.Name)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return names
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// edges lists each file's imports. An import of a project package becomes an edge to each file that
|
|
203
|
+
// declares a name the importer uses, so a check can tell a port from the adapter beside it.
|
|
204
|
+
func (p *project) edges(files []string) []edge {
|
|
205
|
+
edges := []edge{}
|
|
206
|
+
for _, file := range files {
|
|
207
|
+
parsed := p.parse(file)
|
|
208
|
+
for _, imported := range parsed.Imports {
|
|
209
|
+
importPath := strings.Trim(imported.Path.Value, `"`)
|
|
210
|
+
line := p.line(imported)
|
|
211
|
+
if importPath != p.modulePath && !strings.HasPrefix(importPath, p.modulePath+"/") {
|
|
212
|
+
// The go command reserves import paths whose first element has no dot for the standard library.
|
|
213
|
+
std := !strings.Contains(strings.SplitN(importPath, "/", 2)[0], ".")
|
|
214
|
+
edges = append(edges, edge{From: file, Line: line, External: importPath, Std: std})
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
dir := strings.TrimPrefix(strings.TrimPrefix(importPath, p.modulePath), "/")
|
|
218
|
+
if dir == "" {
|
|
219
|
+
dir = "."
|
|
220
|
+
}
|
|
221
|
+
target := p.pkgAt(dir)
|
|
222
|
+
alias := target.name
|
|
223
|
+
if imported.Name != nil {
|
|
224
|
+
alias = imported.Name.Name
|
|
225
|
+
}
|
|
226
|
+
for _, to := range filesUsed(parsed, alias, target) {
|
|
227
|
+
edges = append(edges, edge{From: file, Line: line, To: to})
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return edges
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
func filesUsed(file *ast.File, alias string, target *pkg) []string {
|
|
235
|
+
used := map[string]bool{}
|
|
236
|
+
ast.Inspect(file, func(node ast.Node) bool {
|
|
237
|
+
if selector, ok := node.(*ast.SelectorExpr); ok {
|
|
238
|
+
if ident, ok := selector.X.(*ast.Ident); ok && ident.Name == alias {
|
|
239
|
+
if declaring, ok := target.declared[selector.Sel.Name]; ok {
|
|
240
|
+
used[declaring] = true
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return true
|
|
245
|
+
})
|
|
246
|
+
if len(used) == 0 && len(target.files) > 0 {
|
|
247
|
+
used[target.files[0]] = true
|
|
248
|
+
}
|
|
249
|
+
files := make([]string, 0, len(used))
|
|
250
|
+
for file := range used {
|
|
251
|
+
files = append(files, file)
|
|
252
|
+
}
|
|
253
|
+
sort.Strings(files)
|
|
254
|
+
return files
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
func (p *project) scan(files []string, match func(ast.Node) string) []found {
|
|
258
|
+
results := []found{}
|
|
259
|
+
for _, file := range files {
|
|
260
|
+
ast.Inspect(p.parse(file), func(node ast.Node) bool {
|
|
261
|
+
if node != nil {
|
|
262
|
+
if detail := match(node); detail != "" {
|
|
263
|
+
results = append(results, found{File: file, Line: p.line(node), Detail: detail})
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return true
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
return results
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// waits reports Go's ways to wait: a goroutine, a channel send or receive, a select.
|
|
273
|
+
func waits(node ast.Node) string {
|
|
274
|
+
switch node := node.(type) {
|
|
275
|
+
case *ast.GoStmt:
|
|
276
|
+
return "go"
|
|
277
|
+
case *ast.SendStmt:
|
|
278
|
+
return "send"
|
|
279
|
+
case *ast.SelectStmt:
|
|
280
|
+
return "select"
|
|
281
|
+
case *ast.UnaryExpr:
|
|
282
|
+
if node.Op == token.ARROW {
|
|
283
|
+
return "receive"
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return ""
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
func usesIOBuiltin(node ast.Node) string {
|
|
290
|
+
if call, ok := node.(*ast.CallExpr); ok {
|
|
291
|
+
if ident, ok := call.Fun.(*ast.Ident); ok && ioBuiltins[ident.Name] {
|
|
292
|
+
return "uses " + ident.Name
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return ""
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
func (p *project) envReads(files []string) []found {
|
|
299
|
+
results := []found{}
|
|
300
|
+
for _, file := range files {
|
|
301
|
+
parsed := p.parse(file)
|
|
302
|
+
alias := ""
|
|
303
|
+
for _, imported := range parsed.Imports {
|
|
304
|
+
if strings.Trim(imported.Path.Value, `"`) == "os" {
|
|
305
|
+
alias = "os"
|
|
306
|
+
if imported.Name != nil {
|
|
307
|
+
alias = imported.Name.Name
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if alias == "" {
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
ast.Inspect(parsed, func(node ast.Node) bool {
|
|
315
|
+
if selector, ok := node.(*ast.SelectorExpr); ok {
|
|
316
|
+
if ident, ok := selector.X.(*ast.Ident); ok && ident.Name == alias && envReads[selector.Sel.Name] {
|
|
317
|
+
results = append(results, found{File: file, Line: p.line(node), Detail: "reads os." + selector.Sel.Name})
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return true
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
return results
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// whiteBox reports test files compiled into the package they test, which lets them see its inside.
|
|
327
|
+
func (p *project) whiteBox(files []string) []found {
|
|
328
|
+
results := []found{}
|
|
329
|
+
for _, file := range files {
|
|
330
|
+
name := p.parse(file).Name
|
|
331
|
+
if !strings.HasSuffix(name.Name, "_test") {
|
|
332
|
+
detail := fmt.Sprintf("is in package %s, so it sees inside the module: use package %s_test and import the door", name.Name, name.Name)
|
|
333
|
+
results = append(results, found{File: file, Line: p.line(name), Detail: detail})
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return results
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// complexity counts each function's paths the way cyclomatic complexity does: one, plus each branch.
|
|
340
|
+
func (p *project) complexity(files []string, max int) []found {
|
|
341
|
+
results := []found{}
|
|
342
|
+
for _, file := range files {
|
|
343
|
+
for _, decl := range p.parse(file).Decls {
|
|
344
|
+
function, ok := decl.(*ast.FuncDecl)
|
|
345
|
+
if !ok || function.Body == nil {
|
|
346
|
+
continue
|
|
347
|
+
}
|
|
348
|
+
paths := 1
|
|
349
|
+
ast.Inspect(function.Body, func(node ast.Node) bool {
|
|
350
|
+
switch node := node.(type) {
|
|
351
|
+
case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt:
|
|
352
|
+
paths++
|
|
353
|
+
case *ast.CaseClause:
|
|
354
|
+
if node.List != nil {
|
|
355
|
+
paths++
|
|
356
|
+
}
|
|
357
|
+
case *ast.CommClause:
|
|
358
|
+
if node.Comm != nil {
|
|
359
|
+
paths++
|
|
360
|
+
}
|
|
361
|
+
case *ast.BinaryExpr:
|
|
362
|
+
if node.Op == token.LAND || node.Op == token.LOR {
|
|
363
|
+
paths++
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return true
|
|
367
|
+
})
|
|
368
|
+
if paths > max {
|
|
369
|
+
detail := fmt.Sprintf("function %s has a complexity of %d, above %d", function.Name.Name, paths, max)
|
|
370
|
+
results = append(results, found{File: file, Line: p.line(function), Detail: detail})
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return results
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// exports lists what a door package exports, one line each: functions and methods with their
|
|
378
|
+
// signatures, types with their definitions, constants and variables.
|
|
379
|
+
func (p *project) exports(dir string) []string {
|
|
380
|
+
lines := []string{}
|
|
381
|
+
for _, file := range p.pkgAt(dir).files {
|
|
382
|
+
for _, decl := range p.parse(file).Decls {
|
|
383
|
+
switch decl := decl.(type) {
|
|
384
|
+
case *ast.FuncDecl:
|
|
385
|
+
if decl.Name.IsExported() && (decl.Recv == nil || receiverExported(decl.Recv)) {
|
|
386
|
+
lines = append(lines, p.print(&ast.FuncDecl{Recv: decl.Recv, Name: decl.Name, Type: decl.Type}))
|
|
387
|
+
}
|
|
388
|
+
case *ast.GenDecl:
|
|
389
|
+
for _, spec := range decl.Specs {
|
|
390
|
+
switch spec := spec.(type) {
|
|
391
|
+
case *ast.TypeSpec:
|
|
392
|
+
if spec.Name.IsExported() {
|
|
393
|
+
lines = append(lines, "type "+p.print(spec))
|
|
394
|
+
}
|
|
395
|
+
case *ast.ValueSpec:
|
|
396
|
+
for _, name := range spec.Names {
|
|
397
|
+
if !name.IsExported() {
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
line := decl.Tok.String() + " " + name.Name
|
|
401
|
+
if spec.Type != nil {
|
|
402
|
+
line += " " + p.print(spec.Type)
|
|
403
|
+
}
|
|
404
|
+
lines = append(lines, line)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
sort.Strings(lines)
|
|
412
|
+
return lines
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
func receiverExported(receiver *ast.FieldList) bool {
|
|
416
|
+
expr := receiver.List[0].Type
|
|
417
|
+
if star, ok := expr.(*ast.StarExpr); ok {
|
|
418
|
+
expr = star.X
|
|
419
|
+
}
|
|
420
|
+
if index, ok := expr.(*ast.IndexExpr); ok {
|
|
421
|
+
expr = index.X
|
|
422
|
+
}
|
|
423
|
+
ident, ok := expr.(*ast.Ident)
|
|
424
|
+
return ok && ident.IsExported()
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
func (p *project) print(node any) string {
|
|
428
|
+
var buffer bytes.Buffer
|
|
429
|
+
if err := printer.Fprint(&buffer, p.fset, node); err != nil {
|
|
430
|
+
fail("printing an export: %v", err)
|
|
431
|
+
}
|
|
432
|
+
return strings.Join(strings.Fields(buffer.String()), " ")
|
|
433
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { toPosix } from "../../files.js";
|
|
5
|
+
import { copyTemplates } from "../../templating.js";
|
|
6
|
+
|
|
7
|
+
const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), "templates");
|
|
8
|
+
|
|
9
|
+
/** The module's Go package name and import path, from go.mod at the project root. */
|
|
10
|
+
function goValues(root, dir) {
|
|
11
|
+
const goMod = path.join(root, "go.mod");
|
|
12
|
+
if (!fs.existsSync(goMod)) throw new Error("a Go module needs go.mod at the project root: run go mod init");
|
|
13
|
+
const modulePath = /^module\s+"?([^\s"]+)"?/m.exec(fs.readFileSync(goMod, "utf8"))?.[1];
|
|
14
|
+
if (!modulePath) throw new Error("go.mod names no module");
|
|
15
|
+
return { pkg: path.basename(dir).toLowerCase().replace(/[^a-z0-9_]/g, ""), importPath: `${modulePath}/${toPosix(dir)}` };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function newModule(root, dir, values) {
|
|
19
|
+
const written = [];
|
|
20
|
+
copyTemplates(root, path.join(TEMPLATES, "module"), dir, { ...values, ...goValues(root, dir) }, written);
|
|
21
|
+
return written;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function newPort(root, dir, values) {
|
|
25
|
+
const written = [];
|
|
26
|
+
copyTemplates(root, path.join(TEMPLATES, "port"), dir, { ...values, ...goValues(root, dir) }, written);
|
|
27
|
+
return written;
|
|
28
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Package shell loads what the core needs, calls it, and saves what it returns.
|
|
2
|
+
package shell
|
|
3
|
+
|
|
4
|
+
// Deps is what the app passes in.
|
|
5
|
+
type Deps struct{}
|
|
6
|
+
|
|
7
|
+
// {{Name}} is what the module does for its callers.
|
|
8
|
+
type {{Name}} struct{}
|
|
9
|
+
|
|
10
|
+
// New builds {{name}} from what the app passes in.
|
|
11
|
+
func New(deps Deps) {{Name}} {
|
|
12
|
+
_ = deps
|
|
13
|
+
return {{Name}}{}
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Package {{pkg}} owns {{owns}}. This file is its door: only what it exports is public.
|
|
2
|
+
package {{pkg}}
|
|
3
|
+
|
|
4
|
+
import "{{importPath}}/internal/shell"
|
|
5
|
+
|
|
6
|
+
// Deps is what the app passes in.
|
|
7
|
+
type Deps = shell.Deps
|
|
8
|
+
|
|
9
|
+
// {{Name}} is what the module does for its callers.
|
|
10
|
+
type {{Name}} = shell.{{Name}}
|
|
11
|
+
|
|
12
|
+
// New builds {{name}} from what the app passes in.
|
|
13
|
+
func New(deps Deps) {{Name}} {
|
|
14
|
+
return shell.New(deps)
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package {{pkg}}_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
|
|
6
|
+
"{{importPath}}/internal/adapters"
|
|
7
|
+
"{{importPath}}/internal/ports"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// {{portCamel}}Contract holds one test per promise {{Port}} makes. Every adapter runs this same
|
|
11
|
+
// suite, so the fake cannot drift from the real one.
|
|
12
|
+
func {{portCamel}}Contract(t *testing.T, make func() ports.{{Port}}) {
|
|
13
|
+
if make() == nil {
|
|
14
|
+
t.Fatal("make() returned no {{Port}}")
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func Test{{Port}}ContractInMemory(t *testing.T) {
|
|
19
|
+
{{portCamel}}Contract(t, adapters.NewInMemory{{Port}})
|
|
20
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import go from "./go/index.js";
|
|
4
|
+
import python from "./python/index.js";
|
|
5
|
+
import typescript from "./typescript/index.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Everything the kit knows about one language. The rules never name a language: they ask this.
|
|
9
|
+
* A language is supported once its folder passes the conformance suite in __tests__/conformance.
|
|
10
|
+
*
|
|
11
|
+
* @typedef {object} Language
|
|
12
|
+
* @property {string} name its key in module-spec.json and in messages: "typescript"
|
|
13
|
+
* @property {string[]} aliases short names for `new-module --lang`: ["ts"]
|
|
14
|
+
* @property {string} door the door as people name it, for messages: "index.ts"
|
|
15
|
+
* @property {(inner: string) => boolean} isDoor whether a path inside a module is its door
|
|
16
|
+
* @property {string} partsDir the folder that holds core/, shell/, ports/ and adapters/, or "" for the module root
|
|
17
|
+
* @property {(file: string) => boolean} isSource whether a file is this language's source code
|
|
18
|
+
* @property {(segments: string[]) => boolean} isTest whether a file, as path segments inside its module, is a test
|
|
19
|
+
* @property {(name: string) => boolean} isContractTest whether a file name is a port's contract test
|
|
20
|
+
* @property {(file: string) => string | null} contractTestFor the contract test's file name for a file in ports/, or null when it holds no port
|
|
21
|
+
* @property {{ io: string[], ui: string[] }} stdlib the standard library modules that touch the outside, and those that draw
|
|
22
|
+
* the user interface; an entry ending in "/" matches a scope. A third-party package is never named here: the project lists it
|
|
23
|
+
* @property {string[]} viewUi the packages the view template draws with, which `new-module --view` lists under ui
|
|
24
|
+
* @property {object} configDefaults this language's own keys in module-spec.json, under its name
|
|
25
|
+
* @property {(root: string) => boolean} detect whether the project at root is written in this language
|
|
26
|
+
* @property {(input: { root: string, config: object, modules: object[], plan: import("../scans.js").ScanPlan }) => Promise<Reading>} analyze
|
|
27
|
+
* @property {(root: string, dir: string, values: object) => string[]} newModule writes a module, returns the files written
|
|
28
|
+
* @property {(root: string, dir: string, values: object) => string[]} newPort writes a port, returns the files written
|
|
29
|
+
* @property {((root: string, dir: string, values: object) => string[]) | undefined} newViewModule writes a module with a view/, when the language has a template for one
|
|
30
|
+
* @property {(input: { root: string, config: object, module: object, files: string[], tests: string[] }) => Promise<Mutation>} mutate
|
|
31
|
+
* mutation-tests `files` with only `tests`, by the language's own tool, without writing into the project
|
|
32
|
+
*
|
|
33
|
+
* @typedef {object} Reading what a language's own tools read from its modules
|
|
34
|
+
* @property {object[]} edges { from, line, to, typeOnly } for a project file, or { from, line, external, std } for a package.
|
|
35
|
+
* typeOnly is true when every import of that file is erased before the code runs. std says whether the
|
|
36
|
+
* language's toolchain counts the package as the standard library
|
|
37
|
+
* @property {import("../scans.js").Sighting[]} sightings what the plan's scans saw; the kit names the rule
|
|
38
|
+
* @property {object[]} findings violations only this language's tools can see: { id, file, line, detail }
|
|
39
|
+
* @property {Map<string, string[]>} api each module's door exports, one line each, by module name
|
|
40
|
+
*
|
|
41
|
+
* @typedef {object} Mutation what a mutation run found
|
|
42
|
+
* @property {number} killed mutants some test failed for, timeouts included
|
|
43
|
+
* @property {{ file: string, line: number, detail: string }[]} survivors mutants every test passed with, or no test reached
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** @type {Language[]} Adding a language means adding its folder and one entry here. */
|
|
47
|
+
export const LANGUAGES = [typescript, python, go];
|
|
48
|
+
|
|
49
|
+
/** The language a name or alias names, such as "py". */
|
|
50
|
+
export function languageNamed(name) {
|
|
51
|
+
const found = LANGUAGES.find((language) => language.name === name || language.aliases.includes(name));
|
|
52
|
+
if (!found) throw new Error(`unknown language ${name}: use one of ${LANGUAGES.flatMap((language) => language.aliases).join(", ")}`);
|
|
53
|
+
return found;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The language of the module folder `dir`, told by which door it has, or null. */
|
|
57
|
+
export function languageOfDir(dir) {
|
|
58
|
+
if (!fs.existsSync(dir)) return null;
|
|
59
|
+
const names = fs.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
60
|
+
return LANGUAGES.find((language) => names.some((name) => language.isDoor(name))) ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Every door's name, for messages such as "add index.ts or __init__.py". */
|
|
64
|
+
export function doorNames() {
|
|
65
|
+
return LANGUAGES.map((language) => language.door).join(" or ");
|
|
66
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** The project's own interpreter: module-spec.json python.executable, else .venv, else python3 on the PATH. */
|
|
5
|
+
export function pythonOf(root, config) {
|
|
6
|
+
if (config.python.executable) return path.resolve(root, config.python.executable);
|
|
7
|
+
const venv = path.join(root, ".venv", "bin", "python");
|
|
8
|
+
return fs.existsSync(venv) ? venv : "python3";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A tool installed in the same environment as `python`, else the one on the PATH. */
|
|
12
|
+
export function toolBeside(python, name) {
|
|
13
|
+
const beside = path.join(path.dirname(python), name);
|
|
14
|
+
return fs.existsSync(beside) ? beside : name;
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { mutate } from "./mutate.js";
|
|
4
|
+
import { analyze } from "./read.js";
|
|
5
|
+
import { newModule, newPort } from "./scaffold.js";
|
|
6
|
+
|
|
7
|
+
const TEST = /^test_.*\.py$|_test\.py$/;
|
|
8
|
+
const CONTRACT_TEST = /^test_.*_contract\.py$/;
|
|
9
|
+
|
|
10
|
+
/** @type {import("../index.js").Language} */
|
|
11
|
+
export default {
|
|
12
|
+
name: "python",
|
|
13
|
+
aliases: ["py"],
|
|
14
|
+
door: "__init__.py",
|
|
15
|
+
isDoor: (inner) => inner === "__init__.py",
|
|
16
|
+
partsDir: "",
|
|
17
|
+
isSource: (file) => file.endsWith(".py"),
|
|
18
|
+
isTest: (segments) => segments.includes("tests") || TEST.test(segments.at(-1)),
|
|
19
|
+
isContractTest: (name) => CONTRACT_TEST.test(name),
|
|
20
|
+
contractTestFor: (file) => (file === "__init__.py" ? null : `test_${file.replace(/\.py$/, "")}_contract.py`),
|
|
21
|
+
stdlib: {
|
|
22
|
+
io: [
|
|
23
|
+
"os", "pathlib", "socket", "subprocess", "sqlite3", "shutil", "http", "asyncio", "ssl", "smtplib",
|
|
24
|
+
"ftplib", "tempfile", "glob", "select", "selectors", "multiprocessing", "threading", "signal",
|
|
25
|
+
],
|
|
26
|
+
ui: ["tkinter", "turtle", "curses"],
|
|
27
|
+
},
|
|
28
|
+
viewUi: [],
|
|
29
|
+
configDefaults: { executable: null },
|
|
30
|
+
detect: (root) => ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"].some((file) => fs.existsSync(path.join(root, file))),
|
|
31
|
+
analyze,
|
|
32
|
+
mutate,
|
|
33
|
+
newModule,
|
|
34
|
+
newPort,
|
|
35
|
+
};
|