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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user