From 8b723009f9ce343defa2fea81e5fe23077928034 Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 05:39:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(orchestration/audit):=20=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E4=B8=8A=E4=B8=8B=E6=96=87=E5=8F=AF=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=EF=BC=88=E5=85=A8=E9=87=8F/=E5=B0=BE?= =?UTF-8?q?=E9=83=A8/=E5=85=B3=E9=94=AE=E8=AF=8D=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=EF=BC=89+=20=E4=B8=BB=E4=BB=A3=E7=90=86=E4=BB=A3=E5=AE=A1?= =?UTF-8?q?=E6=8E=88=E6=9D=83=EF=BC=88=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97?= =?UTF-8?q?+=E5=B0=8F=E5=AD=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime/orchestration/audit/approval.go | 124 +++++++++ .../orchestration/audit/approval_test.go | 146 +++++++++++ internal/runtime/orchestration/audit/audit.go | 24 ++ .../runtime/orchestration/audit/context.go | 122 +++++++++ .../orchestration/audit/context_test.go | 242 ++++++++++++++++++ 5 files changed, 658 insertions(+) create mode 100644 internal/runtime/orchestration/audit/approval.go create mode 100644 internal/runtime/orchestration/audit/approval_test.go create mode 100644 internal/runtime/orchestration/audit/audit.go create mode 100644 internal/runtime/orchestration/audit/context.go create mode 100644 internal/runtime/orchestration/audit/context_test.go diff --git a/internal/runtime/orchestration/audit/approval.go b/internal/runtime/orchestration/audit/approval.go new file mode 100644 index 0000000..6ce5533 --- /dev/null +++ b/internal/runtime/orchestration/audit/approval.go @@ -0,0 +1,124 @@ +package audit + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// Sentinel errors returned by Approver. Use errors.Is to test for them. +var ( + // ErrActionMissing is returned when the action is blank. + ErrActionMissing = errors.New("audit: action is missing") + // ErrUnknownActor is returned when the acting role name is blank. This + // package has no role registry, so a blank name is the only "unknown" it + // can detect; whether a role is truly authorized is decided upstream. + ErrUnknownActor = errors.New("audit: actor is unknown") + // ErrExplainMissing is returned when the explanation is blank. + ErrExplainMissing = errors.New("audit: explanation is missing") +) + +// reviewerMain is the name recorded for the main agent that performs the review. +const reviewerMain = "main" + +// Approval is the immutable record of a single main-agent review-and-approve of +// a subagent's sensitive action. +type Approval struct { + // Actor is the acting role name (角色名). + Actor string + // Action is the sensitive action being approved. + Action string + // Description is the explanation of what the actor wants to do (what the + // action means). It is named Description rather than Explain because the + // Explain method (below) returns the frontend notice. + Description string + // Allowed reports whether the action was granted. + Allowed bool + // Reviewer is the main agent that performed the review. + Reviewer string + // At is when the review-and-approve happened. + At time.Time +} + +// Explain returns the short frontend notice describing the review outcome. For +// an allowed action it is "「 想要干什么,我审查后批准了」"; for a denied +// action it uses "我审查后拒绝了". Actor is interpolated in place of . +func (a Approval) Explain() string { + if a.Allowed { + return "「" + a.Actor + " 想要干什么,我审查后批准了」" + } + return "「" + a.Actor + " 想要干什么,我审查后拒绝了」" +} + +// Approver records main-agent review-and-approve events and their audit logs. +type Approver struct { + log *slog.Logger +} + +// NewApprover builds an Approver that writes audit logs through log. The logger +// is tagged with source "orchestration/audit". A nil logger is allowed, making +// the approver record silently (no log output). +func NewApprover(log *slog.Logger) *Approver { + return &Approver{log: logger.WithSource(log, "orchestration/audit")} +} + +// ReviewAndApprove validates the request, checks ctx, writes a structured audit +// log (who / what / when / result), and returns the recorded approval with +// Allowed set to true. +// +// Whether an action is actually sensitive and whether it should be granted is +// decided by the upper policy layer; this method only records, narrates and +// returns the result model. It returns ErrUnknownActor / ErrActionMissing / +// ErrExplainMissing for blank inputs and wraps ctx.Err() when ctx is canceled. +func (a *Approver) ReviewAndApprove(ctx context.Context, actor, action, describe string) (*Approval, error) { + if err := validateApproval(actor, action, describe); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("audit: review-and-approve: %w", err) + } + ap := &Approval{ + Actor: actor, + Action: action, + Description: describe, + Allowed: true, + Reviewer: reviewerMain, + At: time.Now(), + } + a.logAudit(ap) + return ap, nil +} + +// logAudit writes the structured who/what/when/result audit record. +func (a *Approver) logAudit(ap *Approval) { + if a.log == nil { + return + } + a.log.Info("audit: review-and-approve", + slog.String("actor", ap.Actor), + slog.String("action", ap.Action), + slog.String("explain", ap.Description), + slog.Bool("allowed", ap.Allowed), + slog.String("reviewer", ap.Reviewer), + slog.Time("at", ap.At), + ) +} + +// validateApproval rejects blank inputs with the matching sentinel. +func validateApproval(actor, action, describe string) error { + if strings.TrimSpace(actor) == "" { + return fmt.Errorf("%w: actor is blank", ErrUnknownActor) + } + if strings.TrimSpace(action) == "" { + return fmt.Errorf("%w: action is blank", ErrActionMissing) + } + if strings.TrimSpace(describe) == "" { + return fmt.Errorf("%w: explanation is blank", ErrExplainMissing) + } + return nil +} diff --git a/internal/runtime/orchestration/audit/approval_test.go b/internal/runtime/orchestration/audit/approval_test.go new file mode 100644 index 0000000..3447a6d --- /dev/null +++ b/internal/runtime/orchestration/audit/approval_test.go @@ -0,0 +1,146 @@ +package audit + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// newSilentApprover builds an Approver whose audit log is discarded. +func newSilentApprover() *Approver { + return NewApprover(logger.New(logger.Options{Out: io.Discard})) +} + +func TestReviewAndApprove_Records(t *testing.T) { + t.Parallel() + + ap, err := newSilentApprover().ReviewAndApprove(context.Background(), "worker", "run ls", "list the project dir") + if err != nil { + t.Fatalf("ReviewAndApprove unexpected error: %v", err) + } + if ap.Actor != "worker" { + t.Fatalf("Actor = %q, want worker", ap.Actor) + } + if ap.Action != "run ls" { + t.Fatalf("Action = %q, want run ls", ap.Action) + } + if ap.Description != "list the project dir" { + t.Fatalf("Description = %q, want list the project dir", ap.Description) + } + if !ap.Allowed { + t.Fatal("Allowed = false, want true") + } + if ap.Reviewer != "main" { + t.Fatalf("Reviewer = %q, want main", ap.Reviewer) + } + if ap.At.IsZero() { + t.Fatal("At is zero, want a timestamp") + } +} + +func TestApproval_Explain_Allowed(t *testing.T) { + t.Parallel() + + ap := Approval{Actor: "worker", Allowed: true} + if got, want := ap.Explain(), "「worker 想要干什么,我审查后批准了」"; got != want { + t.Fatalf("Explain() = %q, want %q", got, want) + } +} + +func TestApproval_Explain_Denied(t *testing.T) { + t.Parallel() + + ap := Approval{Actor: "worker", Allowed: false} + if got, want := ap.Explain(), "「worker 想要干什么,我审查后拒绝了」"; got != want { + t.Fatalf("Explain() = %q, want %q", got, want) + } +} + +func TestReviewAndApprove_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + actor string + action string + desc string + wantErr error + }{ + {name: "blank actor", actor: "", action: "run ls", desc: "list dir", wantErr: ErrUnknownActor}, + {name: "space actor", actor: " ", action: "run ls", desc: "list dir", wantErr: ErrUnknownActor}, + {name: "blank action", actor: "worker", action: "", desc: "list dir", wantErr: ErrActionMissing}, + {name: "blank explain", actor: "worker", action: "run ls", desc: "", wantErr: ErrExplainMissing}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ap, err := newSilentApprover().ReviewAndApprove(context.Background(), tt.actor, tt.action, tt.desc) + if err == nil { + t.Fatalf("ReviewAndApprove returned nil error, want %v", tt.wantErr) + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("error = %v, want Is(%v)", err, tt.wantErr) + } + if ap != nil { + t.Fatalf("Approval = %+v, want nil on validation failure", ap) + } + }) + } +} + +func TestReviewAndApprove_WritesAuditLog(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + ap := NewApprover(logger.New(logger.Options{Level: "debug", Format: "json", Out: &buf})) + + _, err := ap.ReviewAndApprove(context.Background(), "worker", "run command", "delete prod db") + if err != nil { + t.Fatalf("ReviewAndApprove unexpected error: %v", err) + } + + got := buf.String() + for _, want := range []string{ + `"source":"orchestration/audit"`, + `"actor":"worker"`, + `"action":"run command"`, + `"explain":"delete prod db"`, + `"allowed":true`, + `"reviewer":"main"`, + } { + if !strings.Contains(got, want) { + t.Fatalf("audit log missing %s, got: %s", want, got) + } + } +} + +func TestReviewAndApprove_CanceledContext(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := newSilentApprover().ReviewAndApprove(ctx, "worker", "run ls", "list dir") + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } +} + +func TestNewApprover_NilLogger(t *testing.T) { + t.Parallel() + + ap, err := NewApprover(nil).ReviewAndApprove(context.Background(), "worker", "run ls", "list dir") + if err != nil { + t.Fatalf("ReviewAndApprove with nil logger returned error: %v", err) + } + if ap == nil || ap.Actor != "worker" || !ap.Allowed { + t.Fatalf("Approval with nil logger not recorded: %+v", ap) + } +} diff --git a/internal/runtime/orchestration/audit/audit.go b/internal/runtime/orchestration/audit/audit.go new file mode 100644 index 0000000..0e83538 --- /dev/null +++ b/internal/runtime/orchestration/audit/audit.go @@ -0,0 +1,24 @@ +// Package audit provides the subagent-auditing primitives specified by the +// Cyrene orchestration design (DevDocs/design/05-子代理.md §3 and §6) and the +// security/audit requirements in DevDocs/design/11-安全与权限.md §4 and §8. +// +// It has two halves: +// +// - ContextAuditor gives a main agent read-only, traceable access to a +// subagent's context (a []model.Message) so it can audit and pinpoint what +// the subagent did: the full context, a tail window, or a keyword hit with +// its surrounding lines. It is a pure value type over the message list the +// orchestral layer hands it; it holds no storage and no policy. +// +// - Approver records a main-agent review-and-approve (代审授权) of a +// subagent's sensitive action. It writes a structured audit log (who / what +// / when / result) and produces the short frontend notice "「xxx 想要干什么, +// 我审查后批准了」". Whether an action is actually sensitive and whether it is +// allowed is decided by the upper policy layer; this package only records, +// narrates, and returns the result model — it never judges permission. +// +// The package is a leaf: it depends only on the standard library, +// internal/runtime/model and internal/logger, and never imports the agent loop +// or other runtime packages, so it can sit inside runtime/orchestration without +// an import cycle. +package audit diff --git a/internal/runtime/orchestration/audit/context.go b/internal/runtime/orchestration/audit/context.go new file mode 100644 index 0000000..e2b4877 --- /dev/null +++ b/internal/runtime/orchestration/audit/context.go @@ -0,0 +1,122 @@ +package audit + +import ( + "context" + "errors" + "fmt" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// ErrQueryEmpty is returned by ContextAuditor.Keyword when the query is empty +// or blank. Use errors.Is to test for it. +var ErrQueryEmpty = errors.New("audit: query is empty") + +// contextRadius is how many messages on each side of a keyword hit are included +// in the surrounding window (one above and one below). +const contextRadius = 1 + +// Located describes a single keyword match within a subagent context. +type Located struct { + // Line is the 1-based index of the matched message in the full context. + Line int + // Surrounding is a window centered on the matched message: the message + // immediately before it, the matched message, and the message immediately + // after it, clamped at the context boundaries. It always contains the + // matched message. + Surrounding []model.Message +} + +// ContextAuditor provides read-only, traceable access to a subagent's context. +// It snapshots the message list at construction so later mutation by the caller +// does not change what the auditor reports. It is a value type that holds no +// storage or policy; the orchestral layer decides which context to inspect. +type ContextAuditor struct { + messages []model.Message +} + +// NewContextAuditor snapshots messages so subsequent caller mutations do not +// affect the auditor. A nil input is allowed and yields an empty context. +func NewContextAuditor(messages []model.Message) ContextAuditor { + cp := make([]model.Message, len(messages)) + copy(cp, messages) + return ContextAuditor{messages: cp} +} + +// Full returns a defensive copy of the entire context. +func (a ContextAuditor) Full() []model.Message { + cp := make([]model.Message, len(a.messages)) + copy(cp, a.messages) + return cp +} + +// Tail returns the last n messages of the context in their original order. +// +// n <= 0 yields an empty, non-nil slice; n >= len returns a copy of the full +// context. The result is a defensive copy. +func (a ContextAuditor) Tail(n int) []model.Message { + if n <= 0 { + return []model.Message{} + } + if n >= len(a.messages) { + return a.Full() + } + start := len(a.messages) - n + out := make([]model.Message, n) + copy(out, a.messages[start:]) + return out +} + +// Keyword locates the first message whose text contains query, using a +// case-insensitive substring match against the message's text parts (image +// payloads are ignored). It returns the message's 1-based index and a +// surrounding window, or nil, nil when there is no match. +// +// It returns ErrQueryEmpty for a blank query and wraps ctx.Err() when ctx is +// canceled. Only the earliest match is reported. +func (a ContextAuditor) Keyword(ctx context.Context, query string) (*Located, error) { + if strings.TrimSpace(query) == "" { + return nil, fmt.Errorf("%w: query is blank", ErrQueryEmpty) + } + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("audit: keyword: %w", err) + } + q := strings.ToLower(query) + for i, m := range a.messages { + if strings.Contains(strings.ToLower(messageText(m)), q) { + return &Located{Line: i + 1, Surrounding: a.window(i)}, nil + } + } + return nil, nil +} + +// window returns up to 2*contextRadius+1 messages centered on index i, clamped +// to the context boundaries. +func (a ContextAuditor) window(i int) []model.Message { + lo := i - contextRadius + hi := i + contextRadius + if lo < 0 { + lo = 0 + } + if hi >= len(a.messages) { + hi = len(a.messages) - 1 + } + if lo > hi { + return nil + } + out := make([]model.Message, 0, hi-lo+1) + out = append(out, a.messages[lo:hi+1]...) + return out +} + +// messageText returns the concatenated text of a message's text parts. +func messageText(m model.Message) string { + var b strings.Builder + for _, c := range m.Content { + if c.Type == model.ContentTypeText && c.Text != "" { + b.WriteString(c.Text) + } + } + return b.String() +} diff --git a/internal/runtime/orchestration/audit/context_test.go b/internal/runtime/orchestration/audit/context_test.go new file mode 100644 index 0000000..0c537f1 --- /dev/null +++ b/internal/runtime/orchestration/audit/context_test.go @@ -0,0 +1,242 @@ +package audit + +import ( + "context" + "errors" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// texts builds a context of text-only messages with the given body texts. +func texts(role model.Role, bodies ...string) []model.Message { + out := make([]model.Message, 0, len(bodies)) + for _, b := range bodies { + out = append(out, model.TextMessage(role, b)) + } + return out +} + +// sameMessage compares a message by role and concatenated text; the test +// messages are text-only, so this is sufficient for equality. +func sameMessage(a, b model.Message) bool { + return a.Role == b.Role && messageText(a) == messageText(b) +} + +func TestNewContextAuditor_Snapshot(t *testing.T) { + t.Parallel() + + msgs := texts(model.RoleUser, "alpha", "bravo", "charlie") + aud := NewContextAuditor(msgs) + + // Mutating the caller's slice after construction must not affect the audit. + msgs[0].Role = model.RoleSystem + got := aud.Full() + if got[0].Role != model.RoleUser { + t.Fatalf("snapshot leaked caller mutation: role = %q, want %q", got[0].Role, model.RoleUser) + } +} + +func TestFull_ReturnsDefensiveCopy(t *testing.T) { + t.Parallel() + + aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo")) + + got := aud.Full() + got[0].Role = model.RoleSystem + got[1].Role = model.RoleSystem + + fresh := aud.Full() + if fresh[0].Role != model.RoleUser || fresh[1].Role != model.RoleUser { + t.Fatalf("Full leaked mutation of its returned copy: %+v", fresh) + } +} + +func TestTail(t *testing.T) { + t.Parallel() + + full := texts(model.RoleUser, "alpha", "bravo", "charlie", "delta") + aud := NewContextAuditor(full) + + tests := []struct { + name string + n int + want []string + }{ + {name: "zero returns empty", n: 0, want: nil}, + {name: "negative returns empty", n: -1, want: nil}, + {name: "single", n: 1, want: []string{"delta"}}, + {name: "pair", n: 2, want: []string{"charlie", "delta"}}, + {name: "exact length", n: 4, want: []string{"alpha", "bravo", "charlie", "delta"}}, + {name: "beyond length clamps to full", n: 9, want: []string{"alpha", "bravo", "charlie", "delta"}}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := aud.Tail(tt.n) + if len(got) != len(tt.want) { + t.Fatalf("Tail(%d) length = %d, want %d", tt.n, len(got), len(tt.want)) + } + for i := range got { + if got[i].Role != model.RoleUser || messageText(got[i]) != tt.want[i] { + t.Fatalf("Tail(%d)[%d] = %q/%s, want text %q", tt.n, i, messageText(got[i]), got[i].Role, tt.want[i]) + } + } + }) + } +} + +func TestKeyword_LocateWithSurroundings(t *testing.T) { + t.Parallel() + + aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo", "charlie")) + + loc, err := aud.Keyword(context.Background(), "rav") + if err != nil { + t.Fatalf("Keyword unexpected error: %v", err) + } + if loc == nil { + t.Fatal("Keyword returned nil for a present match") + } + if loc.Line != 2 { + t.Fatalf("Line = %d, want 2", loc.Line) + } + if len(loc.Surrounding) != 3 { + t.Fatalf("Surrounding length = %d, want 3", len(loc.Surrounding)) + } + if !sameMessage(loc.Surrounding[1], model.TextMessage(model.RoleUser, "bravo")) { + t.Fatalf("matched message missing from window: %+v", loc.Surrounding) + } + if !sameMessage(loc.Surrounding[0], model.TextMessage(model.RoleUser, "alpha")) { + t.Fatalf("above-message missing from window: %+v", loc.Surrounding) + } + if !sameMessage(loc.Surrounding[2], model.TextMessage(model.RoleUser, "charlie")) { + t.Fatalf("below-message missing from window: %+v", loc.Surrounding) + } +} + +func TestKeyword_Edges(t *testing.T) { + t.Parallel() + + t.Run("first message has no above-neighbor", func(t *testing.T) { + t.Parallel() + aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo")) + loc, err := aud.Keyword(context.Background(), "alp") + if err != nil { + t.Fatalf("Keyword unexpected error: %v", err) + } + if loc.Line != 1 { + t.Fatalf("Line = %d, want 1", loc.Line) + } + if len(loc.Surrounding) != 2 { + t.Fatalf("Surrounding length = %d, want 2", len(loc.Surrounding)) + } + }) + + t.Run("last message has no below-neighbor", func(t *testing.T) { + t.Parallel() + aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo")) + loc, err := aud.Keyword(context.Background(), "bra") + if err != nil { + t.Fatalf("Keyword unexpected error: %v", err) + } + if loc.Line != 2 { + t.Fatalf("Line = %d, want 2", loc.Line) + } + if len(loc.Surrounding) != 2 { + t.Fatalf("Surrounding length = %d, want 2", len(loc.Surrounding)) + } + }) +} + +func TestKeyword_CaseInsensitive(t *testing.T) { + t.Parallel() + + aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo")) + loc, err := aud.Keyword(context.Background(), "BRAVO") + if err != nil { + t.Fatalf("Keyword unexpected error: %v", err) + } + if loc == nil || loc.Line != 2 { + t.Fatalf("case-insensitive match failed: line = %d", loc.Line) + } +} + +func TestKeyword_NoMatch(t *testing.T) { + t.Parallel() + + aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo")) + loc, err := aud.Keyword(context.Background(), "zzz") + if err != nil { + t.Fatalf("Keyword unexpected error: %v", err) + } + if loc != nil { + t.Fatalf("Keyword = %+v, want nil for a missing match", loc) + } +} + +func TestKeyword_EmptyAndBlankQuery(t *testing.T) { + t.Parallel() + + aud := NewContextAuditor(texts(model.RoleUser, "alpha")) + for _, q := range []string{"", " "} { + _, err := aud.Keyword(context.Background(), q) + if !errors.Is(err, ErrQueryEmpty) { + t.Fatalf("Keyword(%q) error = %v, want ErrQueryEmpty", q, err) + } + } +} + +func TestKeyword_CanceledContext(t *testing.T) { + t.Parallel() + + aud := NewContextAuditor(texts(model.RoleUser, "alpha")) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := aud.Keyword(ctx, "alpha") + if !errors.Is(err, context.Canceled) { + t.Fatalf("Keyword error = %v, want context.Canceled", err) + } +} + +func TestKeyword_FirstMatchOnly(t *testing.T) { + t.Parallel() + + aud := NewContextAuditor(texts(model.RoleUser, "foo", "bar", "foo")) + loc, err := aud.Keyword(context.Background(), "foo") + if err != nil { + t.Fatalf("Keyword unexpected error: %v", err) + } + if loc.Line != 1 { + t.Fatalf("Line = %d, want 1 (earliest match)", loc.Line) + } +} + +func TestKeyword_IgnoresImagePayload(t *testing.T) { + t.Parallel() + + msgs := []model.Message{ + model.TextMessage(model.RoleUser, "alpha"), + model.ImageMessage(model.RoleUser, "", "http://example.com/bravo.png"), + } + aud := NewContextAuditor(msgs) + + loc, err := aud.Keyword(context.Background(), "bravo") + if err != nil { + t.Fatalf("Keyword unexpected error: %v", err) + } + if loc != nil { + t.Fatalf("Keyword matched an image URL (ignored): %+v", loc) + } + + loc, err = aud.Keyword(context.Background(), "alpha") + if err != nil { + t.Fatalf("Keyword unexpected error: %v", err) + } + if loc == nil || loc.Line != 1 { + t.Fatalf("text-part match failed: line = %d", loc.Line) + } +}