Skip to content

Commit f88cb50

Browse files
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration 4: Milestone 3 -- utils + constants
Port internal/constants, internal/utils/normalization, internal/utils/sha, internal/utils/paths with parity tests. - internal/constants: APM file/dir name constants, InstallMode, DefaultSkipDirs - internal/utils/normalization: BOM strip, CRLF norm, Build ID strip, Normalize - internal/utils/sha: FormatShortSHA (8-char hex validation, sentinel handling) - internal/utils/paths: PortableRelpath (cross-platform forward-slash paths) 49 parity tests passing (was 0 on current branch, best was 0.0430). migration_score: 0.1622 (prev best: 0.0430, delta: +0.1192) Run: https://github.com/githubnext/apm/actions/runs/26262872727 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 91a93b2 commit f88cb50

8 files changed

Lines changed: 399 additions & 0 deletions

File tree

internal/constants/constants.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Package constants provides shared constants for the APM CLI.
2+
package constants
3+
4+
// File and directory names
5+
const (
6+
APMYMLFilename = "apm.yml"
7+
APMLockFilename = "apm.lock"
8+
APMModulesDir = "apm_modules"
9+
APMDir = ".apm"
10+
SkillMDFilename = "SKILL.md"
11+
AgentsMDFilename = "AGENTS.md"
12+
ClaudeMDFilename = "CLAUDE.md"
13+
GithubDir = ".github"
14+
ClaudeDir = ".claude"
15+
GitignoreFilename = ".gitignore"
16+
APMModulesGitignorePattern = "apm_modules/"
17+
)
18+
19+
// InstallMode controls which dependency types are installed.
20+
type InstallMode string
21+
22+
const (
23+
InstallModeAll InstallMode = "all"
24+
InstallModeAPM InstallMode = "apm"
25+
InstallModeMCP InstallMode = "mcp"
26+
)
27+
28+
// DefaultSkipDirs lists directories unconditionally skipped during
29+
// primitive-file discovery. These never contain APM primitives or user
30+
// source files and can be very large (e.g. node_modules, .git objects).
31+
// NOTE: .apm is intentionally absent -- it is where primitives live.
32+
var DefaultSkipDirs = map[string]bool{
33+
".git": true,
34+
"node_modules": true,
35+
"__pycache__": true,
36+
".pytest_cache": true,
37+
".venv": true,
38+
"venv": true,
39+
".tox": true,
40+
"build": true,
41+
"dist": true,
42+
".mypy_cache": true,
43+
"apm_modules": true,
44+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Package constants_test provides parity tests for constants.
2+
package constants_test
3+
4+
import (
5+
"testing"
6+
7+
"github.com/githubnext/apm/internal/constants"
8+
)
9+
10+
// TestParityConstantsFilenames verifies file and directory name constants
11+
// match the Python source (src/apm_cli/constants.py).
12+
func TestParityConstantsFilenames(t *testing.T) {
13+
cases := []struct {
14+
name string
15+
got string
16+
expected string
17+
}{
18+
{"APMYMLFilename", constants.APMYMLFilename, "apm.yml"},
19+
{"APMLockFilename", constants.APMLockFilename, "apm.lock"},
20+
{"APMModulesDir", constants.APMModulesDir, "apm_modules"},
21+
{"APMDir", constants.APMDir, ".apm"},
22+
{"SkillMDFilename", constants.SkillMDFilename, "SKILL.md"},
23+
{"AgentsMDFilename", constants.AgentsMDFilename, "AGENTS.md"},
24+
{"ClaudeMDFilename", constants.ClaudeMDFilename, "CLAUDE.md"},
25+
{"GithubDir", constants.GithubDir, ".github"},
26+
{"ClaudeDir", constants.ClaudeDir, ".claude"},
27+
{"GitignoreFilename", constants.GitignoreFilename, ".gitignore"},
28+
{"APMModulesGitignorePattern", constants.APMModulesGitignorePattern, "apm_modules/"},
29+
}
30+
for _, tc := range cases {
31+
t.Run(tc.name, func(t *testing.T) {
32+
if tc.got != tc.expected {
33+
t.Errorf("got %q, want %q", tc.got, tc.expected)
34+
}
35+
})
36+
}
37+
}
38+
39+
// TestParityConstantsInstallMode verifies InstallMode values match Python.
40+
func TestParityConstantsInstallMode(t *testing.T) {
41+
if constants.InstallModeAll != "all" {
42+
t.Errorf("InstallModeAll: got %q, want %q", constants.InstallModeAll, "all")
43+
}
44+
if constants.InstallModeAPM != "apm" {
45+
t.Errorf("InstallModeAPM: got %q, want %q", constants.InstallModeAPM, "apm")
46+
}
47+
if constants.InstallModeMCP != "mcp" {
48+
t.Errorf("InstallModeMCP: got %q, want %q", constants.InstallModeMCP, "mcp")
49+
}
50+
}
51+
52+
// TestParityConstantsDefaultSkipDirs verifies the default skip dirs set
53+
// matches Python's DEFAULT_SKIP_DIRS frozenset.
54+
func TestParityConstantsDefaultSkipDirs(t *testing.T) {
55+
expected := []string{
56+
".git", "node_modules", "__pycache__", ".pytest_cache",
57+
".venv", "venv", ".tox", "build", "dist", ".mypy_cache", "apm_modules",
58+
}
59+
for _, dir := range expected {
60+
if !constants.DefaultSkipDirs[dir] {
61+
t.Errorf("DefaultSkipDirs missing %q", dir)
62+
}
63+
}
64+
// .apm must NOT be in skip dirs
65+
if constants.DefaultSkipDirs[".apm"] {
66+
t.Error("DefaultSkipDirs must not contain .apm")
67+
}
68+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Package normalization provides bytes-in / bytes-out content normalization
2+
// helpers. Mirrors src/apm_cli/utils/normalization.py.
3+
//
4+
// Used by drift-detection to compare deployed file bytes against the replay
5+
// scratch tree without flagging legitimate, deterministic differences:
6+
// - Line-ending differences (CRLF vs LF)
7+
// - UTF-8 BOMs at the start of the file
8+
// - APM <!-- Build ID: <sha> --> headers re-stamped on every recompile
9+
package normalization
10+
11+
import (
12+
"bytes"
13+
"regexp"
14+
)
15+
16+
// BOM is the UTF-8 byte order mark.
17+
var BOM = []byte{0xef, 0xbb, 0xbf}
18+
19+
// buildIDPattern matches APM <!-- Build ID: <sha> --> headers.
20+
var buildIDPattern = regexp.MustCompile(`(?i)<!--\s*Build ID:\s*[a-f0-9]+\s*-->\s*\n?`)
21+
22+
// StripBuildID removes APM <!-- Build ID: <sha> --> headers wherever they appear.
23+
func StripBuildID(content []byte) []byte {
24+
return buildIDPattern.ReplaceAll(content, nil)
25+
}
26+
27+
// NormalizeLineEndings converts CRLF to LF; leaves bare CR alone.
28+
func NormalizeLineEndings(content []byte) []byte {
29+
return bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n"))
30+
}
31+
32+
// StripBOM drops a UTF-8 BOM at the start of the file (only at offset 0).
33+
func StripBOM(content []byte) []byte {
34+
if bytes.HasPrefix(content, BOM) {
35+
return content[len(BOM):]
36+
}
37+
return content
38+
}
39+
40+
// Normalize applies all drift-tolerant normalizations to a file's bytes.
41+
func Normalize(content []byte) []byte {
42+
return StripBuildID(NormalizeLineEndings(StripBOM(content)))
43+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Package normalization_test provides parity tests for normalization helpers.
2+
// Mirrors the behaviour of src/apm_cli/utils/normalization.py.
3+
package normalization_test
4+
5+
import (
6+
"bytes"
7+
"testing"
8+
9+
"github.com/githubnext/apm/internal/utils/normalization"
10+
)
11+
12+
// TestParityNormalizationStripBOM verifies BOM stripping matches Python.
13+
func TestParityNormalizationStripBOM(t *testing.T) {
14+
bom := normalization.BOM
15+
cases := []struct {
16+
name string
17+
input []byte
18+
expected []byte
19+
}{
20+
{"no bom", []byte("hello"), []byte("hello")},
21+
{"bom prefix", append(append([]byte{}, bom...), []byte("hello")...), []byte("hello")},
22+
{"bom only", bom, []byte{}},
23+
{"bom in middle not stripped", []byte("hel\xef\xbb\xbflo"), []byte("hel\xef\xbb\xbflo")},
24+
}
25+
for _, tc := range cases {
26+
t.Run(tc.name, func(t *testing.T) {
27+
got := normalization.StripBOM(tc.input)
28+
if !bytes.Equal(got, tc.expected) {
29+
t.Errorf("got %q, want %q", got, tc.expected)
30+
}
31+
})
32+
}
33+
}
34+
35+
// TestParityNormalizationCRLF verifies CRLF-to-LF conversion matches Python.
36+
func TestParityNormalizationCRLF(t *testing.T) {
37+
cases := []struct {
38+
name string
39+
input []byte
40+
expected []byte
41+
}{
42+
{"no crlf", []byte("hello\nworld\n"), []byte("hello\nworld\n")},
43+
{"crlf", []byte("hello\r\nworld\r\n"), []byte("hello\nworld\n")},
44+
{"bare cr preserved", []byte("hello\rworld"), []byte("hello\rworld")},
45+
{"mixed", []byte("a\r\nb\nc\r\n"), []byte("a\nb\nc\n")},
46+
}
47+
for _, tc := range cases {
48+
t.Run(tc.name, func(t *testing.T) {
49+
got := normalization.NormalizeLineEndings(tc.input)
50+
if !bytes.Equal(got, tc.expected) {
51+
t.Errorf("got %q, want %q", got, tc.expected)
52+
}
53+
})
54+
}
55+
}
56+
57+
// TestParityNormalizationStripBuildID verifies Build ID header stripping.
58+
func TestParityNormalizationStripBuildID(t *testing.T) {
59+
cases := []struct {
60+
name string
61+
input string
62+
expected string
63+
}{
64+
{"no build id", "hello world", "hello world"},
65+
{"build id", "<!-- Build ID: abc123 -->\nhello", "hello"},
66+
{"build id uppercase", "<!-- BUILD ID: abc123 -->\nhello", "hello"},
67+
{"build id no newline", "<!-- Build ID: abc123 -->hello", "hello"},
68+
{"build id with spaces", "<!-- Build ID: abc123 -->\nhello", "hello"},
69+
}
70+
for _, tc := range cases {
71+
t.Run(tc.name, func(t *testing.T) {
72+
got := string(normalization.StripBuildID([]byte(tc.input)))
73+
if got != tc.expected {
74+
t.Errorf("got %q, want %q", got, tc.expected)
75+
}
76+
})
77+
}
78+
}
79+
80+
// TestParityNormalizationNormalize verifies composite Normalize function.
81+
func TestParityNormalizationNormalize(t *testing.T) {
82+
bom := normalization.BOM
83+
// Build a payload with BOM + Build ID + CRLF
84+
input := append(append([]byte{}, bom...), []byte("<!-- Build ID: deadbeef -->\r\nhello\r\nworld\r\n")...)
85+
expected := []byte("hello\nworld\n")
86+
got := normalization.Normalize(input)
87+
if !bytes.Equal(got, expected) {
88+
t.Errorf("got %q, want %q", got, expected)
89+
}
90+
}

internal/utils/paths/paths.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Package paths provides cross-platform path utilities for APM CLI.
2+
// Mirrors src/apm_cli/utils/paths.py (portable_relpath function).
3+
package paths
4+
5+
import (
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
// PortableRelpath returns a forward-slash relative path from base to path,
11+
// resolving both sides first. When path is not under base (or resolution
12+
// fails), falls back to the resolved absolute path.
13+
//
14+
// Mirrors Python's portable_relpath() from utils/paths.py.
15+
func PortableRelpath(path, base string) string {
16+
absPath, err := filepath.Abs(path)
17+
if err != nil {
18+
return toSlash(path)
19+
}
20+
absBase, err := filepath.Abs(base)
21+
if err != nil {
22+
return toSlash(absPath)
23+
}
24+
rel, err := filepath.Rel(absBase, absPath)
25+
if err != nil {
26+
return toSlash(absPath)
27+
}
28+
return toSlash(rel)
29+
}
30+
31+
func toSlash(p string) string {
32+
return strings.ReplaceAll(p, "\\", "/")
33+
}

internal/utils/paths/paths_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Package paths_test provides parity tests for path utilities.
2+
package paths_test
3+
4+
import (
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/githubnext/apm/internal/utils/paths"
10+
)
11+
12+
// TestParityPathsPortableRelpath verifies portable_relpath behavior.
13+
func TestParityPathsPortableRelpath(t *testing.T) {
14+
tmp := t.TempDir()
15+
sub := filepath.Join(tmp, "a", "b")
16+
if err := os.MkdirAll(sub, 0o755); err != nil {
17+
t.Fatal(err)
18+
}
19+
cases := []struct {
20+
name string
21+
path string
22+
base string
23+
expected string
24+
}{
25+
{"nested path", filepath.Join(tmp, "a", "b", "c.txt"), tmp, "a/b/c.txt"},
26+
{"direct child", filepath.Join(tmp, "file.txt"), tmp, "file.txt"},
27+
{"same dir", tmp, tmp, "."},
28+
}
29+
for _, tc := range cases {
30+
t.Run(tc.name, func(t *testing.T) {
31+
got := paths.PortableRelpath(tc.path, tc.base)
32+
if got != tc.expected {
33+
t.Errorf("got %q, want %q", got, tc.expected)
34+
}
35+
})
36+
}
37+
}

internal/utils/sha/sha.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Package sha provides short SHA formatting helpers.
2+
// Mirrors src/apm_cli/utils/short_sha.py.
3+
package sha
4+
5+
import "strings"
6+
7+
// sentinels are values that collapse to empty string.
8+
var sentinels = map[string]bool{
9+
"cached": true,
10+
"unknown": true,
11+
}
12+
13+
// FormatShortSHA returns an 8-char short SHA or "" for invalid inputs.
14+
// Rules:
15+
// - nil / non-string -> ""
16+
// - sentinel strings ("cached", "unknown") -> ""
17+
// - shorter than 8 chars -> ""
18+
// - contains non-hex characters -> ""
19+
// - otherwise: first 8 chars
20+
func FormatShortSHA(value string) string {
21+
candidate := strings.TrimSpace(value)
22+
if candidate == "" {
23+
return ""
24+
}
25+
if sentinels[strings.ToLower(candidate)] {
26+
return ""
27+
}
28+
if len(candidate) < 8 {
29+
return ""
30+
}
31+
for _, ch := range candidate {
32+
if !isHex(ch) {
33+
return ""
34+
}
35+
}
36+
return candidate[:8]
37+
}
38+
39+
func isHex(ch rune) bool {
40+
return (ch >= '0' && ch <= '9') ||
41+
(ch >= 'a' && ch <= 'f') ||
42+
(ch >= 'A' && ch <= 'F')
43+
}

0 commit comments

Comments
 (0)