From 8220d585da0a91e11754b0004c5a4c629e1f0c56 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 8 Sep 2026 16:34:00 +0500 Subject: [PATCH 1/5] feat(github): classify App coverage by immutable repository ID gds github coverage unions installation inventories with optional local GDS identities. Rename keeps migrated; archived stays not-applicable. Process-group cleanup still does not claim setsid or Docker children. Signed-off-by: rldyourmnd --- core/app/github_readonly.go | 73 +++++++++ core/app/github_readonly_test.go | 9 ++ core/app/module_process_unix.go | 4 +- core/app/module_process_unix_test.go | 20 +++ core/app/relationship_index.go | 35 +++++ core/cli/github_readonly_test.go | 19 +++ core/cli/root.go | 28 ++++ core/estate/compiler.go | 5 +- core/estate/compiler_test.go | 12 ++ core/estate/types.go | 1 + core/githubcoverage/evaluate.go | 219 +++++++++++++++++++++++++++ core/githubcoverage/evaluate_test.go | 137 +++++++++++++++++ docs/contracts/cli-v1.md | 17 +++ docs/contracts/github-provider-v1.md | 2 + 14 files changed, 578 insertions(+), 3 deletions(-) create mode 100644 core/githubcoverage/evaluate.go create mode 100644 core/githubcoverage/evaluate_test.go diff --git a/core/app/github_readonly.go b/core/app/github_readonly.go index 7cd6612..24f29b0 100644 --- a/core/app/github_readonly.go +++ b/core/app/github_readonly.go @@ -9,6 +9,7 @@ import ( "github.com/NDDev-OpenNetwork/github-device-sync/core/compiler" "github.com/NDDev-OpenNetwork/github-device-sync/core/domain" "github.com/NDDev-OpenNetwork/github-device-sync/core/estate" + "github.com/NDDev-OpenNetwork/github-device-sync/core/githubcoverage" "github.com/NDDev-OpenNetwork/github-device-sync/core/githubruntime" "github.com/NDDev-OpenNetwork/github-device-sync/core/governance" githubprovider "github.com/NDDev-OpenNetwork/github-device-sync/core/providers/github" @@ -47,6 +48,21 @@ type ReconciliationPlanData struct { Result reconciler.Result `json:"result"` } +type GitHubCoverageOptions struct { + GitHubReadOptions + IncludeLocal bool + LocalRoot string + LocalMaxDepth int + LocalMaxRepositories int + LocalConcurrency int + IncludeArchived bool +} + +type GitHubCoverageData struct { + Coverage githubcoverage.Report `json:"coverage"` + Result reconciler.Result `json:"result"` +} + type githubRuntime struct { desired estate.Config config githubruntime.Config @@ -218,6 +234,63 @@ func (services *Services) ReconcileGitHub( return envelopeValue } +func (services *Services) GitHubCoverage( + ctx context.Context, + path string, + options GitHubCoverageOptions, +) domain.Envelope { + const command = "gds github coverage" + runtime, envelope := services.loadGitHubRuntime(ctx, path, options.GitHubReadOptions, command) + if envelope != nil { + return *envelope + } + readers := make(map[string]reconciler.InstallationReader, len(runtime.readers)) + for id, reader := range runtime.readers { + readers[id] = reader + } + result := (reconciler.Reconciler{ + Config: runtime.desired, Readers: readers, + Concurrency: runtime.desired.Root.Rollout.MaxParallelObservation, + MaxRepositories: runtime.maxRepositories, + }).ReconcileAll(ctx) + findings := append([]domain.Finding(nil), result.Findings...) + var identities []estate.IdentityRepository + localCollected := false + if options.IncludeLocal { + local := DiscoveryOptions{ + Root: options.LocalRoot, MaxDepth: options.LocalMaxDepth, + MaxRepositories: options.LocalMaxRepositories, Concurrency: options.LocalConcurrency, + IncludeArchived: options.IncludeArchived, + } + if local.Root == "" { + local.Root = path + } + if local.MaxDepth == 0 { + local.MaxDepth = 8 + } + if local.MaxRepositories == 0 { + local.MaxRepositories = 2000 + } + if local.Concurrency == 0 { + local.Concurrency = 4 + } + var localFindings []domain.Finding + identities, localFindings = services.coverageIdentities(ctx, local) + localCollected = true + findings = append(findings, localFindings...) + } + coverage := githubcoverage.Evaluate(runtime.desired, result, identities, localCollected) + class := classifyFindings(findings) + if class == domain.ExitSuccess && len(result.Findings) != 0 { + class = domain.ExitNotProven + } + envelopeValue := domain.NewEnvelope(command, class, GitHubCoverageData{ + Coverage: coverage, Result: result, + }, findings...) + envelopeValue.Scope["estate_id"] = runtime.desired.Root.Estate.ID + return envelopeValue +} + func (services *Services) loadGitHubRuntime( ctx context.Context, path string, diff --git a/core/app/github_readonly_test.go b/core/app/github_readonly_test.go index ae5dd21..3508646 100644 --- a/core/app/github_readonly_test.go +++ b/core/app/github_readonly_test.go @@ -80,6 +80,15 @@ func TestGitHubInventoryAndReconciliationUseLiveReadOnlyRuntime(t *testing.T) { summaryData.DriftByClass["identity"] != 5 { t.Fatalf("summary=%#v", summary) } + coverage := services.GitHubCoverage(context.Background(), root, GitHubCoverageOptions{ + GitHubReadOptions: GitHubReadOptions{RuntimeConfig: runtimePath}, + }) + coverageData, ok := coverage.Data.(GitHubCoverageData) + if coverage.ExitClass != domain.ExitSuccess || !ok || coverage.Mutation.Attempted || + coverageData.Coverage.Counts["partial"] != 5 || + coverageData.Coverage.LocalIdentitiesCollected { + t.Fatalf("coverage=%#v", coverage) + } } func TestGitHubInventoryRejectsWrongInstallationOwner(t *testing.T) { diff --git a/core/app/module_process_unix.go b/core/app/module_process_unix.go index 78c6c82..60d9dbf 100644 --- a/core/app/module_process_unix.go +++ b/core/app/module_process_unix.go @@ -17,7 +17,9 @@ const moduleTerminationWait = 2 * time.Second // Own a new group, never the caller's group. Cancel and normal-exit cleanup // share one synchronous operation, so no delayed signal goroutine outlives the -// command or its verification workspace. +// command or its verification workspace. Descendants that call setsid, or a +// daemon such as Docker started by the command, are a different process group +// and are not reaped here. func configureModuleProcess(command *exec.Cmd) (func() (bool, error), error) { command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} var once sync.Once diff --git a/core/app/module_process_unix_test.go b/core/app/module_process_unix_test.go index 2016945..345df3d 100644 --- a/core/app/module_process_unix_test.go +++ b/core/app/module_process_unix_test.go @@ -76,6 +76,26 @@ func TestDeclaredSuccessCannotLeaveBackgroundWriter(t *testing.T) { assertTestChildStopped(t, pid) } +func TestDeclaredTimeoutDoesNotClaimSetsidChildren(t *testing.T) { + // Process-group cleanup is not ownership of setsid/Docker-daemon children. + dir := t.TempDir() + report := runDeclaredCommand( + context.Background(), + dir, + "setsid bash -c 'echo $$ > child.pid; trap \"\" TERM; while :; do sleep 0.05; done' & wait", + 350*time.Millisecond, + ) + pid := readOwnedTestChild(t, dir) + defer stopOwnedTestChild(pid) + if report.Status == "passed" { + t.Fatalf("setsid child made the parent look finished: %#v", report) + } + b, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output() + if err != nil || strings.TrimSpace(string(b)) == "" || strings.HasPrefix(strings.TrimSpace(string(b)), "Z") { + t.Fatalf("setsid child %d did not remain outside the module process group: err=%v stat=%q", pid, err, b) + } +} + func readOwnedTestChild(t *testing.T, dir string) int { t.Helper() deadline := time.Now().Add(3 * time.Second) diff --git a/core/app/relationship_index.go b/core/app/relationship_index.go index b2b8d36..d9d949a 100644 --- a/core/app/relationship_index.go +++ b/core/app/relationship_index.go @@ -48,3 +48,38 @@ func (services *Services) completeRelationshipIndex( findings = append(findings, indexFindings...) return index, findings } + +func (services *Services) coverageIdentities( + ctx context.Context, + options DiscoveryOptions, +) ([]estate.IdentityRepository, []domain.Finding) { + if finding := validateDiscoveryOptions(options); finding != nil { + return nil, []domain.Finding{*finding} + } + discovered, err := services.Discovery.Discover(ctx, options.Root, discovery.Options{ + MaxDepth: options.MaxDepth, MaxRepositories: options.MaxRepositories, + Concurrency: options.Concurrency, IncludeArchived: options.IncludeArchived, + }) + if err != nil { + return nil, []domain.Finding{{ + Code: "GDS_IDENTITY_INDEX_DISCOVERY_FAILED", Severity: domain.SeverityHigh, + Message: err.Error(), Evidence: map[string]any{"root": options.Root}, + }} + } + indexed := make([]estate.IndexedRepository, 0, len(discovered.Boundaries)) + findings := append([]domain.Finding(nil), discovered.Findings...) + loader := manifest.NewLoader(services.Schemas) + for _, boundary := range discovered.Boundaries { + if boundary.AnchorState != "valid" { + continue + } + anchorValue, anchorFindings := loader.LoadRepository(boundary.Path) + findings = append(findings, anchorFindings...) + if len(anchorFindings) == 0 { + indexed = append(indexed, estate.IndexedRepository{Path: boundary.Path, Anchor: anchorValue}) + } + } + index, indexFindings := estate.BuildIdentityIndex(indexed, false) + findings = append(findings, indexFindings...) + return index.Repositories, findings +} diff --git a/core/cli/github_readonly_test.go b/core/cli/github_readonly_test.go index f055bc6..7d232a3 100644 --- a/core/cli/github_readonly_test.go +++ b/core/cli/github_readonly_test.go @@ -27,6 +27,25 @@ func TestGitHubInventoryRequiresRuntimeEvidenceWithoutAttemptingMutation(t *test assertEnvelopeSchema(t, envelope) } +func TestGitHubCoverageRequiresRuntimeEvidenceWithoutAttemptingMutation(t *testing.T) { + root := repositoryRoot(t) + missing := filepath.Join(t.TempDir(), "github-runtime.yaml") + exitCode, envelope, stderr := executeJSON( + t, + "--json", "--cwd", root, + "github", "coverage", + "--runtime-config", missing, + ) + if exitCode != 3 || envelope.ExitClass != domain.ExitNotProven || stderr != "" { + t.Fatalf("exit=%d stderr=%q envelope=%#v", exitCode, stderr, envelope) + } + if !containsFinding(envelope.Findings, "GDS_GITHUB_RUNTIME_NOT_PROVEN") || + envelope.Mutation.Attempted || envelope.Mutation.Completed { + t.Fatalf("envelope=%#v", envelope) + } + assertEnvelopeSchema(t, envelope) +} + func TestGitHubGovernanceRequiresExactRepositoryScopeBeforeRuntimeAccess(t *testing.T) { root := repositoryRoot(t) exitCode, envelope, stderr := executeJSON( diff --git a/core/cli/root.go b/core/cli/root.go index 1e20842..6069c10 100644 --- a/core/cli/root.go +++ b/core/cli/root.go @@ -2488,6 +2488,34 @@ func (executor *executor) githubCommand() *cobra.Command { &options.InstallationID, "installation", "", "exact logical estate installation id", ) command.AddCommand(inventory) + coverageOptions := app.GitHubCoverageOptions{} + coverage := &cobra.Command{ + Use: "coverage", + Short: "Classify App inventories against local GDS identities by GitHub repository ID", + Args: cobra.NoArgs, + RunE: func(child *cobra.Command, _ []string) error { + return executor.run(child, func(ctx context.Context) domain.Envelope { + return executor.services.GitHubCoverage(ctx, executor.options.cwd, coverageOptions) + }) + }, + } + addGitHubReadFlags(coverage, &coverageOptions.GitHubReadOptions) + coverage.Flags().BoolVar( + &coverageOptions.IncludeLocal, "include-local", false, + "union device-local GDS identities with App inventories by immutable GitHub repository ID", + ) + coverage.Flags().StringVar(&coverageOptions.LocalRoot, "root", "", "filesystem root for local identity discovery") + coverage.Flags().IntVar(&coverageOptions.LocalMaxDepth, "max-depth", 8, "maximum directory depth for local identity discovery") + coverage.Flags().IntVar( + &coverageOptions.LocalMaxRepositories, "local-max-repositories", 2000, + "hard local identity count limit", + ) + coverage.Flags().IntVar(&coverageOptions.LocalConcurrency, "concurrency", 4, "bounded Git inspection workers") + coverage.Flags().BoolVar( + &coverageOptions.IncludeArchived, "include-archived", false, + "also index local repositories whose anchor declares lifecycle: archived", + ) + command.AddCommand(coverage) governanceOptions := app.GitHubGovernanceOperationOptions{} governancePlan := false governanceApply := "" diff --git a/core/estate/compiler.go b/core/estate/compiler.go index 3752599..b585c10 100644 --- a/core/estate/compiler.go +++ b/core/estate/compiler.go @@ -46,8 +46,9 @@ func Compile( seenProviderIDs[repository.ProviderID] = struct{}{} assignment := Assignment{ ProviderID: repository.ProviderID, Owner: repository.Owner, Name: repository.Name, - IdentityState: "unassigned", ManagementMode: config.Root.Discovery.DefaultManagementMode, - RolloutRing: config.Root.Rollout.DefaultRing, + Archived: repository.Archived, IdentityState: "unassigned", + ManagementMode: config.Root.Discovery.DefaultManagementMode, + RolloutRing: config.Root.Rollout.DefaultRing, } owner, found := ownerByLogin[strings.ToLower(repository.Owner)] if !found { diff --git a/core/estate/compiler_test.go b/core/estate/compiler_test.go index 0268e42..12872b9 100644 --- a/core/estate/compiler_test.go +++ b/core/estate/compiler_test.go @@ -184,6 +184,18 @@ func estateHasFinding(findings []domain.Finding, code string) bool { return false } +func TestCompilePreservesArchivedObservation(t *testing.T) { + t.Parallel() + config := loadCanonical(t) + compiled, findings := Compile(config, []ObservedRepository{{ + ProviderID: 42, Owner: "example-user", Name: "retired", + Archived: true, Visibility: "private", DefaultBranch: "main", + }}) + if len(findings) != 0 || len(compiled.Repositories) != 1 || !compiled.Repositories[0].Archived { + t.Fatalf("compiled=%#v findings=%#v", compiled, findings) + } +} + func organizationForksSelector(t *testing.T, config Config) Selector { t.Helper() for _, selector := range config.Selectors { diff --git a/core/estate/types.go b/core/estate/types.go index 0aa6a80..0f4a34f 100644 --- a/core/estate/types.go +++ b/core/estate/types.go @@ -170,6 +170,7 @@ type Assignment struct { ProviderID int64 `json:"provider_id"` Owner string `json:"owner"` Name string `json:"name"` + Archived bool `json:"archived"` OwnerID string `json:"owner_id,omitempty"` InstallationID string `json:"installation_id,omitempty"` IdentityState string `json:"identity_state"` diff --git a/core/githubcoverage/evaluate.go b/core/githubcoverage/evaluate.go new file mode 100644 index 0000000..385e0c5 --- /dev/null +++ b/core/githubcoverage/evaluate.go @@ -0,0 +1,219 @@ +// Package githubcoverage classifies GitHub App inventories against local GDS +// identities by immutable provider repository ID. It does not invent a second +// inspector: gds github inventory remains one installation, gds reconcile +// remains the App union, and this evaluator is the ID-stable coverage view. +package githubcoverage + +import ( + "sort" + "strings" + + "github.com/NDDev-OpenNetwork/github-device-sync/core/estate" + "github.com/NDDev-OpenNetwork/github-device-sync/core/reconciler" +) + +const ( + StatusMigrated = "migrated" + StatusPartial = "partial" + StatusDenied = "denied" + StatusUnknown = "unknown" + StatusNotApplicable = "not-applicable" +) + +type RepositoryCoverage struct { + ProviderID int64 `json:"provider_id"` + Owner string `json:"owner"` + Name string `json:"name"` + InstallationID string `json:"installation_id,omitempty"` + GDSRepositoryID string `json:"gds_repository_id,omitempty"` + Status string `json:"status"` + Reasons []string `json:"reasons,omitempty"` +} + +type InstallationCoverage struct { + InstallationID string `json:"installation_id"` + Status string `json:"status"` + RepositoryCount int `json:"repository_count"` +} + +type Report struct { + LocalIdentitiesCollected bool `json:"local_identities_collected"` + Installations []InstallationCoverage `json:"installations"` + Repositories []RepositoryCoverage `json:"repositories"` + Counts map[string]int `json:"counts"` +} + +func Evaluate( + config estate.Config, + result reconciler.Result, + identities []estate.IdentityRepository, + localCollected bool, +) Report { + report := Report{ + LocalIdentitiesCollected: localCollected, + Counts: map[string]int{}, + } + installationStatus := map[string]string{} + for _, installation := range result.Installations { + status := classifyInstallation(result, installation) + installationStatus[installation.InstallationID] = status + report.Installations = append(report.Installations, InstallationCoverage{ + InstallationID: installation.InstallationID, + Status: status, + RepositoryCount: installation.RepositoryCount, + }) + } + for _, installationID := range config.Root.Installations { + if _, seen := installationStatus[installationID]; seen { + continue + } + installationStatus[installationID] = StatusUnknown + report.Installations = append(report.Installations, InstallationCoverage{ + InstallationID: installationID, Status: StatusUnknown, + }) + } + ownerInstallation := map[string]string{} + for _, owner := range config.Owners { + ownerInstallation[strings.ToLower(owner.Owner.ProviderLogin)] = owner.Owner.Installation + } + + observed := map[int64]estate.Assignment{} + for _, assignment := range result.Inventory.Repositories { + observed[assignment.ProviderID] = assignment + } + local := map[int64]estate.IdentityRepository{} + for _, identity := range identities { + if identity.ProviderID <= 0 { + continue + } + local[identity.ProviderID] = identity + } + + seen := map[int64]struct{}{} + for id, assignment := range observed { + seen[id] = struct{}{} + identity, hasLocal := local[id] + report.Repositories = append(report.Repositories, classifyObserved( + assignment, identity, hasLocal, localCollected, installationStatus, + )) + } + for id, identity := range local { + if _, already := seen[id]; already { + continue + } + installationID := ownerInstallation[strings.ToLower(identity.Owner)] + report.Repositories = append(report.Repositories, classifyLocalOnly( + identity, installationID, installationStatus[installationID], + )) + } + + sort.Slice(report.Installations, func(left, right int) bool { + return report.Installations[left].InstallationID < report.Installations[right].InstallationID + }) + sort.Slice(report.Repositories, func(left, right int) bool { + return report.Repositories[left].ProviderID < report.Repositories[right].ProviderID + }) + for _, repository := range report.Repositories { + report.Counts[repository.Status]++ + } + return report +} + +func classifyInstallation(result reconciler.Result, installation reconciler.InstallationResult) string { + for _, finding := range result.Findings { + if finding.Evidence["installation"] != installation.InstallationID { + continue + } + switch finding.Code { + case "GDS_RECONCILE_PERMISSION_CONTRACT_MISMATCH": + return StatusDenied + case "GDS_RECONCILE_INSTALLATION_NOT_PROVEN": + return StatusUnknown + } + } + switch installation.Status { + case "observed", "observed-unpersisted": + return "observed" + case "identity-mismatch": + return StatusUnknown + case "not-proven": + return StatusUnknown + default: + if installation.Status == "" { + return StatusUnknown + } + return installation.Status + } +} + +func classifyObserved( + assignment estate.Assignment, + identity estate.IdentityRepository, + hasLocal bool, + localCollected bool, + installationStatus map[string]string, +) RepositoryCoverage { + coverage := RepositoryCoverage{ + ProviderID: assignment.ProviderID, + Owner: assignment.Owner, + Name: assignment.Name, + InstallationID: assignment.InstallationID, + } + if hasLocal { + coverage.GDSRepositoryID = identity.ID + if !strings.EqualFold(identity.Owner, assignment.Owner) || + !strings.EqualFold(identity.Name, assignment.Name) { + coverage.Reasons = append(coverage.Reasons, "locator_changed") + } + } + if assignment.Archived || strings.EqualFold(identity.Lifecycle, "archived") { + coverage.Status = StatusNotApplicable + coverage.Reasons = append(coverage.Reasons, "archived") + return coverage + } + if hasLocal { + coverage.Status = StatusMigrated + return coverage + } + coverage.Status = StatusPartial + if !localCollected { + coverage.Reasons = append(coverage.Reasons, "local_identity_not_collected") + } else { + coverage.Reasons = append(coverage.Reasons, "gds_identity_missing") + } + if status := installationStatus[assignment.InstallationID]; status == StatusDenied { + coverage.Status = StatusDenied + } + return coverage +} + +func classifyLocalOnly( + identity estate.IdentityRepository, + installationID string, + installationStatus string, +) RepositoryCoverage { + coverage := RepositoryCoverage{ + ProviderID: identity.ProviderID, + Owner: identity.Owner, + Name: identity.Name, + InstallationID: installationID, + GDSRepositoryID: identity.ID, + } + if strings.EqualFold(identity.Lifecycle, "archived") { + coverage.Status = StatusNotApplicable + coverage.Reasons = []string{"archived"} + return coverage + } + switch installationStatus { + case StatusDenied: + coverage.Status = StatusDenied + coverage.Reasons = []string{"installation_denied"} + case "", StatusUnknown: + coverage.Status = StatusUnknown + coverage.Reasons = []string{"installation_not_proven"} + default: + coverage.Status = StatusPartial + coverage.Reasons = []string{"app_inventory_missing"} + } + return coverage +} diff --git a/core/githubcoverage/evaluate_test.go b/core/githubcoverage/evaluate_test.go new file mode 100644 index 0000000..c6bec03 --- /dev/null +++ b/core/githubcoverage/evaluate_test.go @@ -0,0 +1,137 @@ +package githubcoverage + +import ( + "testing" + + "github.com/NDDev-OpenNetwork/github-device-sync/core/domain" + "github.com/NDDev-OpenNetwork/github-device-sync/core/estate" + "github.com/NDDev-OpenNetwork/github-device-sync/core/reconciler" +) + +func TestEvaluateMigratesByImmutableProviderIDAcrossRename(t *testing.T) { + t.Parallel() + report := Evaluate(coverageConfig(), reconciler.Result{ + Installations: []reconciler.InstallationResult{{ + InstallationID: "installation:github-personal", RepositoryCount: 1, Status: "observed", + }}, + Inventory: estate.CompiledInventory{Repositories: []estate.Assignment{{ + ProviderID: 42, Owner: "example-user", Name: "renamed", + InstallationID: "installation:github-personal", + }}}, + }, []estate.IdentityRepository{{ + ID: "repo_01TEST", ProviderID: 42, Owner: "example-user", Name: "original", + }}, true) + if report.Counts[StatusMigrated] != 1 || len(report.Repositories) != 1 { + t.Fatalf("report=%#v", report) + } + if report.Repositories[0].Status != StatusMigrated || + !containsReason(report.Repositories[0], "locator_changed") { + t.Fatalf("repository=%#v", report.Repositories[0]) + } +} + +func TestEvaluateMarksArchivedNotApplicable(t *testing.T) { + t.Parallel() + report := Evaluate(coverageConfig(), reconciler.Result{ + Installations: []reconciler.InstallationResult{{ + InstallationID: "installation:github-personal", RepositoryCount: 1, Status: "observed", + }}, + Inventory: estate.CompiledInventory{Repositories: []estate.Assignment{{ + ProviderID: 7, Owner: "example-user", Name: "old", Archived: true, + InstallationID: "installation:github-personal", + }}}, + }, []estate.IdentityRepository{{ + ID: "repo_archived", ProviderID: 7, Owner: "example-user", Name: "old", Lifecycle: "archived", + }}, true) + if report.Counts[StatusNotApplicable] != 1 || report.Counts[StatusMigrated] != 0 { + t.Fatalf("report=%#v", report) + } +} + +func TestEvaluateClassifiesMissingInstallationAsUnknown(t *testing.T) { + t.Parallel() + report := Evaluate(coverageConfig(), reconciler.Result{ + Installations: []reconciler.InstallationResult{{ + InstallationID: "installation:github-personal", Status: "not-proven", + }}, + Findings: []domain.Finding{{ + Code: "GDS_RECONCILE_INSTALLATION_NOT_PROVEN", + Evidence: map[string]any{"installation": "installation:github-personal"}, + }}, + }, []estate.IdentityRepository{{ + ID: "repo_local", ProviderID: 99, Owner: "example-user", Name: "private", + }}, true) + if report.Counts[StatusUnknown] != 1 || report.Installations[0].Status != StatusUnknown { + t.Fatalf("report=%#v", report) + } +} + +func TestEvaluateClassifiesPermissionMismatchAsDenied(t *testing.T) { + t.Parallel() + report := Evaluate(coverageConfig(), reconciler.Result{ + Installations: []reconciler.InstallationResult{{ + InstallationID: "installation:github-personal", Status: "not-proven", + }}, + Findings: []domain.Finding{{ + Code: "GDS_RECONCILE_PERMISSION_CONTRACT_MISMATCH", + Evidence: map[string]any{"installation": "installation:github-personal"}, + }}, + }, []estate.IdentityRepository{{ + ID: "repo_local", ProviderID: 99, Owner: "example-user", Name: "private", + }}, true) + if report.Counts[StatusDenied] != 1 { + t.Fatalf("report=%#v", report) + } +} + +func TestEvaluateAppOnlyWithoutLocalIdentitiesIsPartial(t *testing.T) { + t.Parallel() + report := Evaluate(coverageConfig(), reconciler.Result{ + Installations: []reconciler.InstallationResult{{ + InstallationID: "installation:github-personal", RepositoryCount: 1, Status: "observed", + }}, + Inventory: estate.CompiledInventory{Repositories: []estate.Assignment{{ + ProviderID: 5, Owner: "example-user", Name: "example", + InstallationID: "installation:github-personal", + }}}, + }, nil, false) + if report.Counts[StatusPartial] != 1 || + !containsReason(report.Repositories[0], "local_identity_not_collected") { + t.Fatalf("report=%#v", report) + } +} + +func TestEvaluateLocalOnlyWhenAppObservedIsPartial(t *testing.T) { + t.Parallel() + report := Evaluate(coverageConfig(), reconciler.Result{ + Installations: []reconciler.InstallationResult{{ + InstallationID: "installation:github-personal", RepositoryCount: 0, Status: "observed", + }}, + }, []estate.IdentityRepository{{ + ID: "repo_local", ProviderID: 11, Owner: "example-user", Name: "missing-from-app", + }}, true) + if report.Counts[StatusPartial] != 1 || + !containsReason(report.Repositories[0], "app_inventory_missing") { + t.Fatalf("report=%#v", report) + } +} + +func coverageConfig() estate.Config { + return estate.Config{ + Root: estate.Root{Installations: []string{"installation:github-personal"}}, + Owners: []estate.Owner{{ + Owner: estate.OwnerIdentity{ + Installation: "installation:github-personal", ProviderLogin: "example-user", + }, + }}, + } +} + +func containsReason(coverage RepositoryCoverage, reason string) bool { + for _, value := range coverage.Reasons { + if value == reason { + return true + } + } + return false +} diff --git a/docs/contracts/cli-v1.md b/docs/contracts/cli-v1.md index 61aa89e..03a486f 100644 --- a/docs/contracts/cli-v1.md +++ b/docs/contracts/cli-v1.md @@ -414,6 +414,23 @@ The token response must exactly match the installation permission and repository-selection contract before the inventory request is sent. Missing, extra, stronger, or differently scoped permissions return exit 12. +### `gds github coverage --runtime-config ` + +Unions every estate GitHub App installation inventory (the same read path as +`gds reconcile --plan`) with optional device-local GDS identities, keyed by +immutable GitHub repository ID. Owner/name rename of the same ID is +`migrated` with `locator_changed`. Archived repositories stay +`not-applicable`; they are not auto-unarchived. App-visible repositories +without a collected local identity are `partial`. An installation whose +permission contract fails is `denied`; a missing inventory is `unknown`. + +`--include-local` discovers anchors under `--root` (default: cwd). User +memberships and PAT-visible repositories that no App installation can see +remain outside this command until a user-token reader exists. + +The command performs no provider mutation. Missing runtime evidence returns +exit 3. + ### `gds github governance --installation --owner --repository ` Reads one exact repository metadata/governance snapshot: merge and available diff --git a/docs/contracts/github-provider-v1.md b/docs/contracts/github-provider-v1.md index e6dccee..1d04197 100644 --- a/docs/contracts/github-provider-v1.md +++ b/docs/contracts/github-provider-v1.md @@ -97,6 +97,8 @@ private schema-validated runtime file, verifies the installation/account identity, and returns current request IDs and rate metadata without persisting the token or response. `gds reconcile --plan` performs the same current reads for the exact estate installation set and emits no external mutation. +`gds github coverage` classifies that App union against optional local GDS +identities by immutable GitHub repository ID. `gds github governance` reads one exact repository and defaults to `observed-only`. `--compare-local` additionally proves that the current local From 38fbc72d656a214b2d151e82c97ed9b0c18fdcae Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 8 Sep 2026 17:20:13 +0500 Subject: [PATCH 2/5] fix(github): treat permission-denied installs as coverage denied A local identity match is not coverage when the App permission contract failed. cli-v1 already named that status denied. Signed-off-by: rldyourmnd Co-authored-by: Cursor --- core/githubcoverage/evaluate.go | 8 +++++--- core/githubcoverage/evaluate_test.go | 26 ++++++++++++++++++++++++++ docs/contracts/cli-v1.md | 3 ++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/core/githubcoverage/evaluate.go b/core/githubcoverage/evaluate.go index 385e0c5..6cdc71b 100644 --- a/core/githubcoverage/evaluate.go +++ b/core/githubcoverage/evaluate.go @@ -171,6 +171,11 @@ func classifyObserved( coverage.Reasons = append(coverage.Reasons, "archived") return coverage } + if status := installationStatus[assignment.InstallationID]; status == StatusDenied { + coverage.Status = StatusDenied + coverage.Reasons = append(coverage.Reasons, "installation_denied") + return coverage + } if hasLocal { coverage.Status = StatusMigrated return coverage @@ -181,9 +186,6 @@ func classifyObserved( } else { coverage.Reasons = append(coverage.Reasons, "gds_identity_missing") } - if status := installationStatus[assignment.InstallationID]; status == StatusDenied { - coverage.Status = StatusDenied - } return coverage } diff --git a/core/githubcoverage/evaluate_test.go b/core/githubcoverage/evaluate_test.go index c6bec03..b4c08bd 100644 --- a/core/githubcoverage/evaluate_test.go +++ b/core/githubcoverage/evaluate_test.go @@ -84,6 +84,32 @@ func TestEvaluateClassifiesPermissionMismatchAsDenied(t *testing.T) { } } +func TestEvaluateDeniedInstallDoesNotCountLocalMatchAsMigrated(t *testing.T) { + t.Parallel() + report := Evaluate(coverageConfig(), reconciler.Result{ + Installations: []reconciler.InstallationResult{{ + InstallationID: "installation:github-personal", RepositoryCount: 1, Status: "not-proven", + }}, + Findings: []domain.Finding{{ + Code: "GDS_RECONCILE_PERMISSION_CONTRACT_MISMATCH", + Evidence: map[string]any{"installation": "installation:github-personal"}, + }}, + Inventory: estate.CompiledInventory{Repositories: []estate.Assignment{{ + ProviderID: 42, Owner: "example-user", Name: "renamed", + InstallationID: "installation:github-personal", + }}}, + }, []estate.IdentityRepository{{ + ID: "repo_01TEST", ProviderID: 42, Owner: "example-user", Name: "original", + }}, true) + if report.Counts[StatusDenied] != 1 || report.Counts[StatusMigrated] != 0 { + t.Fatalf("report=%#v", report) + } + if !containsReason(report.Repositories[0], "installation_denied") || + !containsReason(report.Repositories[0], "locator_changed") { + t.Fatalf("repository=%#v", report.Repositories[0]) + } +} + func TestEvaluateAppOnlyWithoutLocalIdentitiesIsPartial(t *testing.T) { t.Parallel() report := Evaluate(coverageConfig(), reconciler.Result{ diff --git a/docs/contracts/cli-v1.md b/docs/contracts/cli-v1.md index 03a486f..7db4bcb 100644 --- a/docs/contracts/cli-v1.md +++ b/docs/contracts/cli-v1.md @@ -422,7 +422,8 @@ immutable GitHub repository ID. Owner/name rename of the same ID is `migrated` with `locator_changed`. Archived repositories stay `not-applicable`; they are not auto-unarchived. App-visible repositories without a collected local identity are `partial`. An installation whose -permission contract fails is `denied`; a missing inventory is `unknown`. +permission contract fails is `denied`, including repositories that already +have a local GDS identity; a missing inventory is `unknown`. `--include-local` discovers anchors under `--root` (default: cwd). User memberships and PAT-visible repositories that no App installation can see From ffaa8e41c1d7269af4edabcd6b84c670c1a9b142 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 8 Sep 2026 17:21:15 +0500 Subject: [PATCH 3/5] chore(gds): restamp development projections after coverage source core/app is inside the development source boundary, so the coverage CLI wiring must refresh the applied source_tree_digest. Signed-off-by: rldyourmnd Co-authored-by: Cursor --- .gds/bundle.lock.yaml | 10 +++++----- .github/workflows/gds-ci.yml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index b65b0da..e327fb6 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -5,14 +5,14 @@ bundle: version: "0.8.0-dev" release_sequence: 0 channel: "development" - source_tree_digest: "sha256:fc5b140798948c5f0075bf03c0d9798479a915dc9799b645d72a04f06588f93b" - digest: "sha256:9fc077c1299b2e22608501eddecfc1ed25a5c8fe448f5c1b3587630cdbfc0037" + source_tree_digest: "sha256:4888a3486f87ad2aa8882f400d90f64dfc4d2c47b8083fda34b8595e66ea3242" + digest: "sha256:1f1de689c264c340f75a1f2436a4550afa4da331b7af72daee419df4ef0ac1d6" projection: - input_digest: "sha256:48c167ca2b9a0543540374c3212c44ca9745d56f5f38a5734447dd247832f604" - output_digest: "sha256:0a9798865f28716f1c524884e8d989186cf26dd63606f9737febe77774f9134c" + input_digest: "sha256:6df3ed7d9216900b3bfbb379a803cb6f1fe4ba2def410789e05e5ad1979e571b" + output_digest: "sha256:9bb4a985a63e262c8407ad0395fbdf15e9eb30dfe89dd0e0817b85b969ccdc68" files: - path: ".gds/compiled-policy.json" digest: "sha256:807282f820294914e1c7e6ad1bf27c54a799d56305c58630254ab50ab286f379" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:3cebb82fa1407d47f7a4044e0ebf4c1e092bc8d6ef3c568fe2813fb1a508410f" + digest: "sha256:78cf3839754b6623f4e51a58ba9ce062f88f0180352f93110a5641cf8c2f8b49" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index f8216bc..0e42cee 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.8.0-dev -# source-tree-digest: sha256:fc5b140798948c5f0075bf03c0d9798479a915dc9799b645d72a04f06588f93b -# input-digest: sha256:48c167ca2b9a0543540374c3212c44ca9745d56f5f38a5734447dd247832f604 +# source-tree-digest: sha256:4888a3486f87ad2aa8882f400d90f64dfc4d2c47b8083fda34b8595e66ea3242 +# input-digest: sha256:6df3ed7d9216900b3bfbb379a803cb6f1fe4ba2def410789e05e5ad1979e571b # output-digest: sha256:8c045e745cc69b731bc695a4a9d58a48c10f1ab7dd85b7354db7bfd0e072711c # edit-source: # - .gds/repository.yaml From 2b21955a7ff6c455fcb4c73c64238fff059deb1c Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 8 Sep 2026 17:50:12 +0500 Subject: [PATCH 4/5] test(app): prove setsid children with python3 on linux and darwin util-linux setsid(1) is not on GitHub-hosted macOS. os.setsid is the same session boundary the process-group cleanup must not claim. Signed-off-by: rldyourmnd Co-authored-by: Cursor --- core/app/module_process_unix_test.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/core/app/module_process_unix_test.go b/core/app/module_process_unix_test.go index 345df3d..90806d0 100644 --- a/core/app/module_process_unix_test.go +++ b/core/app/module_process_unix_test.go @@ -78,11 +78,25 @@ func TestDeclaredSuccessCannotLeaveBackgroundWriter(t *testing.T) { func TestDeclaredTimeoutDoesNotClaimSetsidChildren(t *testing.T) { // Process-group cleanup is not ownership of setsid/Docker-daemon children. + // Darwin images have no util-linux `setsid(1)`; python3.os.setsid is the + // same syscall on linux and darwin. + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 is required to create a new session without util-linux") + } dir := t.TempDir() + child := "import os, signal, time\n" + + "os.setsid()\n" + + "open('child.pid', 'w', encoding='ascii').write(str(os.getpid()))\n" + + "signal.signal(signal.SIGTERM, signal.SIG_IGN)\n" + + "while True:\n" + + " time.sleep(0.05)\n" + if err := os.WriteFile(filepath.Join(dir, "setsid_child.py"), []byte(child), 0o600); err != nil { + t.Fatal(err) + } report := runDeclaredCommand( context.Background(), dir, - "setsid bash -c 'echo $$ > child.pid; trap \"\" TERM; while :; do sleep 0.05; done' & wait", + "python3 setsid_child.py & wait", 350*time.Millisecond, ) pid := readOwnedTestChild(t, dir) From 5f40653741340f952170386ad6cb4b62efb167b5 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 8 Sep 2026 17:50:13 +0500 Subject: [PATCH 5/5] chore(gds): restamp development projections after setsid test core/app is inside the development source boundary. Signed-off-by: rldyourmnd Co-authored-by: Cursor --- .gds/bundle.lock.yaml | 10 +++++----- .github/workflows/gds-ci.yml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index e327fb6..1a90ac8 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -5,14 +5,14 @@ bundle: version: "0.8.0-dev" release_sequence: 0 channel: "development" - source_tree_digest: "sha256:4888a3486f87ad2aa8882f400d90f64dfc4d2c47b8083fda34b8595e66ea3242" - digest: "sha256:1f1de689c264c340f75a1f2436a4550afa4da331b7af72daee419df4ef0ac1d6" + source_tree_digest: "sha256:9bc634940f75b183e5a902de5fc5f3c4afdd9f2e8de02ef2a459ae4527841d55" + digest: "sha256:c37ff41502eec54ad1d72293599902fe35165c60bc5629727a0ac67150fb1579" projection: - input_digest: "sha256:6df3ed7d9216900b3bfbb379a803cb6f1fe4ba2def410789e05e5ad1979e571b" - output_digest: "sha256:9bb4a985a63e262c8407ad0395fbdf15e9eb30dfe89dd0e0817b85b969ccdc68" + input_digest: "sha256:bf64ffc42fdab777b0519f2930a4d513e2adfe324bc9a27011c7140c0511261c" + output_digest: "sha256:64b0ebb161455923ef3689ac05b56797fa0f7e2b522ce0b4fcf9cf6b1963833b" files: - path: ".gds/compiled-policy.json" digest: "sha256:807282f820294914e1c7e6ad1bf27c54a799d56305c58630254ab50ab286f379" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:78cf3839754b6623f4e51a58ba9ce062f88f0180352f93110a5641cf8c2f8b49" + digest: "sha256:2e9b931e590fa503a8adc669d97b528f3136c95cefef4ad13e0ad1a92a4502a2" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index 0e42cee..d2532f9 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.8.0-dev -# source-tree-digest: sha256:4888a3486f87ad2aa8882f400d90f64dfc4d2c47b8083fda34b8595e66ea3242 -# input-digest: sha256:6df3ed7d9216900b3bfbb379a803cb6f1fe4ba2def410789e05e5ad1979e571b +# source-tree-digest: sha256:9bc634940f75b183e5a902de5fc5f3c4afdd9f2e8de02ef2a459ae4527841d55 +# input-digest: sha256:bf64ffc42fdab777b0519f2930a4d513e2adfe324bc9a27011c7140c0511261c # output-digest: sha256:8c045e745cc69b731bc695a4a9d58a48c10f1ab7dd85b7354db7bfd0e072711c # edit-source: # - .gds/repository.yaml