Skip to content

Commit a2655d1

Browse files
Antonis Kalipetisclaude
authored andcommitted
feat(discovery): add project discovery package
Extract project detection logic from question handlers into a new discovery/ package. The Discoverer operates on an fs.FS and provides memoized methods for detecting stack, runtime, dependency managers, build steps, application root, and environment. - Add discovery package with Stack, Type, DependencyManagers, BuildSteps, ApplicationRoot, Environment detection - Add comprehensive tests for all discovery methods - Add Discoverer field to Answers, initialized in WorkingDirectory - Rewrite stack.go to delegate to Discoverer.Stack() instead of inline detection - Add Symfony, Ibexa, Shopware stack constants to platformifier - Add CountFiles utility for runtime detection heuristic Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9fd9c6f commit a2655d1

19 files changed

Lines changed: 1231 additions & 164 deletions

discovery/application_root.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package discovery
2+
3+
import (
4+
"path"
5+
"slices"
6+
7+
"github.com/platformsh/platformify/internal/utils"
8+
)
9+
10+
// Returns the application root, either from memory or by discovering it on the spot
11+
func (d *Discoverer) ApplicationRoot() (string, error) {
12+
if applicationRoot, ok := d.memory["application_root"]; ok {
13+
return applicationRoot.(string), nil
14+
}
15+
16+
appRoot, err := d.discoverApplicationRoot()
17+
if err != nil {
18+
return "", err
19+
}
20+
21+
d.memory["application_root"] = appRoot
22+
return appRoot, nil
23+
}
24+
25+
func (d *Discoverer) discoverApplicationRoot() (string, error) {
26+
depManagers, err := d.DependencyManagers()
27+
if err != nil {
28+
return "", err
29+
}
30+
31+
for _, dependencyManager := range dependencyManagersMap {
32+
if !slices.Contains(depManagers, dependencyManager.name) {
33+
continue
34+
}
35+
36+
lockPath := utils.FindFile(d.fileSystem, "", dependencyManager.lockFile)
37+
if lockPath == "" {
38+
continue
39+
}
40+
41+
return path.Dir(lockPath), nil
42+
}
43+
44+
return "", nil
45+
}

discovery/application_root_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package discovery
2+
3+
import (
4+
"io/fs"
5+
"testing"
6+
"testing/fstest"
7+
)
8+
9+
func TestDiscoverer_discoverApplicationRoot(t *testing.T) {
10+
type fields struct {
11+
fileSystem fs.FS
12+
memory map[string]any
13+
}
14+
tests := []struct {
15+
name string
16+
fields fields
17+
want string
18+
wantErr bool
19+
}{
20+
{
21+
name: "Simple",
22+
fields: fields{
23+
fileSystem: fstest.MapFS{
24+
"package-lock.json": &fstest.MapFile{},
25+
},
26+
},
27+
want: ".",
28+
},
29+
{
30+
name: "No root",
31+
fields: fields{
32+
fileSystem: fstest.MapFS{},
33+
},
34+
want: "",
35+
},
36+
{
37+
name: "Priority",
38+
fields: fields{
39+
fileSystem: fstest.MapFS{
40+
"yarn/yarn.lock": &fstest.MapFile{},
41+
"poetry/poetry.lock": &fstest.MapFile{},
42+
"composer/composer-lock.json": &fstest.MapFile{},
43+
},
44+
},
45+
want: "poetry",
46+
},
47+
}
48+
for _, tt := range tests {
49+
t.Run(tt.name, func(t *testing.T) {
50+
d := &Discoverer{
51+
fileSystem: tt.fields.fileSystem,
52+
memory: tt.fields.memory,
53+
}
54+
if d.memory == nil {
55+
d.memory = make(map[string]any)
56+
}
57+
got, err := d.discoverApplicationRoot()
58+
if (err != nil) != tt.wantErr {
59+
t.Errorf("Discoverer.discoverApplicationRoot() error = %v, wantErr %v", err, tt.wantErr)
60+
return
61+
}
62+
if got != tt.want {
63+
t.Errorf("Discoverer.discoverApplicationRoot() = %v, want %v", got, tt.want)
64+
}
65+
})
66+
}
67+
}

