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.
Files changed (79) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +132 -2
  3. package/bin/module-spec.js +193 -0
  4. package/lib/affected.js +54 -0
  5. package/lib/baseline.js +34 -0
  6. package/lib/branch.js +26 -0
  7. package/lib/check.js +53 -0
  8. package/lib/checks/api.js +36 -0
  9. package/lib/checks/clones.js +33 -0
  10. package/lib/checks/imports.js +101 -0
  11. package/lib/checks/manifests.js +52 -0
  12. package/lib/checks/ports.js +32 -0
  13. package/lib/checks/tables.js +37 -0
  14. package/lib/config.js +56 -0
  15. package/lib/files.js +56 -0
  16. package/lib/languages/go/go.mod +5 -0
  17. package/lib/languages/go/index.js +37 -0
  18. package/lib/languages/go/mutate.js +56 -0
  19. package/lib/languages/go/read.js +40 -0
  20. package/lib/languages/go/read_project.go +433 -0
  21. package/lib/languages/go/scaffold.js +28 -0
  22. package/lib/languages/go/templates/module/internal/shell/create.go +14 -0
  23. package/lib/languages/go/templates/module/{{pkg}}.go +15 -0
  24. package/lib/languages/go/templates/module/{{pkg}}_test.go +13 -0
  25. package/lib/languages/go/templates/port/internal/adapters/in_memory_{{portSnake}}.go +10 -0
  26. package/lib/languages/go/templates/port/internal/ports/{{portSnake}}.go +5 -0
  27. package/lib/languages/go/templates/port/{{portSnake}}_contract_test.go +20 -0
  28. package/lib/languages/index.js +66 -0
  29. package/lib/languages/python/environment.js +15 -0
  30. package/lib/languages/python/index.js +35 -0
  31. package/lib/languages/python/mutate.js +72 -0
  32. package/lib/languages/python/package.js +16 -0
  33. package/lib/languages/python/read.js +81 -0
  34. package/lib/languages/python/read_project.py +145 -0
  35. package/lib/languages/python/scaffold.js +19 -0
  36. package/lib/languages/python/templates/module/__init__.py +3 -0
  37. package/lib/languages/python/templates/module/shell/__init__.py +0 -0
  38. package/lib/languages/python/templates/module/shell/create.py +16 -0
  39. package/lib/languages/python/templates/module/tests/__init__.py +0 -0
  40. package/lib/languages/python/templates/module/tests/test_{{snake}}.py +5 -0
  41. package/lib/languages/python/templates/port/adapters/in_memory_{{portSnake}}.py +9 -0
  42. package/lib/languages/python/templates/port/ports/{{portSnake}}.py +5 -0
  43. package/lib/languages/python/templates/port/tests/test_{{portSnake}}_contract.py +11 -0
  44. package/lib/languages/typescript/index.js +37 -0
  45. package/lib/languages/typescript/mutate.js +95 -0
  46. package/lib/languages/typescript/project.js +24 -0
  47. package/lib/languages/typescript/read.js +220 -0
  48. package/lib/languages/typescript/scaffold.js +60 -0
  49. package/lib/languages/typescript/templates/module/index.ts +2 -0
  50. package/lib/languages/typescript/templates/module/shell/create{{Name}}.ts +8 -0
  51. package/lib/languages/typescript/templates/module-test.node.ts +7 -0
  52. package/lib/languages/typescript/templates/module-test.vitest.ts +6 -0
  53. package/lib/languages/typescript/templates/port/adapters/inMemory{{Port}}.ts +5 -0
  54. package/lib/languages/typescript/templates/port/ports/{{Port}}.ts +1 -0
  55. package/lib/languages/typescript/templates/port-test.node.ts +12 -0
  56. package/lib/languages/typescript/templates/port-test.vitest.ts +12 -0
  57. package/lib/languages/typescript/templates/view-module/__tests__/{{name}}.test.tsx +9 -0
  58. package/lib/languages/typescript/templates/view-module/core/model.ts +25 -0
  59. package/lib/languages/typescript/templates/view-module/index.ts +4 -0
  60. package/lib/languages/typescript/templates/view-module/shell/create{{Name}}Model.ts +20 -0
  61. package/lib/languages/typescript/templates/view-module/view/{{Name}}Page.tsx +14 -0
  62. package/lib/modules.js +104 -0
  63. package/lib/mutation.js +124 -0
  64. package/lib/place.js +25 -0
  65. package/lib/report.js +42 -0
  66. package/lib/rules.js +145 -0
  67. package/lib/scaffold.js +57 -0
  68. package/lib/scans.js +62 -0
  69. package/lib/templating.js +28 -0
  70. package/lib/tools.js +17 -0
  71. package/manifest.schema.json +35 -0
  72. package/package.json +38 -4
  73. package/spec/adopting.md +70 -0
  74. package/spec/backend.md +176 -0
  75. package/spec/common.md +163 -0
  76. package/spec/frontend.md +181 -0
  77. package/spec/roles.md +93 -0
  78. package/spec/tests.md +146 -0
  79. 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,13 @@
1
+ package {{pkg}}_test
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "{{importPath}}"
7
+ )
8
+
9
+ func TestNewBuilds{{Name}}FromItsDeps(t *testing.T) {
10
+ if got := {{pkg}}.New({{pkg}}.Deps{}); got != ({{pkg}}.{{Name}}{}) {
11
+ t.Fatalf("New() = %v", got)
12
+ }
13
+ }
@@ -0,0 +1,10 @@
1
+ package adapters
2
+
3
+ import "{{importPath}}/internal/ports"
4
+
5
+ type inMemory{{Port}} struct{}
6
+
7
+ // NewInMemory{{Port}} is the fake every behaviour test passes in.
8
+ func NewInMemory{{Port}}() ports.{{Port}} {
9
+ return inMemory{{Port}}{}
10
+ }
@@ -0,0 +1,5 @@
1
+ // Package ports holds the interfaces the core needs from the outside, named after the need.
2
+ package ports
3
+
4
+ // {{Port}} is one thing the core needs from the outside.
5
+ type {{Port}} interface{}
@@ -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
+ };