From 6c362eb3a2408f1b65e9f721369cb0bc512211e4 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:49 -0400 Subject: [PATCH 1/2] feat(doctor): check a solution against the one shape it has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codefly doctor solution [module]` judges a solution against the shape `codefly add solution` generates, read-only, with stable diagnostic codes and a versioned `--json` report. The shape was previously checkable only against somebody's survey of the solutions that happened to exist, which is how obin-ai/core-solutions reached nine solutions in four different states — two of them unable to receive a person capability at all, with nothing failing to say so. A shape nothing can check is a convention, and a convention drifts. Every rule is one that silently broke something. The two that cost the most: a contract without `owner_aware: true`, which the authority refuses by name so the solution can act for nobody; and a `service-dependencies` entry naming a module, which renders a mesh allow rule past the authority's check. Both host doors are known, as core-solutions' own check_solution_mesh.py holds them: a saas host's gateway and frontend unconditionally, and the authority's hop only in a manifest that also requires the authority interface. Running the first draft against the real solutions is what caught that — it flagged lastlogin-go's legitimate saas door. Verified against all nine solutions in obin-ai/core-solutions: wiki and proposals conform; lastlogin-go, releases, summary, robin-nest and lastlogin-python fail, reproducing the gap ledger in obin-ai/core-solutions#466 that was derived by hand. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 1 + cmd/doctor_solution.go | 181 +++++++++++++ cmd/doctor_solution_test.go | 58 ++++ docs/cli-reference.md | 56 ++++ docs/commands.md | 38 +++ pkg/solutionshape/check.go | 456 ++++++++++++++++++++++++++++++++ pkg/solutionshape/check_test.go | 322 ++++++++++++++++++++++ 7 files changed, 1112 insertions(+) create mode 100644 cmd/doctor_solution.go create mode 100644 cmd/doctor_solution_test.go create mode 100644 pkg/solutionshape/check.go create mode 100644 pkg/solutionshape/check_test.go diff --git a/AGENTS.md b/AGENTS.md index 2b31c8c3..212b7f62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ new runbook (and its skill) whenever you do a multi-step operational task a seco ### Extending the CLI - **Add a new command** (Cobra wiring, help, MCP exposure) → [docs/runbooks/add-a-command.md](docs/runbooks/add-a-command.md) - **Scaffold a solution** (`codefly add solution`: the one shape, its two services, and what the composition still owes it) → [docs/commands.md#add-solution](docs/commands.md) +- **Check a solution conforms** (`codefly doctor solution`: stable diagnostic codes, `--json`; the shape is checkable, not a survey) → [docs/commands.md](docs/commands.md) - **Rebuild the CLI and agents from local source** → [docs/runbooks/update-agents.md](docs/runbooks/update-agents.md) - **Point a workspace at a coordinate** (import explicit `codefly/coordinate/v1` environment declarations) → [docs/commands.md#codefly-environment](docs/commands.md) - **Export a module's API contracts** → [docs/commands.md#generate-contracts](docs/commands.md#generate-contracts) diff --git a/cmd/doctor_solution.go b/cmd/doctor_solution.go new file mode 100644 index 00000000..4447b7dc --- /dev/null +++ b/cmd/doctor_solution.go @@ -0,0 +1,181 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "sort" + + "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/pkg/solutionshape" + "github.com/spf13/cobra" +) + +var doctorSolutionJSON bool + +// DoctorSolutionCmd judges a solution against the one shape a solution has. +// +// It exists because the shape was, until now, checkable only against +// somebody's survey of the solutions that happened to exist — which is how one +// fleet reached nine solutions in four different states, two of them unable to +// receive a person capability at all with nothing failing to say so. A shape +// nothing can check is a convention, and a convention drifts. +var DoctorSolutionCmd = &cobra.Command{ + Use: "solution [module]", + // A non-conforming solution is an expected outcome, not a usage mistake. + SilenceUsage: true, + Short: "Check a solution against the one shape a solution has, without changing anything", + Long: `Judge a solution module against the shape ` + "`codefly add solution`" + ` generates. + +With no argument, every module in the workspace is judged and the ones that +ship no solution manifest are reported as "not a solution" rather than as +broken. Name a module to judge only that one. + +It is strictly read-only: it opens files and writes none. + +Every rule is one that silently broke something. The two worth knowing: + + owner_aware The authority issues a person a capability at a solution ONLY + when its module contract declares owner_aware: true, and + refuses by name otherwise. Without it the solution receives no + owner-selected scope and can act for nobody — and nothing + fails at build time to tell you. + + module edges A solution reaches every module through the host's door, + which checks the call. A service-dependency naming a module + renders into a mesh allow rule straight to that module's + service, past the check. + +Exit codes: + 0 every judged solution conforms (warnings allowed) + 1 at least one check failed, or the command itself failed + +With --json, a versioned report per module is printed to stdout: + {schema_version, module, dir, is_solution, status: ok|fail, + checks: [{code, status, message, remediation?}]} + +Stable diagnostic codes: not_a_solution, solution_manifest_unreadable, +consumed_api_unnamed, service_entry_missing, service_entry_not_the_entry, +entry_service_missing, entry_service_unreadable, module_identity_missing, +module_edge_declared, entry_endpoint_missing, module_contract_missing, +module_contract_unreadable, owner_aware_missing, module_package_missing, +module_package_unreadable, module_package_publishes_shell, +frontend_declaration_missing, frontend_declaration_unreadable, +page_shell_missing, module_interface_missing, entry_endpoint_not_exported, +page_shell_exported_too_widely.`, + Example: ` # Judge every solution in the workspace. + codefly doctor solution + + # Judge one, and script against the result. + codefly doctor solution wiki --json`, + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + ctx, done := common.NewContext() + defer done() + + workspace, err := common.LoadWorkspace(ctx) + if err != nil { + return fmt.Errorf("cannot load workspace: %w", err) + } + + var reports []*solutionshape.Report + if len(args) == 1 { + module, loadErr := workspace.LoadModuleFromName(ctx, args[0]) + if loadErr != nil { + return fmt.Errorf("cannot load module %s: %w", args[0], loadErr) + } + reports = append(reports, solutionshape.Inspect(module.Name, module.Dir())) + } else { + names := make([]string, 0, len(workspace.Modules)) + for _, reference := range workspace.Modules { + if reference != nil { + names = append(names, reference.Name) + } + } + sort.Strings(names) + for _, name := range names { + module, loadErr := workspace.LoadModuleFromName(ctx, name) + if loadErr != nil { + // A module that does not resolve is the workspace's + // problem, not the shape's; `doctor workspace` reports it. + continue + } + reports = append(reports, solutionshape.Inspect(module.Name, module.Dir())) + } + } + + if doctorSolutionJSON { + return printSolutionReportsJSON(reports) + } + return printSolutionReports(reports) + }, +} + +func printSolutionReportsJSON(reports []*solutionshape.Report) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + for _, report := range reports { + if err := encoder.Encode(report); err != nil { + return err + } + } + return solutionReportsOutcome(reports) +} + +func printSolutionReports(reports []*solutionshape.Report) error { + solutions := 0 + for _, report := range reports { + if !report.IsSolution { + continue + } + solutions++ + fmt.Printf("\n%s %s\n", statusGlyph(report.Status), report.Module) + for _, check := range report.Checks { + if check.Status == solutionshape.StatusOK { + continue + } + fmt.Printf(" %s %s: %s\n", statusGlyph(check.Status), check.Code, check.Message) + if check.Remediation != "" { + fmt.Printf(" %s\n", check.Remediation) + } + } + if report.Conforms() { + fmt.Printf(" conforms to the solution shape\n") + } + } + if solutions == 0 { + // Never a vacuous pass: a run that judged nothing must not read as a + // run that found nothing wrong. + return fmt.Errorf("no solution was found to judge; a module is a solution when it ships a solution manifest") + } + return solutionReportsOutcome(reports) +} + +func solutionReportsOutcome(reports []*solutionshape.Report) error { + var broken []string + for _, report := range reports { + if report.IsSolution && !report.Conforms() { + broken = append(broken, report.Module) + } + } + if len(broken) > 0 { + return fmt.Errorf("%d solution(s) do not conform to the shape: %v", len(broken), broken) + } + return nil +} + +func statusGlyph(status solutionshape.Status) string { + switch status { + case solutionshape.StatusFail: + return "FAIL" + case solutionshape.StatusWarn: + return "WARN" + default: + return "OK " + } +} + +func init() { + DoctorSolutionCmd.Flags().BoolVar(&doctorSolutionJSON, "json", false, "Print a versioned report per module to stdout") + DoctorCmd.AddCommand(DoctorSolutionCmd) +} diff --git a/cmd/doctor_solution_test.go b/cmd/doctor_solution_test.go new file mode 100644 index 00000000..a170c077 --- /dev/null +++ b/cmd/doctor_solution_test.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/codefly-dev/cli/pkg/solutionshape" +) + +func TestDoctorSolutionIsRegisteredAndHelpStandsAlone(t *testing.T) { + var found bool + for _, child := range DoctorCmd.Commands() { + if child.Name() == "solution" { + found = true + } + } + if !found { + t.Fatal("doctor solution is not registered under doctor") + } + if DoctorSolutionCmd.RunE == nil || DoctorSolutionCmd.Run != nil { + t.Error("doctor solution must use RunE so the root owns exit codes") + } + for name, text := range map[string]string{ + "Short": DoctorSolutionCmd.Short, "Long": DoctorSolutionCmd.Long, "Example": DoctorSolutionCmd.Example, + } { + if strings.TrimSpace(text) == "" { + t.Errorf("%s is empty; help must stand alone without network access", name) + } + } + if !DoctorSolutionCmd.SilenceUsage { + t.Error("a non-conforming solution is an outcome, not a usage mistake") + } + if DoctorSolutionCmd.Flags().Lookup("json") == nil { + t.Error("--json is not registered") + } +} + +// The help promises stable diagnostic codes, so a caller scripts against them. +// A code that exists in the package and is not documented cannot be relied on. +func TestEveryDiagnosticCodeIsDocumented(t *testing.T) { + for _, code := range []string{ + solutionshape.CodeNotASolution, solutionshape.CodeManifestUnreadable, + solutionshape.CodeConsumedAPIUnnamed, solutionshape.CodeServiceEntryMissing, + solutionshape.CodeServiceEntryNotTheEntry, solutionshape.CodeEntryServiceMissing, + solutionshape.CodeEntryServiceUnreadable, solutionshape.CodeModuleIdentityMissing, + solutionshape.CodeModuleEdgeDeclared, solutionshape.CodeEntryEndpointMissing, + solutionshape.CodeContractMissing, solutionshape.CodeContractUnreadable, + solutionshape.CodeOwnerAwareMissing, solutionshape.CodePackageMissing, + solutionshape.CodePackageUnreadable, solutionshape.CodePackagePublishesShell, + solutionshape.CodeFrontendMissing, solutionshape.CodeFrontendUnreadable, + solutionshape.CodePageServiceMissing, solutionshape.CodeInterfaceMissing, + solutionshape.CodeEntryNotExported, solutionshape.CodePageExportedTooWidely, + } { + if !strings.Contains(DoctorSolutionCmd.Long, code) { + t.Errorf("diagnostic code %q is not documented in the command's help", code) + } + } +} diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c5c9eefd..e9afb8b6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2476,8 +2476,64 @@ codefly doctor [flags] Subcommands: +- [`codefly doctor solution`](#codefly-doctor-solution) - [`codefly doctor workspace`](#codefly-doctor-workspace) +## `codefly doctor solution` + +Check a solution against the one shape a solution has, without changing anything + +``` +Judge a solution module against the shape `codefly add solution` generates. + +With no argument, every module in the workspace is judged and the ones that +ship no solution manifest are reported as "not a solution" rather than as +broken. Name a module to judge only that one. + +It is strictly read-only: it opens files and writes none. + +Every rule is one that silently broke something. The two worth knowing: + + owner_aware The authority issues a person a capability at a solution ONLY + when its module contract declares owner_aware: true, and + refuses by name otherwise. Without it the solution receives no + owner-selected scope and can act for nobody — and nothing + fails at build time to tell you. + + module edges A solution reaches every module through the host's door, + which checks the call. A service-dependency naming a module + renders into a mesh allow rule straight to that module's + service, past the check. + +Exit codes: + 0 every judged solution conforms (warnings allowed) + 1 at least one check failed, or the command itself failed + +With --json, a versioned report per module is printed to stdout: + {schema_version, module, dir, is_solution, status: ok|fail, + checks: [{code, status, message, remediation?}]} + +Stable diagnostic codes: not_a_solution, solution_manifest_unreadable, +consumed_api_unnamed, service_entry_missing, service_entry_not_the_entry, +entry_service_missing, entry_service_unreadable, module_identity_missing, +module_edge_declared, entry_endpoint_missing, module_contract_missing, +module_contract_unreadable, owner_aware_missing, module_package_missing, +module_package_unreadable, module_package_publishes_shell, +frontend_declaration_missing, frontend_declaration_unreadable, +page_shell_missing, module_interface_missing, entry_endpoint_not_exported, +page_shell_exported_too_widely. +``` + +``` +codefly doctor solution [module] +``` + +Flags: + +``` + --json Print a versioned report per module to stdout +``` + ## `codefly doctor workspace` Validate workspace paths, configuration, and agent readiness without changes diff --git a/docs/commands.md b/docs/commands.md index 6ee216f7..f4e37d88 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2442,6 +2442,44 @@ which modules it pins and at what commits, and the configuration groups those modules need, are the composition's to say. The command prints each of them, with the failure each one causes when it is skipped. +### `codefly doctor solution [module]` + +Judge a solution against the shape `add solution` generates, without changing +anything. With no argument every module in the workspace is judged; a module +that ships no solution manifest is reported as "not a solution" rather than as +broken. + +```bash +codefly doctor solution +codefly doctor solution wiki --json +``` + +It exists because the shape was previously checkable only against somebody's +survey of the solutions that happened to exist — which is how one fleet reached +nine solutions in four different states, two of them unable to receive a person +capability at all with nothing failing to say so. A shape nothing can check is a +convention, and a convention drifts. + +Every rule is one that silently broke something. The two worth knowing: + +| | | +|---|---| +| `owner_aware_missing` | The authority issues a person a capability at a solution **only** when its module contract declares `owner_aware: true`, and refuses by name otherwise. Without it the solution receives no owner-selected scope and can act for nobody — and nothing fails at build time to tell you. | +| `module_edge_declared` | A solution reaches every module through the **host's door** — a saas host's gateway and frontend, or the authority's hop beside the authority interface. A `service-dependencies` entry naming anything else renders into a mesh allow rule straight to that module's service, past the authority's check. | + +Exit codes: `0` every judged solution conforms (warnings allowed), `1` at least +one check failed. `--json` prints a versioned report per module +(`{schema_version, module, dir, is_solution, status, checks[]}`), so a +consumer repository can gate on it instead of hand-rolling the equivalent. + +A warning is not a failure: it names something worth seeing that does not make +the solution wrong — publishing the page's shell, a headless solution with no +page declaration, a solution with no shell at all. + +| Flag | Description | +|------|-------------| +| `--json` | Print a versioned report per module to stdout | + ### `codefly override service /` Run one service of a composed module from somewhere else on this machine, diff --git a/pkg/solutionshape/check.go b/pkg/solutionshape/check.go new file mode 100644 index 00000000..fdefe8bb --- /dev/null +++ b/pkg/solutionshape/check.go @@ -0,0 +1,456 @@ +// Package solutionshape holds the one shape a solution has, as a check a +// module is judged against. +// +// It exists because "the right shape" was, until now, checkable only against +// somebody's survey of the solutions that happened to exist — which is how +// obin-ai/core-solutions reached nine solutions in four different states, two +// of them unable to receive a person capability at all and nobody able to see +// it. A shape nothing can check is a convention, and a convention drifts. +// +// Every rule here is one that silently broke something. None is a style +// preference, and a rule whose breach only looks untidy does not belong here. +package solutionshape + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/codefly-dev/core/composition" + modulecontract "github.com/codefly-dev/core/contracts/module" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/solution/manifest" + "github.com/codefly-dev/core/solutionhost" + "github.com/codefly-dev/core/standards" +) + +// SchemaVersion is the report's version, so a consumer that parses --json can +// tell what it is reading. +const SchemaVersion = "codefly.solution-shape-report/v1" + +// The services the shape names. The entry service is the workload a host +// installs; the page service is the shell that serves the page where no host +// shell mounts it. +const ( + EntryService = "backend" + PageService = "web" + // FrontendFileName is the page declaration, beside the ENTRY service. + // pkg/solutionrun reads it from there and nowhere else. + FrontendFileName = "solution.frontend.codefly.yaml" +) + +// Status is a check's outcome. +type Status string + +const ( + StatusOK Status = "ok" + StatusWarn Status = "warn" + StatusFail Status = "fail" +) + +// Stable diagnostic codes. A caller scripts against these, so a code is never +// renamed or reused for a different meaning — it is retired and a new one +// added. +const ( + CodeNotASolution = "not_a_solution" + CodeManifestUnreadable = "solution_manifest_unreadable" + CodeConsumedAPIUnnamed = "consumed_api_unnamed" + CodeServiceEntryMissing = "service_entry_missing" + CodeServiceEntryNotTheEntry = "service_entry_not_the_entry" + CodeEntryServiceMissing = "entry_service_missing" + CodeEntryServiceUnreadable = "entry_service_unreadable" + CodeModuleIdentityMissing = "module_identity_missing" + CodeModuleEdgeDeclared = "module_edge_declared" + CodeEntryEndpointMissing = "entry_endpoint_missing" + CodeContractMissing = "module_contract_missing" + CodeContractUnreadable = "module_contract_unreadable" + CodeOwnerAwareMissing = "owner_aware_missing" + CodePackageMissing = "module_package_missing" + CodePackageUnreadable = "module_package_unreadable" + CodePackagePublishesShell = "module_package_publishes_shell" + CodeFrontendMissing = "frontend_declaration_missing" + CodeFrontendUnreadable = "frontend_declaration_unreadable" + CodePageServiceMissing = "page_shell_missing" + CodeInterfaceMissing = "module_interface_missing" + CodeEntryNotExported = "entry_endpoint_not_exported" + CodePageExportedTooWidely = "page_shell_exported_too_widely" +) + +// Check is one rule's outcome, with the fix when it failed. +type Check struct { + Code string `json:"code"` + Status Status `json:"status"` + Message string `json:"message"` + Remediation string `json:"remediation,omitempty"` +} + +// Report is what one module was judged to be. +type Report struct { + SchemaVersion string `json:"schema_version"` + Module string `json:"module"` + Dir string `json:"dir"` + IsSolution bool `json:"is_solution"` + Status Status `json:"status"` + Checks []Check `json:"checks"` +} + +// Conforms reports whether nothing failed. A warning is not a failure: it +// names something worth seeing that does not make the solution wrong. +func (r *Report) Conforms() bool { return r.Status != StatusFail } + +// Inspect judges the module at dir against the shape. It is strictly +// read-only: it opens files and writes none. +// +// A directory with no solution manifest is not a solution, and is reported as +// such rather than as a failing one — this command is run across a tree of +// modules, and a plain module is not a broken solution. +func Inspect(moduleName, dir string) *Report { + report := &Report{SchemaVersion: SchemaVersion, Module: moduleName, Dir: dir, Status: StatusOK} + + manifestPath := filepath.Join(dir, manifest.FileName) + raw, err := os.ReadFile(manifestPath) + if errors.Is(err, os.ErrNotExist) { + report.add(Check{Code: CodeNotASolution, Status: StatusOK, + Message: fmt.Sprintf("no %s: this module is not a solution", manifest.FileName)}) + return report + } + report.IsSolution = true + if err != nil { + report.fail(CodeManifestUnreadable, fmt.Sprintf("cannot read %s: %v", manifest.FileName, err), + "the file must be readable for anything else to be judged") + return report + } + + report.checkManifest(raw) + module := report.checkModule(dir) + report.checkEntryService(dir) + report.checkContract(dir) + report.checkPackage(dir) + report.checkFrontend(dir) + if module != nil { + report.checkInterface(module) + } + return report +} + +// checkManifest holds the solution manifest to the reader every render, sync +// and authority derivation actually uses, then to the one rule those readers +// do not enforce: a consumed entry with no route name. +func (r *Report) checkManifest(raw []byte) { + loaded, err := manifest.LoadConsumption(raw) + if err != nil { + r.fail(CodeManifestUnreadable, + fmt.Sprintf("%s is not readable by core's consumption reader: %v", manifest.FileName, err), + "every render, sync and authority derivation reads the manifest this way, so nothing can resolve what this solution consumes") + return + } + r.ok(CodeManifestUnreadable, fmt.Sprintf("%s declares %d consumed binding(s)", manifest.FileName, len(loaded.API.Consumes))) + + // `as` is the route name the solution addresses a module by. core's schema + // lets it default; the runtime projection and the consumer repository's + // own gate both require it, so an entry without one resolves to nothing. + for index := range loaded.API.Consumes { + consumed := &loaded.API.Consumes[index] + if consumed.As == "" { + r.fail(CodeConsumedAPIUnnamed, + fmt.Sprintf("api.consumes entry %q names no route (`as`)", consumed.ID), + "give it `as: `; the runtime addresses the module by that name and an entry without one is unroutable") + return + } + } + if len(loaded.API.Consumes) > 0 { + r.ok(CodeConsumedAPIUnnamed, "every consumed API names its route") + } +} + +// checkModule holds the module manifest's service-entry, which is load-bearing +// and silent when wrong. +func (r *Report) checkModule(dir string) *resources.Module { + module, err := resources.LoadModuleFromDir(context.Background(), dir) + if err != nil { + r.fail(CodeServiceEntryMissing, fmt.Sprintf("cannot read %s: %v", resources.ModuleConfigurationName, err), + "the module manifest must be readable") + return nil + } + switch module.ServiceEntry { + case "": + r.fail(CodeServiceEntryMissing, "the module declares no service-entry", + fmt.Sprintf("set `service-entry: %s`; a run resolves the solution manifest only through the entry, so without it the api.consumes projection is never built and every consumed route stays unrouted", EntryService)) + case EntryService: + r.ok(CodeServiceEntryMissing, "service-entry names the backend") + default: + r.fail(CodeServiceEntryNotTheEntry, + fmt.Sprintf("service-entry is %q, not %q", module.ServiceEntry, EntryService), + "a run resolves the solution manifest only for the service named here, so the api.consumes projection is built for the wrong service or not at all") + } + + if hasService(module, PageService) { + r.ok(CodePageServiceMissing, "the page's shell is declared") + } else { + r.warn(CodePageServiceMissing, + fmt.Sprintf("the module declares no %q service", PageService), + "the shell serves the page where no host shell mounts the remote; a solution that is only ever federated by a host does not need one, but every other composition does") + } + return module +} + +// checkEntryService holds the backend to the two things that are the shape's +// and not the agent's: it presents the solution's identity, and it opens no +// path to a module. +func (r *Report) checkEntryService(dir string) { + path := filepath.Join(dir, "services", EntryService, resources.ServiceConfigurationName) + service, err := resources.LoadFromDir[resources.Service](context.Background(), filepath.Dir(path)) + if errors.Is(err, os.ErrNotExist) { + r.fail(CodeEntryServiceMissing, fmt.Sprintf("no %s", path), + "the entry service is the workload a host installs; a solution manifest with no backend beside it is refused by the consumer repository's own gate") + return + } + if err != nil { + r.fail(CodeEntryServiceUnreadable, fmt.Sprintf("cannot read the %s service manifest: %v", EntryService, err), "") + return + } + r.ok(CodeEntryServiceMissing, "the entry service is present") + + if service.ModuleIdentity { + r.ok(CodeModuleIdentityMissing, "the backend presents the solution's identity") + } else { + r.fail(CodeModuleIdentityMissing, "the backend does not declare module-identity", + "the authority takes this service's projected credential as the actor of every Work Context it issues the solution, and refuses an authority request from a module with no such service") + } + + // THE MESH RULE. A solution reaches every module through the HOST'S DOOR, + // which checks the call. A service-dependency on anything else renders + // into an allow rule straight to that module's service, past the check. + // + // There are two doors, and which one a solution uses is the composition's, + // not the solution's. Under a saas host it is that host's gateway and + // frontend; under the host authority interface it is the authority's hop, + // and ONLY in a manifest that also requires that interface — without it, + // an edge to the hop is a path to a module service like any other. + // This is the same table core-solutions' check_solution_mesh.py holds. + interfaces := map[string]bool{} + for _, dependency := range service.ServiceDependencies { + if dependency != nil && dependency.Interface != "" { + if key := interfaceKey(dependency.Interface); key != "" { + interfaces[key] = true + } + } + } + offending := 0 + for _, dependency := range service.ServiceDependencies { + if dependency == nil || dependency.Module == "" { + continue + } + if isHostDoor(dependency.Module, dependency.Name, interfaces) { + continue + } + offending++ + r.fail(CodeModuleEdgeDeclared, + fmt.Sprintf("the backend declares a direct path to %s/%s", dependency.Module, dependency.Name), + "a solution reaches a module only through the host's door — the saas gateway and frontend, or the authority's hop beside the authority interface. This edge renders into a mesh allow rule past the authority's check") + } + if offending == 0 { + r.ok(CodeModuleEdgeDeclared, "the backend opens no direct path to a module") + } + + if endpointNamed(service, standards.HTTP) { + r.ok(CodeEntryEndpointMissing, "the backend serves its http endpoint") + } else { + r.fail(CodeEntryEndpointMissing, "the backend declares no http endpoint", + "the page and the API behind it are served on it, and the module interface exports it") + } +} + +// checkContract holds the contract to the declaration that fails silently. +func (r *Report) checkContract(dir string) { + if _, err := os.Stat(filepath.Join(dir, modulecontract.FileName)); errors.Is(err, os.ErrNotExist) { + r.fail(CodeContractMissing, fmt.Sprintf("no %s", modulecontract.FileName), + "it declares the principal every credential the solution mints is issued to; without it the solution has no declared identity to issue against") + return + } + contract, err := modulecontract.Load(dir) + if err != nil { + r.fail(CodeContractUnreadable, fmt.Sprintf("cannot read %s: %v", modulecontract.FileName, err), + "note that a core older than v0.19.7 refuses the WHOLE contract when it carries owner_aware, because core holds the document to its known fields") + return + } + r.ok(CodeContractMissing, fmt.Sprintf("the contract declares principal %q", contract.Principal)) + + // The one that fails silently, and the reason this checker exists. + if contract.OwnerAware { + r.ok(CodeOwnerAwareMissing, "the contract declares owner-aware checking") + } else { + r.fail(CodeOwnerAwareMissing, "the contract does not declare owner-aware checking", + "the authority issues a person a capability at a solution ONLY when it declares owner_aware: true, and refuses by name otherwise. Without it this solution receives no owner-selected scope and can act for nobody — and nothing fails at build time to tell you") + } +} + +// checkPackage holds the published identity, and the one thing about it that +// is easy to get wrong in the right-looking direction. +func (r *Report) checkPackage(dir string) { + if _, err := os.Stat(filepath.Join(dir, composition.PackageManifestFileName)); errors.Is(err, os.ErrNotExist) { + r.fail(CodePackageMissing, fmt.Sprintf("no %s", composition.PackageManifestFileName), + "the render declares the solution's presence from this file; without it the host never lists the solution and nobody can install it, and the contract's scopes reach no host either") + return + } + packaged, err := composition.LoadPackageManifest(dir) + if err != nil { + r.fail(CodePackageUnreadable, fmt.Sprintf("cannot read %s: %v", composition.PackageManifestFileName, err), "") + return + } + r.ok(CodePackageMissing, fmt.Sprintf("the package publishes %s@%s", packaged.ID, packaged.Version)) + + for index := range packaged.Services { + if packaged.Services[index].Name == PageService { + r.warn(CodePackagePublishesShell, + fmt.Sprintf("the package publishes the %q shell", PageService), + "a host that federates the page's remote needs no shell of its own, so the shell is composed but not published") + return + } + } + r.ok(CodePackagePublishesShell, "the package does not publish the shell") +} + +// checkFrontend reads the page declaration with core's own strict parser — +// the reader that refuses it at delivery — and from beside the ENTRY service, +// which is the only place anything looks. +func (r *Report) checkFrontend(dir string) { + path := filepath.Join(dir, "services", EntryService, FrontendFileName) + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + r.warn(CodeFrontendMissing, fmt.Sprintf("no %s beside the entry service", FrontendFileName), + "a solution with no page declaration is headless: the host lists it but mounts nothing") + return + } + if err != nil { + r.fail(CodeFrontendUnreadable, fmt.Sprintf("cannot read %s: %v", FrontendFileName, err), "") + return + } + frontend, err := solutionhost.ParseFrontend(raw) + if err != nil { + r.fail(CodeFrontendUnreadable, fmt.Sprintf("%s is refused by core's parser: %v", FrontendFileName, err), + "this is the same reader that refuses it at delivery, so the solution would be delivered and then fail") + return + } + r.ok(CodeFrontendMissing, fmt.Sprintf("the page declares %q exposing %s", frontend.Title, frontend.ExposedModule)) +} + +// checkInterface holds what the composition may reach across the solution's +// boundary. An endpoint the interface does not export admits no other module, +// so an edge cannot be routed to it. +func (r *Report) checkInterface(module *resources.Module) { + if module.Interface == nil || len(module.Interface.Endpoints) == 0 { + r.fail(CodeInterfaceMissing, "the module exports no interface", + fmt.Sprintf("export %s/%s at public so an edge can be routed to it, and the shell at internal", EntryService, standards.HTTP)) + return + } + var entry, page *resources.InterfaceEndpoint + for _, endpoint := range module.Interface.Endpoints { + switch endpoint.Service { + case EntryService: + entry = endpoint + case PageService: + page = endpoint + } + } + switch { + case entry == nil: + r.fail(CodeEntryNotExported, fmt.Sprintf("the interface does not export %s/%s", EntryService, standards.HTTP), + "an endpoint the interface does not export admits no other module, so the edge in front of the solution cannot be routed to it") + case entry.Visibility != "" && entry.Visibility != resources.VisibilityPublic: + r.fail(CodeEntryNotExported, + fmt.Sprintf("the interface exports %s at %q, not public", EntryService, entry.Visibility), + "the edge that fronts the solution reaches it from outside the module") + default: + r.ok(CodeEntryNotExported, "the interface exports the backend at public") + } + + // The shell is reached by the edge that serves it and by nothing else. + if page != nil && page.Visibility == resources.VisibilityPublic { + r.warn(CodePageExportedTooWidely, + fmt.Sprintf("the interface exports the %q shell at public", PageService), + "the shell is reached only by the edge that serves it as the browser's origin; internal is the reach it needs") + } else if page != nil { + r.ok(CodePageExportedTooWidely, "the interface exports the shell at internal") + } +} + +func (r *Report) add(check Check) { + r.Checks = append(r.Checks, check) + if check.Status == StatusFail { + r.Status = StatusFail + } +} + +func (r *Report) ok(code, message string) { + r.add(Check{Code: code, Status: StatusOK, Message: message}) +} + +func (r *Report) warn(code, message, remediation string) { + r.add(Check{Code: code, Status: StatusWarn, Message: message, Remediation: remediation}) +} + +func (r *Report) fail(code, message, remediation string) { + r.add(Check{Code: code, Status: StatusFail, Message: message, Remediation: remediation}) +} + +func hasService(module *resources.Module, name string) bool { + for _, reference := range module.ServiceReferences { + if reference != nil && reference.Name == name { + return true + } + } + return false +} + +func endpointNamed(service *resources.Service, name string) bool { + for _, endpoint := range service.Endpoints { + if endpoint != nil && endpoint.Name == name { + return true + } + } + return false +} + +// hostDoors are the (module, service) pairs a solution may hold an edge to +// unconditionally: a saas host's own gateway and frontend. +var hostDoors = map[[2]string]bool{ + {"saas", "auth-gateway"}: true, + {"saas", "frontend"}: true, +} + +// authorityDoors are the (module, service) pairs that are a door ONLY in a +// manifest that also requires the named interface. Without it, an edge to the +// hop is a path to a module service like any other. +var authorityDoors = map[[2]string]string{ + {"authority", "hop"}: "codefly.dev/authority", +} + +// isHostDoor reports whether an edge is the host's own door rather than a path +// past it. +func isHostDoor(module, name string, interfaces map[string]bool) bool { + if hostDoors[[2]string{module, name}] { + return true + } + if required, ok := authorityDoors[[2]string{module, name}]; ok { + return interfaces[required] + } + return false +} + +// interfaceKey is the / of an interface requirement, which +// core spells /@, or "" when it is not one. +func interfaceKey(reference string) string { + key, _, found := strings.Cut(reference, "@") + if !found || strings.Count(key, "/") != 1 { + return "" + } + publisher, name, _ := strings.Cut(key, "/") + if publisher == "" || name == "" { + return "" + } + return key +} diff --git a/pkg/solutionshape/check_test.go b/pkg/solutionshape/check_test.go new file mode 100644 index 00000000..28d44590 --- /dev/null +++ b/pkg/solutionshape/check_test.go @@ -0,0 +1,322 @@ +package solutionshape + +import ( + "os" + "path/filepath" + "testing" +) + +// conformingSolution writes the shape `codefly add solution` generates, so a +// test that breaks one rule changes exactly that rule. +func conformingSolution(t *testing.T) string { + t.Helper() + dir := t.TempDir() + write(t, filepath.Join(dir, "module.codefly.yaml"), `kind: module +name: wiki +service-entry: backend +services: + - name: backend + - name: web +interface: + endpoints: + - service: backend + endpoint: http + visibility: public + - service: web + endpoint: http + visibility: internal +`) + write(t, filepath.Join(dir, "solution.codefly.yaml"), `schema_version: codefly.solution-manifest/v0 +protocol_version: codefly.solution/v0 +api: + exposes: + - id: http + protocol: http + consumes: + - id: documents + protocol: connect + module: documents + service: documents + endpoint: grpc + as: documents +lifecycle: + package: true + render: true +`) + write(t, filepath.Join(dir, "module.contract.codefly.yaml"), `schema: codefly/module-contract/v1 +principal: wiki +owner_aware: true +namespaces: [] +queues: [] +scope_ceilings: [] +bindings: [] +`) + write(t, filepath.Join(dir, "module.package.codefly.yaml"), `kind: module-package +schema: codefly/module-package/v2 +id: obin/wiki +version: 0.1.0 +minimum-codefly-version: ">=0.1.220 <1.0.0" +artifact-roots: + - services +services: + - name: backend + endpoints: [http] +contracts: + composition: ">=2.0.0 <3.0.0" +`) + write(t, filepath.Join(dir, "services", "backend", "service.codefly.yaml"), `kind: service +name: backend +version: 0.0.0 +agent: + publisher: codefly.dev + kind: codefly:service + name: go + version: 0.0.70 +module-identity: true +service-dependencies: + - interface: codefly.dev/authority@>=0.2.0 <0.4.0 + - name: hop + module: authority + kind: external + endpoints: + - name: grpc +endpoints: + - name: http + visibility: public + exposure: none + api: http +`) + write(t, filepath.Join(dir, "services", "backend", FrontendFileName), `title: Wiki +order: 60 +exposed_module: ./Page +host_contract: 1 +`) + write(t, filepath.Join(dir, "services", "web", "service.codefly.yaml"), `name: web +version: 0.0.0 +agent: + publisher: codefly.dev + kind: codefly:service + name: nextjs + version: 0.0.167 +endpoints: + - name: http + visibility: public + exposure: none +`) + return dir +} + +func write(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func checkFor(t *testing.T, report *Report, code string) Check { + t.Helper() + for _, check := range report.Checks { + if check.Code == code { + return check + } + } + t.Fatalf("no check %q in report: %+v", code, report.Checks) + return Check{} +} + +// The shape the CLI generates must be the shape the CLI accepts. If these ever +// disagree, one of them is wrong and a scaffolded solution is born failing. +func TestTheGeneratedShapeConforms(t *testing.T) { + report := Inspect("wiki", conformingSolution(t)) + if !report.IsSolution { + t.Fatal("a module shipping a solution manifest was not read as a solution") + } + if !report.Conforms() { + for _, check := range report.Checks { + if check.Status != StatusOK { + t.Errorf("%s: %s — %s", check.Status, check.Code, check.Message) + } + } + t.Fatal("the shape the scaffold generates does not pass the check") + } +} + +// A plain module is not a broken solution. This command is run across a whole +// tree, and reporting every library as a failing solution would make it useless. +func TestAModuleWithNoManifestIsNotASolution(t *testing.T) { + dir := t.TempDir() + write(t, filepath.Join(dir, "module.codefly.yaml"), "kind: module\nname: plain\nservices: []\n") + report := Inspect("plain", dir) + if report.IsSolution { + t.Fatal("a module with no solution manifest was read as a solution") + } + if !report.Conforms() { + t.Fatal("a plain module was reported as a failing solution") + } +} + +// The rule that fails silently in production, which is why this checker exists. +func TestOwnerAwareMissingFails(t *testing.T) { + dir := conformingSolution(t) + write(t, filepath.Join(dir, "module.contract.codefly.yaml"), `schema: codefly/module-contract/v1 +principal: wiki +namespaces: [] +queues: [] +scope_ceilings: [] +bindings: [] +`) + report := Inspect("wiki", dir) + check := checkFor(t, report, CodeOwnerAwareMissing) + if check.Status != StatusFail { + t.Fatalf("owner_aware missing was %s, want fail", check.Status) + } + if report.Conforms() { + t.Fatal("a solution that can act for nobody was reported as conforming") + } +} + +// The mesh rule: an edge to a module is a path past the authority's check. +func TestADirectModuleEdgeFails(t *testing.T) { + dir := conformingSolution(t) + write(t, filepath.Join(dir, "services", "backend", "service.codefly.yaml"), `kind: service +name: backend +version: 0.0.0 +agent: + publisher: codefly.dev + kind: codefly:service + name: go + version: 0.0.70 +module-identity: true +service-dependencies: + - interface: codefly.dev/authority@>=0.2.0 <0.4.0 + - name: documents + module: documents + endpoints: + - name: grpc +endpoints: + - name: http + visibility: public + exposure: none + api: http +`) + report := Inspect("wiki", dir) + check := checkFor(t, report, CodeModuleEdgeDeclared) + if check.Status != StatusFail { + t.Fatalf("a direct module edge was %s, want fail", check.Status) + } + // The authority's own door is not an offence. + if got := check.Message; got == "" { + t.Fatal("the failure does not name the edge") + } +} + +// service-entry is load-bearing: a run resolves the solution manifest only +// through it, so a wrong one leaves every consumed route unrouted, silently. +func TestAWrongServiceEntryFails(t *testing.T) { + dir := conformingSolution(t) + write(t, filepath.Join(dir, "module.codefly.yaml"), `kind: module +name: wiki +service-entry: web +services: + - name: backend + - name: web +`) + report := Inspect("wiki", dir) + if checkFor(t, report, CodeServiceEntryNotTheEntry).Status != StatusFail { + t.Fatal("a service-entry naming the shell was accepted") + } +} + +// Without the package the render declares no presence, so nobody can install +// the solution and the contract's scopes reach no host. +func TestAMissingPackageFails(t *testing.T) { + dir := conformingSolution(t) + if err := os.Remove(filepath.Join(dir, "module.package.codefly.yaml")); err != nil { + t.Fatal(err) + } + report := Inspect("wiki", dir) + if checkFor(t, report, CodePackageMissing).Status != StatusFail { + t.Fatal("a solution with no published identity was accepted") + } +} + +// Publishing the shell is wrong but not broken: a host that federates the +// remote simply does not need it. That is a warning, not a failure. +func TestPublishingTheShellWarnsRatherThanFails(t *testing.T) { + dir := conformingSolution(t) + write(t, filepath.Join(dir, "module.package.codefly.yaml"), `kind: module-package +schema: codefly/module-package/v2 +id: obin/wiki +version: 0.1.0 +minimum-codefly-version: ">=0.1.220 <1.0.0" +artifact-roots: + - services +services: + - name: backend + endpoints: [http] + - name: web + endpoints: [http] +contracts: + composition: ">=2.0.0 <3.0.0" +`) + report := Inspect("wiki", dir) + if checkFor(t, report, CodePackagePublishesShell).Status != StatusWarn { + t.Fatal("publishing the shell should warn, not fail") + } + if !report.Conforms() { + t.Fatal("a warning was treated as a failure") + } +} + +// A consumed entry with no route name is unroutable, and core's own schema +// lets it default — so nothing else catches it. +func TestAConsumedAPIWithNoRouteNameFails(t *testing.T) { + dir := conformingSolution(t) + write(t, filepath.Join(dir, "solution.codefly.yaml"), `schema_version: codefly.solution-manifest/v0 +protocol_version: codefly.solution/v0 +api: + exposes: + - id: http + protocol: http + consumes: + - id: documents + protocol: connect + module: documents + service: documents + endpoint: grpc +lifecycle: + package: true + render: true +`) + report := Inspect("wiki", dir) + if checkFor(t, report, CodeConsumedAPIUnnamed).Status != StatusFail { + t.Fatal("a consumed API with no route name was accepted") + } +} + +// The backend must present the solution's identity, or the authority refuses +// the whole authority request. +func TestAMissingModuleIdentityFails(t *testing.T) { + dir := conformingSolution(t) + write(t, filepath.Join(dir, "services", "backend", "service.codefly.yaml"), `kind: service +name: backend +version: 0.0.0 +agent: + publisher: codefly.dev + kind: codefly:service + name: go + version: 0.0.70 +endpoints: + - name: http + visibility: public + exposure: none + api: http +`) + report := Inspect("wiki", dir) + if checkFor(t, report, CodeModuleIdentityMissing).Status != StatusFail { + t.Fatal("a backend that presents no identity was accepted") + } +} From 61abf1a9289d44d8fc58a54dc3943f347497926f Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:31:41 -0400 Subject: [PATCH 2/2] feat(doctor): judge a solution directory without a workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the check across the real tree found two gaps in it. `--dir` judges a module directory on its own. A consumer repository gating `solutions/*/` has module directories and not necessarily a workspace at each one: core-solutions' excel-extract is a real solution with no workspace.codefly.yaml, which made it the single solution the check could not judge at all. `--require-solution` closes what --dir leaves open. A directory with no solution manifest reads as "not a solution" and passes — right for a library in the tree, wrong for a solution whose manifest is missing, which is exactly robin-builder's state. A gate walking solutions/*/ means "every one of these is a solution", and now says so. In --dir mode the no-solution-found guard does not apply: the caller named the directory, so "it is not a solution" is an answer rather than a vacuous pass. Co-Authored-By: Claude Opus 5 --- cmd/doctor_solution.go | 60 +++++++++++++++++++++++++++++++++++-- cmd/doctor_solution_test.go | 6 ++-- docs/cli-reference.md | 4 ++- docs/commands.md | 17 +++++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/cmd/doctor_solution.go b/cmd/doctor_solution.go index 4447b7dc..c0070a47 100644 --- a/cmd/doctor_solution.go +++ b/cmd/doctor_solution.go @@ -4,14 +4,23 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "sort" "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/solutionshape" + "github.com/codefly-dev/core/resources" "github.com/spf13/cobra" ) -var doctorSolutionJSON bool +var ( + doctorSolutionJSON bool + doctorSolutionDir string + doctorSolutionRequire bool +) + +// solutionManifestName is the file whose presence makes a module a solution. +const solutionManifestName = "solution.codefly.yaml" // DoctorSolutionCmd judges a solution against the one shape a solution has. // @@ -67,12 +76,57 @@ page_shell_exported_too_widely.`, codefly doctor solution # Judge one, and script against the result. - codefly doctor solution wiki --json`, + codefly doctor solution wiki --json + + # Gate a consumer repository's tree, where a solution need not be a workspace. + for s in solutions/*/; do codefly doctor solution --dir "$s" || exit 1; done + + # The same gate, where every directory walked must BE a solution: this + # catches a solution whose manifest is missing, which otherwise reads as + # "not a solution" and passes. + codefly doctor solution --dir solutions/robin-builder --require-solution`, Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { ctx, done := common.NewContext() defer done() + // --dir judges a module directory on its own, with no workspace + // resolution. A consumer repository gating `solutions/*/` has a tree + // of module directories and not necessarily a workspace at each one: + // obin-ai/core-solutions' excel-extract is a real solution with no + // workspace.codefly.yaml, and requiring one made it the single + // solution that could not be judged at all. + if doctorSolutionDir != "" { + if len(args) > 0 { + return fmt.Errorf("--dir judges the directory itself, so it takes no module name") + } + absolute, err := filepath.Abs(doctorSolutionDir) + if err != nil { + return fmt.Errorf("cannot resolve %s: %w", doctorSolutionDir, err) + } + name := filepath.Base(absolute) + if module, loadErr := resources.LoadModuleFromDir(ctx, absolute); loadErr == nil && module.Name != "" { + name = module.Name + } + report := solutionshape.Inspect(name, absolute) + reports := []*solutionshape.Report{report} + if doctorSolutionJSON { + return printSolutionReportsJSON(reports) + } + // The caller named this directory, so "it is not a solution" is an + // answer and not a vacuous pass. A tree gate looping solutions/*/ + // meets directories that are not solutions — core-solutions' + // client-engagement is one — and must not fail on them. + if !report.IsSolution { + if doctorSolutionRequire { + return fmt.Errorf("%s ships no %s, so it is not a solution — and --require-solution says it must be", report.Module, solutionManifestName) + } + fmt.Printf("OK %s: not a solution (no %s)\n", report.Module, solutionManifestName) + return nil + } + return printSolutionReports(reports) + } + workspace, err := common.LoadWorkspace(ctx) if err != nil { return fmt.Errorf("cannot load workspace: %w", err) @@ -177,5 +231,7 @@ func statusGlyph(status solutionshape.Status) string { func init() { DoctorSolutionCmd.Flags().BoolVar(&doctorSolutionJSON, "json", false, "Print a versioned report per module to stdout") + DoctorSolutionCmd.Flags().BoolVar(&doctorSolutionRequire, "require-solution", false, "With --dir, fail when the directory ships no solution manifest instead of reporting it as not a solution. A tree gate uses it to say every directory it walks must BE a solution") + DoctorSolutionCmd.Flags().StringVar(&doctorSolutionDir, "dir", "", "Judge this module directory on its own, without resolving a workspace. For a consumer repository gating a tree of solutions, where a directory need not be a workspace") DoctorCmd.AddCommand(DoctorSolutionCmd) } diff --git a/cmd/doctor_solution_test.go b/cmd/doctor_solution_test.go index a170c077..6474b5ae 100644 --- a/cmd/doctor_solution_test.go +++ b/cmd/doctor_solution_test.go @@ -30,8 +30,10 @@ func TestDoctorSolutionIsRegisteredAndHelpStandsAlone(t *testing.T) { if !DoctorSolutionCmd.SilenceUsage { t.Error("a non-conforming solution is an outcome, not a usage mistake") } - if DoctorSolutionCmd.Flags().Lookup("json") == nil { - t.Error("--json is not registered") + for _, flag := range []string{"json", "dir", "require-solution"} { + if DoctorSolutionCmd.Flags().Lookup(flag) == nil { + t.Errorf("--%s is not registered", flag) + } } } diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e9afb8b6..6cc49f0c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2531,7 +2531,9 @@ codefly doctor solution [module] Flags: ``` - --json Print a versioned report per module to stdout + --dir string Judge this module directory on its own, without resolving a workspace. For a consumer repository gating a tree of solutions, where a directory need not be a workspace + --json Print a versioned report per module to stdout + --require-solution With --dir, fail when the directory ships no solution manifest instead of reporting it as not a solution. A tree gate uses it to say every directory it walks must BE a solution ``` ## `codefly doctor workspace` diff --git a/docs/commands.md b/docs/commands.md index f4e37d88..b78bbfeb 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2479,6 +2479,23 @@ page declaration, a solution with no shell at all. | Flag | Description | |------|-------------| | `--json` | Print a versioned report per module to stdout | +| `--dir` | Judge this module directory on its own, without resolving a workspace | +| `--require-solution` | With `--dir`, fail when the directory ships no solution manifest | + +`--dir` exists because a consumer repository gating a tree of solutions has +module directories, not necessarily a workspace at each one — `excel-extract` +is a real solution with no `workspace.codefly.yaml`, and requiring one made it +the single solution that could not be judged at all. + +`--require-solution` closes the gap `--dir` leaves: a directory with no +solution manifest reads as "not a solution" and passes, which is right for a +library sitting in the tree and wrong for a solution whose manifest is +missing. A gate that walks `solutions/*/` means "every one of these is a +solution", and says so with the flag. + +```bash +for s in solutions/*/; do codefly doctor solution --dir "$s" --require-solution || exit 1; done +``` ### `codefly override service /`