From 871027df36b47df23f952109a26ed6dd4a87900e Mon Sep 17 00:00:00 2001 From: btwshivam Date: Sun, 24 May 2026 04:33:17 +0530 Subject: [PATCH 1/6] docs(api): add OpenAPI 3.1 spec for the optiqor backend public surface Signed-off-by: btwshivam --- docs/api/openapi.yaml | 604 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 604 insertions(+) create mode 100644 docs/api/openapi.yaml diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml new file mode 100644 index 0000000..307e4ee --- /dev/null +++ b/docs/api/openapi.yaml @@ -0,0 +1,604 @@ +openapi: 3.1.0 +info: + title: Optiqor HTTP API + version: "1.0.0" + summary: Public + tenant-scoped HTTP surface of the Optiqor backend. + description: | + This spec mirrors the route registrations in `cmd/api/routes.go` of + the proprietary [optiqor/optiqor](https://github.com/optiqor/optiqor) + backend. The CLI repo owns this file because the spec is part of the + public OSS surface — the CLI's TS client generator, third-party + integrations, and the dashboard all consume it. + + Stability: routes prefixed `/v1/` are stable; breaking changes ship + under `/v2/` with a documented deprecation window. Routes without a + version prefix (`/healthz`, `/r/`, `/v/`, `/webhooks/*`, + `/oauth/*`) are operationally-stable but follow a separate + deprecation contract per CLAUDE.md. + + Authentication: Phase 2 reads tenant from `X-Optiqor-Tenant` header + for tenant-scoped routes. Phase 5 swaps to a JWT extractor (the + `bearerAuth` / `sessionCookie` schemes below) without changing the + route shapes. + + CI gate: `scripts/check-openapi-parity.sh` in the backend repo + asserts every path defined here is also registered in the Go + handlers, and vice versa. + contact: + name: Optiqor Engineering + url: https://optiqor.dev + license: + name: Spec is Apache-2.0; the backend implementation is proprietary. + url: https://github.com/optiqor/optiqor-cli/blob/main/LICENSE +servers: + - url: https://api.optiqor.dev + description: Production + - url: http://localhost:8080 + description: Local dev (single api binary) + +tags: + - name: sandbox + description: Public, unauth sandbox surface. Rate-limited by IP. + - name: receipts + description: Public verification of signed Receipts. + - name: session + description: Dashboard authentication bridge. + - name: onboarding + description: Per-tenant onboarding funnel. + - name: apply-fix + description: Tenant-scoped PR remediation. + - name: ingest + description: Agent → SaaS data plane. + - name: cost-spikes + description: Bill anomaly webhooks. + - name: ops + description: Liveness, readiness, metrics, meta. + - name: webhooks + description: Inbound webhooks from external systems. + +paths: + /healthz: + get: + tags: [ops] + summary: Liveness probe. + description: Always 200 on a running binary. No deps checked. + responses: + "200": + description: ok + content: + text/plain: + schema: { type: string, example: "ok\n" } + + /readyz: + get: + tags: [ops] + summary: Readiness probe. + description: | + Runs the dependency-check registry (postgres, redis, temporal, + anthropic in later phases). Returns 200 only when every check + is healthy; 503 with a JSON breakdown otherwise. + responses: + "200": { description: ready, content: { application/json: { schema: { $ref: "#/components/schemas/ReadinessResponse" } } } } + "503": { description: not ready, content: { application/json: { schema: { $ref: "#/components/schemas/ReadinessResponse" } } } } + + /metrics: + get: + tags: [ops] + summary: Prometheus exposition. + description: | + OpenMetrics-format metrics for the api binary. Naming follows + `optiqor___` per CLAUDE.md. + responses: + "200": + description: prometheus exposition + content: + text/plain: + schema: { type: string } + + /v1/meta: + get: + tags: [ops] + summary: Endpoint manifest for the dashboard. + description: Returns the list of public routes so the dashboard can route-discover without reading this spec. + responses: + "200": + description: manifest + content: + application/json: + schema: { $ref: "#/components/schemas/MetaResponse" } + + /v1/analyze: + post: + tags: [sandbox] + summary: Run the deterministic detector pipeline on a Helm values document. + description: | + Body is a raw `values.yaml`. The handler parses it into normalised + Workload structs, runs every detector from + `optiqor-cli/pkg/rules.All()`, and returns findings plus a + sandbox-grade cost estimate. The response always carries the + mandatory ±40% accuracy disclosure. + + Rate-limited 60 req/min/IP in Phase 2. + requestBody: + required: true + content: + text/yaml: + schema: { type: string } + example: | + api: + resources: + requests: { cpu: "2", memory: "4Gi" } + limits: { cpu: "8", memory: "16Gi" } + replicas: 10 + responses: + "200": + description: findings + cost estimates + content: + application/json: + schema: { $ref: "#/components/schemas/AnalyzeResponse" } + "400": { description: malformed YAML / empty body } + "413": { description: body exceeds 1 MiB cap } + "429": { description: rate limited; Retry-After header in seconds } + + /r/{hash}: + get: + tags: [sandbox] + summary: Fetch a previously shared analysis. + description: | + Default response is a styled HTML page rendered by + `pkg/htmlrender` (Apache-2.0) so Slack / GitHub link unfurls + index correctly. `?format=json` or `Accept: application/json` + returns the cached JSON instead. + parameters: + - in: path + name: hash + required: true + schema: { type: string, minLength: 1, example: "a1b2c3d4e5f6" } + responses: + "200": + description: share found + content: + text/html: { schema: { type: string } } + application/json: { schema: { $ref: "#/components/schemas/AnalyzeResponse" } } + "404": { description: unknown or expired hash } + + /v/{id}: + get: + tags: [receipts] + summary: Receipt verifier page. + description: | + Self-contained HTML with live signature-status badge, canonical + payload, base64url signature, and offline-verify instructions. + parameters: + - in: path + name: id + required: true + schema: { type: string, minLength: 1 } + responses: + "200": + description: verifier page + content: + text/html: { schema: { type: string } } + "404": { description: receipt not found } + + /v1/receipts/{id}: + get: + tags: [receipts] + summary: Fetch a Receipt JSON envelope. + description: | + Returns the canonical payload + Ed25519 signature so third-party + verifiers can check signatures offline against the published + public keys. + parameters: + - in: path + name: id + required: true + schema: { type: string } + responses: + "200": + description: receipt envelope + content: + application/json: + schema: { $ref: "#/components/schemas/Receipt" } + "404": { description: not found } + + /v1/apply-fixes: + post: + tags: [apply-fix] + summary: Preview the Apply Fix PR body + diff. + description: | + Tenant-scoped. The composer runs the LLM under the $0.40/call + budget, the deterministic post-validators reject diffs that + violate schema or quantity bounds, and the response carries + both the rendered Markdown PR comment and the unified diff. + security: + - tenantHeader: [] + - bearerAuth: [] + - sessionCookie: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ApplyFixRequest" } + responses: + "200": + description: preview + content: + application/json: + schema: { $ref: "#/components/schemas/ApplyFixResponse" } + "400": { description: malformed input } + "401": { description: tenant context missing } + "413": { description: body exceeds 1 MiB cap } + + /v1/ingest: + post: + tags: [ingest] + summary: Agent → SaaS metrics ingestion. + description: | + Used by the in-cluster agent to ship workload state + Prometheus + snapshots over mTLS. Body cap is 16 MiB to cover a medium tenant's + 30-day snapshot. + requestBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + "202": { description: accepted for async processing } + "401": { description: agent identity not verified } + "413": { description: body exceeds 16 MiB cap } + + /v1/cost-spikes: + post: + tags: [cost-spikes] + summary: AWS Cost Anomaly Detection webhook receiver. + description: Tiny payload (≤ 64 KiB). The handler enqueues a CostSpike workflow that maps the bill anomaly to its most-likely-PR. + requestBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + "202": { description: enqueued } + "400": { description: malformed payload } + "413": { description: body exceeds 64 KiB cap } + + /v1/session/whoami: + get: + tags: [session] + summary: Identity + tenant context for the dashboard. + description: | + Reads (in priority order) `Authorization: Bearer `, the + `optiqor_session` cookie, then the `X-Optiqor-Tenant` header. + Returns 401 when none yield a usable identity. The `source` + field tells the dashboard which path was hit. + security: + - bearerAuth: [] + - sessionCookie: [] + - tenantHeader: [] + responses: + "200": + description: identity envelope + content: + application/json: + schema: { $ref: "#/components/schemas/WhoamiResponse" } + "401": { description: no usable identity } + + /v1/session/issue: + post: + tags: [session] + summary: Mint a backend JWT (Auth.js bridge). + description: | + Called by the dashboard after Auth.js has authenticated the + user via the GitHub OAuth flow. Returns the JWT in the + response body and sets the `optiqor_session` HttpOnly cookie. + The caller is responsible for validating `subject` + `tenant_id` + against the Auth.js session server-side; Phase 5 ties this to + a verified provider callback so the handler can fail closed. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/IssueRequest" } + responses: + "200": + description: issued + content: + application/json: + schema: { $ref: "#/components/schemas/IssueResponse" } + "400": { description: missing fields / unknown fields } + "413": { description: body exceeds 16 KiB cap } + + /v1/onboarding/state: + get: + tags: [onboarding] + summary: Get the tenant's onboarding state. + description: | + Tenant-scoped. Lazily creates a `signed_up` record on first read + so the dashboard can land on an empty tenant without a separate + signup event. + security: + - tenantHeader: [] + - bearerAuth: [] + - sessionCookie: [] + responses: + "200": + description: onboarding envelope + content: + application/json: + schema: { $ref: "#/components/schemas/OnboardingStateResponse" } + "401": { description: tenant context missing } + + /v1/onboarding/transition: + post: + tags: [onboarding] + summary: Advance the onboarding state machine. + description: | + Forward-only. Backwards transitions and unknown stages return 400. + security: + - tenantHeader: [] + - bearerAuth: [] + - sessionCookie: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OnboardingTransitionRequest" } + responses: + "200": + description: updated state + content: + application/json: + schema: { $ref: "#/components/schemas/OnboardingStateResponse" } + "400": { description: illegal transition / unknown stage } + "401": { description: tenant context missing } + "413": { description: body exceeds 4 KiB cap } + + /oauth/github/callback: + get: + tags: [webhooks] + summary: GitHub OAuth callback receiver. + description: | + Phase 1 ack stub; Phase 5 wires the full code-exchange and hands + off to /v1/session/issue. The stub logs the (state, code) pair + and returns a deterministic JSON ack so the GitHub App's + redirect URI is reachable during onboarding. + parameters: + - in: query + name: state + required: false + schema: { type: string } + - in: query + name: code + required: true + schema: { type: string } + responses: + "200": + description: ack + content: + application/json: + schema: { type: object } + "400": { description: missing ?code } + + /webhooks/github: + post: + tags: [webhooks] + summary: GitHub App webhook receiver. + description: | + HMAC-SHA256 verifies the signature against the configured webhook + secret. Phase 1 acks with 202; Phase 4 dispatches the event into + a Temporal workflow keyed off (event, delivery). + parameters: + - in: header + name: X-Hub-Signature-256 + required: true + schema: { type: string } + - in: header + name: X-GitHub-Event + required: true + schema: { type: string } + - in: header + name: X-GitHub-Delivery + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + "202": { description: enqueued (or noop in Phase 1) } + "400": { description: read failed } + "401": { description: signature verification failed } + "413": { description: body exceeds 8 MiB cap } + +components: + securitySchemes: + tenantHeader: + type: apiKey + in: header + name: X-Optiqor-Tenant + description: Phase 2 dev surface. Phase 5 retires this in favour of the JWT extractor. + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: HS256 JWT issued by `/v1/session/issue`. + sessionCookie: + type: apiKey + in: cookie + name: optiqor_session + description: HttpOnly cookie set by `/v1/session/issue`. + + schemas: + ReadinessResponse: + type: object + properties: + ok: { type: boolean } + version: { type: string } + checks: + type: object + additionalProperties: + type: object + properties: + status: { type: string } + error: { type: string, nullable: true } + duration_ms: { type: integer } + required: [ok, checks] + + MetaResponse: + type: object + properties: + version: { type: string } + endpoints: + type: array + items: + type: object + properties: + method: { type: string } + path: { type: string } + notes: { type: string } + required: [version, endpoints] + + Finding: + type: object + properties: + DetectorID: { type: string, example: "cpu-overprovisioned" } + Workload: { type: string } + Title: { type: string } + Detail: { type: string } + MonthlyUSDCents: { type: integer, format: int64 } + Severity: { type: string, enum: [HIGH, MED, LOW, INFO] } + Confidence: { type: string, enum: [high, medium, low] } + Category: { type: string, enum: [cost, security] } + required: [DetectorID, Workload, Title, Severity, Confidence, Category] + + CostEstimate: + type: object + properties: + workload: { type: string } + region: { type: string } + replicas: { type: integer } + cpu_millicores: { type: integer } + memory_bytes: { type: integer, format: int64 } + monthly_usd_cents: { type: integer, format: int64 } + cpu_monthly_cents: { type: integer, format: int64 } + mem_monthly_cents: { type: integer, format: int64 } + note: { type: string } + accuracy_band_pct: { type: integer } + unpriceable_field: { type: string } + + AnalyzeResponse: + type: object + properties: + accuracy_disclosure: { type: string } + source: { type: string } + workloads_analyzed: { type: integer } + findings: { type: array, items: { $ref: "#/components/schemas/Finding" } } + cost_findings: { type: array, items: { $ref: "#/components/schemas/Finding" } } + security_findings_bonus: { type: array, items: { $ref: "#/components/schemas/Finding" } } + monthly_savings_usd: { type: number, format: double } + annual_savings_usd: { type: number, format: double } + cost_estimates: { type: array, items: { $ref: "#/components/schemas/CostEstimate" } } + share_hash: { type: string } + share_url: { type: string, format: uri } + required: [accuracy_disclosure, source, workloads_analyzed, findings, share_hash, share_url] + + ApplyFixRequest: + type: object + properties: + workload: { type: string } + chart_yaml: { type: string } + finding: { $ref: "#/components/schemas/Finding" } + required: [workload, chart_yaml, finding] + + ApplyFixResponse: + type: object + properties: + markdown_body: { type: string } + unified_diff: { type: string } + explanation: { type: string } + sanitizer_applied: { type: boolean } + required: [markdown_body, unified_diff] + + Receipt: + type: object + properties: + id: { type: string } + tier: { type: string, enum: [cloud, capacity, hybrid] } + payload: { type: object, additionalProperties: true } + signature: { type: string, description: "base64url Ed25519 signature over the canonical payload" } + issuer_key_id: { type: string } + issued_at: { type: string, format: date-time } + required: [id, tier, payload, signature, issuer_key_id] + + WhoamiResponse: + type: object + properties: + tenant_id: { type: string } + workspace_id: { type: string } + subject: { type: string } + name: { type: string } + source: { type: string, enum: [jwt, header] } + expires_at: { type: string, format: date-time } + required: [tenant_id, source] + + IssueRequest: + type: object + properties: + subject: { type: string, description: "Auth.js stable identity (provider id or verified email)" } + name: { type: string } + tenant_id: { type: string } + workspace_id: { type: string } + required: [subject, tenant_id] + + IssueResponse: + type: object + properties: + token: { type: string, description: "Compact HS256 JWT" } + expires_at: { type: string, format: date-time } + required: [token, expires_at] + + OnboardingStage: + type: string + enum: + - signed_up + - vcs_connected + - repo_selected + - first_pr_analyzed + - agent_installed + - first_apply_fix + - first_receipt_issued + + OnboardingStateResponse: + type: object + properties: + current: { $ref: "#/components/schemas/OnboardingStage" } + reached_at: + type: object + additionalProperties: { type: string, format: date-time } + progress_percent: { type: integer, minimum: 0, maximum: 100 } + activated: { type: boolean } + activation_window: { type: string } + time_to_first_receipt: + type: object + nullable: true + properties: + seconds: { type: integer, format: int64 } + label: { type: string } + next_stage: { $ref: "#/components/schemas/OnboardingStage" } + slos: + type: object + properties: + sandbox_latency: { type: string } + install_to_first_pr: { type: string } + install_to_first_reco: { type: string } + install_to_first_receipt: { type: string } + required: [current, reached_at, progress_percent, activated, slos] + + OnboardingTransitionRequest: + type: object + properties: + to: { $ref: "#/components/schemas/OnboardingStage" } + required: [to] + additionalProperties: false From feba8e0138fa41aa6de426317018331beec689ca Mon Sep 17 00:00:00 2001 From: btwshivam Date: Sun, 24 May 2026 05:48:52 +0530 Subject: [PATCH 2/6] docs(api): tighten openapi spec description block, drop AI-shape padding Signed-off-by: btwshivam --- docs/api/openapi.yaml | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 307e4ee..ad5c519 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4,26 +4,22 @@ info: version: "1.0.0" summary: Public + tenant-scoped HTTP surface of the Optiqor backend. description: | - This spec mirrors the route registrations in `cmd/api/routes.go` of - the proprietary [optiqor/optiqor](https://github.com/optiqor/optiqor) - backend. The CLI repo owns this file because the spec is part of the - public OSS surface — the CLI's TS client generator, third-party - integrations, and the dashboard all consume it. - - Stability: routes prefixed `/v1/` are stable; breaking changes ship - under `/v2/` with a documented deprecation window. Routes without a - version prefix (`/healthz`, `/r/`, `/v/`, `/webhooks/*`, - `/oauth/*`) are operationally-stable but follow a separate + Mirrors the route registrations in `cmd/api/routes.go` of the + proprietary backend. Lives in the OSS CLI repo because the spec is + part of the public surface (TS client generation, third-party + integrations, dashboard). + + Stability: `/v1/*` routes are stable; breaking changes ship under + `/v2/*` with a deprecation window. Unversioned routes (`/healthz`, + `/r/`, `/v/`, `/webhooks/*`, `/oauth/*`) follow a separate deprecation contract per CLAUDE.md. - Authentication: Phase 2 reads tenant from `X-Optiqor-Tenant` header - for tenant-scoped routes. Phase 5 swaps to a JWT extractor (the - `bearerAuth` / `sessionCookie` schemes below) without changing the + Auth: Phase 2 reads tenant from `X-Optiqor-Tenant`; Phase 5 swaps + in the bearerAuth / sessionCookie schemes below without changing route shapes. - CI gate: `scripts/check-openapi-parity.sh` in the backend repo - asserts every path defined here is also registered in the Go - handlers, and vice versa. + CI gate `scripts/check-openapi-parity.sh` in the backend repo + enforces spec ↔ handler parity in both directions. contact: name: Optiqor Engineering url: https://optiqor.dev From 9f817cdb049d1ddd45028cd95c90b114ec911ed6 Mon Sep 17 00:00:00 2001 From: btwshivam Date: Sun, 24 May 2026 06:50:38 +0530 Subject: [PATCH 3/6] docs(comments): strip AI-shape across cli (phase C) per CLAUDE.md, keep WHY-context Signed-off-by: btwshivam --- cmd/optiqor/color.go | 18 +-- cmd/optiqor/golden_test.go | 26 +--- cmd/optiqor/main.go | 98 ++++++-------- cmd/optiqor/main_test.go | 19 +-- internal/analyze/analyze.go | 21 +-- internal/analyze/diff.go | 53 +++----- internal/analyze/diff_render.go | 5 +- internal/analyze/filter.go | 13 +- internal/analyze/grade.go | 66 +++------ internal/analyze/score.go | 28 ++-- internal/analyze/score_render.go | 26 +--- internal/config/config.go | 46 +++---- internal/render/doc.go | 5 +- internal/render/style/style.go | 112 +++++---------- internal/render/style/style_test.go | 2 +- internal/render/text.go | 172 +++++++----------------- internal/render/text_test.go | 2 +- internal/roast/roast.go | 80 +++++------ internal/share/doc.go | 5 +- internal/share/share.go | 82 ++++------- internal/share/share_test.go | 4 +- pkg/htmlrender/htmlrender.go | 105 ++++++--------- pkg/htmlrender/htmlrender_test.go | 15 +-- pkg/htmlrender/template.go | 8 +- pkg/parser/helm.go | 68 ++++------ pkg/parser/quantity.go | 11 +- pkg/rules/allow_privilege_escalation.go | 3 +- pkg/rules/categories.go | 13 +- pkg/rules/cpu_overprovisioned.go | 7 +- pkg/rules/memory_overprovisioned.go | 7 +- pkg/rules/missing_memory_limit.go | 1 - pkg/rules/new_detectors_test.go | 5 +- pkg/rules/read_only_root_fs.go | 1 - pkg/rules/rules_test.go | 2 - pkg/rules/run_as_root.go | 6 +- pkg/rules/sa_token_automount.go | 1 - pkg/rules/types.go | 90 +++++-------- 37 files changed, 416 insertions(+), 810 deletions(-) diff --git a/cmd/optiqor/color.go b/cmd/optiqor/color.go index 4072e19..e3f70ef 100644 --- a/cmd/optiqor/color.go +++ b/cmd/optiqor/color.go @@ -6,21 +6,15 @@ import ( "github.com/optiqor/optiqor-cli/internal/config" ) -// colorPolicyKey is the context key under which the resolved -// colour-policy boolean is stashed by the root command. Using a -// distinct unexported type prevents collisions with anything else -// the cobra command tree might tuck into the context. +// Unexported context-key types so cobra-tree values don't collide. type colorPolicyKey struct{} -// withColorPolicy returns a context that carries the resolved -// "should we emit ANSI?" decision. func withColorPolicy(ctx context.Context, useColor bool) context.Context { return context.WithValue(ctx, colorPolicyKey{}, useColor) } -// colorPolicyFrom recovers the colour-policy decision; defaults to -// false (plain) so subcommands that bypass the persistent pre-run -// fall back to safe-by-default plain output. +// colorPolicyFrom defaults to false (plain) so subcommands bypassing +// the persistent pre-run stay safe-by-default. func colorPolicyFrom(ctx context.Context) bool { if ctx == nil { return false @@ -32,16 +26,14 @@ func colorPolicyFrom(ctx context.Context) bool { return v } -// configKey is the context key for the loaded .optiqor.yaml. type configKey struct{} -// withConfig stashes the loaded Config in ctx. func withConfig(ctx context.Context, c config.Config) context.Context { return context.WithValue(ctx, configKey{}, c) } -// configFrom recovers the Config; returns the zero Config when none -// is set so callers don't need to nil-check. +// configFrom returns the zero Config when none is set so callers +// skip the nil-check. func configFrom(ctx context.Context) config.Config { if ctx == nil { return config.Config{} diff --git a/cmd/optiqor/golden_test.go b/cmd/optiqor/golden_test.go index 5eaa658..5fa3305 100644 --- a/cmd/optiqor/golden_test.go +++ b/cmd/optiqor/golden_test.go @@ -9,14 +9,10 @@ import ( "testing" ) -// -update regenerates the golden files from current output. Use -// sparingly: only after a deliberate UX change. The test diffs against -// the recorded output otherwise. +// -update regenerates the golden files. Use only after a deliberate +// UX change. var update = flag.Bool("update", false, "update golden files") -// goldenDir holds the recorded outputs for stability tests. Adding a -// new test case is one fixture and one test entry — no hand-curating -// of expected strings. const goldenDir = "../../testdata/golden" type goldenCase struct { @@ -40,9 +36,8 @@ func TestGolden(t *testing.T) { t.Fatal(err) } - // Pin terminal width so the boxed-card layout is deterministic - // across machines. Without this the developer's $COLUMNS leaks - // into golden output and CI (no TTY → fallback 80) diverges. + // Pin width or the dev's $COLUMNS leaks into goldens and diverges + // from CI (no TTY → fallback 80). t.Setenv("COLUMNS", "80") for _, tc := range goldenCases { @@ -52,8 +47,7 @@ func TestGolden(t *testing.T) { cmd.SetOut(&buf) cmd.SetErr(&buf) cmd.SetArgs(tc.args) - // Tests must run in the cmd/optiqor directory so the - // "../../testdata/..." paths resolve. + // Must run in cmd/optiqor so ../../testdata/... resolves. _ = cmd.Execute() got := normalize(buf.String()) @@ -75,14 +69,8 @@ func TestGolden(t *testing.T) { } } -// normalize replaces filesystem-dependent paths with stable placeholders -// so the golden file is portable across machines and CI runners. -// -// The analyze command resolves chart paths via filepath.Abs, which -// embeds the runner's home/workspace prefix in the report's "Source" -// field. We strip both the test's cwd and the repo root (one level -// up) so the golden output stays bit-identical between a developer's -// laptop and the GitHub Actions ubuntu-latest / macos-latest runners. +// normalize strips the test's cwd and the repo root from filepath.Abs +// output so goldens stay bit-identical across laptops and CI runners. func normalize(s string) string { if cwd, err := os.Getwd(); err == nil { s = strings.ReplaceAll(s, cwd, "") diff --git a/cmd/optiqor/main.go b/cmd/optiqor/main.go index 2503180..477d675 100644 --- a/cmd/optiqor/main.go +++ b/cmd/optiqor/main.go @@ -1,9 +1,6 @@ -// Command optiqor is the entrypoint for the open-source Optiqor CLI. -// -// The CLI is a deterministic rule engine that analyzes Helm charts for cost -// inefficiencies. It also flags obvious security misconfigurations as a bonus -// side-effect of parsing. It does NOT call any LLM and does NOT phone home by -// default — see ../../CLAUDE.md for the hard rules. +// Command optiqor is the open-source CLI entrypoint. Deterministic +// rule engine — no LLM, no telemetry by default. See ../../CLAUDE.md +// for the hard rules. package main import ( @@ -39,6 +36,8 @@ var errFindings = errors.New("optiqor: findings exceed threshold") var version = "dev" +// accuracyDisclosure is the mandatory line every command's help and +// output must contain (hard rule per CLAUDE.md). const accuracyDisclosure = "Sandbox accuracy: ±40%. Install the Optiqor agent for exact numbers (optiqor.dev/get)." func main() { @@ -47,7 +46,7 @@ func main() { case err == nil: os.Exit(exitSuccess) case errors.Is(err, errFindings): - // Already-rendered finding output; suppress an additional error line. + // Findings already rendered; suppress an extra error line. os.Exit(exitFindings) default: printError(os.Stderr, err) @@ -92,14 +91,12 @@ namespaces, etc.). Cost is the headline; security is a side-effect. root.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output (also: NO_COLOR env)") root.PersistentFlags().StringVar(&configPath, "config", "", "path to .optiqor.yaml (default: ./.optiqor.yaml or $OPTIQOR_CONFIG)") - // Stash the no-color decision and the loaded config in context so - // subcommands can read both. root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { cfg, err := config.Load(configPath) if err != nil { return err } - // Config-level no_color implies --no-color unless flag explicitly disabled. + // Config-level no_color implies --no-color. effectiveNoColor := noColor || cfg.NoColor ctx := cmd.Context() ctx = withColorPolicy(ctx, resolveColor(cmd, effectiveNoColor)) @@ -123,7 +120,6 @@ namespaces, etc.). Cost is the headline; security is a side-effect. return root } -// versionTemplate prints a polished one-liner including the brand. func versionTemplate() string { return fmt.Sprintf("optiqor %s — %s\n", version, "Helm chart cost analysis (security bonus)") } @@ -168,7 +164,7 @@ side-effect of parsing — they are not the headline feature. return err } - // Merge config-file defaults with flags. Flags win when supplied. + // Flags win over config when supplied. cfg := configFrom(cmd.Context()) effSev := minSev if effSev == "" { @@ -194,8 +190,7 @@ side-effect of parsing — they are not the headline feature. if err := writeHTMLReport(htmlPath, rep); err != nil { return err } - // --html is a side-channel: text/JSON still prints to - // stdout so users get the terminal report AND a file. + // --html is side-channel; stdout still gets text/JSON. } if err := emitReport(cmd, rep, jsonOut, outputPath, roast); err != nil { return err @@ -219,10 +214,9 @@ side-effect of parsing — they are not the headline feature. return cmd } -// writeHTMLReport renders rep through pkg/htmlrender into the file at -// path. The same package is consumed by the backend's share-page -// handler so the local file and the optiqor.dev/r/ page render -// byte-identically. +// writeHTMLReport: pkg/htmlrender is also consumed by the backend's +// share-page handler so the local file and optiqor.dev/r/ +// render byte-identically. func writeHTMLReport(path string, rep render.Report) error { f, err := os.Create(path) //nolint:gosec // user-supplied output path if err != nil { @@ -237,12 +231,9 @@ func writeHTMLReport(path string, rep render.Report) error { }) } -// emitReport renders the report in JSON or styled text. When -// outputPath is non-empty the rendered bytes go to that file instead -// of stdout (CI use case: `optiqor analyze --json --output result.json`). -// The roast flag swaps the brand tagline and footer quip for the -// `--roast` variants; finding titles are roasted upstream by the -// analyze command before the report reaches here. +// emitReport writes JSON or styled text. outputPath redirects to a +// file (CI use case); roast swaps tagline/footer — finding titles +// are roasted upstream in the analyze command. func emitReport(cmd *cobra.Command, rep render.Report, jsonOut bool, outputPath string, roast bool) error { w, closeFn, err := openOutput(cmd, outputPath) if err != nil { @@ -259,9 +250,9 @@ func emitReport(cmd *cobra.Command, rep render.Report, jsonOut bool, outputPath return render.Text(w, rep, opts) } -// openOutput resolves the destination: stdout when path is empty; -// otherwise creates / truncates the file. The returned closer is a -// no-op for stdout so callers can defer it unconditionally. +// openOutput returns stdout when path is empty, otherwise opens the +// file. The closer is a no-op for stdout so callers defer it +// unconditionally. func openOutput(cmd *cobra.Command, path string) (io.Writer, func(), error) { if path == "" { return cmd.OutOrStdout(), func() {}, nil @@ -273,22 +264,15 @@ func openOutput(cmd *cobra.Command, path string) (io.Writer, func(), error) { return f, func() { _ = f.Close() }, nil } -// emitShareURL handles the `--share` flag end-to-end. -// -// It computes the local content-addressable hash, attempts to upload -// the sanitised payload to the sandbox endpoint, and prints the -// resulting `optiqor.dev/r/` URL to stderr (so JSON/text output on -// stdout stays clean). -// -// The function never blocks the caller's success path — if the upload -// fails (offline, sandbox down, 5xx), we still print the URL so the -// user has a stable identifier they can re-share later. The endpoint -// is overridable via OPTIQOR_SHARE_URL for self-hosted deploys. +// emitShareURL handles --share end-to-end. Prints to stderr so +// stdout (JSON/text) stays clean. Never blocks the success path — on +// upload failure we still print the URL so the user has a stable +// identifier to re-share later. OPTIQOR_SHARE_URL overrides the +// endpoint for self-hosted deploys. func emitShareURL(cmd *cobra.Command, rep any) { endpoint := os.Getenv("OPTIQOR_SHARE_URL") res := share.Upload(rep, endpoint) if res.Hash == "" { - // Hash failed entirely — nothing to print. return } suffix := "" @@ -301,7 +285,7 @@ func emitShareURL(cmd *cobra.Command, rep any) { } // checkFailOn returns errFindings when any finding meets or exceeds -// the threshold severity. Empty threshold is a no-op. +// threshold. Empty threshold is a no-op. func checkFailOn(rep render.Report, threshold string) error { if threshold == "" { return nil @@ -346,9 +330,8 @@ func toUpper(s string) string { return string(out) } -// demoChart is the bundled demo values file. //go:embed lets us ship -// the fixture inside the binary so `npx @optiqor/cli demo` works with -// no input. +// demoChart ships inside the binary so `npx @optiqor/cli demo` works +// with no input. // //go:embed demo/values.yaml var demoChart []byte @@ -385,8 +368,8 @@ func newDemoCmd() *cobra.Command { return cmd } -// renderOpts builds a render.Options for the active command, picking up -// the colour-policy decision the persistent pre-run stashed in context. +// renderOpts picks up the colour-policy decision stashed by the +// persistent pre-run. func renderOpts(cmd *cobra.Command) render.Options { return render.Options{ Color: colorPolicyFrom(cmd.Context()), @@ -394,10 +377,8 @@ func renderOpts(cmd *cobra.Command) render.Options { } } -// renderOptsRoast extends renderOpts with the roast-mode strings so -// the renderer prints the playful tagline + footer quip without -// importing the roast package itself. Findings are roasted upstream -// in the analyze command (see internal/roast). +// renderOptsRoast supplies the playful strings so render stays +// unaware of internal/roast. func renderOptsRoast(cmd *cobra.Command) render.Options { o := renderOpts(cmd) o.Roast = true @@ -406,14 +387,13 @@ func renderOptsRoast(cmd *cobra.Command) render.Options { return o } -// resolveColor decides whether to emit ANSI for a given command. -// Order of precedence (highest to lowest): +// resolveColor decides whether to emit ANSI. Precedence: // // 1. --no-color flag -// 2. NO_COLOR env var (any non-empty value, per https://no-color.org) -// 3. CLICOLOR_FORCE=1 forces color even when not a TTY -// 4. stdout is a TTY → color on -// 5. otherwise → color off +// 2. NO_COLOR (per https://no-color.org) +// 3. CLICOLOR_FORCE=1 forces colour even when not a TTY +// 4. stdout is a TTY → on +// 5. otherwise → off func resolveColor(cmd *cobra.Command, noColor bool) bool { if noColor { return false @@ -431,8 +411,7 @@ func resolveColor(cmd *cobra.Command, noColor bool) bool { return style.IsTTY(out) } -// terminalWidth returns the current terminal width (cols). Falls back -// to 80 when not a TTY or when reading $COLUMNS fails. +// terminalWidth returns $COLUMNS or 80. func terminalWidth() int { if v := os.Getenv("COLUMNS"); v != "" { if n, err := atoi(v); err == nil && n > 20 { @@ -453,7 +432,7 @@ func atoi(s string) (int, error) { return n, nil } -// printError renders an error in red on a TTY; plain on a pipe. +// printError renders in red on a TTY; plain on a pipe. func printError(w io.Writer, err error) { if err == nil { return @@ -466,8 +445,7 @@ func printError(w io.Writer, err error) { _, _ = fmt.Fprintln(w, t.SevHigh.Render(" ERROR ")+" "+err.Error()) } -// bytesReader is a tiny adapter so analyze.Run can read from a byte slice -// without pulling in bytes.NewReader at the import-graph root of main. +// bytesReader avoids pulling bytes.NewReader into the main import graph. func bytesReader(b []byte) *bytesReaderImpl { return &bytesReaderImpl{b: b} } type bytesReaderImpl struct { diff --git a/cmd/optiqor/main_test.go b/cmd/optiqor/main_test.go index 772db2f..635ed50 100644 --- a/cmd/optiqor/main_test.go +++ b/cmd/optiqor/main_test.go @@ -6,8 +6,6 @@ import ( "testing" ) -// TestRoot_Help just exercises the top-level cobra wiring; ensures we -// can build and serialise the help text without panicking. func TestRoot_Help(t *testing.T) { cmd := newRootCmd() var buf bytes.Buffer @@ -30,7 +28,6 @@ func TestRoot_Help(t *testing.T) { } } -// TestVersion_Output checks the polished version line. func TestVersion_Output(t *testing.T) { cmd := newRootCmd() var buf bytes.Buffer @@ -47,7 +44,8 @@ func TestVersion_Output(t *testing.T) { } // TestDemo_RunsAndIncludesDisclosure exercises the full demo path -// (embedded fixture → parser → rules → render). +// (embedded fixture → parser → rules → render) and checks the +// accuracy disclosure shows up. func TestDemo_RunsAndIncludesDisclosure(t *testing.T) { cmd := newRootCmd() var buf bytes.Buffer @@ -75,10 +73,8 @@ func TestDemo_RunsAndIncludesDisclosure(t *testing.T) { } } -// TestAnalyze_FixtureFile exercises the analyze command against the -// versioned testdata fixture. Asserts the well-known severities and -// detectors fire; lets the count grow naturally as the detector -// library expands. +// TestAnalyze_FixtureFile asserts well-known severities and +// detectors fire; lets count grow naturally as the library expands. func TestAnalyze_FixtureFile(t *testing.T) { cmd := newRootCmd() var buf bytes.Buffer @@ -89,9 +85,8 @@ func TestAnalyze_FixtureFile(t *testing.T) { t.Fatalf("execute analyze: %v\n%s", err, buf.String()) } out := buf.String() - // Severity badges + workload names appear on the per-finding line; - // the renderer prints them as bare identifiers rather than as - // "workload: ". + // Renderer emits severity badges and bare workload identifiers + // (no "workload: " prefix). for _, want := range []string{ "15 workloads", "HIGH", @@ -111,8 +106,6 @@ func TestAnalyze_FixtureFile(t *testing.T) { } } -// TestAnalyze_JSONShape exercises --json on a fixture and validates -// the schema is intact. func TestAnalyze_JSONShape(t *testing.T) { cmd := newRootCmd() var buf bytes.Buffer diff --git a/internal/analyze/analyze.go b/internal/analyze/analyze.go index e40266c..6126c4b 100644 --- a/internal/analyze/analyze.go +++ b/internal/analyze/analyze.go @@ -1,7 +1,4 @@ -// Package analyze orchestrates one Optiqor CLI run: parser → rules → render. -// -// Callers (cmd/optiqor/main.go, tests, future SDK consumers) hand in a -// values reader and an Options struct, get back a render.Report. +// Package analyze orchestrates one CLI run: parser → rules → render. package analyze import ( @@ -17,16 +14,11 @@ import ( // Options controls a single analysis run. type Options struct { - // Source identifies what was analysed (a file path or "stdin"). - // Surfaced verbatim in the report header. - Source string - - // Detectors is the set to apply. nil → rules.All(). - Detectors []rules.Detector + Source string // file path or "stdin", surfaced in the report header + Detectors []rules.Detector // nil → rules.All() } -// Run reads a Helm values document from r and returns the populated -// report. Errors come from YAML parse failures or IO. +// Run reads a Helm values document from r and returns the report. func Run(r io.Reader, opts Options) (render.Report, error) { wls, err := parser.ParseValues(r) if err != nil { @@ -43,9 +35,8 @@ func Run(r io.Reader, opts Options) (render.Report, error) { }, nil } -// RunPath is the convenience entrypoint used by the `analyze` -// subcommand. It accepts either a file or a directory; for a directory -// it reads `values.yaml` at the root. +// RunPath accepts a values file or a directory containing +// values.yaml at the root. func RunPath(path string) (render.Report, error) { info, err := os.Stat(path) if err != nil { diff --git a/internal/analyze/diff.go b/internal/analyze/diff.go index 9bf647b..a265ee1 100644 --- a/internal/analyze/diff.go +++ b/internal/analyze/diff.go @@ -11,38 +11,34 @@ import ( ) // DiffEntry is the per-workload change between two values files. -// -// Sets are computed by name; a workload present only in B is "added" -// (NewWorkload=true), only in A is "removed" (RemovedWorkload=true). +// Workloads are matched by name: B-only → NewWorkload, +// A-only → RemovedWorkload. type DiffEntry struct { - Name string - NewWorkload bool - RemovedWorkload bool - CPURequestDelta int64 // millicores; B - A - CPULimitDelta int64 - MemoryRequestDelta int64 // bytes; B - A - MemoryLimitDelta int64 - // MonthlyUSDCentsDelta is a sandbox-grade dollar estimate based on - // the resource deltas. ±40% disclosure caveat applies. - MonthlyUSDCentsDelta int64 + Name string + NewWorkload bool + RemovedWorkload bool + CPURequestDelta int64 // millicores; B - A + CPULimitDelta int64 + MemoryRequestDelta int64 // bytes; B - A + MemoryLimitDelta int64 + MonthlyUSDCentsDelta int64 // sandbox estimate; ±40% disclosure applies } -// DiffReport is the renderer-facing view of a diff run. type DiffReport struct { A string `json:"a"` B string `json:"b"` Entries []DiffEntry `json:"entries"` } -// Pricing constants must match those in internal/rules so the diff's -// monthly delta is consistent with the analyze report's savings. +// Pricing constants must stay in lockstep with internal/rules so the +// diff's monthly delta matches the analyze report's savings. const ( - cpuPriceCentsPerCoreHour = 4 // CPU $/vCPU-hour, AWS m5 baseline - cpuMonthlyHours = 730 // hours per month, AWS billing convention + cpuPriceCentsPerCoreHour = 4 // $/vCPU-hour, AWS m5 baseline + cpuMonthlyHours = 730 // hours/month, AWS billing convention memPriceCentsPerGiBMonth = 350 // $3.50 per GiB-month ) -// Diff returns a DiffReport between two streams of Helm values. +// Diff returns the DiffReport between two streams of Helm values. func Diff(a, b io.Reader, aLabel, bLabel string) (DiffReport, error) { wlA, err := parser.ParseValues(a) if err != nil { @@ -79,10 +75,9 @@ func Diff(a, b io.Reader, aLabel, bLabel string) (DiffReport, error) { entry.MemoryRequestDelta = qDelta(get(p.a, reqMem), get(p.b, reqMem)) entry.MemoryLimitDelta = qDelta(get(p.a, limMem), get(p.b, limMem)) entry.MonthlyUSDCentsDelta = monthlyDelta(entry) - // Suppress no-op entries: same workload on both sides with zero - // deltas. Keep New/Removed entries even when the resource math - // happens to net to zero — an added workload with no resources - // is still useful to surface. + // Suppress no-op entries (same on both sides, all zero deltas). + // Keep New/Removed even when math nets to zero — an added + // workload with no resources is still useful to surface. if !entry.NewWorkload && !entry.RemovedWorkload && entry.CPURequestDelta == 0 && entry.CPULimitDelta == 0 && entry.MemoryRequestDelta == 0 && entry.MemoryLimitDelta == 0 { @@ -95,7 +90,6 @@ func Diff(a, b io.Reader, aLabel, bLabel string) (DiffReport, error) { return DiffReport{A: aLabel, B: bLabel, Entries: entries}, nil } -// DiffPaths is the convenience wrapper used by `optiqor diff `. func DiffPaths(a, b string) (DiffReport, error) { fa, err := openValues(a) if err != nil { @@ -110,9 +104,8 @@ func DiffPaths(a, b string) (DiffReport, error) { return Diff(fa, fb, a, b) } -// MonthlyUSDCentsDelta totals the per-entry deltas. Negative means -// the change is a saving (B costs less than A); positive means a -// regression. +// MonthlyUSDCentsDelta totals the per-entry deltas. Negative is a +// saving (B < A); positive is a regression. func (r DiffReport) MonthlyUSDCentsDelta() int64 { var sum int64 for _, e := range r.Entries { @@ -121,7 +114,6 @@ func (r DiffReport) MonthlyUSDCentsDelta() int64 { return sum } -// pair is an internal helper; one workload-name → A side and B side. type pair struct { a, b *parser.Workload } @@ -163,9 +155,8 @@ func qDelta(a, b parser.Quantity) int64 { return bv - av } -// monthlyDelta converts CPU and memory deltas into an estimated -// monthly USD-cents difference. Sandbox-grade per the ±40% -// disclosure; replaced by measured numbers when the agent ships. +// monthlyDelta is sandbox-grade per the ±40% disclosure; the agent +// replaces it with measured numbers. func monthlyDelta(e DiffEntry) int64 { cpuMillicoreDelta := e.CPURequestDelta // request drives reserved capacity memBytesDelta := e.MemoryRequestDelta diff --git a/internal/analyze/diff_render.go b/internal/analyze/diff_render.go index cdf1e55..bb60b02 100644 --- a/internal/analyze/diff_render.go +++ b/internal/analyze/diff_render.go @@ -11,8 +11,8 @@ import ( "github.com/optiqor/optiqor-cli/pkg/parser" ) -// WriteText renders the diff as styled text. Always includes the -// accuracy disclosure (CLAUDE.md hard rule). +// WriteText renders the diff as styled text. The accuracy +// disclosure is mandatory (CLAUDE.md hard rule). func (r DiffReport) WriteText(w io.Writer, opts render.Options) error { t := style.NewTheme(opts.Color) width := opts.Width @@ -59,7 +59,6 @@ func (r DiffReport) WriteText(w io.Writer, opts render.Options) error { return err } -// WriteJSON renders the diff as machine-readable JSON. func (r DiffReport) WriteJSON(w io.Writer) error { enc := json.NewEncoder(w) enc.SetIndent("", " ") diff --git a/internal/analyze/filter.go b/internal/analyze/filter.go index faa29d2..77c875d 100644 --- a/internal/analyze/filter.go +++ b/internal/analyze/filter.go @@ -8,20 +8,13 @@ import ( ) // FilterOptions narrows a Report's findings before rendering. -// -// - MinSeverity drops findings whose severity is below the threshold. -// - DetectorIDs (when non-empty) keeps only findings emitted by the -// listed detectors. -// - SecurityOnly keeps only the security-class findings that the -// `optiqor audit` command surfaces. type FilterOptions struct { MinSeverity rules.Severity - DetectorIDs []string - SecurityOnly bool + DetectorIDs []string // empty → all detectors + SecurityOnly bool // backs the `optiqor audit` command } -// Filter applies the options to a Report's findings and returns a -// new Report. The original Report is not mutated. +// Filter returns a new Report; the input is not mutated. func Filter(r render.Report, opts FilterOptions) render.Report { if opts.MinSeverity == "" && len(opts.DetectorIDs) == 0 && !opts.SecurityOnly { return r diff --git a/internal/analyze/grade.go b/internal/analyze/grade.go index d28fa71..31b7197 100644 --- a/internal/analyze/grade.go +++ b/internal/analyze/grade.go @@ -2,35 +2,19 @@ package analyze import "sort" -// Grade is a letter projection of a 0–100 efficiency score, paired -// with the percentile rank against the calibration distribution -// baked into the binary. -// -// Why two numbers? -// -// The raw 0–100 score is precise but has no context: scoring 72 only -// matters relative to peers. The letter grade gives an immediate -// "where do I sit?" signal (B-, C+, F …); the percentile rank is the -// honest comparison that drives the social loop ("better than 64% of -// charts we benchmarked"). Both derive deterministically from the -// same underlying [Score]. +// Grade pairs a letter projection of a 0–100 efficiency score with a +// percentile rank against the baked-in calibration distribution. The +// letter is the at-a-glance signal; the percentile is the honest +// comparison ("better than 64% of charts we benchmarked") that drives +// the social-share loop. type Grade struct { - // Letter is the conventional A+/A/A-/B+/B/B-/C+/C/C-/D/F mapping. - Letter string `json:"letter"` - - // PercentileRank is the percentage of calibration scores that - // this score beats (0–100, integer). 50 means median; 0 means - // worst-in-class; 100 means top of the calibrated set. - PercentileRank int `json:"percentile_rank"` - - // Sample is the size of the calibration distribution. Surfaced in - // the renderer as "better than X% of N benchmark charts" so users - // know what they're being compared against. - Sample int `json:"sample_size"` + Letter string `json:"letter"` // A+/A/A-/B+/B/B-/C+/C/C-/D/F + PercentileRank int `json:"percentile_rank"` // 0–100, % of calibration set beaten + Sample int `json:"sample_size"` // size of the calibration distribution } -// GradeFor folds a numeric score into a [Grade]. Pure function; -// deterministic given the baked calibration distribution. +// GradeFor folds a numeric score into a Grade. Pure and deterministic +// against the baked calibration set. func GradeFor(score int) Grade { return Grade{ Letter: letterFor(score), @@ -39,10 +23,9 @@ func GradeFor(score int) Grade { } } -// letterFor maps a 0–100 score to a conventional letter grade. -// Bands are informed by US academic convention (90+=A, 80+=B, etc.) -// and biased slightly toward "easier to get a B" since most real -// Helm charts cluster in the 60–80 band. +// letterFor uses US academic bands (90+=A, 80+=B, ...) biased +// slightly toward "easier to get a B" since real Helm charts cluster +// in the 60–80 band. func letterFor(score int) string { switch { case score >= 95: @@ -70,15 +53,12 @@ func letterFor(score int) string { } } -// percentileRank returns the integer percentage of the population -// that scored strictly less than score. Population must already be -// sorted ascending. +// percentileRank returns the integer percentage of population that +// scored strictly less than score. Population must be sorted asc. func percentileRank(score int, population []int) int { if len(population) == 0 { return 0 } - // sort.Search finds the lowest index i where population[i] >= - // score; the count of strictly-less is exactly i. below := sort.Search(len(population), func(i int) bool { return population[i] >= score }) @@ -89,17 +69,11 @@ func percentileRank(score int, population []int) int { return rank } -// calibrationScores is the baked-in benchmark distribution that -// powers the percentile readout in [GradeFor]. The distribution is -// modelled (not telemetered) — the CLI keeps its no-telemetry -// promise; a live percentile derived from real merged-PR outcomes -// arrives with the agent install per the strategy docs. -// -// Shape: 100 samples, beta-style curve centred ~70 with realistic -// spread. The mean and median fall in the C+/B- band, which matches -// the "most charts have one or two HIGH findings" empirical pattern -// we see in public charts. Must remain sorted ascending — the -// percentile lookup is a binary search. +// calibrationScores is the modelled (not telemetered) benchmark +// distribution that powers GradeFor's percentile readout — the CLI's +// no-telemetry hard rule means we can't ship a real distribution +// until the agent does. 100 samples, beta-style curve centred ~70. +// Must stay sorted ascending; percentileRank is a binary search. var calibrationScores = []int{ 18, 22, 24, 26, 28, 30, 31, 33, 34, 35, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, diff --git a/internal/analyze/score.go b/internal/analyze/score.go index 83f6361..599cb13 100644 --- a/internal/analyze/score.go +++ b/internal/analyze/score.go @@ -2,18 +2,11 @@ package analyze import "github.com/optiqor/optiqor-cli/pkg/rules" -// Score is the result of `optiqor score [chart]` — a 0-100 efficiency -// score derived from the severities of detector findings, plus the -// qualitative confidence band, a letter grade, and a percentile rank -// against the baked-in calibration distribution. -// -// The numerical value is "100 minus the per-finding penalty cap"; -// numerical Confidence Scores arrive in Year 2 once we have enough -// merged-PR outcomes to calibrate. For now Confidence is qualitative. -// -// Grade turns the abstract score into a screenshot-friendly social -// signal ("B+ · better than 64% of charts"); it is fully derived -// from Value and the static distribution in [GradeFor]. +// Score is the result of `optiqor score [chart]`: 0–100 efficiency +// computed as "100 minus per-finding penalty (capped)", plus a +// qualitative confidence band and a Grade (letter + percentile). +// Numerical confidence arrives in Year 2 once we have merged-PR +// outcomes to calibrate against. type Score struct { Workloads int `json:"workloads_analyzed"` Source string `json:"source"` @@ -24,10 +17,8 @@ type Score struct { Findings []rules.Finding `json:"findings"` } -// Penalty weights per severity. High-severity findings drag the score -// down faster than low-severity ones; the cap (100) is the maximum -// penalty any single workload can incur, so a chart with one HIGH -// finding never scores below ~50 from that finding alone. +// Penalty weights per severity; capped so one HIGH finding alone +// can't push a chart below ~50. const ( penaltyHigh = 25 penaltyMed = 10 @@ -76,9 +67,8 @@ func penaltyFor(s rules.Severity) int { } } -// bandFor maps a numerical score into a qualitative confidence band. -// Stable mapping; Year-2 numerical scores derive their confidence -// differently (calibrated against measured outcomes). +// bandFor: stable Year-1 mapping; Year-2 confidence will be +// calibrated against measured outcomes instead. func bandFor(score int) rules.Confidence { switch { case score >= 85: diff --git a/internal/analyze/score_render.go b/internal/analyze/score_render.go index 5aabef0..16c891b 100644 --- a/internal/analyze/score_render.go +++ b/internal/analyze/score_render.go @@ -15,19 +15,8 @@ import ( const scoreDefaultWidth = 78 -// WriteText renders the score panel as styled text. The Grade row is -// the headline — letter + percentile against the calibration set — -// because that is what platform engineers screenshot. The numeric -// score still appears so CI gates and analytics can pin against it. -// -// Layout: -// -// ── header ── -// Source / Workloads -// Grade B+ better than 64% of 100 benchmark charts -// Score 78 / 100 ●●○ medium confidence -// Penalty breakdown … -// ── footer (accuracy disclosure + calibration note) ── +// WriteText renders the score panel as styled text. Grade leads +// (screenshot-friendly); numeric score follows for CI gates. func (s Score) WriteText(w io.Writer, opts render.Options) error { t := style.NewTheme(opts.Color) width := opts.Width @@ -94,10 +83,8 @@ func writeScoreNumericRow(b *strings.Builder, t style.Theme, s Score) { ) } -// gradeBadge picks a badge style for the letter grade so it visually -// matches the severity palette: red for F/D, amber for C, green for -// B/A. Reuses the existing severity badges so the brand palette stays -// consistent. +// gradeBadge reuses the severity palette: red for F/D, amber for C, +// green for B/A — keeps the brand palette consistent. func gradeBadge(t style.Theme, value int) lipgloss.Style { switch { case value < 60: @@ -139,9 +126,8 @@ func writeScoreFooter(b *strings.Builder, t style.Theme, width int) { ) } -// WriteJSON renders the score as machine-readable JSON. Includes the -// full Grade so consumers can drive their own dashboards without -// reimplementing the percentile lookup. +// WriteJSON includes the full Grade so consumers can drive their own +// dashboards without reimplementing the percentile lookup. func (s Score) WriteJSON(w io.Writer) error { enc := json.NewEncoder(w) enc.SetIndent("", " ") diff --git a/internal/config/config.go b/internal/config/config.go index caf698f..1f99b70 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,16 +1,12 @@ -// Package config loads `.optiqor.yaml` for the CLI. -// -// The config file lets users persist common flag combinations (e.g. -// "always exclude this detector", "always set --severity=med") so a -// `optiqor analyze` invocation in a known repo behaves consistently -// without flag-soup. Flags still override config when supplied. +// Package config loads `.optiqor.yaml` for the CLI. Flags override +// config when supplied. // // Lookup order (first match wins): // // 1. --config // 2. OPTIQOR_CONFIG env var -// 3. ./.optiqor.yaml in the current working directory -// 4. zero value (no config) +// 3. ./.optiqor.yaml in cwd +// 4. zero value package config import ( @@ -23,28 +19,20 @@ import ( "gopkg.in/yaml.v3" ) -// Config is the on-disk schema. Add fields in additive-only fashion -// — old configs must keep loading after schema growth. +// Config is the on-disk schema. Add fields additive-only — old +// configs must keep loading after schema growth. type Config struct { - // MinSeverity is the default --severity flag. - MinSeverity string `yaml:"min_severity,omitempty"` - // Detectors is the default --detector allow-list. - Detectors []string `yaml:"detectors,omitempty"` - // FailOn is the default --fail-on threshold. - FailOn string `yaml:"fail_on,omitempty"` - // NoColor disables ANSI output everywhere. - NoColor bool `yaml:"no_color,omitempty"` + MinSeverity string `yaml:"min_severity,omitempty"` + Detectors []string `yaml:"detectors,omitempty"` + FailOn string `yaml:"fail_on,omitempty"` + NoColor bool `yaml:"no_color,omitempty"` } -// ConfigName is the conventional filename. Hidden so it doesn't -// clutter `ls`. const ConfigName = ".optiqor.yaml" -// Load resolves and reads the config. Returns the zero Config when no -// file is present (which is the safe default — users opt in by -// creating one). Returns an error only when a file is named -// explicitly via --config or OPTIQOR_CONFIG and that file fails to -// load. +// Load returns the zero Config when no file is present (users opt in +// by creating one). Errors only when an explicit --config / +// OPTIQOR_CONFIG path fails to load. func Load(explicit string) (Config, error) { if explicit != "" { return readFile(explicit) @@ -72,7 +60,6 @@ func readFile(path string) (Config, error) { return Decode(f) } -// Decode reads a YAML config from r. func Decode(r io.Reader) (Config, error) { raw, err := io.ReadAll(r) if err != nil { @@ -91,7 +78,6 @@ func Decode(r io.Reader) (Config, error) { return c, nil } -// Validate checks the config for known-good values. func (c Config) Validate() error { for _, key := range []struct { name, value string @@ -104,7 +90,6 @@ func (c Config) Validate() error { } switch toLower(key.value) { case "low", "med", "medium", "high": - // ok default: return fmt.Errorf("config: %s must be low|med|high (got %q)", key.name, key.value) } @@ -112,9 +97,8 @@ func (c Config) Validate() error { return nil } -// ErrNotFound is returned by Load when an explicit config path was -// supplied but did not resolve to a file. Default Load (no explicit -// path) does not return this — it returns a zero Config. +// ErrNotFound is returned only when an explicit config path was +// supplied and the file is missing; default Load returns a zero Config. var ErrNotFound = errors.New("config: file not found") func toLower(s string) string { diff --git a/internal/render/doc.go b/internal/render/doc.go index 34db248..0aaf9fd 100644 --- a/internal/render/doc.go +++ b/internal/render/doc.go @@ -1,4 +1,3 @@ -// Package render formats analysis results as ASCII tables, JSON, or the -// humorous "roast" mode. Every renderer must include the ±40% accuracy -// disclosure — see ../../CLAUDE.md. +// Package render formats analysis results. Every renderer must +// include the ±40% accuracy disclosure (CLAUDE.md hard rule). package render diff --git a/internal/render/style/style.go b/internal/render/style/style.go index d2dce99..680976d 100644 --- a/internal/render/style/style.go +++ b/internal/render/style/style.go @@ -1,10 +1,6 @@ -// Package style centralises the Optiqor CLI's visual language: colors, -// badges, dividers, and section formatters. Renderers compose these -// styles; they never reach for raw ANSI codes. -// -// All styles auto-degrade based on terminal capability: when output is -// not a TTY, when NO_COLOR is set, or when the user passes --no-color, -// every Style here renders as its plain-text equivalent. +// Package style centralises the CLI's visual language. Styles +// auto-degrade to plain text when colour is off (non-TTY, NO_COLOR, +// or --no-color). package style import ( @@ -16,52 +12,43 @@ import ( "github.com/muesli/termenv" ) -// BrandGlyph is the ASCII stand-in for the optiqor logomark — a -// circular Q rendered at terminal scale. Using a single glyph keeps the -// header readable on every emulator (we don't depend on Nerd Fonts or -// Unicode powerline glyphs, which still render as boxes on stock CI). +// BrandGlyph is a single-rune stand-in for the optiqor logomark. +// Avoids Nerd Font / powerline glyphs that render as boxes on stock CI. const BrandGlyph = "◐" -// Theme bundles the entire palette + reusable styles. Construct one -// per render invocation via NewTheme so colour-vs-plain is a single -// decision, not threaded through every helper. +// Theme bundles the palette + reusable styles. Construct one per +// render via NewTheme so colour-vs-plain is decided once. type Theme struct { UseColor bool - // Brand Brand lipgloss.Style BrandMark lipgloss.Style Tagline lipgloss.Style HeaderBorder lipgloss.Style - // Boxed-finding card + signal-bar palette CardBorder lipgloss.Style BarFilled lipgloss.Style BarEmpty lipgloss.Style - BarOverflow lipgloss.Style // for ratios > 1 (limit < request, etc.) + BarOverflow lipgloss.Style // ratios > 1 (limit < request, etc.) - // Sections - SectionPrimary lipgloss.Style // headline section (Cost optimizations) - SectionBonus lipgloss.Style // bonus section (Security) - SectionSubtle lipgloss.Style // light explanatory line under a section + SectionPrimary lipgloss.Style + SectionBonus lipgloss.Style + SectionSubtle lipgloss.Style - // Severity badges SevHigh lipgloss.Style SevMed lipgloss.Style SevLow lipgloss.Style SevInfo lipgloss.Style - // Confidence ConfHigh lipgloss.Style ConfMed lipgloss.Style ConfLow lipgloss.Style - // Output elements Workload lipgloss.Style Title lipgloss.Style Detail lipgloss.Style Savings lipgloss.Style - BigSavings lipgloss.Style // hero number in the executive summary + BigSavings lipgloss.Style NoSavings lipgloss.Style Muted lipgloss.Style Divider lipgloss.Style @@ -70,27 +57,18 @@ type Theme struct { OK lipgloss.Style } -// NewTheme builds a theme. If useColor is false, every style falls back -// to plain text and bold/foreground attributes are no-ops. -// -// When useColor is true, the theme uses its own renderer with the color -// profile pinned to TrueColor so output is consistent regardless of -// what TTY detection says — the CLI's outer layer already gated on -// TTY/NO_COLOR/--no-color before deciding to call NewTheme(true). +// NewTheme builds a theme. When useColor is true we pin TrueColor on +// our own renderer so pipe-redirected output still emits ANSI — the +// CLI's outer layer already decided "should I color?". func NewTheme(useColor bool) Theme { if !useColor { return plainTheme() } - // Use our own renderer so tests and pipe-redirected output still - // emit ANSI when the caller explicitly asked for color. The CLI's - // outer layer is the source of truth for "should I color?". r := lipgloss.NewRenderer(io.Discard) r.SetColorProfile(termenv.TrueColor) - // Adaptive colors: pick a value that reads well on both dark and - // light backgrounds. Dark variants are tuned for the most common - // terminal default (dark). + // Adaptive colors: dark variants tuned for the default-dark terminal. brand := lipgloss.AdaptiveColor{Light: "#5C2EE5", Dark: "#A78BFA"} red := lipgloss.AdaptiveColor{Light: "#C92A2A", Dark: "#FF6B6B"} amber := lipgloss.AdaptiveColor{Light: "#B45309", Dark: "#F59E0B"} @@ -115,10 +93,10 @@ func NewTheme(useColor bool) Theme { Tagline: r.NewStyle().Foreground(subtle).Italic(true), HeaderBorder: r.NewStyle().Foreground(border), - CardBorder: r.NewStyle().Foreground(border), - BarFilled: r.NewStyle().Foreground(green), - BarEmpty: r.NewStyle().Foreground(border), - BarOverflow: r.NewStyle().Foreground(red), + CardBorder: r.NewStyle().Foreground(border), + BarFilled: r.NewStyle().Foreground(green), + BarEmpty: r.NewStyle().Foreground(border), + BarOverflow: r.NewStyle().Foreground(red), SectionPrimary: r.NewStyle().Foreground(brand).Bold(true), SectionBonus: r.NewStyle().Foreground(amber).Bold(true), @@ -142,9 +120,8 @@ func NewTheme(useColor bool) Theme { Muted: r.NewStyle().Foreground(subtle), Divider: r.NewStyle().Foreground(border), Disclosure: r.NewStyle().Foreground(amber), - // The hyperlink already renders as clickable in modern terminals - // (OSC 8); doubling that with `Underline(true)` makes lipgloss - // emit per-character styling that confuses some terminals. + // OSC 8 already renders as clickable; adding Underline(true) + // makes lipgloss emit per-char styling that breaks some terms. CallToLink: r.NewStyle().Foreground(brand).Bold(true), OK: r.NewStyle().Foreground(green).Bold(true), } @@ -186,9 +163,8 @@ func plainTheme() Theme { } } -// Hyperlink wraps url with an OSC 8 hyperlink escape so modern -// terminals (iTerm2, kitty, WezTerm, Ghostty, VSCode) render it as a -// clickable link. Falls back to plain text when colours are off. +// Hyperlink wraps url in an OSC 8 escape (clickable in iTerm2, +// kitty, WezTerm, Ghostty, VSCode). Plain text when colour is off. func (t Theme) Hyperlink(label, url string) string { if !t.UseColor { return fmt.Sprintf("%s (%s)", label, url) @@ -196,7 +172,6 @@ func (t Theme) Hyperlink(label, url string) string { return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, label) } -// DividerLine returns a horizontal rule the given width. func (t Theme) DividerLine(width int) string { if width <= 0 { width = 64 @@ -204,24 +179,14 @@ func (t Theme) DividerLine(width int) string { return t.Divider.Render(repeat("─", width)) } -// SignalBar renders a horizontal ratio bar of the form -// -// ████████████░░░░░░░░ (have/want = 0.6) -// -// width is the total cell count; have and want are in the same unit -// and need not be normalized — the bar shows have/want as a fraction -// of width. When have > want (over-saturated) the overflow tail is -// drawn in BarOverflow so it visually screams. -// -// Returns a fixed-rune-width string regardless of color setting; the -// caller is responsible for any leading/trailing labels. +// SignalBar renders a have/want ratio bar. Over-saturated (>1) fills +// in BarOverflow so the eye catches it; the magnitude is left to the +// caller's note. Returns a fixed-rune-width string. func (t Theme) SignalBar(have, want float64, width int) string { if width <= 0 { width = 20 } if want <= 0 { - // Degenerate: render an empty bar. Renderers should avoid - // calling this when want is zero, but we tolerate it. return t.BarEmpty.Render(repeat("░", width)) } ratio := have / want @@ -238,20 +203,11 @@ func (t Theme) SignalBar(have, want float64, width int) string { t.BarEmpty.Render(repeat("░", width-filled)) } - // Over-saturated: fill the whole bar in overflow tone so the eye - // catches it. We don't try to encode the magnitude — the Note - // (e.g. "10x burst") carries that. return t.BarOverflow.Render(repeat("█", width)) } -// SectionRule renders a labelled section divider using heavy hyphens -// so it visually outranks the regular dividers around the header and -// footer. Renders identically with or without color. -// -// ━━