discovery/build_steps.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package discovery
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
7+
"github.com/platformsh/platformify/internal/utils"
8+
"github.com/platformsh/platformify/platformifier"
9+
)
10+
11+
// Returns the application build steps, either from memory or by discovering it on the spot
12+
func (d *Discoverer) BuildSteps() ([]string, error) {
13+
if buildSteps, ok := d.memory["build_steps"]; ok {
14+
return buildSteps.([]string), nil
15+
}
16+
17+
buildSteps, err := d.discoverBuildSteps()
18+
if err != nil {
19+
return nil, err
20+
}
21+
22+
d.memory["build_steps"] = buildSteps
23+
return buildSteps, nil
24+
}
25+
26+
func (d *Discoverer) discoverBuildSteps() ([]string, error) {
27+
dependencyManagers, err := d.DependencyManagers()
28+
if err != nil {
29+
return nil, err
30+
}
31+
32+
typ, err := d.Type()
33+
if err != nil {
34+
return nil, err
35+
}
36+
37+
stack, err := d.Stack()
38+
if err != nil {
39+
return nil, err
40+
}
41+
42+
appRoot, err := d.ApplicationRoot()
43+
if err != nil {
44+
return nil, err
45+
}
46+
47+
buildSteps := make([]string, 0)
48+
49+
// Start with lower priority dependency managers first
50+
slices.Reverse(dependencyManagers)
51+
for _, dm := range dependencyManagers {
52+
switch dm {
53+
case "poetry":
54+
buildSteps = append(
55+
buildSteps,
56+
"# Set PIP_USER to 0 so that Poetry does not complain",
57+
"export PIP_USER=0",
58+
"# Install poetry as a global tool",
59+
"python -m venv /app/.global",
60+
"pip install poetry==$POETRY_VERSION",
61+
"poetry install",
62+
)
63+
case "pipenv":
64+
buildSteps = append(
65+
buildSteps,
66+
"# Set PIP_USER to 0 so that Pipenv does not complain",
67+
"export PIP_USER=0",
68+
"# Install Pipenv as a global tool",
69+
"python -m venv /app/.global",
70+
"pip install pipenv==$PIPENV_TOOL_VERSION",
71+
"pipenv install",
72+
)
73+
case "pip":
74+
buildSteps = append(
75+
buildSteps,
76+
"pip install -r requirements.txt",
77+
)
78+
case "yarn", "npm":
79+
// Install n, if on different runtime
80+
if typ != "nodejs" {
81+
buildSteps = append(
82+
buildSteps,
83+
"n auto || n lts",
84+
"hash -r",
85+
)
86+
}
87+
88+
if dm == "yarn" {
89+
buildSteps = append(
90+
buildSteps,
91+
"yarn",
92+
)
93+
} else {
94+
buildSteps = append(
95+
buildSteps,
96+
"npm i",
97+
)
98+
}
99+
100+
if _, ok := utils.GetJSONValue(
101+
d.fileSystem,
102+
[]string{"scripts", "build"},
103+
"package.json",
104+
true,
105+
); ok {
106+
buildSteps = append(buildSteps, d.nodeScriptPrefix()+"build")
107+
}
108+
case "composer":
109+
buildSteps = append(
110+
buildSteps,
111+
"composer --no-ansi --no-interaction install --no-progress --prefer-dist --optimize-autoloader --no-dev",
112+
)
113+
}
114+
}
115+
116+
switch stack {
117+
case platformifier.Django:
118+
if managePyPath := utils.FindFile(
119+
d.fileSystem,
120+
appRoot,
121+
managePyFile,
122+
); managePyPath != "" {
123+
buildSteps = append(
124+
buildSteps,
125+
"# Collect static files",
126+
fmt.Sprintf("%spython %s collectstatic --noinput", d.pythonPrefix(), managePyPath),
127+
)
128+
}
129+
case platformifier.NextJS:
130+
// If there is no custom build script, fallback to next build for Next.js projects
131+
if !slices.Contains(buildSteps, "yarn build") && !slices.Contains(buildSteps, "npm run build") {
132+
buildSteps = append(buildSteps, d.nodeExecPrefix()+"next build")
133+
}
134+
}
135+
136+
return buildSteps, nil
137+
}

