Skip to content

Commit c043b65

Browse files
authored
Harden Rust skill scaffold hygiene (#64)
* Harden Rust scaffold hygiene * Refresh evaluation evidence * Refresh conversion evaluation evidence
1 parent 2aaa678 commit c043b65

7 files changed

Lines changed: 146 additions & 12 deletions

File tree

cmd/yskill/bootstrap.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,12 @@ func applyBootstrapPlan(plan bootstrapPlan) error {
311311
}
312312
sort.Strings(keys)
313313
for _, rel := range keys {
314+
if rel == ".gitignore" {
315+
if err := writeBootstrapFileIfAbsent(filepath.Join(plan.SkillDir, filepath.FromSlash(rel)), plan.Files[rel]); err != nil {
316+
return err
317+
}
318+
continue
319+
}
314320
if err := writeBootstrapFile(filepath.Join(plan.SkillDir, filepath.FromSlash(rel)), plan.Files[rel]); err != nil {
315321
return err
316322
}
@@ -359,6 +365,24 @@ func writeBootstrapFile(path, content string) error {
359365
return os.Rename(temporaryPath, path)
360366
}
361367

368+
func writeBootstrapFileIfAbsent(path, content string) error {
369+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
370+
return err
371+
}
372+
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
373+
if errors.Is(err, fs.ErrExist) {
374+
return nil
375+
}
376+
if err != nil {
377+
return err
378+
}
379+
if _, err := file.WriteString(content); err != nil {
380+
file.Close()
381+
return err
382+
}
383+
return file.Close()
384+
}
385+
362386
func readBootstrapProfile(repoRoot string) (bootstrapProfile, error) {
363387
path := filepath.Join(repoRoot, ".yield", "bootstrap.json")
364388
b, err := os.ReadFile(path)

cmd/yskill/bootstrap_templates.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ log. It is not a generated destination file.
7575
files["skill.json"] = fmt.Sprintf("{\"version\":1,\"yield_version\":%q,\"language\":\"go\",\"run\":[\"go\",\"run\",\"-mod=readonly\",\".\"]}\n", version)
7676
dependency = "go mod tidy (inside skills/yield-workflow-builder)"
7777
case "rust":
78+
files[".gitignore"] = rustSkillGitignore
7879
files["src/main.rs"] = bootstrapRust
7980
files["Cargo.toml"] = fmt.Sprintf("[package]\nname = \"yield-workflow-builder\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nyieldskill = { version = \"=%s\" }\nserde_json = \"1\"\n", version)
8081
files["skill.json"] = fmt.Sprintf("{\"version\":1,\"yield_version\":%q,\"language\":\"rust\",\"run\":[\"cargo\",\"run\",\"--quiet\"]}\n", version)

cmd/yskill/bootstrap_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,49 @@ func TestBuilderTemplatesExposeEquivalentOperations(t *testing.T) {
152152
}
153153
}
154154

155+
func TestBootstrapRustTemplateAddsAndPreservesSkillGitignore(t *testing.T) {
156+
profile := bootstrapProfile{YieldVersion: "1.2.3", Agents: []string{"codex"}}
157+
files, _, err := renderBootstrapSkill("rust", profile)
158+
if err != nil {
159+
t.Fatal(err)
160+
}
161+
if got := files[".gitignore"]; got != rustSkillGitignore {
162+
t.Fatalf("Rust bootstrap .gitignore = %q, want %q", got, rustSkillGitignore)
163+
}
164+
for _, language := range []string{"typescript", "python", "go"} {
165+
files, _, err := renderBootstrapSkill(language, profile)
166+
if err != nil {
167+
t.Fatal(err)
168+
}
169+
if _, found := files[".gitignore"]; found {
170+
t.Fatalf("%s bootstrap template created Rust-specific .gitignore", language)
171+
}
172+
}
173+
174+
root := t.TempDir()
175+
skillDir := filepath.Join(root, "skills", bootstrapSkillName)
176+
if err := os.MkdirAll(skillDir, 0o755); err != nil {
177+
t.Fatal(err)
178+
}
179+
const existing = "user-owned-rule/\n"
180+
if err := os.WriteFile(filepath.Join(skillDir, ".gitignore"), []byte(existing), 0o644); err != nil {
181+
t.Fatal(err)
182+
}
183+
plan := bootstrapPlan{
184+
Root: root,
185+
Language: "python",
186+
SkillDir: skillDir,
187+
Profile: bootstrapProfile{Version: 1, YieldVersion: "1.2.3", Language: "python"},
188+
Files: map[string]string{".gitignore": rustSkillGitignore},
189+
}
190+
if err := applyBootstrapPlan(plan); err != nil {
191+
t.Fatal(err)
192+
}
193+
if got := readTestFile(t, filepath.Join(skillDir, ".gitignore")); got != existing {
194+
t.Fatalf("existing bootstrap .gitignore changed: %q", got)
195+
}
196+
}
197+
155198
func TestBuilderCreateFixtureDoesNotRequireProjection(t *testing.T) {
156199
if strings.Contains(bootstrapFixtureResponses, `"project-semantics"`) {
157200
t.Fatal("create mode fixture must remain unchanged by conversion projection")

cmd/yskill/main_test.go

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,12 @@ func TestScaffoldSkillWritesLanguageSpecificEntrypoints(t *testing.T) {
186186
files []string
187187
command string
188188
pin string
189+
ignore string
189190
}{
190-
{"typescript", []string{"main.ts", "package.json", "skill.json"}, "npm exec -- yskill run .", `"@operatorstack/yield": "0.1.9"`},
191-
{"python", []string{"main.py", "requirements.txt", "skill.json"}, "python -m yieldskill run .", "yieldskill==0.1.9"},
192-
{"go", []string{"main.go", "go.mod", "skill.json"}, "yskill run .", "github.com/operatorstack/yield v0.1.9"},
193-
{"rust", []string{"src/main.rs", "Cargo.toml", "skill.json"}, "yskill run .", `version = "=0.1.9"`},
191+
{"typescript", []string{"main.ts", "package.json", "skill.json"}, "npm exec -- yskill run .", `"@operatorstack/yield": "0.1.9"`, ""},
192+
{"python", []string{"main.py", "requirements.txt", "skill.json"}, "python -m yieldskill run .", "yieldskill==0.1.9", ""},
193+
{"go", []string{"main.go", "go.mod", "skill.json"}, "yskill run .", "github.com/operatorstack/yield v0.1.9", ""},
194+
{"rust", []string{"src/main.rs", "Cargo.toml", "skill.json", ".gitignore"}, "yskill run .", `version = "=0.1.9"`, rustSkillGitignore},
194195
}
195196
for _, tt := range tests {
196197
t.Run(tt.language, func(t *testing.T) {
@@ -246,13 +247,36 @@ func TestScaffoldSkillWritesLanguageSpecificEntrypoints(t *testing.T) {
246247
if tt.language == "rust" && strings.Contains(manifest, "registry =") {
247248
t.Fatalf("public Rust scaffold contains a private package registry:\n%s", manifest)
248249
}
250+
ignorePath := filepath.Join(dir, ".gitignore")
251+
if tt.ignore == "" {
252+
if _, err := os.Stat(ignorePath); !os.IsNotExist(err) {
253+
t.Fatalf("%s scaffold created Rust-specific .gitignore: %v", tt.language, err)
254+
}
255+
} else if got := readTestFile(t, ignorePath); got != tt.ignore {
256+
t.Fatalf(".gitignore = %q, want %q", got, tt.ignore)
257+
}
249258
})
250259
}
251260
if tidyCalls != 1 {
252261
t.Fatalf("go mod tidy calls = %d, want 1", tidyCalls)
253262
}
254263
}
255264

265+
func TestRustScaffoldPreservesExistingGitignore(t *testing.T) {
266+
dir := filepath.Join(t.TempDir(), "safe-change")
267+
if err := os.MkdirAll(dir, 0o755); err != nil {
268+
t.Fatal(err)
269+
}
270+
const existing = "user-owned-rule/\n"
271+
writeTestFile(t, filepath.Join(dir, ".gitignore"), existing)
272+
if err := scaffoldSkill(dir, "rust", "", "Check a safe change before applying it."); err != nil {
273+
t.Fatal(err)
274+
}
275+
if got := readTestFile(t, filepath.Join(dir, ".gitignore")); got != existing {
276+
t.Fatalf("existing .gitignore changed: %q", got)
277+
}
278+
}
279+
256280
func TestPackageScaffoldsPrintCreatedWorkflowInNextCommands(t *testing.T) {
257281
previousVersion := version
258282
version = "0.1.28"
@@ -458,6 +482,44 @@ func TestRustScaffoldPinsTheInvokedRuntimeWithoutPrivateRegistryConfig(t *testin
458482
}
459483
}
460484

485+
func TestCmdInitRustScaffoldIsDoctorValid(t *testing.T) {
486+
if _, err := exec.LookPath("cargo"); err != nil {
487+
t.Skip("cargo is unavailable")
488+
}
489+
previousVersion := version
490+
previousExecutable := currentExecutable
491+
previousInspect := inspectRuntimeVersion
492+
version = "0.1.37"
493+
t.Cleanup(func() {
494+
version = previousVersion
495+
currentExecutable = previousExecutable
496+
inspectRuntimeVersion = previousInspect
497+
})
498+
499+
root := t.TempDir()
500+
writeTestFile(t, filepath.Join(root, ".git"), "gitdir: fixture\n")
501+
writeTestFile(t, localRuntimePath(root), "packaged runtime")
502+
currentExecutable = func() (string, error) { return localRuntimePath(root), nil }
503+
inspectRuntimeVersion = func(path string) (string, error) {
504+
resolved, err := filepath.EvalSymlinks(localRuntimePath(root))
505+
if err != nil {
506+
t.Fatal(err)
507+
}
508+
if path != localRuntimePath(root) && path != resolved {
509+
t.Fatalf("inspected unexpected runtime %s", path)
510+
}
511+
return "0.1.37", nil
512+
}
513+
514+
dir := filepath.Join(root, "skills", "safe-change")
515+
if err := cmdInit([]string{"--language", "rust", "--description", "Check a safe change before applying it.", dir}); err != nil {
516+
t.Fatal(err)
517+
}
518+
if err := cmdDoctor([]string{dir, "--root", root}); err != nil {
519+
t.Fatalf("doctor rejected Rust scaffold: %v", err)
520+
}
521+
}
522+
461523
func TestPythonScaffoldUsesRelocatableInterpreter(t *testing.T) {
462524
previousVersion := version
463525
version = "0.1.9"

cmd/yskill/scaffold.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import (
1212
)
1313

1414
var releaseVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$`)
15+
16+
const rustSkillGitignore = "target/\n.yield/\n"
17+
1518
var tidyGoModule = func(dir string) error {
1619
cmd := exec.Command("go", "mod", "tidy")
1720
cmd.Dir = dir
@@ -256,6 +259,7 @@ func scaffoldFiles(name, language, sdkPath string) map[string]string {
256259
}
257260
case "rust":
258261
return map[string]string{
262+
".gitignore": rustSkillGitignore,
259263
"Cargo.toml": fmt.Sprintf("[package]\nname = %q\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nyieldskill = { version = \"=%s\" }\nserde_json = \"1\"\n", name, v),
260264
"src/main.rs": mainRust,
261265
"skill.json": fmt.Sprintf("{\"version\":1,\"yield_version\":%q,\"language\":\"rust\",\"run\":[\"cargo\",\"run\",\"--quiet\",\"--bin\",%q]}\n", v, name),

evals/results/latest-conversion.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"schema_version": 1,
33
"methodology_version": "semantic-disposition-v1",
4-
"generated_at": "2026-08-08T22:03:02.341Z",
5-
"source_hash": "172b2e12624cb922d7f15f03f745d46107e42297b9a3da3c9ca9e4549f46e465",
4+
"generated_at": "2026-08-09T09:21:44.116Z",
5+
"source_hash": "b87e914bc9406c400e82b8dd35c8e2b35df38664cfcf375ea6eba1884fc1adb3",
66
"fixture_source_hash": "9ab03fffe6716da8298b461f79c9eebae7c7bb01151328686ec51ed9c0b77fe5",
77
"status": "passed",
88
"model": {
@@ -13,10 +13,10 @@
1313
},
1414
"sessions": 2,
1515
"token_usage": {
16-
"input_tokens": 425017,
17-
"cached_input_tokens": 382499,
18-
"output_tokens": 8032,
19-
"reasoning_output_tokens": 1802
16+
"input_tokens": 795134,
17+
"cached_input_tokens": 732165,
18+
"output_tokens": 10401,
19+
"reasoning_output_tokens": 2874
2020
},
2121
"clause_counts": {
2222
"total": 4,

evals/results/latest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"schema_version": 2,
33
"methodology_version": "1.1",
4-
"generated_at": "2026-08-08T21:55:26.622Z",
5-
"source_digest": "a29a1bff252107e06d7ff43bd14d10abf0cc20a9eb57e3115c250caaf7ef1b19",
4+
"generated_at": "2026-08-09T09:13:26.116Z",
5+
"source_digest": "255b4fcf353708369ba9aaf49d41273c4f7114747418f72d94e8bc7a08c32cad",
66
"status": "passed",
77
"workflow_conformance": {
88
"passed": 40,

0 commit comments

Comments
 (0)