diff --git a/scripts/run-assess.ts b/scripts/run-assess.ts index a0488a2..86659e9 100644 --- a/scripts/run-assess.ts +++ b/scripts/run-assess.ts @@ -43,6 +43,13 @@ const emit: JsonLineEmitter = (event) => { process.stdout.write(`${JSON.stringify(event)}\n`); }; +/** + * Emit a standardized progress event for the current audit step. + * + * @param step - Identifier or name of the progress step + * @param status - Progress state: `active`, `complete`, or `failed` + * @param message - Human-readable status message + */ function emitProgress( step: string, status: "active" | "complete" | "failed", @@ -52,7 +59,14 @@ function emitProgress( } // readConfigLine + interview answers share a single stdin reader so the -// stdin pipe doesn't get half-consumed by an async iterator and then closed. +/** + * Load interview answers from a JSON file on disk. + * + * Attempts to read and parse interview answers from the filesystem; if reading or parsing fails the function logs a warning and returns an empty array. + * + * @param path - Filesystem path to the answers JSON file + * @returns An array of parsed `InterviewAnswer` objects, or an empty array if the file could not be read or parsed + */ async function loadInterviewAnswersFromFile( path: string, @@ -67,6 +81,15 @@ async function loadInterviewAnswersFromFile( } } +/** + * Run the headless maturity-assessment flow driven by a single JSON config line on stdin. + * + * Reads an `AssessCommandInput` object from the first stdin line, configures the assessment + * (interactive interview transport or preloaded answers, optional filesystem audit store, + * and the AI scorer), executes the assessment, and emits progress, result, and error events + * as JSON-lines on stdout while logging to stderr. Exits the process with code `0` on success + * or `1` on error or malformed/missing input. + */ async function main(): Promise { const logger = createConsola({ defaults: { tag: "maturity" } }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 2758fe5..3ee990f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -128,6 +128,14 @@ async function spawnTui(deps: CliDependencies, args: string[]): Promise { }); } +/** + * Build the CLI program with subcommands that forward execution to the Go TUI. + * + * @param deps - Runtime dependencies (auth and logger) used when delegating work to the TUI and reporting errors. + * @param options - Optional CLI construction flags. + * @param options.exitOverride - If set, configures Commander to throw instead of exiting on parse errors. + * @returns The configured Commander `Command` instance ready to parse CLI arguments. + */ export function createCli( deps: CliDependencies, options: CliOptions = {}, @@ -224,6 +232,17 @@ export async function createDefaultDependencies(): Promise { } satisfies CliDependencies; } +/** + * Parse CLI arguments and dispatch execution to the Commander program or the external TUI binary. + * + * When the first positional subcommand is one of "report", "doctor", "setup", or "assess" + * and the arguments include `--help`, this function forwards the raw arguments to the Go TUI + * binary instead of letting Commander render top-level help. Otherwise it delegates to the + * Commander program returned by `createCli`. + * + * @param argv - The argument vector to parse; defaults to `process.argv` + * @param deps - Optional runtime dependencies (logger and auth); if omitted, defaults are created + */ export async function run( argv: string[] = process.argv, deps?: CliDependencies, diff --git a/src/services/maturity/adjacent-repos.ts b/src/services/maturity/adjacent-repos.ts index b771ae6..3e2cf61 100644 --- a/src/services/maturity/adjacent-repos.ts +++ b/src/services/maturity/adjacent-repos.ts @@ -13,13 +13,12 @@ const STDLIB_OWNERS = new Set([ ]); /** - * Detect adjacent repos referenced from the local repo. Mirrors the four - * detection commands in references/preflight.md (multi-repo section): + * Discover external GitHub repositories referenced by the local repository. * - * 1. External GitHub Actions referenced in workflows (`uses: owner/repo@vX`) - * 2. Terraform modules sourced from external Git - * 3. Submodules - * 4. Generic cross-repo references in docs/scripts + * Scans the repository root (scope.localPath) for references via GitHub Actions `uses`, Terraform module sources, `.gitmodules` submodules, and `github.com` links in README.md; excludes known standard-library owners and de-duplicates results case-insensitively. + * + * @param scope - Descriptor whose `localPath` is the repository root to scan; if `localPath` is falsy the function returns an empty array + * @returns An array of detected `AdjacentRepo` objects (`{ owner, name, reason }`), de-duplicated by `owner/name` (case-insensitive) */ export async function detectAdjacentRepos( scope: ScopeDescriptor, @@ -98,6 +97,14 @@ export async function detectAdjacentRepos( return [...found.values()]; } +/** + * Add an adjacent repository to the map if it is not already present. + * + * @param map - Map used for de-duplication, keyed by the lowercase `owner/name` + * @param owner - Repository owner (organization or user) + * @param name - Repository name + * @param reason - Short human-readable reason describing why the repository was detected + */ function addRepo( map: Map, owner: string, diff --git a/src/services/maturity/ai-scorer.ts b/src/services/maturity/ai-scorer.ts index 07b1d6c..d32eea6 100644 --- a/src/services/maturity/ai-scorer.ts +++ b/src/services/maturity/ai-scorer.ts @@ -28,6 +28,13 @@ export interface MaturityAIScorerOptions { const TIER3_CAPPED = new Set([2, 3, 9, 11]); +/** + * Convert a score string produced by the AI into a typed `ItemScoreValue`. + * + * @param raw - The raw score string (expected: `"0"`, `"1"`, `"0.5"`, or `"n/a"`). + * @returns `0`, `1`, `0.5`, or `"n/a"` corresponding to the input string. + * @throws Error if `raw` is not one of the expected strings. + */ function parseScore(raw: string): ItemScoreValue { if (raw === "0") return 0; if (raw === "1") return 1; @@ -37,9 +44,15 @@ function parseScore(raw: string): ItemScoreValue { } /** - * Enforce tier-3 caps: if an item is in TIER3_CAPPED and the AI awarded 1.0 - * on a git-only audit, downgrade to 0.5 and append a note. We do this - * post-hoc so the AI's reasoning is preserved but the rubric is honored. + * Apply tier-3 capping rules to item scores for git-only audits. + * + * For `tier === "git-only"`, any item whose `itemId` is in `TIER3_CAPPED` and + * whose score equals `1` will be downgraded to `0.5` and have a marker + * appended to `whyThisScore`. Returns the adjusted items and explanatory notes. + * + * @param items - The list of item scores to process + * @param tier - The audit tier; caps are applied only when equal to `"git-only"` + * @returns An object containing `items` (the possibly modified scores) and `notes` (explanations for any caps applied) */ function applyTier3Caps( items: ItemScore[], @@ -64,9 +77,9 @@ function applyTier3Caps( } /** - * Validate that the AI returned exactly 12 items covering ids 1..12. - * Adds neutral 0/0.5/1 placeholders for any missing items so the audit - * always renders all rows. + * Ensures the returned item list includes every rubric item (IDs 1–12) by adding neutral placeholders for any missing entries. + * + * @returns An object with `items`: the original items augmented with placeholder `ItemScore` entries for missing rubric IDs (sorted by `itemId`), and `missing`: an array of rubric IDs that were absent from the input. */ function ensureAllItems(items: ItemScore[]): { items: ItemScore[]; diff --git a/src/services/maturity/audit-store.ts b/src/services/maturity/audit-store.ts index 6a7f969..91b194d 100644 --- a/src/services/maturity/audit-store.ts +++ b/src/services/maturity/audit-store.ts @@ -33,9 +33,12 @@ export class FileSystemAuditStore implements AuditStore { } /** - * Parse the `## Org-level answers` section of CONFIG.md. Heading mapping - * comes from interview.md verbatim. - */ + * Extracts org-level interview answers from a CONFIG.md document. + * + * Parses the "## Org-level answers" section, reading each `###` question heading and its following lines as the answer value. + * + * @param text - Full contents of a CONFIG.md file + * @returns An array of `InterviewAnswer` objects for recognized questions. Headings are matched case-insensitively to known interview questions; multi-line answers are preserved and trimmed; empty answers and unknown headings are ignored. export function parseConfigMd(text: string): InterviewAnswer[] { const answers: InterviewAnswer[] = []; const lines = text.split(/\r?\n/); @@ -83,6 +86,12 @@ export function parseConfigMd(text: string): InterviewAnswer[] { return answers; } +/** + * Finds the interview question id whose config heading matches the given heading (case-insensitive). + * + * @param heading - The heading text to match against question config headings. + * @returns The matching `InterviewQuestionId` if found, `null` otherwise. + */ function matchQuestionByHeading(heading: string): InterviewQuestionId | null { const q = INTERVIEW_QUESTIONS.find( (q) => q.configHeading.toLowerCase() === heading.toLowerCase(), @@ -90,6 +99,13 @@ function matchQuestionByHeading(heading: string): InterviewQuestionId | null { return q?.id ?? null; } +/** + * Render the contents of CONFIG.md's "Org-level answers" section from provided answers. + * + * @param answers - Collected interview answers to include in the document + * @param today - Date string written to the `last_updated` line + * @returns A CONFIG.md-formatted string containing the header, `last_updated: {today}`, and one `### {question}` section per interview question; unanswered questions are rendered as `unknown` + */ export function renderConfigMd( answers: InterviewAnswer[], today: string, @@ -109,8 +125,10 @@ export function renderConfigMd( } /** - * Read pre-supplied interview answers from a JSON file (used by --interview-answers - * in headless mode). Format: { "q1": "...", "q2": "...", ... }. + * Load pre-supplied interview answers from a JSON file for headless mode. + * + * @param path - Filesystem path to a JSON file shaped like `{ "q1": "…", "q2": "…", … }` + * @returns An array of `InterviewAnswer` for entries whose question IDs are recognized; unrecognized IDs are skipped. */ export async function readAnswersJson( path: string, diff --git a/src/services/maturity/audit-writer.ts b/src/services/maturity/audit-writer.ts index 1a3792a..324f5c3 100644 --- a/src/services/maturity/audit-writer.ts +++ b/src/services/maturity/audit-writer.ts @@ -8,6 +8,12 @@ import type { ItemScoreValue, } from "./types.js"; +/** + * Map an artifact evidence tier code to its formatted label. + * + * @param tier - One of `"gh"`, `"github-mcp"`, or `"git-only"` representing the evidence tier + * @returns A human-readable label for `tier` (e.g. `"1: gh"`, `"2: GitHub MCP"`, `"3: git-only"`) + */ function tierLabel(tier: AssessmentArtifact["tier"]): string { switch (tier) { case "gh": @@ -19,6 +25,12 @@ function tierLabel(tier: AssessmentArtifact["tier"]): string { } } +/** + * Format an ItemScoreValue into its display string. + * + * @param score - The score value (may be `1`, `0`, `"n/a"`, or another numeric value) + * @returns The display string: `"1"` for `1`, `"0"` for `0`, `"n/a"` for `"n/a"`, and `"0.5"` for any other numeric value + */ function formatScore(score: ItemScoreValue): string { if (score === "n/a") return "n/a"; if (score === 1) return "1"; @@ -26,10 +38,25 @@ function formatScore(score: ItemScoreValue): string { return "0.5"; } +/** + * Format a number as a string with a fixed number of decimal places. + * + * @param num - The number to format + * @param digits - The number of digits after the decimal point (defaults to 1) + * @returns The numeric value formatted as a string with exactly `digits` digits after the decimal point + */ function fixed(num: number, digits = 1): string { return num.toFixed(digits); } +/** + * Retrieve the ItemScore object for a specific item ID from a list. + * + * @param items - Array of item scores to search + * @param itemId - The item identifier to find + * @returns The matching `ItemScore` + * @throws Error if no score for `itemId` is found (message: `Missing score for item `) + */ function findItemScore(items: ItemScore[], itemId: number): ItemScore { const score = items.find((s) => s.itemId === itemId); if (!score) { @@ -38,6 +65,15 @@ function findItemScore(items: ItemScore[], itemId: number): ItemScore { return score; } +/** + * Builds the Markdown section for a rubric category, including the category header, a table of items with scores and reasons, and a computed subtotal line. + * + * @param artifact - The assessment artifact containing item scores and category subtotals + * @param categoryId - The rubric category identifier to render + * @returns A Markdown-formatted string for the category section (header, item table, and subtotal) + * @throws Error If the `categoryId` is not defined in `RUBRIC_CATEGORIES` (`Unknown category `) + * @throws Error If the artifact has no subtotal for `categoryId` (`Missing subtotal for `) + */ function categoryTable( artifact: AssessmentArtifact, categoryId: CategoryId, @@ -69,6 +105,12 @@ function categoryTable( return lines.join("\n"); } +/** + * Render a complete Markdown maturity assessment report for an assessment artifact. + * + * @param artifact - The assessment artifact containing scope, scores, band/tier info, fixes, strengths, adjacent repos, notes, and rubric metadata + * @returns The full report as a Markdown-formatted string + */ export function renderAuditMarkdown(artifact: AssessmentArtifact): string { const lines: string[] = []; lines.push( @@ -159,10 +201,23 @@ export function renderAuditMarkdown(artifact: AssessmentArtifact): string { return lines.join("\n"); } +/** + * Serializes an assessment artifact to a pretty-printed JSON string. + * + * @param artifact - The assessment artifact to serialize + * @returns The artifact as a JSON string formatted with 2-space indentation + */ export function renderAuditJson(artifact: AssessmentArtifact): string { return JSON.stringify(artifact, null, 2); } +/** + * Ensure the directory containing `filePath` exists, creating it recursively if needed. + * + * Skips creation when the directory portion is ".", "" or "/". + * + * @param filePath - The target file path whose parent directory should be ensured + */ async function ensureDir(filePath: string): Promise { const dir = dirname(filePath); if (dir === "." || dir === "" || dir === "/") return; @@ -175,6 +230,13 @@ export interface WriteAuditOptions { format: "markdown" | "json" | "both"; } +/** + * Writes an assessment artifact to disk in Markdown, JSON, or both formats. + * + * @param artifact - The assessment data to serialize and write + * @param options - Output options including destination path(s) and format + * @returns An object with `outputPath` set to the primary file written and `jsonOutputPath` set when a separate JSON file was written. If `options.format` is `"json"`, `outputPath` will point to the JSON file. + */ export async function writeAudit( artifact: AssessmentArtifact, options: WriteAuditOptions, @@ -211,8 +273,11 @@ export async function writeAudit( } /** - * Compute the default markdown output path from a scope + date, mirroring the - * report file convention (`teamhero-report--.md`). + * Build a default Markdown filename for an audit by slugifying the display name and appending the date. + * + * @param displayName - Source string to slugify: converted to lowercase, runs of non-alphanumeric characters replaced with `-`, and leading/trailing `-` removed + * @param date - Date portion to append (used verbatim, e.g. `YYYY-MM-DD`) + * @returns A relative path like `./teamhero-maturity--.md` */ export function defaultOutputPath(displayName: string, date: string): string { const slug = displayName diff --git a/src/services/maturity/evidence-collectors.ts b/src/services/maturity/evidence-collectors.ts index 0e5f0b1..ce9c422 100644 --- a/src/services/maturity/evidence-collectors.ts +++ b/src/services/maturity/evidence-collectors.ts @@ -27,10 +27,26 @@ interface CollectInput { adjacentRepos: AdjacentRepo[]; } +/** + * Retrieve the repository local filesystem path from a scope descriptor. + * + * @param scope - The scope descriptor containing contextual data about the repository + * @returns The `localPath` string from `scope`, or `null` if it is not set + */ function localPath(scope: ScopeDescriptor): string | null { return scope.localPath ?? null; } +/** + * Constructs an EvidenceFact object for a rubric item. + * + * @param itemId - The numeric rubric item identifier (e.g., 1–12) + * @param signal - The signal for the fact (`"positive"`, `"neutral"`, or `"negative"`) + * @param summary - A short, human-readable summary of the evidence + * @param source - Identifier of the evidence source (for example `"evidence-collectors"`) + * @param details - Optional additional metadata to attach to the fact + * @returns The assembled `EvidenceFact` containing the provided fields; `details` is included only if supplied + */ function fact( itemId: number, signal: EvidenceFact["signal"], @@ -708,6 +724,14 @@ class HiringCollector implements MaturityProvider { } } +/** + * Create the default set of evidence collectors for all rubric items in the canonical order. + * + * @returns An array of `MaturityProvider` instances for items 1 through 12, ordered as: + * ReproducibleDevCollector, IntegrationCadenceCollector, TestabilityCollector, ObservabilityCollector, + * DesignDisciplineCollector, DeepModulesCollector, AgentContextCollector, SanctionedAiCollector, + * HumanReviewCollector, EvalsCollector, BlastRadiusCollector, HiringCollector. + */ export function defaultCollectors(): MaturityProvider[] { return [ new ReproducibleDevCollector(), @@ -725,6 +749,16 @@ export function defaultCollectors(): MaturityProvider[] { ]; } +/** + * Run each maturity collector in order and aggregate their emitted evidence facts. + * + * If a collector throws, its error is caught and a single neutral `EvidenceFact` is appended + * for that item with the error message as the summary. + * + * @param collectors - Array of maturity collectors to execute + * @param input - Collection input (scope and tier) supplied to each collector + * @returns The concatenated list of `EvidenceFact` objects produced by all collectors, in execution order + */ export async function runAllCollectors( collectors: MaturityProvider[], input: CollectInput, diff --git a/src/services/maturity/fs-utils.ts b/src/services/maturity/fs-utils.ts index 098d219..9e8f5ea 100644 --- a/src/services/maturity/fs-utils.ts +++ b/src/services/maturity/fs-utils.ts @@ -27,8 +27,18 @@ export interface FindOptions { } /** - * Walk a directory tree and return matching file paths (relative to root). - * Skips DEFAULT_IGNORES entries and symlinks. + * Recursively collect relative file paths under `root` that match the provided filters. + * + * Traversal stops at `options.maxDepth` (default 4) and after collecting `options.limit` matches (default 200). + * Skips entries listed in `DEFAULT_IGNORES` and symbolic links. If `root` is not a directory or cannot be read, returns an empty array. + * + * @param root - The directory to scan; returned paths are relative to this root + * @param options - Optional filters and limits: + * - `maxDepth` — maximum recursion depth + * - `nameRegex` — only include files whose basename matches this regex + * - `pathContains` — only include files whose lowercased relative path contains at least one of these substrings + * - `limit` — maximum number of matches to return + * @returns An array of matching file paths relative to `root` */ export async function findFiles( root: string, @@ -38,6 +48,14 @@ export async function findFiles( const limit = options.limit ?? 200; const matches: string[] = []; + /** + * Recursively traverses a directory subtree and appends relative file paths that satisfy the configured filters to the surrounding `matches` collection. + * + * Traversal stops when `depth` exceeds the configured maximum, when the match `limit` is reached, or when a directory cannot be read. During iteration this function skips ignored entry names, symbolic links, non-matching file names (when `options.nameRegex` is set), and files whose lowercased relative path does not contain any of the `options.pathContains` needles. + * + * @param dir - Absolute path of the directory to walk + * @param depth - Current recursion depth (root call uses 0) + */ async function walk(dir: string, depth: number): Promise { if (depth > maxDepth || matches.length >= limit) return; let entries; @@ -79,7 +97,13 @@ export async function findFiles( return matches; } -/** Convenience: does any file matching options exist? */ +/** + * Check whether any file matching the given options exists under `root`. + * + * @param root - The directory path to search from + * @param options - Optional search filters and limits + * @returns `true` if at least one matching file exists, `false` otherwise. + */ export async function anyFile( root: string, options: FindOptions = {}, @@ -88,7 +112,11 @@ export async function anyFile( return found.length > 0; } -/** Read a file or return null if it doesn't exist / can't be read. */ +/** + * Read a UTF-8 file and return its contents, or `null` if the file cannot be read. + * + * @returns The file contents as a UTF-8 string, or `null` if the file does not exist or is unreadable + */ export async function readIfExists(path: string): Promise { try { return await readFile(path, "utf8"); @@ -98,8 +126,11 @@ export async function readIfExists(path: string): Promise { } /** - * Check whether the file content matches a regex. Returns true if the file - * exists AND contains a match. + * Determine whether a file's contents match a regular expression. + * + * @param path - Filesystem path to the file to test + * @param pattern - Regular expression to test against the file contents + * @returns `true` if the file exists and its contents match `pattern`, `false` otherwise */ export async function fileContains( path: string, @@ -110,7 +141,13 @@ export async function fileContains( return pattern.test(content); } -/** Look for a substring across many candidate files; return first hit's path. */ +/** + * Finds the first path whose file contents match a regular expression. + * + * @param paths - Ordered list of file paths to check + * @param pattern - Regular expression to test against each file's contents + * @returns The first path whose file content matches `pattern`, or `null` if none match + */ export async function firstFileContaining( paths: string[], pattern: RegExp, diff --git a/src/services/maturity/interview.ts b/src/services/maturity/interview.ts index 5a9b4fa..61ead9a 100644 --- a/src/services/maturity/interview.ts +++ b/src/services/maturity/interview.ts @@ -109,10 +109,23 @@ const UNKNOWN_TOKENS = new Set( ), ); +/** + * Determine whether an answer string represents "unknown" or "not applicable". + * + * @param value - The raw answer text to classify (may include surrounding whitespace or mixed case) + * @returns `true` if `value` matches a known unknown/not-applicable token (case- and whitespace-insensitive), `false` otherwise + */ export function isUnknownAnswer(value: string): boolean { return UNKNOWN_TOKENS.has(value.trim().toLowerCase()); } +/** + * Retrieve the interview question object for the given question identifier. + * + * @param id - The interview question id (e.g., `"q1"` through `"q7"`) to look up + * @returns The matching `InterviewQuestion` for `id` + * @throws Error if no question with the supplied `id` exists + */ export function getQuestion(id: InterviewQuestionId): InterviewQuestion { const q = INTERVIEW_QUESTIONS.find((q) => q.id === id); if (!q) { diff --git a/src/services/maturity/maturity-prompts.ts b/src/services/maturity/maturity-prompts.ts index 9b1fbb7..a461181 100644 --- a/src/services/maturity/maturity-prompts.ts +++ b/src/services/maturity/maturity-prompts.ts @@ -78,6 +78,15 @@ export interface MaturityScoringContext { interviewAnswers: InterviewAnswer[]; } +/** + * Render the full rubric as a Markdown-formatted text block. + * + * Includes category headings with their formatted weights and, for each rubric item, an item header, + * score level explanations for `1.0`, `0.5`, and `0.0`, an optional interview linkage line, + * an optional tier-3 cap note, and the item's "Why it matters" text. + * + * @returns A Markdown string containing the complete rubric organized by category and item. + */ function rubricBlock(): string { const lines: string[] = []; for (const cat of RUBRIC_CATEGORIES) { @@ -106,6 +115,17 @@ function rubricBlock(): string { return lines.join("\n"); } +/** + * Render deterministic evidence grouped by rubric item into a Markdown string. + * + * Groups the provided evidence facts by their `itemId` and produces a section for + * every rubric item. Each section contains either bullet lines in the form + * `- [] ` for facts or the placeholder + * `- (no deterministic evidence collected)` when no facts exist for that item. + * + * @param evidence - Collected deterministic evidence facts to include + * @returns A Markdown-formatted string with a section per rubric item listing its evidence or a placeholder when none is present + */ function evidenceBlock(evidence: EvidenceFact[]): string { const byItem = new Map(); for (const f of evidence) { @@ -129,11 +149,26 @@ function evidenceBlock(evidence: EvidenceFact[]): string { return lines.join("\n"); } +/** + * Formats interview answers into a Markdown bullet list. + * + * @param answers - Array of interview answers; each item should contain `questionId` and `value` + * @returns `_No interview answers supplied._` if `answers` is empty, otherwise a newline-separated list of `- : ` lines + */ function interviewBlock(answers: InterviewAnswer[]): string { if (answers.length === 0) return "_No interview answers supplied._"; return answers.map((a) => `- ${a.questionId}: ${a.value}`).join("\n"); } +/** + * Builds the full audit prompt used to assess agent maturity, embedding scope, rules, + * the full rubric, collected deterministic evidence, and interview answers. + * + * @param context - Inputs that populate the prompt: scope (mode/displayName), evidence tier, + * adjacent repositories, deterministic evidence facts, and interview answers. + * @returns The complete Markdown prompt text instructing the auditor and ending with an + * instruction to return JSON matching the `agent_maturity_assessment` schema. + */ export function buildMaturityPrompt(context: MaturityScoringContext): string { const scopeLine = `${context.scope.mode} | ${context.scope.displayName}`; const adjacentLine = diff --git a/src/services/maturity/maturity.service.ts b/src/services/maturity/maturity.service.ts index 8a43e19..2c3c9b2 100644 --- a/src/services/maturity/maturity.service.ts +++ b/src/services/maturity/maturity.service.ts @@ -196,7 +196,13 @@ export class MaturityService { } } -/** Convenience: quick non-interactive run with default deps. */ +/** + * Run an assessment non-interactively using default dependencies. + * + * @param input - Assessment command input describing scope, tier, and output options + * @param overrides - Optional dependency overrides for collectors, scorer, transport, logger, or audit store + * @returns The assessment result containing the generated artifact and output path(s) + */ export async function runHeadlessAssessment( input: AssessCommandInput, overrides?: MaturityServiceDeps, diff --git a/src/services/maturity/preflight.ts b/src/services/maturity/preflight.ts index db77a2f..a27a392 100644 --- a/src/services/maturity/preflight.ts +++ b/src/services/maturity/preflight.ts @@ -5,12 +5,13 @@ import { getEnv } from "../../lib/env.js"; import type { EvidenceTier } from "./types.js"; /** - * Detects which evidence-fidelity tier we can operate at. + * Choose the evidence-fidelity tier the system should operate at. * - * Order: - * 1. `gh` CLI in PATH and authenticated → "gh" - * 2. Hint env var TEAMHERO_GITHUB_MCP=1 (set by the Go TUI when an MCP is wired) → "github-mcp" - * 3. Anything else → "git-only" + * Detection precedence (highest → lowest): explicit `override` (unless `"auto"`), authenticated `gh` CLI, `TEAMHERO_GITHUB_MCP="1"`, then git-only fallback. + * + * @param cwd - Working directory used when probing for a Git repository + * @param override - Explicit tier to use or `"auto"` to perform detection + * @returns The selected evidence tier: `'gh'`, `'github-mcp'`, or `'git-only'` */ export async function detectTier( cwd: string, @@ -27,6 +28,12 @@ export async function detectTier( return "git-only"; } +/** + * Determines whether the given directory appears to be a Git repository by checking for a `.git` entry. + * + * @param cwd - Filesystem path to the directory to inspect + * @returns `true` if a `.git` entry exists and is a file or directory, `false` otherwise + */ async function isGitRepo(cwd: string): Promise { try { const s = await stat(join(cwd, ".git")); @@ -36,6 +43,11 @@ async function isGitRepo(cwd: string): Promise { } } +/** + * Detects whether the GitHub CLI is installed and currently authenticated. + * + * @returns `true` if the `gh` CLI is present and reports an authenticated session, `false` otherwise. + */ async function ghIsAuthenticated(): Promise { return new Promise((resolve) => { const child = spawn("gh", ["auth", "status"], { @@ -46,6 +58,12 @@ async function ghIsAuthenticated(): Promise { }); } +/** + * Provide a human-readable label for an evidence-fidelity tier. + * + * @param tier - The evidence tier to describe (`"gh"`, `"github-mcp"`, or `"git-only"`) + * @returns A descriptive label for `tier` indicating its name and relative fidelity. + */ export function describeTier(tier: EvidenceTier): string { switch (tier) { case "gh": diff --git a/src/services/maturity/rubric.ts b/src/services/maturity/rubric.ts index ff75641..cc6a873 100644 --- a/src/services/maturity/rubric.ts +++ b/src/services/maturity/rubric.ts @@ -314,6 +314,13 @@ export const RUBRIC_ITEMS: ReadonlyArray = [ }, ] as const; +/** + * Retrieves a rubric item by its numeric identifier. + * + * @param id - The numeric identifier of the rubric item + * @returns The matching RubricItem + * @throws Error when no rubric item with the given `id` exists + */ export function getRubricItem(id: number): RubricItem { const item = RUBRIC_ITEMS.find((i) => i.id === id); if (!item) { @@ -322,6 +329,13 @@ export function getRubricItem(id: number): RubricItem { return item; } +/** + * Retrieve a rubric category by its ID. + * + * @param id - The category identifier (`"A"`, `"B"`, `"C"`, or `"D"`) + * @returns The `RubricCategory` matching `id` + * @throws Error if no category with the provided `id` exists + */ export function getCategory(id: "A" | "B" | "C" | "D"): RubricCategory { const cat = RUBRIC_CATEGORIES.find((c) => c.id === id); if (!cat) { diff --git a/src/services/maturity/scoring.ts b/src/services/maturity/scoring.ts index 4d5e8fa..c5de258 100644 --- a/src/services/maturity/scoring.ts +++ b/src/services/maturity/scoring.ts @@ -13,7 +13,10 @@ import type { } from "./types.js"; /** - * Per-item numeric value, treating "n/a" as null. + * Convert an item's score to a numeric value, returning `null` for `"n/a"`. + * + * @param score - The item's score, which may be a number or the string `"n/a"` + * @returns The numeric score, or `null` if `score` is `"n/a"` */ function scoreNumeric(score: ItemScore["score"]): number | null { if (score === "n/a") return null; @@ -28,6 +31,19 @@ export interface CategorySubtotal { maxWeighted: number; // adjusted for n/a } +/** + * Compute per-category subtotals for the provided item scores. + * + * Items with `"n/a"` scores are excluded from sums and assessment counts. + * + * @param items - Array of item scores to aggregate by rubric category + * @returns An array of `CategorySubtotal` objects (one per rubric category, in the same order as `RUBRIC_CATEGORIES`). Each subtotal includes: + * - `id`: category id + * - `rawSum`: sum of numeric scores in the category + * - `weighted`: `rawSum` multiplied by the category weight + * - `maxRaw`: number of assessed items in the category (each contributes at most 1.0) + * - `maxWeighted`: `maxRaw` multiplied by the category weight + */ export function categorySubtotals(items: ItemScore[]): CategorySubtotal[] { return RUBRIC_CATEGORIES.map((cat) => { const inCat = items.filter((s) => { @@ -67,6 +83,18 @@ export interface OverallScore { band: MaturityBand; } +/** + * Computes aggregated raw and weighted scores, the percent score, and its maturity band for the supplied item scores. + * + * @param items - Array of `ItemScore` entries to include; `"n/a"` scores are excluded from numeric aggregates. + * @returns An `OverallScore` object containing: + * - `rawScore`: sum of raw (unweighted) scores across categories + * - `rawScoreMax`: maximum possible raw score given assessed items + * - `weightedScore`: sum of category-weighted scores + * - `weightedScoreMax`: maximum possible weighted score given assessed items + * - `scorePercent`: weighted score expressed as a percentage of `weightedScoreMax` (0 when `weightedScoreMax` is 0) + * - `band`: the maturity band corresponding to `scorePercent` + */ export function computeOverallScore(items: ItemScore[]): OverallScore { const subtotals = categorySubtotals(items); @@ -89,6 +117,12 @@ export function computeOverallScore(items: ItemScore[]): OverallScore { }; } +/** + * Selects the maturity band whose inclusive range contains the given score percentage. + * + * @param scorePercent - The score percentage (typically 0–100) to classify + * @returns The `MaturityBand` whose `min`..`max` range includes `scorePercent`; if no band matches, returns the last entry of `MATURITY_BANDS` as a fallback + */ export function classifyBand(scorePercent: number): MaturityBand { for (const band of MATURITY_BANDS) { if (scorePercent >= band.min && scorePercent <= band.max) { @@ -99,6 +133,13 @@ export function classifyBand(scorePercent: number): MaturityBand { return MATURITY_BANDS[MATURITY_BANDS.length - 1]; } +/** + * Get the maturity band for the given band name. + * + * @param name - The name of the maturity band to look up + * @returns The `MaturityBand` whose `name` matches `name` + * @throws Error if no maturity band with the provided name exists + */ export function bandByName(name: MaturityBandName): MaturityBand { const band = MATURITY_BANDS.find((b) => b.name === name); if (!band) { @@ -107,14 +148,23 @@ export function bandByName(name: MaturityBandName): MaturityBand { return band; } -/** Returns the unweighted-max constants for diagnostics. */ +/** + * Provide the maximum attainable raw and weighted scores for diagnostics. + * + * @returns An object with `raw` equal to the maximum raw score and `weighted` equal to the maximum weighted score + */ export function maxScores(): { raw: number; weighted: number } { return { raw: MAX_RAW_SCORE, weighted: MAX_WEIGHTED_SCORE }; } /** - * Validate that a list of ItemScores covers all 12 items exactly once. - * Returns missing item IDs (empty array if valid). + * Identify which of the 12 expected rubric item IDs (1–12) are not present in the provided scores. + * + * This compares against the fixed set of expected IDs {1..12} and returns those that never appear in `items`. + * Duplicate or extra entries in `items` are ignored; only the presence of an `itemId` matters. + * + * @param items - Array of scored items to check for coverage + * @returns A sorted array of missing item IDs from 1 through 12; empty if all are present */ export function findMissingItems(items: ItemScore[]): number[] { const expected = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); diff --git a/tui/assess.go b/tui/assess.go index 7479083..20bed20 100644 --- a/tui/assess.go +++ b/tui/assess.go @@ -8,6 +8,10 @@ import ( "github.com/charmbracelet/lipgloss" ) +// printAssessUsage prints the usage and help message for the `teamhero assess` subcommand to stderr. +// +// The message describes the assessment's purpose and outputs, saved configuration location, +// scope and run flags (including headless and interview options), examples, and exit codes. func printAssessUsage() { fmt.Fprintf(os.Stderr, `Usage: teamhero assess [flags] @@ -55,7 +59,11 @@ Exit codes: } // runAssess is the entry point for the "assess" subcommand. It dispatches to -// either the headless run loop or the interactive wizard based on environment. +// runAssess dispatches the `assess` subcommand behavior. +// If `--show-assess-config` is set, it prints the saved assess configuration (exits with status 1 if none) and returns nil. +// Otherwise it loads or initializes the config, applies flag overrides, and fills defaults. +// In headless mode it verifies that minimal scope is present (exits with status 1 if missing) and runs the headless assessment, returning any error from that run. +// In interactive mode it runs the interactive assessment and returns any error from that run. func runAssess() error { if *flagAssessShowConfig { cfg, err := LoadAssessConfig() @@ -83,6 +91,8 @@ func runAssess() error { return runAssessInteractive(&cfg) } +// loadOrInitAssessConfig returns a previously saved AssessConfig if one exists. +// If no saved config is available or loading it fails, it returns the DefaultAssessConfig(). func loadOrInitAssessConfig() AssessConfig { saved, _ := LoadAssessConfig() if saved != nil { @@ -92,7 +102,7 @@ func loadOrInitAssessConfig() AssessConfig { } // runAssessHeadless drives the assess service runner without any TTY UI. -// Interview answers must come from --interview-answers or a CONFIG.md file. +// command to fail. func runAssessHeadless(cfg AssessConfig) error { cfg.Mode = "headless" cfg.InteractiveInterview = false diff --git a/tui/assess_config.go b/tui/assess_config.go index dd6a935..896aae1 100644 --- a/tui/assess_config.go +++ b/tui/assess_config.go @@ -29,13 +29,16 @@ type AssessScope struct { DisplayName string `json:"displayName"` } -// assessConfigPath returns ~/.config/teamhero/assess-config.json (XDG-compliant). +// assessConfigPath returns the full path to the XDG-compliant assess-config.json file +// inside the application's configuration directory. func assessConfigPath() string { return filepath.Join(configDir(), "assess-config.json") } // LoadAssessConfig reads the saved assess configuration. Returns nil with no -// error if the file does not exist. +// LoadAssessConfig reads the saved assess configuration from the user's config directory and returns it. +// If the config file does not exist it returns (nil, nil). +// If reading the file or decoding the JSON fails it returns a non-nil error. func LoadAssessConfig() (*AssessConfig, error) { data, err := os.ReadFile(assessConfigPath()) if err != nil { @@ -51,7 +54,9 @@ func LoadAssessConfig() (*AssessConfig, error) { return &cfg, nil } -// SaveAssessConfig persists the assess configuration to disk. +// SaveAssessConfig writes cfg to the persistent assess configuration file. +// It creates the parent directory if necessary, writes the configuration as +// indented JSON with file mode 0600, and returns any error encountered. func SaveAssessConfig(cfg *AssessConfig) error { if err := os.MkdirAll(filepath.Dir(assessConfigPath()), 0o755); err != nil { return err @@ -63,7 +68,9 @@ func SaveAssessConfig(cfg *AssessConfig) error { return os.WriteFile(assessConfigPath(), data, 0o600) } -// DefaultAssessConfig returns a sensible starting config for a new user. +// DefaultAssessConfig builds a sensible starting AssessConfig configured for a local repository. +// The returned config uses the current working directory as Scope.LocalPath and Scope.DisplayName, +// sets Scope.Mode to "local-repo", EvidenceTier to "auto", and OutputFormat to "both". func DefaultAssessConfig() AssessConfig { cwd, _ := os.Getwd() return AssessConfig{ diff --git a/tui/assess_flags.go b/tui/assess_flags.go index 5e48c2d..d597dad 100644 --- a/tui/assess_flags.go +++ b/tui/assess_flags.go @@ -23,7 +23,8 @@ var ( flagAssessShowConfig = flag.Bool("show-assess-config", false, "Print saved assess configuration as JSON and exit") ) -// applyAssessFlagsTo merges explicitly-set CLI flags into cfg. +// applyAssessFlagsTo updates cfg with values from assess CLI flags that were explicitly set. +// For each supported flag, if wasSet reports it was provided, the corresponding cfg field is overwritten with the flag's value. func applyAssessFlagsTo(cfg *AssessConfig, wasSet func(string) bool) { if wasSet("scope-mode") { cfg.Scope.Mode = strings.TrimSpace(*flagAssessScopeMode) @@ -61,7 +62,20 @@ func applyAssessFlagsTo(cfg *AssessConfig, wasSet func(string) bool) { } // fillAssessDefaults populates required fields if they're missing. Mirrors -// DefaultAssessConfig but applied to an already-loaded config. +// fillAssessDefaults populates missing fields on an AssessConfig with sensible defaults. +// +// If Scope.Mode is empty it is derived from the presence of Scope.Org and Scope.LocalPath: +// - only LocalPath present -> "local-repo" +// - only Org present -> "org" +// - both present -> "both" +// - neither present -> "local-repo" +// +// If Scope.DisplayName is empty it is set based on Scope.Mode: +// - "org" -> Scope.Org +// - "local-repo" -> base name of Scope.LocalPath (if set) +// - "both" -> Scope.Org if present, otherwise base name of Scope.LocalPath (if set) +// +// OutputFormat defaults to "both" and EvidenceTier defaults to "auto" when unset. func fillAssessDefaults(cfg *AssessConfig) { if cfg.Scope.Mode == "" { if cfg.Scope.LocalPath != "" && cfg.Scope.Org == "" { @@ -99,7 +113,8 @@ func fillAssessDefaults(cfg *AssessConfig) { } // hasMinimalAssessConfig returns true if enough config is present to run -// headless without further interactive input. +// hasMinimalAssessConfig determines whether cfg contains the minimal fields required to run an assess operation without interactive input. +// It returns true when cfg is non-nil, the scope mode is one of "org", "local-repo", or "both" with the corresponding required scope value present (org for "org"/"both", local path for "local-repo"), and Scope.DisplayName is non-empty after trimming whitespace. func hasMinimalAssessConfig(cfg *AssessConfig) bool { if cfg == nil { return false diff --git a/tui/assess_preview.go b/tui/assess_preview.go index eeeac4f..e59877a 100644 --- a/tui/assess_preview.go +++ b/tui/assess_preview.go @@ -46,6 +46,12 @@ type assessPreviewModel struct { spinner spinner.Model } +// newAssessPreviewModel creates and initializes an assessPreviewModel for the given audit and JSON inputs. +// +// It converts path to an absolute path and attempts to read the audit file into the model's markdown. +// On read failure the model's renderErr is set and markdown is left empty. It also stores jsonPath and +// jsonData, allocates and sizes viewports for each tab, configures the initial spinner, sets the initial +// active tab to the audit tab, and marks the model as awaiting its initial render. func newAssessPreviewModel(path, jsonPath, jsonData string) assessPreviewModel { absPath, _ := filepath.Abs(path) @@ -308,7 +314,7 @@ func (m *assessPreviewModel) previewFrameHeight() int { // buildAssessEvidenceMarkdown extracts the evidence facts and per-item scores // from the audit JSON and renders them as a single markdown document for the -// Evidence tab. Falls back to a placeholder when no JSON is present. +// optional explanation, followed by a "Notes for re-audit" section when present. func buildAssessEvidenceMarkdown(jsonData string) string { if jsonData == "" { return "## Evidence\n\n_No JSON data available — re-run with `--audit-output-format both`._\n" @@ -357,7 +363,10 @@ func buildAssessEvidenceMarkdown(jsonData string) string { } // RunAssessPreview displays the audit markdown in a tabbed Glamour-rendered -// preview matching the report flow's RunReportPreviewFull look-and-feel. +// RunAssessPreview launches a full-screen interactive preview UI for an audit file and its associated JSON. +// It presents three tabs — Audit (rendered markdown from path), Evidence (markdown built from jsonData), and JSON Data (pretty-printed jsonData) — and handles resizing, tab switching, scrolling, and exiting. +// path is the path to the audit markdown file to display. jsonPath, if provided, is shown in the info panel. jsonData is the raw audit JSON used to populate the Evidence and JSON Data tabs. +// It returns any error encountered while running the TUI program. func RunAssessPreview(path, jsonPath, jsonData string) error { m := newAssessPreviewModel(path, jsonPath, jsonData) p := tea.NewProgram(m, tea.WithOutput(os.Stderr), tea.WithAltScreen()) diff --git a/tui/assess_progress.go b/tui/assess_progress.go index 5418dc0..c55905d 100644 --- a/tui/assess_progress.go +++ b/tui/assess_progress.go @@ -98,6 +98,12 @@ var canonicalAssessSteps = []string{ "complete", } +// newAssessProgressModel creates an assessProgressModel configured for the TUI run. +// +// The returned model is initialized with a styled spinner, a progress bar (width derived +// from terminal width), two viewports (content and shell) sized for the terminal, and +// populated fields: cfg, title, canonical expected steps, totalQuestions, sendAnswer, +// and initial width/height. func newAssessProgressModel( title string, cfg *AssessConfig, @@ -675,7 +681,7 @@ type AssessProgressResult struct { // don't release the terminal — so the framed two-pane layout is continuous. // // sendAnswer is invoked when each embedded interview form completes. It -// must write the answer JSON line back to the runner's stdin. +// an error message if the display/runtime failed, and whether the run was cancelled. func RunAssessProgressDisplay( title string, cfg *AssessConfig, @@ -710,6 +716,11 @@ func RunAssessProgressDisplay( } } +// assessStepElapsed returns a formatted elapsed-time string for the given step. +// If the step has no start time, it returns an empty string. If the step has a +// finish time, it returns the duration from start to finish. If the step is +// still running, it returns the elapsed duration only after at least 3 seconds +// have passed since the start; otherwise it returns an empty string. func assessStepElapsed(s assessStepState, now time.Time) string { if s.startedAt.IsZero() { return "" @@ -724,7 +735,8 @@ func assessStepElapsed(s assessStepState, now time.Time) string { } // humanizeStep maps the lower-kebab step name to a label that fits the -// existing report's tone (capitalized verb-phrases). +// humanizeStep maps a canonical step key to a human-readable, capitalized label. +// If the step is unknown, it returns the input unchanged. func humanizeStep(step string) string { switch step { case "startup": diff --git a/tui/assess_runner.go b/tui/assess_runner.go index eeffa50..bd8ac75 100644 --- a/tui/assess_runner.go +++ b/tui/assess_runner.go @@ -13,7 +13,11 @@ import ( ) // assessScriptPath returns the path to scripts/run-assess.ts. Mirrors -// resolveScriptPath but for the assess service runner. +// assessScriptPath determines the filesystem path to scripts/run-assess.ts using a series of fallbacks. +// It first checks for ../scripts/run-assess.ts relative to the running executable, then checks +// "scripts/run-assess.ts" and "./scripts/run-assess.ts" in the current working directory, and finally +// checks $HOME/teamhero.cli/scripts/run-assess.ts when the home directory is available. If none of the +// candidates exist, it returns "scripts/run-assess.ts" as a final fallback (which may not exist). func assessScriptPath() string { exePath, err := os.Executable() if err == nil { @@ -61,7 +65,9 @@ func (r *AssessRunResult) Close() { // RunAssessServiceRunner spawns the TS service runner for the maturity // assessment. The first stdin write is the AssessConfig JSON; the stream is -// kept open so the TUI can send subsequent interview-answer JSON lines. +// RunAssessServiceRunner starts the external "assess" runner with the given AssessConfig and streams parsed JSON events from its stdout. +// It writes the config as the first newline-delimited JSON line to the runner's stdin and keeps stdin open so callers may send subsequent interview-answer messages. +// The returned AssessRunResult exposes channels for streamed events and a single termination error, a buffer capturing the runner's stderr, and a writer for stdin; callers should call Close on the result to run cleanup. func RunAssessServiceRunner(input AssessConfig) (*AssessRunResult, error) { configJSON, err := json.Marshal(input) if err != nil { @@ -147,7 +153,10 @@ func RunAssessServiceRunner(input AssessConfig) (*AssessRunResult, error) { }, nil } -// SendInterviewAnswer writes a JSON-line answer event to the runner's stdin. +// SendInterviewAnswer writes an `interview-answer` JSON-line event for the given +// question and value to the runner's stdin. +// +// It returns an error if marshaling the event or writing to the runner's stdin fails. func SendInterviewAnswer(r *AssessRunResult, questionID, value string, isOption bool) error { evt := InterviewAnswerEvent{ Type: "interview-answer", diff --git a/tui/assess_summary.go b/tui/assess_summary.go index c416bd0..7edd2c1 100644 --- a/tui/assess_summary.go +++ b/tui/assess_summary.go @@ -11,7 +11,12 @@ import ( // // Each field shows a value when it has been resolved, "—" (dim) otherwise. // The "Assessment Setup" header includes an AI badge on the right when an -// AI model has been selected (matches the report's "Report Setup" header). +// renderAssessSummary renders a right-pane, bordered summary of an assessment configuration sized to the provided width. +// +// The rendered box contains labeled fields for Scope, Target, Display name, Evidence tier, Output format, Output path, +// Interview answers, and Mode. If cfg is nil the box contains "No configuration". A minimum width of 20 is enforced. +// Empty or whitespace-only values are shown as a dim em dash ("—"). When cfg.DryRun is true a right-aligned "dry-run" +// badge is shown and is placed on the same header line if there is sufficient space. func renderAssessSummary(cfg *AssessConfig, width int) string { if width < 20 { width = 20 @@ -89,6 +94,9 @@ func renderAssessSummary(cfg *AssessConfig, width int) string { return boxStyle.Width(innerWidth).Render(content) } +// fmtAssessScopeMode converts the assessment scope mode in cfg into a human-readable label. +// It maps "org" to "GitHub org", "local-repo" to "Local repository", and "both" to "Org + local checkout". +// For any other mode it returns an empty string. func fmtAssessScopeMode(cfg *AssessConfig) string { switch cfg.Scope.Mode { case "org": @@ -101,6 +109,12 @@ func fmtAssessScopeMode(cfg *AssessConfig) string { return "" } +// fmtAssessTarget returns a human-readable target string based on cfg.Scope.Mode. +// For "org" it returns "" when Org is empty, the Org name when no repos are listed, +// or "Org (repos...)" when repos are present (repos rendered compactly inside parentheses). +// For "local-repo" it returns cfg.Scope.LocalPath. +// For "both" it joins any non-empty Org and LocalPath with " · ". +// For any other mode it returns an empty string. func fmtAssessTarget(cfg *AssessConfig) string { switch cfg.Scope.Mode { case "org": @@ -126,6 +140,10 @@ func fmtAssessTarget(cfg *AssessConfig) string { return "" } +// fmtAssessTier converts an evidence tier identifier into a human-readable label. +// It maps "" and "auto" to "auto-detect", "gh" to "1 — gh CLI", "github-mcp" to +// "2 — GitHub MCP", and "git-only" to "3 — git-only". For any other input it +// returns the original tier string unchanged. func fmtAssessTier(tier string) string { switch tier { case "", "auto": @@ -140,6 +158,9 @@ func fmtAssessTier(tier string) string { return tier } +// fmtAssessOutputFormat returns a user-facing label for an output format key. +// It maps the empty string to "both", "both" to "both (md + json)", and preserves +// "markdown" and "json" as-is. For any other input it returns the input unchanged. func fmtAssessOutputFormat(format string) string { switch format { case "": @@ -154,6 +175,8 @@ func fmtAssessOutputFormat(format string) string { return format } +// fmtAssessAnswersFile provides a display string for the interview answers file. +// If path is empty it returns "interactive"; otherwise it returns the provided path. func fmtAssessAnswersFile(path string) string { if path == "" { return "interactive" @@ -161,6 +184,9 @@ func fmtAssessAnswersFile(path string) string { return path } +// fmtAssessRunMode determines the assessment run mode based on the provided configuration. +// If cfg.Mode is non-empty that value is used; otherwise it returns "interactive" when +// cfg.InteractiveInterview is true and "headless" otherwise. func fmtAssessRunMode(cfg *AssessConfig) string { if cfg.Mode != "" { return cfg.Mode diff --git a/tui/assess_wizard.go b/tui/assess_wizard.go index 94d8e73..35040c9 100644 --- a/tui/assess_wizard.go +++ b/tui/assess_wizard.go @@ -15,7 +15,11 @@ import ( // 1. Runs the framed scope wizard (matches the report's two-pane layout). // 2. Spawns the service runner and drives the Bubble Tea progress display. // 3. Round-trips interview questions through huh prompts (one at a time). -// 4. Opens the tabbed Glamour preview when the audit is written. +// runAssessInteractive runs an interactive assessment flow: it prompts for scope and settings via a framed wizard, starts the assessment service, streams progress and interview prompts to that service, and opens a preview of the resulting audit when available. +// +// The function prints cancellation and error notes to stderr, attempts to persist the updated assessment configuration (best-effort), and logs a note if preview rendering is unavailable. +// +// It returns an error if the assessment service reports a fatal error or if progress reports an unrecoverable error; otherwise it returns nil. func runAssessInteractive(cfg *AssessConfig) error { res, err := runAssessScopeWizard(cfg) if err != nil { @@ -122,7 +126,9 @@ type AssessWizardResult struct { // runAssessScopeWizard runs the scope-selection wizard inside a Bubble Tea // program. The View() renders the same shell-header + two-pane layout as -// the report wizard, with the right pane showing renderAssessSummary(). +// runAssessScopeWizard runs a framed, two-pane interactive wizard to collect and confirm assessment scope settings. +// It returns an AssessWizardResult containing a possibly-updated AssessConfig and flags indicating whether the +// user confirmed or aborted the wizard. An error is returned if the terminal UI failed to run. func runAssessScopeWizard(cfg *AssessConfig) (*AssessWizardResult, error) { cwd, _ := os.Getwd() @@ -152,6 +158,8 @@ func runAssessScopeWizard(cfg *AssessConfig) (*AssessWizardResult, error) { }, nil } +// defaultScopeMode returns cfg.Scope.Mode when it is non-empty; otherwise it returns "local-repo". +// The cwd parameter is accepted for callers' convenience but is not used. func defaultScopeMode(cfg *AssessConfig, cwd string) string { if cfg.Scope.Mode != "" { return cfg.Scope.Mode @@ -160,6 +168,7 @@ func defaultScopeMode(cfg *AssessConfig, cwd string) string { return "local-repo" } +// defaultLocalPath returns the configured local path for the assessment scope if set; otherwise it falls back to the provided working directory. func defaultLocalPath(cfg *AssessConfig, cwd string) string { if cfg.Scope.LocalPath != "" { return cfg.Scope.LocalPath @@ -429,7 +438,9 @@ func (m *assessWizardModel) formWidth() int { // --------------------------------------------------------------------------- // Helpers -// --------------------------------------------------------------------------- +// validateLocalPath reports an error when the provided path string is empty, does not +// exist, or exists but is not a directory. It trims surrounding whitespace before +// performing the checks and returns an explanatory error for each failure case. func validateLocalPath(s string) error { trimmed := strings.TrimSpace(s) @@ -446,6 +457,8 @@ func validateLocalPath(s string) error { return nil } +// requireNonEmpty returns a validator function that ensures a string is not empty. +// The returned function trims whitespace and returns an error formatted as " is required" when the result is empty, or nil otherwise. func requireNonEmpty(field string) func(string) error { return func(s string) error { if strings.TrimSpace(s) == "" { @@ -455,6 +468,8 @@ func requireNonEmpty(field string) func(string) error { } } +// parseRepoCSV splits s on commas and returns a slice of non-empty, trimmed segments. +// It trims leading and trailing whitespace from the input and from each segment; empty segments are omitted. func parseRepoCSV(s string) []string { parts := strings.Split(strings.TrimSpace(s), ",") out := make([]string, 0, len(parts)) diff --git a/tui/main.go b/tui/main.go index 7199f00..a4f5824 100644 --- a/tui/main.go +++ b/tui/main.go @@ -14,6 +14,8 @@ import ( // version is injected at build time via -ldflags "-X main.version=X.Y.Z" var version = "dev" +// printUsage writes the top-level help text to stderr, listing available subcommands +// and global flags. func printUsage() { fmt.Fprintf(os.Stderr, `Usage: teamhero [flags] @@ -129,6 +131,12 @@ Examples: `) } +// main is the CLI entrypoint; it parses command-line arguments, routes `--help` to +// subcommand-specific usage, and dispatches execution for `setup`, `doctor`, +// `assess`, headless, or interactive modes. +// It also handles global flags such as `--version` (prints build version) and +// `--show-config` (prints the saved configuration), and maps common cancellation +// or error conditions to appropriate exit codes. func main() { // Detect subcommand first so --help can be routed to the right usage. subcommand := ""