discovery/build_steps_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package discovery
2+
3+
import (
4+
"io/fs"
5+
"reflect"
6+
"testing"
7+
"testing/fstest"
8+
9+
"github.com/platformsh/platformify/platformifier"
10+
)
11+
12+
func TestDiscoverer_discoverBuildSteps(t *testing.T) {
13+
type fields struct {
14+
fileSystem fs.FS
15+
memory map[string]any
16+
}
17+
tests := []struct {
18+
name string
19+
fields fields
20+
want []string
21+
wantErr bool
22+
}{
23+
{
24+
name: "Poetry Django",
25+
fields: fields{
26+
fileSystem: fstest.MapFS{
27+
"project/manage.py": &fstest.MapFile{},
28+
},
29+
memory: map[string]any{
30+
"stack": platformifier.Django,
31+
"type": "python",
32+
"dependency_managers": []string{"poetry"},
33+
"application_root": ".",
34+
},
35+
},
36+
want: []string{
37+
"# Set PIP_USER to 0 so that Poetry does not complain",
38+
"export PIP_USER=0",
39+
"# Install poetry as a global tool",
40+
"python -m venv /app/.global",
41+
"pip install poetry==$POETRY_VERSION",
42+
"poetry install",
43+
"# Collect static files",
44+
"poetry run python project/manage.py collectstatic --noinput",
45+
},
46+
},
47+
{
48+
name: "Pipenv Django with Yarn build",
49+
fields: fields{
50+
fileSystem: fstest.MapFS{
51+
"project/manage.py": &fstest.MapFile{},
52+
"package.json": &fstest.MapFile{Data: []byte(`{"scripts": {"build": "nuxt build"}}`)},
53+
},
54+
memory: map[string]any{
55+
"stack": platformifier.Django,
56+
"type": "python",
57+
"dependency_managers": []string{"poetry", "yarn"},
58+
"application_root": ".",
59+
},
60+
},
61+
want: []string{
62+
"n auto || n lts",
63+
"hash -r",
64+
"yarn",
65+
"yarn build",
66+
"# Set PIP_USER to 0 so that Poetry does not complain",
67+
"export PIP_USER=0",
68+
"# Install poetry as a global tool",
69+
"python -m venv /app/.global",
70+
"pip install poetry==$POETRY_VERSION",
71+
"poetry install",
72+
"# Collect static files",
73+
"poetry run python project/manage.py collectstatic --noinput",
74+
},
75+
},
76+
{
77+
name: "Next.js without build script",
78+
fields: fields{
79+
fileSystem: fstest.MapFS{},
80+
memory: map[string]any{
81+
"stack": platformifier.NextJS,
82+
"type": "nodejs",
83+
"dependency_managers": []string{"npm"},
84+
"application_root": ".",
85+
},
86+
},
87+
want: []string{
88+
"npm i",
89+
"npm exec next build",
90+
},
91+
},
92+
}
93+
for _, tt := range tests {
94+
t.Run(tt.name, func(t *testing.T) {
95+
d := &Discoverer{
96+
fileSystem: tt.fields.fileSystem,
97+
memory: tt.fields.memory,
98+
}
99+
got, err := d.discoverBuildSteps()
100+
if (err != nil) != tt.wantErr {
101+
t.Errorf("Discoverer.discoverBuildSteps() error = %v, wantErr %v", err, tt.wantErr)
102+
return
103+
}
104+
if !reflect.DeepEqual(got, tt.want) {
105+
t.Errorf("Discoverer.discoverBuildSteps() = %v, want %v", got, tt.want)
106+
}
107+
})
108+
}
109+
}

0 commit comments

Comments
 (0)