Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion scripts/run-assess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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<void> {
const logger = createConsola({ defaults: { tag: "maturity" } });

Expand Down
19 changes: 19 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ async function spawnTui(deps: CliDependencies, args: string[]): Promise<void> {
});
}

/**
* 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 = {},
Expand Down Expand Up @@ -224,6 +232,17 @@ export async function createDefaultDependencies(): Promise<CliDependencies> {
} 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,
Expand Down
19 changes: 13 additions & 6 deletions src/services/maturity/adjacent-repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, AdjacentRepo>,
owner: string,
Expand Down
25 changes: 19 additions & 6 deletions src/services/maturity/ai-scorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[],
Expand All @@ -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[];
Expand Down
28 changes: 23 additions & 5 deletions src/services/maturity/audit-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down Expand Up @@ -83,13 +86,26 @@ 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(),
);
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,
Expand All @@ -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,
Expand Down
69 changes: 67 additions & 2 deletions src/services/maturity/audit-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -19,17 +25,38 @@ 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";
if (score === 0) return "0";
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 <itemId>`)
*/
function findItemScore(items: ItemScore[], itemId: number): ItemScore {
const score = items.find((s) => s.itemId === itemId);
if (!score) {
Expand All @@ -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 <categoryId>`)
* @throws Error If the artifact has no subtotal for `categoryId` (`Missing subtotal for <categoryId>`)
*/
function categoryTable(
artifact: AssessmentArtifact,
categoryId: CategoryId,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<void> {
const dir = dirname(filePath);
if (dir === "." || dir === "" || dir === "/") return;
Expand All @@ -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,
Expand Down Expand Up @@ -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-<org>-<date>.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-<slug>-<date>.md`
*/
export function defaultOutputPath(displayName: string, date: string): string {
const slug = displayName
Expand Down
Loading
Loading