feat: add local coding agent harness
This commit is contained in:
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
|
||||
cd "$ROOT"
|
||||
exec go run ./scripts/policy/coding-agent-harness -root "$ROOT" "$@"
|
||||
@@ -0,0 +1,403 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var guideLineLimits = map[string]int{
|
||||
"AGENTS.md": 80,
|
||||
"internal/cli/AGENTS.md": 80,
|
||||
"internal/helpers/AGENTS.md": 100,
|
||||
}
|
||||
|
||||
var markdownLinkPattern = regexp.MustCompile(`\[[^]]+\]\(([^)]+)\)`)
|
||||
|
||||
type fileContract struct {
|
||||
path string
|
||||
required []string
|
||||
}
|
||||
|
||||
type taskField struct {
|
||||
label string
|
||||
allowNone bool
|
||||
allowedValue map[string]bool
|
||||
}
|
||||
|
||||
var taskFields = []taskField{
|
||||
{label: "Task kind", allowedValue: map[string]bool{"bug": true, "feature": true, "refactor": true, "docs": true, "policy": true, "release": true}},
|
||||
{label: "Goal (one primary outcome)"},
|
||||
{label: "Current behavior and evidence"},
|
||||
{label: "Acceptance criteria"},
|
||||
{label: "In scope (packages/files/surfaces)"},
|
||||
{label: "Out of scope", allowNone: true},
|
||||
{label: "Compatibility constraints", allowNone: true},
|
||||
{label: "Interface impact (commands/flags/output/errors/exit codes/Schema)", allowNone: true},
|
||||
{label: "Safety or data-mutation constraints", allowNone: true},
|
||||
{label: "Expected validation"},
|
||||
{label: "Known environment limitations", allowNone: true},
|
||||
}
|
||||
|
||||
var guideContracts = []fileContract{
|
||||
{
|
||||
path: "AGENTS.md",
|
||||
required: []string{
|
||||
"docs/coding-agent-guide.md",
|
||||
"docs/coding-agent-task-template.md",
|
||||
"docs/schema-contributor-guide.md",
|
||||
"internal/helpers/AGENTS.md",
|
||||
"docs/automation.md",
|
||||
"docs/agent-code.md",
|
||||
"Do not depend on generated Wiki or CodeWiki content.",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "CONTRIBUTING.md",
|
||||
required: []string{
|
||||
"docs/coding-agent-guide.md",
|
||||
"docs/schema-contributor-guide.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "docs/coding-agent-guide.md",
|
||||
required: []string{
|
||||
"## 1. Normalize the task input",
|
||||
"coding-agent-task-template.md",
|
||||
"make coding-agent-task TASK=",
|
||||
"primary outcome per task",
|
||||
"github.com/larksuite/cli/blob/",
|
||||
"github.com/WecomTeam/wecom-cli/blob/",
|
||||
"## 2. Establish the baseline",
|
||||
"## 3. Implement from authoritative inputs",
|
||||
"## 4. Select validation by change surface",
|
||||
"Documentation only",
|
||||
"Go implementation",
|
||||
"CLI paths or flags",
|
||||
"Schema registry, hints, or generators",
|
||||
"CI or test sharding",
|
||||
"Packaging or installers",
|
||||
"Authentication, transport, or OS-specific code",
|
||||
"## 5. Pre-handoff self-check",
|
||||
"Outcome: what is now true",
|
||||
"Validation: exact commands and results",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "docs/coding-agent-task-template.md",
|
||||
required: []string{
|
||||
"Goal (one primary outcome):",
|
||||
"Current behavior and evidence:",
|
||||
"Acceptance criteria:",
|
||||
"In scope (packages/files/surfaces):",
|
||||
"Out of scope:",
|
||||
"Compatibility constraints:",
|
||||
"Interface impact (commands/flags/output/errors/exit codes/Schema):",
|
||||
"Safety or data-mutation constraints:",
|
||||
"Expected validation:",
|
||||
"Known environment limitations:",
|
||||
"Expected stdout/stderr and exit behavior:",
|
||||
"Mutation preview/confirmation behavior:",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "docs/schema-contributor-guide.md",
|
||||
required: []string{
|
||||
"internal/cli/schema_command_registry.json",
|
||||
"internal/cli/schema_command_registry.schema.json",
|
||||
"internal/cli/schema_hints/metadata/<product>.json",
|
||||
"internal/cli/schema_hints/selection/<product>.json",
|
||||
"internal/cli/schema_parameter_bindings.json",
|
||||
"internal/cli/schema_mcp_metadata.json",
|
||||
"internal/cli/schema_command_exclusions.json",
|
||||
"internal/cli/schema_catalog.json",
|
||||
"make generate-schema",
|
||||
"make test-schema-agent-examples",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "internal/helpers/AGENTS.md",
|
||||
required: []string{
|
||||
"../../CONTRIBUTING.md",
|
||||
"../../docs/coding-agent-guide.md",
|
||||
"../../docs/schema-contributor-guide.md",
|
||||
"Keep stdout machine-readable business data.",
|
||||
"structured error category",
|
||||
"preview/confirmation",
|
||||
"Do not claim a live",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "internal/cli/AGENTS.md",
|
||||
required: []string{
|
||||
"../../docs/schema-contributor-guide.md",
|
||||
"schema_command_registry.json",
|
||||
"schema_parameter_bindings.json",
|
||||
"schema_hints/metadata/<product>.json",
|
||||
"schema_hints/selection/<product>.json",
|
||||
"schema_command_exclusions.json",
|
||||
"schema_catalog.json",
|
||||
"make generate-schema",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var requiredPaths = []string{
|
||||
"docs/automation.md",
|
||||
"docs/agent-code.md",
|
||||
"scripts/policy/check-runtime-confirmation-truth.sh",
|
||||
"scripts/policy/check-coding-agent-harness.sh",
|
||||
"scripts/policy/check-generated-drift.sh",
|
||||
"scripts/policy/check-schema-catalog.sh",
|
||||
"scripts/policy/check-command-surface.sh",
|
||||
"scripts/release/verify-package-managers.sh",
|
||||
}
|
||||
|
||||
var requiredMakeTargets = []string{
|
||||
"build",
|
||||
"coding-agent-harness",
|
||||
"coding-agent-task",
|
||||
"format-check",
|
||||
"test",
|
||||
"policy",
|
||||
"interface-integrity",
|
||||
"skill-command-integrity",
|
||||
"test-schema-agent-examples",
|
||||
"generate-schema",
|
||||
"package",
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := flag.String("root", ".", "repository root")
|
||||
task := flag.String("task", "", "optional filled coding-agent task file to validate")
|
||||
flag.Parse()
|
||||
if flag.NArg() != 0 {
|
||||
fmt.Fprintln(os.Stderr, "coding agent harness: unexpected positional arguments")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
problems := validate(*root)
|
||||
if strings.TrimSpace(*task) != "" {
|
||||
taskPath := *task
|
||||
if !filepath.IsAbs(taskPath) {
|
||||
taskPath = filepath.Join(*root, taskPath)
|
||||
}
|
||||
problems = append(problems, validateTask(taskPath)...)
|
||||
sort.Slice(problems, func(i, j int) bool { return problems[i].Error() < problems[j].Error() })
|
||||
}
|
||||
if len(problems) != 0 {
|
||||
fmt.Fprintln(os.Stderr, "coding agent harness: failed")
|
||||
for _, problem := range problems {
|
||||
fmt.Fprintf(os.Stderr, "- %v\n", problem)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("coding agent harness: ok")
|
||||
}
|
||||
|
||||
func validateTask(path string) []error {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return []error{fmt.Errorf("read task contract %s: %w", path, err)}
|
||||
}
|
||||
|
||||
values := make(map[string][]string)
|
||||
occurrences := make(map[string]int)
|
||||
current := ""
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(content)))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
matched := false
|
||||
for _, field := range taskFields {
|
||||
prefix := field.label + ":"
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
current = field.label
|
||||
occurrences[current]++
|
||||
inline := strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
||||
if inline != "" {
|
||||
values[current] = append(values[current], inline)
|
||||
}
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched || current == "" || line == "" || strings.HasPrefix(line, "```") {
|
||||
continue
|
||||
}
|
||||
values[current] = append(values[current], line)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return []error{fmt.Errorf("scan task contract %s: %w", path, err)}
|
||||
}
|
||||
|
||||
var problems []error
|
||||
for _, field := range taskFields {
|
||||
if occurrences[field.label] > 1 {
|
||||
problems = append(problems, fmt.Errorf("task contract field %q appears %d times; each field must be unique", field.label, occurrences[field.label]))
|
||||
}
|
||||
value := strings.TrimSpace(strings.Join(values[field.label], "\n"))
|
||||
if value == "" {
|
||||
problems = append(problems, fmt.Errorf("task contract is missing a value for %q", field.label))
|
||||
continue
|
||||
}
|
||||
normalized := strings.ToLower(value)
|
||||
if len(field.allowedValue) != 0 && !field.allowedValue[normalized] {
|
||||
problems = append(problems, fmt.Errorf("task contract field %q has unsupported value %q", field.label, value))
|
||||
}
|
||||
if isPlaceholderTaskValue(normalized) {
|
||||
problems = append(problems, fmt.Errorf("task contract field %q still contains placeholder value %q", field.label, value))
|
||||
}
|
||||
if !field.allowNone && (normalized == "none" || normalized == "n/a" || normalized == "not applicable") {
|
||||
problems = append(problems, fmt.Errorf("task contract field %q requires concrete evidence", field.label))
|
||||
}
|
||||
}
|
||||
sort.Slice(problems, func(i, j int) bool { return problems[i].Error() < problems[j].Error() })
|
||||
return problems
|
||||
}
|
||||
|
||||
func isPlaceholderTaskValue(value string) bool {
|
||||
for _, line := range strings.Split(value, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
trimmed = strings.TrimLeft(trimmed, "-*0123456789.) ")
|
||||
switch trimmed {
|
||||
case "todo", "tbd", "unknown", "fill me", "to be decided":
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "todo:") || strings.HasPrefix(trimmed, "tbd:") || strings.Contains(trimmed, "<fill") || strings.Contains(trimmed, "<todo") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validate(root string) []error {
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return []error{fmt.Errorf("resolve root: %w", err)}
|
||||
}
|
||||
|
||||
var problems []error
|
||||
for _, contract := range guideContracts {
|
||||
content, readErr := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(contract.path)))
|
||||
if readErr != nil {
|
||||
problems = append(problems, fmt.Errorf("read %s: %w", contract.path, readErr))
|
||||
continue
|
||||
}
|
||||
text := string(content)
|
||||
for _, required := range contract.required {
|
||||
if !strings.Contains(text, required) {
|
||||
problems = append(problems, fmt.Errorf("%s is missing required contract text %q", contract.path, required))
|
||||
}
|
||||
}
|
||||
problems = append(problems, validateLocalLinks(absRoot, contract.path, text)...)
|
||||
}
|
||||
|
||||
for path, limit := range guideLineLimits {
|
||||
if lineCount, countErr := countLines(filepath.Join(absRoot, filepath.FromSlash(path))); countErr != nil {
|
||||
problems = append(problems, fmt.Errorf("count %s lines: %w", path, countErr))
|
||||
} else if lineCount > limit {
|
||||
problems = append(problems, fmt.Errorf("%s has %d lines; scoped guide limit is %d", path, lineCount, limit))
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range requiredPaths {
|
||||
info, statErr := os.Stat(filepath.Join(absRoot, filepath.FromSlash(path)))
|
||||
if statErr != nil {
|
||||
problems = append(problems, fmt.Errorf("required referenced path %s: %w", path, statErr))
|
||||
} else if strings.HasSuffix(path, ".sh") && info.Mode().Perm()&0o111 == 0 {
|
||||
problems = append(problems, fmt.Errorf("required referenced script %s is not executable", path))
|
||||
}
|
||||
}
|
||||
|
||||
makeTargets, makeErr := loadMakeTargets(filepath.Join(absRoot, "Makefile"))
|
||||
if makeErr != nil {
|
||||
problems = append(problems, makeErr)
|
||||
} else {
|
||||
for _, target := range requiredMakeTargets {
|
||||
if !makeTargets[target] {
|
||||
problems = append(problems, fmt.Errorf("makefile is missing referenced target %q", target))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(problems, func(i, j int) bool { return problems[i].Error() < problems[j].Error() })
|
||||
return problems
|
||||
}
|
||||
|
||||
func validateLocalLinks(root, document, content string) []error {
|
||||
var problems []error
|
||||
documentDir := filepath.Join(root, filepath.Dir(filepath.FromSlash(document)))
|
||||
for _, match := range markdownLinkPattern.FindAllStringSubmatch(content, -1) {
|
||||
target := strings.TrimSpace(match[1])
|
||||
if target == "" || strings.HasPrefix(target, "#") || strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "mailto:") {
|
||||
continue
|
||||
}
|
||||
if cut, _, ok := strings.Cut(target, "#"); ok {
|
||||
target = cut
|
||||
}
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
if filepath.IsAbs(target) {
|
||||
problems = append(problems, fmt.Errorf("%s contains absolute local link %q", document, match[1]))
|
||||
continue
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(documentDir, filepath.FromSlash(target)))
|
||||
rel, err := filepath.Rel(root, resolved)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
problems = append(problems, fmt.Errorf("%s link %q escapes repository root", document, match[1]))
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(resolved); err != nil {
|
||||
problems = append(problems, fmt.Errorf("%s has broken local link %q: %w", document, match[1], err))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func countLines(path string) (int, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
count := 0
|
||||
for scanner.Scan() {
|
||||
count++
|
||||
}
|
||||
return count, scanner.Err()
|
||||
}
|
||||
|
||||
func loadMakeTargets(path string) (map[string]bool, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Makefile targets: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
targets := make(map[string]bool)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" || line[0] == '\t' || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
continue
|
||||
}
|
||||
name, _, ok := strings.Cut(line, ":")
|
||||
if !ok || strings.ContainsAny(name, "= ") || strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
targets[name] = true
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, errors.New("scan Makefile targets: " + err.Error())
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepositoryCodingAgentHarness(t *testing.T) {
|
||||
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
if problems := validate(root); len(problems) != 0 {
|
||||
t.Fatalf("repository harness problems:\n%s", formatProblems(problems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodingAgentHarnessFixturePasses(t *testing.T) {
|
||||
root := newHarnessFixture(t)
|
||||
if problems := validate(root); len(problems) != 0 {
|
||||
t.Fatalf("valid fixture problems:\n%s", formatProblems(problems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodingAgentTaskContractPasses(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "task.md")
|
||||
writeTaskFixture(t, path, validTaskContract)
|
||||
if problems := validateTask(path); len(problems) != 0 {
|
||||
t.Fatalf("valid task problems:\n%s", formatProblems(problems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodingAgentTaskContractFailsClosed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantProblem string
|
||||
}{
|
||||
{
|
||||
name: "missing goal",
|
||||
content: strings.Replace(validTaskContract, "Goal (one primary outcome): Fix retry handling\n", "", 1),
|
||||
wantProblem: `missing a value for "Goal (one primary outcome)"`,
|
||||
},
|
||||
{
|
||||
name: "blank acceptance criteria",
|
||||
content: strings.Replace(validTaskContract, "Acceptance criteria:\n- The retry succeeds once and then stops.\n", "Acceptance criteria:\n", 1),
|
||||
wantProblem: `missing a value for "Acceptance criteria"`,
|
||||
},
|
||||
{
|
||||
name: "unsupported task kind",
|
||||
content: strings.Replace(validTaskContract, "Task kind: bug", "Task kind: experiment", 1),
|
||||
wantProblem: `unsupported value "experiment"`,
|
||||
},
|
||||
{
|
||||
name: "placeholder acceptance bullet",
|
||||
content: strings.Replace(validTaskContract, "- The retry succeeds once and then stops.", "- TBD: decide expected retry result", 1),
|
||||
wantProblem: "still contains placeholder value",
|
||||
},
|
||||
{
|
||||
name: "duplicate goal",
|
||||
content: strings.Replace(validTaskContract, "Goal (one primary outcome): Fix retry handling", "Goal (one primary outcome): Fix retry handling\nGoal (one primary outcome): Refactor transport", 1),
|
||||
wantProblem: `field "Goal (one primary outcome)" appears 2 times`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "task.md")
|
||||
writeTaskFixture(t, path, test.content)
|
||||
problems := formatProblems(validateTask(path))
|
||||
if !strings.Contains(problems, test.wantProblem) {
|
||||
t.Fatalf("problems:\n%s\nwant substring %q", problems, test.wantProblem)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodingAgentHarnessFailsClosed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(t *testing.T, root string)
|
||||
wantProblem string
|
||||
}{
|
||||
{
|
||||
name: "root guide is no longer thin",
|
||||
mutate: func(t *testing.T, root string) {
|
||||
path := filepath.Join(root, "AGENTS.md")
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
for i := 0; i < guideLineLimits["AGENTS.md"]; i++ {
|
||||
if _, err := file.WriteString("extra line\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
},
|
||||
wantProblem: "scoped guide limit",
|
||||
},
|
||||
{
|
||||
name: "task input field is removed",
|
||||
mutate: func(t *testing.T, root string) {
|
||||
replaceFixtureText(t, root, "docs/coding-agent-task-template.md", "Acceptance criteria:", "")
|
||||
},
|
||||
wantProblem: `docs/coding-agent-task-template.md is missing required contract text "Acceptance criteria:"`,
|
||||
},
|
||||
{
|
||||
name: "route link is broken",
|
||||
mutate: func(t *testing.T, root string) {
|
||||
replaceFixtureText(t, root, "AGENTS.md", "[automation](docs/automation.md)", "[automation](docs/missing.md)")
|
||||
},
|
||||
wantProblem: "broken local link",
|
||||
},
|
||||
{
|
||||
name: "referenced script is absent",
|
||||
mutate: func(t *testing.T, root string) {
|
||||
if err := os.Remove(filepath.Join(root, "scripts/policy/check-schema-catalog.sh")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantProblem: "required referenced path scripts/policy/check-schema-catalog.sh",
|
||||
},
|
||||
{
|
||||
name: "make target is absent",
|
||||
mutate: func(t *testing.T, root string) {
|
||||
replaceFixtureText(t, root, "Makefile", "package:\n", "removed-package:\n")
|
||||
},
|
||||
wantProblem: `makefile is missing referenced target "package"`,
|
||||
},
|
||||
{
|
||||
name: "harness script is not executable",
|
||||
mutate: func(t *testing.T, root string) {
|
||||
path := filepath.Join(root, "scripts/policy/check-coding-agent-harness.sh")
|
||||
if err := os.Chmod(path, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantProblem: "required referenced script scripts/policy/check-coding-agent-harness.sh is not executable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := newHarnessFixture(t)
|
||||
test.mutate(t, root)
|
||||
problems := formatProblems(validate(root))
|
||||
if !strings.Contains(problems, test.wantProblem) {
|
||||
t.Fatalf("problems:\n%s\nwant substring %q", problems, test.wantProblem)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newHarnessFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
for _, contract := range guideContracts {
|
||||
var content strings.Builder
|
||||
content.WriteString("# Fixture\n\n")
|
||||
for _, required := range contract.required {
|
||||
content.WriteString(required)
|
||||
content.WriteByte('\n')
|
||||
}
|
||||
if contract.path == "AGENTS.md" {
|
||||
content.WriteString("[coding](docs/coding-agent-guide.md)\n")
|
||||
content.WriteString("[schema](docs/schema-contributor-guide.md)\n")
|
||||
content.WriteString("[automation](docs/automation.md)\n")
|
||||
content.WriteString("[agent code](docs/agent-code.md)\n")
|
||||
}
|
||||
writeFixtureFile(t, root, contract.path, content.String())
|
||||
}
|
||||
|
||||
for _, path := range requiredPaths {
|
||||
writeFixtureFileMode(t, root, path, "fixture\n", 0o755)
|
||||
}
|
||||
var makefile strings.Builder
|
||||
for _, target := range requiredMakeTargets {
|
||||
makefile.WriteString(target)
|
||||
makefile.WriteString(":\n\t@true\n")
|
||||
}
|
||||
writeFixtureFile(t, root, "Makefile", makefile.String())
|
||||
return root
|
||||
}
|
||||
|
||||
func writeFixtureFile(t *testing.T, root, path, content string) {
|
||||
t.Helper()
|
||||
writeFixtureFileMode(t, root, path, content, 0o644)
|
||||
}
|
||||
|
||||
func writeFixtureFileMode(t *testing.T, root, path, content string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
full := filepath.Join(root, filepath.FromSlash(path))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("create fixture directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), mode); err != nil {
|
||||
t.Fatalf("write fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceFixtureText(t *testing.T, root, path, old, replacement string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(root, filepath.FromSlash(path))
|
||||
content, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture %s: %v", path, err)
|
||||
}
|
||||
updated := strings.Replace(string(content), old, replacement, 1)
|
||||
if updated == string(content) {
|
||||
t.Fatalf("fixture %s does not contain %q", path, old)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(updated), 0o644); err != nil {
|
||||
t.Fatalf("update fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func formatProblems(problems []error) string {
|
||||
var lines []string
|
||||
for _, problem := range problems {
|
||||
lines = append(lines, problem.Error())
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func writeTaskFixture(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write task fixture: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const validTaskContract = `# Task
|
||||
|
||||
Task kind: bug
|
||||
Goal (one primary outcome): Fix retry handling
|
||||
Current behavior and evidence: The focused test reproduces two retries.
|
||||
Acceptance criteria:
|
||||
- The retry succeeds once and then stops.
|
||||
In scope (packages/files/surfaces): internal/transport
|
||||
Out of scope: Authentication behavior
|
||||
Compatibility constraints: Preserve existing flags and output
|
||||
Interface impact (commands/flags/output/errors/exit codes/Schema): None
|
||||
Safety or data-mutation constraints: No external writes
|
||||
Expected validation: Focused unit test and make test
|
||||
Known environment limitations: Live service unavailable
|
||||
`
|
||||
@@ -0,0 +1,15 @@
|
||||
# Retry Handling Task
|
||||
|
||||
Task kind: bug
|
||||
Goal (one primary outcome): Fix transient retry handling
|
||||
Current behavior and evidence: A focused transport test reproduces two retries after a successful response.
|
||||
Acceptance criteria:
|
||||
- The retry loop stops after the first successful response.
|
||||
- The existing output and exit behavior remain unchanged.
|
||||
In scope (packages/files/surfaces): internal/transport retry implementation and tests
|
||||
Out of scope: Authentication and endpoint discovery
|
||||
Compatibility constraints: Preserve existing flags, JSON output, and exit codes
|
||||
Interface impact (commands/flags/output/errors/exit codes/Schema): None
|
||||
Safety or data-mutation constraints: No live service calls or external writes
|
||||
Expected validation: Focused transport tests, race test, formatting, and full Go test suite
|
||||
Known environment limitations: Live DingTalk service is intentionally not used
|
||||
Reference in New Issue
Block a user