404 lines
12 KiB
Go
404 lines
12 KiB
Go
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
|
|
}
|