feat(docrevision): attach coverage status to formal human revisions

Unknown, partial, and failed coverage stay distinct from no known dependencies.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-20 15:35:07 +00:00
co-authored by Cursor
parent 33ee59641f
commit 432884911f
6 changed files with 129 additions and 6 deletions
+50
View File
@@ -0,0 +1,50 @@
package docrevision
import "context"
// CoverageResolver reports whether the trace index can support a no-impact claim.
// nil or an empty result means the index has not run; that must not be shown as no impact.
func (s *Service) formalCommitReportLocked(ctx context.Context, docID string, fromID, toID int64, hunks []DiffHunk) *ChangeReport {
impacts := s.impactLocked(ctx, docID)
coverage := ImpactIndexPending
if s.CoverageResolver != nil {
if got := s.CoverageResolver(ctx, docID); got != "" {
coverage = got
}
}
rep := fallbackReport("", "", RuleRiskFloor(hunks, impacts), impacts, hunks, "formal commit")
rep.DocID = docID
rep.FromSnapshotID = fromID
rep.ToSnapshotID = toID
rep.AnalysisStatus = AnalysisFallback
rep.Authority = ReportAuthoritative
rep.Lifecycle = ReportCommitted
if rep.DeterministicFacts == nil {
rep.DeterministicFacts = map[string]any{}
}
rep.DeterministicFacts["coverage_status"] = coverage
rep.DeterministicFacts["suggested_actions"] = formalSuggestions(coverage, len(impacts) > 0)
switch coverage {
case ImpactNoKnown:
rep.Summary = append(rep.Summary, "索引已覆盖,未发现已知关联")
case ImpactConfirmed, ImpactPossible:
rep.Summary = append(rep.Summary, "这次修改可能影响其他文档,见 document_impacts")
default:
rep.Summary = append(rep.Summary, "分析尚未完成或失败,不能判断为无影响")
rep.OpenQuestions = append(rep.OpenQuestions, "不能判断为无影响")
}
stampReportDisposition(rep)
return rep
}
func formalSuggestions(coverage string, hasImpact bool) []string {
switch coverage {
case ImpactIndexPending, ImpactPartial, ImpactFailed:
return []string{"wait_index"}
}
out := []string{"no_change"}
if hasImpact || coverage == ImpactPossible || coverage == ImpactConfirmed {
out = append(out, "l1_revise", "l2_reopen")
}
return out
}
@@ -0,0 +1,45 @@
package docrevision
import (
"context"
"strings"
"testing"
)
func TestHumanRevision_ReportDoesNotClaimNoImpactWithoutIndex(t *testing.T) {
svc := NewService(nil)
res, err := svc.CommitHumanRevision(t.Context(), HumanRevisionRequest{
DocID: "doc-1", Markdown: "UR-001 需要记录", IdempotencyKey: "k1",
ActorUserID: 1, AllowCreateBaseline: true, ProjectID: 3,
})
if err != nil {
t.Fatal(err)
}
if res.Report == nil || res.Report.Authority != ReportAuthoritative {
t.Fatalf("report %#v", res.Report)
}
if res.Report.DeterministicFacts["coverage_status"] != ImpactIndexPending {
t.Fatalf("coverage %#v", res.Report.DeterministicFacts["coverage_status"])
}
for _, line := range res.Report.Summary {
if strings.Contains(line, "未发现已知关联") {
t.Fatal(line)
}
}
}
func TestHumanRevision_IndexedNoKnown(t *testing.T) {
svc := NewService(nil)
svc.CoverageResolver = func(ctx context.Context, docID string) string { return ImpactNoKnown }
res, err := svc.CommitHumanRevision(t.Context(), HumanRevisionRequest{
DocID: "doc-2", Markdown: "没有条目", IdempotencyKey: "k2",
ActorUserID: 1, AllowCreateBaseline: true, ProjectID: 3,
})
if err != nil {
t.Fatal(err)
}
joined := strings.Join(res.Report.Summary, " ")
if !strings.Contains(joined, "未发现已知关联") {
t.Fatalf("summary %s", joined)
}
}
+14 -1
View File
@@ -66,7 +66,7 @@ func (s *Service) openTemplatePGLocked(ctx context.Context, docID, canon, sha st
return s.mustStateLocked(docID), nil
}
func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootPointers, ver *Version, snap *Snapshot, ev *ChangeEvent, isBaseline bool) error {
func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootPointers, ver *Version, snap *Snapshot, ev *ChangeEvent, isBaseline bool, rep *ChangeReport) error {
if !s.usingPG() {
if root != nil && snap != nil {
key := ""
@@ -75,6 +75,10 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP
}
s.noteContentProjectionLocked(root.DocID, snap.ID, root.HeadRevisionNo, key)
}
if rep != nil {
rep.ToSnapshotID = snap.ID
s.assignReportIDLocked(rep)
}
return nil
}
blob := s.blobs[snap.ContentSHA256]
@@ -127,6 +131,15 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP
if err := s.insertContentProjectionTX(ctx, tx, root.DocID, snap.ID, root.HeadRevisionNo, key); err != nil {
return err
}
if rep != nil {
rep.ToSnapshotID = snap.ID
rid, err := s.insertReportTX(ctx, tx, rep)
if err != nil {
return err
}
s.adoptReportID(rep, rid)
s.reports[rep.ID] = rep
}
s.noteContentProjectionLocked(root.DocID, snap.ID, root.HeadRevisionNo, key)
return s.projectRootPointersTX(ctx, tx, root)
})
+1 -1
View File
@@ -174,7 +174,7 @@ func (s *Service) CommitMigrationRepair(ctx context.Context, req MigrationRepair
root.RowRevision++
eventNo := s.nextEventNoLocked(req.DocID)
ev := s.putEventLocked(req.DocID, ver.ID, eventNo, parentPtr, snap.ID, ChangeMigrationRepair, ActorSystem, nil, "migration_repair", actor, nil, "", "", idemp)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false); err != nil {
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false, nil); err != nil {
s.forgetDocLocked(req.DocID)
return nil, err
}
+10 -4
View File
@@ -41,6 +41,10 @@ type Service struct {
// ImpactResolver returns deterministic downstream impacts (lineage); optional.
ImpactResolver func(ctx context.Context, docID string) []DocImpact
// CoverageResolver classifies trace-index coverage for a formal commit.
// When unset, reports stay index_pending and must not claim no impact.
CoverageResolver func(ctx context.Context, docID string) string
// NarrativeFetcher optionally loads typed PI narrative JSON when
// PI_CHANGE_REPORT_NARRATIVE is on and ReportJSON is empty.
NarrativeFetcher func(ctx context.Context, spec NarrativeFetchSpec) (string, error)
@@ -249,12 +253,13 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ
root.ContentSHA256 = sha
root.RowRevision++
ev := s.putEventLocked(req.DocID, ver.ID, 1, nil, snap.ID, ChangeImport, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, nil, req.EditSessionID, req.RequestID, req.IdempotencyKey)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, true); err != nil {
rep := s.formalCommitReportLocked(ctx, req.DocID, 0, snap.ID, nil)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, true, rep); err != nil {
s.forgetDocLocked(req.DocID)
return nil, err
}
st := s.mustStateLocked(req.DocID)
return &HumanRevisionResult{State: *st, Event: *ev}, nil
return &HumanRevisionResult{State: *st, Event: *ev, Report: rep}, nil
}
ver := s.versions[*root.LedgerVersionID]
@@ -300,7 +305,8 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ
root.RowRevision++
eventNo := s.nextEventNoLocked(req.DocID)
ev := s.putEventLocked(req.DocID, ver.ID, eventNo, &parentID, snap.ID, ChangeHumanRevision, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, diff.Hunks, req.EditSessionID, req.RequestID, req.IdempotencyKey)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false); err != nil {
rep := s.formalCommitReportLocked(ctx, req.DocID, parentID, snap.ID, diff.Hunks)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false, rep); err != nil {
s.forgetDocLocked(req.DocID)
return nil, err
}
@@ -309,7 +315,7 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ
overlay := ComputeDiff(baseMD, canon, s.snapshots[ver.BaselineSnapshotID].BlockMap, snap.BlockMap)
overlay.FromSnapshotID = ver.BaselineSnapshotID
overlay.ToSnapshotID = snap.ID
return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay}, nil
return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay, Report: rep}, nil
}
func (s *Service) OpenTemplateVersion(ctx context.Context, docID string, markdown string, actor ActorSnapshot, projectID int64, workDir string) (*RevisionState, error) {
+9
View File
@@ -56,6 +56,14 @@ const (
AnalysisUnassessed = "unassessed"
AnalysisCorrected = "corrected"
// Trace coverage carried on a formal commit report. These are not PI analysis statuses.
ImpactConfirmed = "confirmed_impact"
ImpactPossible = "possible_impact"
ImpactNoKnown = "no_known_dependencies"
ImpactIndexPending = "index_pending"
ImpactPartial = "partial"
ImpactFailed = "failed"
// Report authority separates a candidate estimate from the report written at formal commit.
ReportProposed = "proposed"
ReportAuthoritative = "authoritative"
@@ -318,6 +326,7 @@ type HumanRevisionResult struct {
Event ChangeEvent `json:"event"`
Overlay DiffResult `json:"manual_overlay"`
Conflict *ConflictInfo `json:"conflict,omitempty"`
Report *ChangeReport `json:"report,omitempty"`
}
type ConflictInfo struct {