diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 13753bd..2b4eaed 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Release id: release - uses: google-github-actions/release-please-action@v4 + uses: googleapis/release-please-action@v4 with: config-file: release-please-config.json manifest-file: release-please-manifest.json diff --git a/README.md b/README.md index 39aaeb6..cee6b3c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,18 @@ primer init ## Commands +### `primer analyze` — Inspect Repository Structure + +Detects languages, frameworks, monorepo/workspace structure, and area mappings: + +```bash +primer analyze # terminal summary +primer analyze --json # machine-readable analysis +primer analyze --output analysis.json # save JSON report +primer analyze --output analysis.md # save Markdown report +primer analyze --output analysis.json --force # overwrite existing report +``` + ### `primer readiness` — Assess AI Readiness Score a repo across 9 pillars grouped into **Repo Health** and **AI Setup**: @@ -54,7 +66,10 @@ Score a repo across 9 pillars grouped into **Repo Health** and **AI Setup**: primer readiness # terminal summary primer readiness --visual # GitHub-themed HTML report primer readiness --per-area # include per-area breakdown -primer readiness --policy ./strict.json # apply a custom policy +primer readiness --output readiness.json # save JSON report +primer readiness --output readiness.md # save Markdown report +primer readiness --output readiness.html # save HTML report +primer readiness --policy ./examples/policies/strict.json # apply a custom policy primer readiness --json # machine-readable JSON primer readiness --fail-level 3 # CI gate: exit 1 if below level 3 ``` @@ -133,8 +148,8 @@ All commands support `--json` (structured JSON to stdout) and `--quiet` (suppres Policies customize scoring criteria, override metadata, and tune thresholds: ```bash -primer readiness --policy ./strict.json -primer readiness --policy ./base.json,./strict.json # chain multiple +primer readiness --policy ./examples/policies/strict.json +primer readiness --policy ./examples/policies/strict.json,./my-overrides.json # chain multiple ``` ```json diff --git a/examples/policies/README.md b/examples/policies/README.md new file mode 100644 index 0000000..8f654c2 --- /dev/null +++ b/examples/policies/README.md @@ -0,0 +1,26 @@ +# Example Policies + +Readiness policies customize which criteria are evaluated and how they're scored. + +## Usage + +Pass a policy file with `--policy` using a relative `./` path: + +```sh +primer readiness --policy ./examples/policies/ai-only.json +primer readiness --policy ./examples/policies/strict.json +``` + +Multiple policies can be chained (comma-separated): + +```sh +primer readiness --policy ./examples/policies/ai-only.json,./my-overrides.json +``` + +## Included Policies + +| File | Purpose | +| ----------------------- | ------------------------------------------------------------------------------------ | +| `ai-only.json` | Disables all repo-health criteria, focusing only on AI tooling readiness | +| `repo-health-only.json` | Disables all AI-tooling criteria and the `agents-doc` extra, focusing on repo health | +| `strict.json` | Sets 100% pass-rate threshold and elevates several criteria to high impact | diff --git a/examples/policies/ai-only.json b/examples/policies/ai-only.json new file mode 100644 index 0000000..71292bb --- /dev/null +++ b/examples/policies/ai-only.json @@ -0,0 +1,25 @@ +{ + "name": "ai-only", + "criteria": { + "disable": [ + "lint-config", + "typecheck-config", + "build-script", + "ci-config", + "test-script", + "readme", + "contributing", + "lockfile", + "env-example", + "format-config", + "codeowners", + "license", + "security-policy", + "dependabot", + "observability", + "area-readme", + "area-build-script", + "area-test-script" + ] + } +} diff --git a/examples/policies/repo-health-only.json b/examples/policies/repo-health-only.json new file mode 100644 index 0000000..ab02f12 --- /dev/null +++ b/examples/policies/repo-health-only.json @@ -0,0 +1,15 @@ +{ + "name": "repo-health-only", + "criteria": { + "disable": [ + "custom-instructions", + "mcp-config", + "custom-agents", + "copilot-skills", + "area-instructions" + ] + }, + "extras": { + "disable": ["agents-doc"] + } +} diff --git a/examples/policies/strict.json b/examples/policies/strict.json new file mode 100644 index 0000000..323968e --- /dev/null +++ b/examples/policies/strict.json @@ -0,0 +1,13 @@ +{ + "name": "strict", + "thresholds": { + "passRate": 1.0 + }, + "criteria": { + "override": { + "env-example": { "impact": "high" }, + "format-config": { "impact": "high" }, + "contributing": { "impact": "high" } + } + } +} diff --git a/src/cli.ts b/src/cli.ts index 4072b80..d5e8ce6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -55,6 +55,8 @@ export function runCli(argv: string[]): void { .command("analyze") .description("Detect languages, frameworks, monorepo structure, and areas") .argument("[path]", "Path to a local repository") + .option("--output ", "Write report to file (.json or .md)") + .option("--force", "Overwrite existing output file") .action(withGlobalOpts(analyzeCommand)); program @@ -120,7 +122,7 @@ export function runCli(argv: string[]): void { .command("readiness") .description("AI readiness assessment across 9 maturity pillars") .argument("[path]", "Path to a local repository") - .option("--output ", "Write report to file (.json or .html)") + .option("--output ", "Write report to file (.json, .md, or .html)") .option("--force", "Overwrite existing output file") .option("--visual", "Generate visual HTML report") .option("--per-area", "Show per-area readiness breakdown") diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index c90e901..2ef1578 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -1,13 +1,19 @@ import path from "path"; +import chalk from "chalk"; + +import type { RepoAnalysis } from "../services/analyzer"; import { analyzeRepo } from "../services/analyzer"; +import { safeWriteFile } from "../utils/fs"; +import { prettyPrintSummary } from "../utils/logger"; import type { CommandResult } from "../utils/output"; import { outputResult, outputError, shouldLog } from "../utils/output"; -import { prettyPrintSummary } from "../utils/logger"; type AnalyzeOptions = { json?: boolean; quiet?: boolean; + output?: string; + force?: boolean; }; export async function analyzeCommand( @@ -19,6 +25,41 @@ export async function analyzeCommand( try { const analysis = await analyzeRepo(repoPath); + // Write to file when --output is specified + if (options.output) { + const outputPath = path.resolve(options.output); + const ext = path.extname(outputPath).toLowerCase(); + if (ext !== ".json" && ext !== ".md") { + outputError( + `Unsupported output format: ${ext || "(no extension)"}. Use .json or .md`, + Boolean(options.json) + ); + return; + } + const content = + ext === ".md" ? formatAnalysisMarkdown(analysis) : JSON.stringify(analysis, null, 2); + + const { wrote, reason } = await safeWriteFile(outputPath, content, Boolean(options.force)); + if (!wrote) { + const why = reason === "symlink" ? "path is a symlink" : "file exists (use --force)"; + outputError(`Skipped ${outputPath}: ${why}`, Boolean(options.json)); + return; + } + if (options.json) { + const result: CommandResult = { + ok: true, + status: "success", + data: analysis + }; + outputResult(result, true); + return; + } + if (shouldLog(options)) { + process.stderr.write(chalk.green(`✓ Report saved: ${outputPath}`) + "\n"); + } + return; + } + if (options.json) { const result: CommandResult = { ok: true, @@ -33,3 +74,58 @@ export async function analyzeCommand( outputError(error instanceof Error ? error.message : String(error), Boolean(options.json)); } } + +export function formatAnalysisMarkdown(analysis: RepoAnalysis): string { + const lines: string[] = []; + const repoName = path.basename(analysis.path); + + lines.push(`# Repository Analysis: ${repoName}`); + lines.push(""); + lines.push("## Overview"); + lines.push(""); + lines.push(`| Property | Value |`); + lines.push(`| --- | --- |`); + lines.push(`| Path | \`${analysis.path}\` |`); + lines.push(`| Git repository | ${analysis.isGitRepo ? "Yes" : "No"} |`); + lines.push(`| Languages | ${analysis.languages.join(", ") || "Unknown"} |`); + lines.push(`| Frameworks | ${analysis.frameworks.join(", ") || "None detected"} |`); + lines.push(`| Package manager | ${analysis.packageManager ?? "Unknown"} |`); + if (analysis.isMonorepo) { + lines.push( + `| Monorepo | ${analysis.workspaceType ?? "yes"} (${analysis.apps?.length ?? 0} apps) |` + ); + } + + if (analysis.apps && analysis.apps.length > 0) { + lines.push(""); + lines.push("## Applications"); + lines.push(""); + lines.push("| Name | Ecosystem | TypeScript | Path |"); + lines.push("| --- | --- | --- | --- |"); + for (const app of analysis.apps) { + const rel = path.relative(analysis.path, app.path).replace(/\\/gu, "/") || "."; + lines.push( + `| ${app.name} | ${app.ecosystem ?? "—"} | ${app.hasTsConfig ? "Yes" : "No"} | \`${rel}\` |` + ); + } + } + + if (analysis.areas && analysis.areas.length > 0) { + lines.push(""); + lines.push("## Areas"); + lines.push(""); + lines.push("| Name | Source | Pattern |"); + lines.push("| --- | --- | --- |"); + for (const area of analysis.areas) { + const pattern = Array.isArray(area.applyTo) ? area.applyTo.join(", ") : area.applyTo; + lines.push(`| ${area.name} | ${area.source} | \`${pattern}\` |`); + } + } + + lines.push(""); + lines.push(`---`); + lines.push(`*Generated by [Primer](https://github.com/digitarald/primer)*`); + lines.push(""); + + return lines.join("\n"); +} diff --git a/src/commands/batchReadiness.tsx b/src/commands/batchReadiness.tsx index eaddd06..697849a 100644 --- a/src/commands/batchReadiness.tsx +++ b/src/commands/batchReadiness.tsx @@ -3,8 +3,8 @@ import React from "react"; import { getGitHubToken } from "../services/github"; import { parsePolicySources } from "../services/policy"; -import { outputError } from "../utils/output"; import { BatchReadinessTui } from "../ui/BatchReadinessTui"; +import { outputError } from "../utils/output"; type BatchReadinessOptions = { output?: string; diff --git a/src/commands/readiness.ts b/src/commands/readiness.ts index cae2f2f..7c72080 100644 --- a/src/commands/readiness.ts +++ b/src/commands/readiness.ts @@ -6,7 +6,8 @@ import { parsePolicySources } from "../services/policy"; import type { ReadinessReport, ReadinessCriterionResult, - AreaReadinessReport + AreaReadinessReport, + ReadinessPillarSummary } from "../services/readiness"; import { runReadinessReport, groupPillars } from "../services/readiness"; import { generateVisualReport } from "../services/visualReport"; @@ -31,6 +32,9 @@ export async function readinessCommand( ): Promise { const repoPath = path.resolve(repoPathArg ?? process.cwd()); const repoName = path.basename(repoPath); + const resolvedOutputPath = options.output ? path.resolve(options.output) : ""; + const outputExt = options.output ? path.extname(options.output).toLowerCase() : ""; + let failLevelError: string | undefined; let report: ReadinessReport; try { @@ -52,17 +56,49 @@ export async function readinessCommand( process.stderr.write(`Warning: --fail-level clamped to ${clamped} (valid range: 1–5)\n`); } if ((report.achievedLevel ?? 0) < clamped) { + failLevelError = `Readiness level ${report.achievedLevel ?? 0} is below threshold ${clamped}`; if (shouldLog(options)) { - process.stderr.write( - `Error: Readiness level ${report.achievedLevel ?? 0} is below threshold ${clamped}\n` - ); + process.stderr.write(`Error: ${failLevelError}\n`); } process.exitCode = 1; } } + const jsonResult: CommandResult = failLevelError + ? { + ok: false, + status: "error", + data: report, + errors: [failLevelError] + } + : { + ok: true, + status: "success", + data: report + }; + const emitJsonResult = (): void => outputResult(jsonResult, true); + + // Validate output extension early, before any output branch + if (options.output) { + if (outputExt !== ".json" && outputExt !== ".md" && outputExt !== ".html") { + outputError( + `Unsupported output format: ${outputExt || "(no extension)"}. Use .json, .md, or .html`, + Boolean(options.json) + ); + return; + } + } + + if (options.visual && outputExt && outputExt !== ".html") { + outputError( + `Cannot use --visual with ${outputExt} output. Use a .html output path or omit --output.`, + Boolean(options.json) + ); + return; + } + // Generate visual HTML report - if (options.visual || (options.output && options.output.endsWith(".html"))) { + if (options.visual || outputExt === ".html") { const html = generateVisualReport({ reports: [{ repo: repoName, report }], title: `AI Readiness Report: ${repoName}`, @@ -70,7 +106,7 @@ export async function readinessCommand( }); const outputPath = options.output - ? path.resolve(options.output) + ? resolvedOutputPath : path.join(repoPath, "readiness-report.html"); const { wrote, reason } = await safeWriteFile(outputPath, html, Boolean(options.force)); @@ -82,35 +118,53 @@ export async function readinessCommand( if (shouldLog(options)) { process.stderr.write(chalk.green(`✓ Visual report generated: ${outputPath}`) + "\n"); } + if (options.json) { + emitJsonResult(); + } + return; + } + + // Output to Markdown file + if (outputExt === ".md") { + const md = formatReadinessMarkdown(report, repoName); + const { wrote, reason } = await safeWriteFile(resolvedOutputPath, md, Boolean(options.force)); + if (!wrote) { + const why = reason === "symlink" ? "path is a symlink" : "file exists (use --force)"; + outputError(`Skipped ${resolvedOutputPath}: ${why}`, Boolean(options.json)); + return; + } + if (shouldLog(options)) { + process.stderr.write(chalk.green(`✓ Markdown report saved: ${resolvedOutputPath}`) + "\n"); + } + if (options.json) { + emitJsonResult(); + } return; } // Output to JSON file - if (options.output && options.output.endsWith(".json")) { - const outputPath = path.resolve(options.output); + if (outputExt === ".json") { const { wrote, reason } = await safeWriteFile( - outputPath, + resolvedOutputPath, JSON.stringify(report, null, 2), Boolean(options.force) ); if (!wrote) { const why = reason === "symlink" ? "path is a symlink" : "file exists (use --force)"; - outputError(`Skipped ${outputPath}: ${why}`, Boolean(options.json)); + outputError(`Skipped ${resolvedOutputPath}: ${why}`, Boolean(options.json)); return; } if (shouldLog(options)) { - process.stderr.write(chalk.green(`✓ JSON report saved: ${outputPath}`) + "\n"); + process.stderr.write(chalk.green(`✓ JSON report saved: ${resolvedOutputPath}`) + "\n"); + } + if (options.json) { + emitJsonResult(); } return; } if (options.json) { - const result: CommandResult = { - ok: true, - status: "success", - data: report - }; - outputResult(result, true); + emitJsonResult(); return; } @@ -238,3 +292,90 @@ function printAreaBreakdown(areaReports: AreaReadinessReport[]): void { } } } + +export function formatReadinessMarkdown(report: ReadinessReport, repoName: string): string { + const lines: string[] = []; + + lines.push(`# AI Readiness Report: ${repoName}`); + lines.push(""); + lines.push(`**Level ${report.achievedLevel}** — ${levelName(report.achievedLevel)}`); + lines.push(""); + + // Pillar summary table + const groups = groupPillars(report.pillars); + for (const { label, pillars } of groups) { + if (pillars.length === 0) continue; + lines.push(`## ${label}`); + lines.push(""); + lines.push("| Pillar | Passed | Total | Rate |"); + lines.push("| --- | ---: | ---: | ---: |"); + for (const pillar of pillars) { + const icon = pillar.passRate >= 0.8 ? "✅" : "⚠️"; + lines.push( + `| ${icon} ${pillar.name} | ${pillar.passed} | ${pillar.total} | ${formatPercent(pillar.passRate)} |` + ); + } + lines.push(""); + } + + // Fix-first list + const fixes = rankFixes(report.criteria); + if (fixes.length > 0) { + lines.push("## Fix First"); + lines.push(""); + for (const fix of fixes) { + const detail = fix.appSummary + ? ` (${fix.appSummary.passed}/${fix.appSummary.total} apps)` + : ""; + lines.push(`- **${fix.title}**${detail} — ${fix.impact} impact, ${fix.effort} effort`); + if (fix.reason) { + lines.push(` - ${fix.reason}`); + } + } + lines.push(""); + } + + // Extras + if (report.extras.length > 0) { + lines.push("## AI Readiness Extras"); + lines.push(""); + for (const extra of report.extras) { + const icon = extra.status === "pass" ? "✅" : "❌"; + lines.push(`- ${icon} ${extra.title}`); + } + lines.push(""); + } + + // Area breakdown + if (report.areaReports?.length) { + lines.push("## Per-Area Breakdown"); + lines.push(""); + for (const ar of report.areaReports) { + const passed = ar.pillars.reduce( + (sum: number, p: ReadinessPillarSummary) => sum + p.passed, + 0 + ); + const total = ar.pillars.reduce((sum: number, p: ReadinessPillarSummary) => sum + p.total, 0); + const pct = total ? Math.round((passed / total) * 100) : 0; + lines.push(`### ${ar.area.name} — ${passed}/${total} (${pct}%)`); + lines.push(""); + const failures = ar.criteria.filter((c) => c.status === "fail"); + if (failures.length > 0) { + for (const f of failures) { + lines.push(`- ❌ ${f.title}${f.reason ? ` — ${f.reason}` : ""}`); + } + } else { + lines.push("All criteria passing."); + } + lines.push(""); + } + } + + lines.push("---"); + lines.push( + `*Generated by [Primer](https://github.com/digitarald/primer) on ${report.generatedAt}*` + ); + lines.push(""); + + return lines.join("\n"); +} diff --git a/src/commands/tui.tsx b/src/commands/tui.tsx index df451cd..e1f56a0 100644 --- a/src/commands/tui.tsx +++ b/src/commands/tui.tsx @@ -3,8 +3,8 @@ import path from "path"; import { render } from "ink"; import React from "react"; -import { outputError } from "../utils/output"; import { PrimerTui } from "../ui/tui"; +import { outputError } from "../utils/output"; type TuiOptions = { repo?: string; diff --git a/src/services/__tests__/analyze-output.test.ts b/src/services/__tests__/analyze-output.test.ts new file mode 100644 index 0000000..d0a8374 --- /dev/null +++ b/src/services/__tests__/analyze-output.test.ts @@ -0,0 +1,183 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { analyzeCommand, formatAnalysisMarkdown } from "../../commands/analyze"; +import type { RepoAnalysis } from "../analyzer"; + +describe("formatAnalysisMarkdown", () => { + function makeAnalysis(overrides: Partial = {}): RepoAnalysis { + return { + path: "/tmp/test-repo", + isGitRepo: true, + languages: ["TypeScript", "JavaScript"], + frameworks: ["React", "Next.js"], + packageManager: "npm", + ...overrides + }; + } + + it("renders a heading with repo name", () => { + const md = formatAnalysisMarkdown(makeAnalysis()); + expect(md).toContain("# Repository Analysis: test-repo"); + }); + + it("includes overview table", () => { + const md = formatAnalysisMarkdown(makeAnalysis()); + expect(md).toContain("| Languages | TypeScript, JavaScript |"); + expect(md).toContain("| Frameworks | React, Next.js |"); + expect(md).toContain("| Package manager | npm |"); + }); + + it("shows monorepo info when present", () => { + const md = formatAnalysisMarkdown( + makeAnalysis({ + isMonorepo: true, + workspaceType: "pnpm", + apps: [ + { + name: "app-a", + path: "/tmp/test-repo/apps/a", + ecosystem: "node", + packageJsonPath: "", + scripts: {}, + hasTsConfig: true + } + ] + }) + ); + expect(md).toContain("| Monorepo | pnpm (1 apps) |"); + expect(md).toContain("## Applications"); + expect(md).toContain("| app-a |"); + }); + + it("shows areas when present", () => { + const md = formatAnalysisMarkdown( + makeAnalysis({ + areas: [ + { name: "frontend", applyTo: "frontend/**", source: "auto" }, + { name: "backend", applyTo: "backend/**", source: "config" } + ] + }) + ); + expect(md).toContain("## Areas"); + expect(md).toContain("| frontend | auto |"); + expect(md).toContain("| backend | config |"); + }); + + it("handles empty languages gracefully", () => { + const md = formatAnalysisMarkdown(makeAnalysis({ languages: [], frameworks: [] })); + expect(md).toContain("| Languages | Unknown |"); + expect(md).toContain("| Frameworks | None detected |"); + }); + + it("includes primer footer", () => { + const md = formatAnalysisMarkdown(makeAnalysis()); + expect(md).toContain("Primer"); + }); +}); + +describe("analyzeCommand --output", () => { + let tmpDir: string; + + async function setup() { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "analyze-output-")); + // Create a minimal directory so analyzeRepo can run against it + await fs.mkdir(path.join(tmpDir, "repo")); + return path.join(tmpDir, "repo"); + } + + afterEach(async () => { + vi.restoreAllMocks(); + process.exitCode = undefined; + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("writes JSON file when output ends in .json", async () => { + const repoPath = await setup(); + const out = path.join(tmpDir, "report.json"); + await analyzeCommand(repoPath, { output: out }); + const content = await fs.readFile(out, "utf-8"); + const parsed = JSON.parse(content); + expect(parsed).toHaveProperty("path"); + expect(parsed).toHaveProperty("languages"); + }); + + it("writes Markdown file when output ends in .md", async () => { + const repoPath = await setup(); + const out = path.join(tmpDir, "report.md"); + await analyzeCommand(repoPath, { output: out }); + const content = await fs.readFile(out, "utf-8"); + expect(content).toContain("# Repository Analysis:"); + }); + + it("refuses to overwrite without --force", async () => { + const repoPath = await setup(); + const out = path.join(tmpDir, "report.json"); + await fs.writeFile(out, "existing"); + const spy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await analyzeCommand(repoPath, { output: out }); + spy.mockRestore(); + // File should still have original content + const content = await fs.readFile(out, "utf-8"); + expect(content).toBe("existing"); + }); + + it("overwrites with --force", async () => { + const repoPath = await setup(); + const out = path.join(tmpDir, "report.json"); + await fs.writeFile(out, "existing"); + await analyzeCommand(repoPath, { output: out, force: true }); + const content = await fs.readFile(out, "utf-8"); + expect(content).not.toBe("existing"); + expect(JSON.parse(content)).toHaveProperty("path"); + }); + + it("rejects unsupported extensions", async () => { + const repoPath = await setup(); + const out = path.join(tmpDir, "report.txt"); + const spy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await analyzeCommand(repoPath, { output: out }); + spy.mockRestore(); + expect(process.exitCode).toBe(1); + await expect(fs.access(out)).rejects.toThrow(); + }); + + it("rejects symlinks", async () => { + const repoPath = await setup(); + const real = path.join(tmpDir, "real.json"); + await fs.writeFile(real, "x"); + const link = path.join(tmpDir, "link.json"); + await fs.symlink(real, link); + const spy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await analyzeCommand(repoPath, { output: link }); + spy.mockRestore(); + // Real file content unchanged + const content = await fs.readFile(real, "utf-8"); + expect(content).toBe("x"); + }); + + it("emits JSON to stdout when --json is used with --output", async () => { + const repoPath = await setup(); + const out = path.join(tmpDir, "report.json"); + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await analyzeCommand(repoPath, { output: out, json: true, quiet: true }); + + const stdout = stdoutSpy.mock.calls + .map(([chunk]) => String(chunk)) + .join("") + .trim(); + const parsed = JSON.parse(stdout) as { ok: boolean; status: string; data: unknown }; + expect(parsed.ok).toBe(true); + expect(parsed.status).toBe("success"); + expect(parsed.data).toBeDefined(); + + const fileContent = await fs.readFile(out, "utf-8"); + expect(JSON.parse(fileContent)).toHaveProperty("path"); + }); +}); diff --git a/src/services/__tests__/boundaries.test.ts b/src/services/__tests__/boundaries.test.ts index 5233c69..999ae9c 100644 --- a/src/services/__tests__/boundaries.test.ts +++ b/src/services/__tests__/boundaries.test.ts @@ -4,10 +4,10 @@ import path from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { sanitizeError } from "../batch"; import { safeWriteFile } from "../../utils/fs"; import { deriveFileStatus, shouldLog } from "../../utils/output"; import { GITHUB_REPO_RE, AZURE_REPO_RE } from "../../utils/repo"; +import { sanitizeError } from "../batch"; // ── sanitizeError ── diff --git a/src/services/__tests__/cli.test.ts b/src/services/__tests__/cli.test.ts index 2bc48e2..ddc4f42 100644 --- a/src/services/__tests__/cli.test.ts +++ b/src/services/__tests__/cli.test.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import type { Command } from "commander"; import { describe, expect, it, vi } from "vitest"; import { withGlobalOpts } from "../../cli"; diff --git a/src/services/__tests__/fs.test.ts b/src/services/__tests__/fs.test.ts index 071f3a4..e77c25f 100644 --- a/src/services/__tests__/fs.test.ts +++ b/src/services/__tests__/fs.test.ts @@ -1,8 +1,9 @@ +import type { PathLike } from "fs"; import fs from "fs/promises"; import os from "os"; import path from "path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ensureDir, safeWriteFile } from "../../utils/fs"; @@ -101,4 +102,270 @@ describe("safeWriteFile", () => { expect(result.wrote).toBe(false); expect(result.reason).toBe("symlink"); }); + + it("rejects writes through symlinked parent directory", async () => { + const outsideDir = path.join(tmpDir, "outside"); + const symlinkParent = path.join(tmpDir, "linked"); + await fs.mkdir(outsideDir); + await fs.symlink(outsideDir, symlinkParent); + + const targetPath = path.join(symlinkParent, "blocked.txt"); + const result = await safeWriteFile(targetPath, "content", true); + + expect(result.wrote).toBe(false); + expect(result.reason).toBe("symlink"); + await expect(fs.access(path.join(outsideDir, "blocked.txt"))).rejects.toThrow(); + }); + + it("rejects writes through symlinked ancestor with nested missing directories", async () => { + const outsideDir = path.join(tmpDir, "outside"); + const symlinkParent = path.join(tmpDir, "linked"); + await fs.mkdir(outsideDir); + await fs.symlink(outsideDir, symlinkParent); + + const targetPath = path.join(symlinkParent, "nested", "deeper", "blocked.txt"); + const result = await safeWriteFile(targetPath, "content", true); + + expect(result.wrote).toBe(false); + expect(result.reason).toBe("symlink"); + await expect(fs.access(path.join(outsideDir, "nested"))).rejects.toThrow(); + }); + + it("rejects writes when closest existing ancestor is under a symlinked prefix", async () => { + const outsideDir = path.join(tmpDir, "outside"); + const existingDir = path.join(outsideDir, "existing"); + const symlinkParent = path.join(tmpDir, "linked"); + await fs.mkdir(existingDir, { recursive: true }); + await fs.symlink(outsideDir, symlinkParent); + + const targetPath = path.join(symlinkParent, "existing", "blocked.txt"); + const result = await safeWriteFile(targetPath, "content", true); + + expect(result.wrote).toBe(false); + expect(result.reason).toBe("symlink"); + await expect(fs.access(path.join(existingDir, "blocked.txt"))).rejects.toThrow(); + }); + + it("rejects symlink targets in win32 force mode", async () => { + const realFile = path.join(tmpDir, "real.txt"); + const symlink = path.join(tmpDir, "symlink.txt"); + await fs.writeFile(realFile, "original"); + await fs.symlink(realFile, symlink); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + try { + const result = await safeWriteFile(symlink, "malicious content", true); + expect(result.wrote).toBe(false); + expect(result.reason).toBe("symlink"); + const content = await fs.readFile(realFile, "utf8"); + expect(content).toBe("original"); + } finally { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it("overwrites existing regular files in win32 force mode", async () => { + const canonicalTmpDir = await fs.realpath(tmpDir); + const targetPath = path.join(canonicalTmpDir, "target.txt"); + await fs.writeFile(targetPath, "original"); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + try { + const result = await safeWriteFile(targetPath, "updated", true); + expect(result.wrote).toBe(true); + const content = await fs.readFile(targetPath, "utf8"); + expect(content).toBe("updated"); + } finally { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it("returns exists for existing regular files in win32 non-force mode", async () => { + const canonicalTmpDir = await fs.realpath(tmpDir); + const targetPath = path.join(canonicalTmpDir, "target.txt"); + await fs.writeFile(targetPath, "original"); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + try { + const result = await safeWriteFile(targetPath, "updated", false); + expect(result.wrote).toBe(false); + expect(result.reason).toBe("exists"); + const content = await fs.readFile(targetPath, "utf8"); + expect(content).toBe("original"); + } finally { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it("creates missing files in win32 force mode", async () => { + const canonicalTmpDir = await fs.realpath(tmpDir); + const targetPath = path.join(canonicalTmpDir, "missing.txt"); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + try { + const result = await safeWriteFile(targetPath, "created", true); + expect(result.wrote).toBe(true); + const content = await fs.readFile(targetPath, "utf8"); + expect(content).toBe("created"); + } finally { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it("does not replace directory targets in win32 force mode", async () => { + const canonicalTmpDir = await fs.realpath(tmpDir); + const targetPath = path.join(canonicalTmpDir, "target-dir"); + await fs.mkdir(targetPath); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + try { + const result = await safeWriteFile(targetPath, "updated", true); + expect(result.wrote).toBe(false); + expect(result.reason).toBe("exists"); + const stat = await fs.stat(targetPath); + expect(stat.isDirectory()).toBe(true); + } finally { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it("throws when win32 force replace cannot restore original file", async () => { + const canonicalTmpDir = await fs.realpath(tmpDir); + const targetPath = path.join(canonicalTmpDir, "target.txt"); + await fs.writeFile(targetPath, "original"); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + const originalRename = fs.rename.bind(fs); + const renameSpy = vi.spyOn(fs, "rename"); + let renameCallCount = 0; + renameSpy.mockImplementation(async (oldPath: PathLike, newPath: PathLike) => { + renameCallCount += 1; + if (renameCallCount === 2 || renameCallCount === 3) { + const error = new Error("EEXIST") as NodeJS.ErrnoException; + error.code = "EEXIST"; + throw error; + } + return originalRename(oldPath, newPath); + }); + + try { + let thrownError: unknown; + try { + await safeWriteFile(targetPath, "updated", true); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(Error); + const message = (thrownError as Error).message; + expect(message).toContain("Failed to restore original file"); + const backupPath = message.split("backup retained at ")[1]; + expect(backupPath).toBeTruthy(); + + const backupContent = await fs.readFile(backupPath, "utf8"); + expect(backupContent).toBe("original"); + } finally { + renameSpy.mockRestore(); + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it("restores original file when win32 force replace fails but rollback succeeds", async () => { + const canonicalTmpDir = await fs.realpath(tmpDir); + const targetPath = path.join(canonicalTmpDir, "target.txt"); + await fs.writeFile(targetPath, "original"); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + const originalRename = fs.rename.bind(fs); + const renameSpy = vi.spyOn(fs, "rename"); + let renameCallCount = 0; + renameSpy.mockImplementation(async (oldPath: PathLike, newPath: PathLike) => { + renameCallCount += 1; + if (renameCallCount === 2) { + const error = new Error("EEXIST") as NodeJS.ErrnoException; + error.code = "EEXIST"; + throw error; + } + return originalRename(oldPath, newPath); + }); + + try { + const result = await safeWriteFile(targetPath, "updated", true); + expect(result.wrote).toBe(false); + expect(result.reason).toBe("exists"); + + const content = await fs.readFile(targetPath, "utf8"); + expect(content).toBe("original"); + + const files = await fs.readdir(canonicalTmpDir); + expect(files.some((file) => file.startsWith(".primer-backup-"))).toBe(false); + expect(files.some((file) => file.startsWith(".primer-tmp-"))).toBe(false); + } finally { + renameSpy.mockRestore(); + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); }); diff --git a/src/services/__tests__/policy.test.ts b/src/services/__tests__/policy.test.ts index 41dca70..aabc05f 100644 --- a/src/services/__tests__/policy.test.ts +++ b/src/services/__tests__/policy.test.ts @@ -4,9 +4,9 @@ import path from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { ReadinessCriterion } from "../readiness"; import type { ExtraDefinition, PolicyConfig } from "../policy"; import { loadPolicy, resolveChain, parsePolicySources } from "../policy"; +import type { ReadinessCriterion } from "../readiness"; // ─── Helpers ─── diff --git a/src/services/__tests__/readiness-markdown.test.ts b/src/services/__tests__/readiness-markdown.test.ts new file mode 100644 index 0000000..459a7c1 --- /dev/null +++ b/src/services/__tests__/readiness-markdown.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import { formatReadinessMarkdown } from "../../commands/readiness"; +import type { ReadinessReport } from "../readiness"; + +describe("formatReadinessMarkdown", () => { + function makeReport(overrides: Partial = {}): ReadinessReport { + return { + repoPath: "/tmp/test-repo", + generatedAt: "2026-01-01T00:00:00.000Z", + isMonorepo: false, + apps: [], + pillars: [ + { id: "style-validation", name: "Style & Validation", passed: 2, total: 2, passRate: 1 }, + { id: "build-system", name: "Build System", passed: 1, total: 2, passRate: 0.5 }, + { id: "testing", name: "Testing", passed: 0, total: 1, passRate: 0 }, + { id: "documentation", name: "Documentation", passed: 1, total: 2, passRate: 0.5 }, + { id: "dev-environment", name: "Dev Environment", passed: 1, total: 2, passRate: 0.5 }, + { id: "code-quality", name: "Code Quality", passed: 1, total: 1, passRate: 1 }, + { id: "observability", name: "Observability", passed: 0, total: 1, passRate: 0 }, + { + id: "security-governance", + name: "Security & Governance", + passed: 2, + total: 4, + passRate: 0.5 + }, + { id: "ai-tooling", name: "AI Tooling", passed: 1, total: 4, passRate: 0.25 } + ], + levels: [ + { level: 1, name: "Functional", passed: 5, total: 6, passRate: 0.83, achieved: true }, + { level: 2, name: "Documented", passed: 3, total: 6, passRate: 0.5, achieved: false }, + { level: 3, name: "Standardized", passed: 1, total: 4, passRate: 0.25, achieved: false }, + { level: 4, name: "Optimized", passed: 0, total: 0, passRate: 0, achieved: false }, + { level: 5, name: "Autonomous", passed: 0, total: 0, passRate: 0, achieved: false } + ], + achievedLevel: 1, + criteria: [ + { + id: "lint-config", + title: "Linting configured", + pillar: "style-validation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status: "pass" + }, + { + id: "readme", + title: "README present", + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status: "fail", + reason: "Missing README documentation." + }, + { + id: "custom-instructions", + title: "Custom AI instructions", + pillar: "ai-tooling", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status: "fail", + reason: "Missing custom AI instructions." + } + ], + extras: [ + { id: "agents-doc", title: "AGENTS.md present", status: "pass" }, + { id: "architecture-doc", title: "Architecture guide present", status: "fail" } + ], + ...overrides + }; + } + + it("renders heading with repo name and level", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + expect(md).toContain("# AI Readiness Report: my-repo"); + expect(md).toContain("**Level 1** — Functional"); + }); + + it("includes pillar group sections", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + expect(md).toContain("## Repo Health"); + expect(md).toContain("## AI Setup"); + }); + + it("renders pillar summary table", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + expect(md).toContain("| Pillar | Passed | Total | Rate |"); + expect(md).toContain("Style & Validation"); + expect(md).toContain("AI Tooling"); + }); + + it("uses check emoji for passing pillars", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + // Style & Validation has 100% pass rate + expect(md).toMatch(/✅.*Style & Validation/); + }); + + it("uses warning emoji for low-pass pillars", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + // Build System has 50% pass rate + expect(md).toMatch(/⚠️.*Build System/); + }); + + it("includes fix-first section for failing criteria", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + expect(md).toContain("## Fix First"); + expect(md).toContain("README present"); + expect(md).toContain("Custom AI instructions"); + }); + + it("includes extras section", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + expect(md).toContain("## AI Readiness Extras"); + expect(md).toContain("✅ AGENTS.md present"); + expect(md).toContain("❌ Architecture guide present"); + }); + + it("includes area breakdown when present", () => { + const md = formatReadinessMarkdown( + makeReport({ + areaReports: [ + { + area: { name: "frontend", applyTo: "frontend/**", source: "auto" }, + criteria: [ + { + id: "area-readme", + title: "Area README present", + pillar: "documentation", + level: 1, + scope: "area", + impact: "medium", + effort: "low", + status: "fail", + reason: "Missing README in area directory." + } + ], + pillars: [ + { + id: "documentation", + name: "Documentation", + passed: 0, + total: 1, + passRate: 0 + } + ] + } + ] + }), + "my-repo" + ); + expect(md).toContain("## Per-Area Breakdown"); + expect(md).toContain("### frontend"); + expect(md).toContain("❌ Area README present"); + }); + + it("includes primer footer with timestamp", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + expect(md).toContain("Primer"); + expect(md).toContain("2026-01-01"); + }); + + it("handles report with no failing criteria", () => { + const md = formatReadinessMarkdown( + makeReport({ + criteria: [ + { + id: "lint-config", + title: "Linting configured", + pillar: "style-validation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status: "pass" + } + ] + }), + "my-repo" + ); + expect(md).not.toContain("## Fix First"); + }); +}); diff --git a/src/services/__tests__/readiness-output.test.ts b/src/services/__tests__/readiness-output.test.ts new file mode 100644 index 0000000..1b8e532 --- /dev/null +++ b/src/services/__tests__/readiness-output.test.ts @@ -0,0 +1,241 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { readinessCommand } from "../../commands/readiness"; + +describe("readinessCommand --output", () => { + let tmpDir: string | undefined; + + async function setupRepo(): Promise { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "readiness-output-")); + const repoPath = path.join(tmpDir, "repo"); + await fs.mkdir(repoPath); + return repoPath; + } + + afterEach(async () => { + vi.restoreAllMocks(); + process.exitCode = undefined; + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + it("writes JSON file when output ends in .json", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + + await readinessCommand(repoPath, { output: outputPath, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + const parsed = JSON.parse(content); + expect(parsed.repoPath).toBe(repoPath); + expect(parsed).toHaveProperty("achievedLevel"); + }); + + it("writes Markdown file for uppercase extension", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.MD"); + + await readinessCommand(repoPath, { output: outputPath, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + expect(content).toContain("# AI Readiness Report:"); + expect(content).toContain("## Repo Health"); + }); + + it("writes HTML file for uppercase extension", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.HTML"); + + await readinessCommand(repoPath, { output: outputPath, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + expect(content).toContain(""); + }); + + it("writes default visual HTML file when --visual is used without --output", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(repoPath, "readiness-report.html"); + + await readinessCommand(repoPath, { visual: true, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + expect(content).toContain(""); + }); + + it("rejects unsupported extensions before visual rendering", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.txt"); + const fallbackPath = path.join(repoPath, "readiness-report.html"); + + await readinessCommand(repoPath, { output: outputPath, visual: true, quiet: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.access(outputPath)).rejects.toThrow(); + await expect(fs.access(fallbackPath)).rejects.toThrow(); + }); + + it("rejects --visual with non-HTML output extension", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + const fallbackPath = path.join(repoPath, "readiness-report.html"); + + await readinessCommand(repoPath, { output: outputPath, visual: true, quiet: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.access(outputPath)).rejects.toThrow(); + await expect(fs.access(fallbackPath)).rejects.toThrow(); + }); + + it("refuses to overwrite without --force", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + await fs.writeFile(outputPath, "existing"); + + await readinessCommand(repoPath, { output: outputPath, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + expect(content).toBe("existing"); + }); + + it("overwrites with --force", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + await fs.writeFile(outputPath, "existing"); + + await readinessCommand(repoPath, { output: outputPath, force: true, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + expect(content).not.toBe("existing"); + expect(JSON.parse(content).repoPath).toBe(repoPath); + }); + + it("rejects symlink paths", async () => { + const repoPath = await setupRepo(); + const realPath = path.join(tmpDir ?? repoPath, "real.json"); + const linkPath = path.join(tmpDir ?? repoPath, "readiness.json"); + await fs.writeFile(realPath, "existing"); + await fs.symlink(realPath, linkPath); + + await readinessCommand(repoPath, { output: linkPath, quiet: true }); + + const content = await fs.readFile(realPath, "utf-8"); + expect(content).toBe("existing"); + }); + + it("sets exit code when fail-level threshold is not met", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + + await readinessCommand(repoPath, { output: outputPath, failLevel: "5", quiet: true }); + + expect(process.exitCode).toBe(1); + const content = await fs.readFile(outputPath, "utf-8"); + expect(JSON.parse(content).repoPath).toBe(repoPath); + }); + + it("creates parent directories for nested output paths", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "reports", "nested", "readiness.json"); + + await readinessCommand(repoPath, { output: outputPath, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + expect(JSON.parse(content).repoPath).toBe(repoPath); + }); + + it("emits JSON to stdout when --json is used with --output", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await readinessCommand(repoPath, { output: outputPath, json: true, quiet: true }); + + const stdout = stdoutSpy.mock.calls + .map(([chunk]) => String(chunk)) + .join("") + .trim(); + const parsed = JSON.parse(stdout) as { ok: boolean; status: string; data: unknown }; + expect(parsed.ok).toBe(true); + expect(parsed.status).toBe("success"); + expect(parsed.data).toBeDefined(); + + const fileContent = await fs.readFile(outputPath, "utf-8"); + expect(JSON.parse(fileContent).repoPath).toBe(repoPath); + }); + + it("emits JSON to stdout when --json is used with markdown output", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.md"); + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await readinessCommand(repoPath, { output: outputPath, json: true, quiet: true }); + + const stdout = stdoutSpy.mock.calls + .map(([chunk]) => String(chunk)) + .join("") + .trim(); + const parsed = JSON.parse(stdout) as { ok: boolean; status: string; data: unknown }; + expect(parsed.ok).toBe(true); + expect(parsed.status).toBe("success"); + expect(parsed.data).toBeDefined(); + + const fileContent = await fs.readFile(outputPath, "utf-8"); + expect(fileContent).toContain("# AI Readiness Report:"); + }); + + it("emits JSON to stdout when --json is used with html output", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.html"); + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await readinessCommand(repoPath, { output: outputPath, json: true, quiet: true }); + + const stdout = stdoutSpy.mock.calls + .map(([chunk]) => String(chunk)) + .join("") + .trim(); + const parsed = JSON.parse(stdout) as { ok: boolean; status: string; data: unknown }; + expect(parsed.ok).toBe(true); + expect(parsed.status).toBe("success"); + expect(parsed.data).toBeDefined(); + + const fileContent = await fs.readFile(outputPath, "utf-8"); + expect(fileContent).toContain(""); + }); + + it("emits error JSON status when fail-level threshold is not met", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await readinessCommand(repoPath, { + output: outputPath, + json: true, + quiet: true, + failLevel: "5" + }); + + const stdout = stdoutSpy.mock.calls + .map(([chunk]) => String(chunk)) + .join("") + .trim(); + const parsed = JSON.parse(stdout) as { + ok: boolean; + status: string; + errors?: string[]; + }; + expect(parsed.ok).toBe(false); + expect(parsed.status).toBe("error"); + expect(parsed.errors?.[0]).toContain("below threshold"); + expect(process.exitCode).toBe(1); + + const fileContent = await fs.readFile(outputPath, "utf-8"); + expect(JSON.parse(fileContent).repoPath).toBe(repoPath); + }); +}); diff --git a/src/services/batch.ts b/src/services/batch.ts index 5b51180..dc118e2 100644 --- a/src/services/batch.ts +++ b/src/services/batch.ts @@ -1,10 +1,12 @@ import path from "path"; import { DEFAULT_MODEL } from "../config"; +import { ensureDir, safeWriteFile, validateCachePath } from "../utils/fs"; +import type { ProgressReporter } from "../utils/output"; +import { buildInstructionsPrBody } from "../utils/pr"; + import type { AzureDevOpsRepo } from "./azureDevops"; import { createPullRequest as createAzurePullRequest } from "./azureDevops"; -import type { GitHubRepo } from "./github"; -import { createPullRequest as createGitHubPullRequest } from "./github"; import { buildAuthedUrl, checkoutBranch, @@ -14,10 +16,9 @@ import { pushBranch, setRemoteUrl } from "./git"; +import type { GitHubRepo } from "./github"; +import { createPullRequest as createGitHubPullRequest } from "./github"; import { generateCopilotInstructions } from "./instructions"; -import { ensureDir, safeWriteFile, validateCachePath } from "../utils/fs"; -import type { ProgressReporter } from "../utils/output"; -import { buildInstructionsPrBody } from "../utils/pr"; // ── Types ── diff --git a/src/services/policy.ts b/src/services/policy.ts index 5621c02..53e23a1 100644 --- a/src/services/policy.ts +++ b/src/services/policy.ts @@ -218,12 +218,17 @@ export async function loadPolicy( const config = (mod.default ?? mod) as unknown; return validatePolicyConfig(config, source); } catch (err) { + const message = + err instanceof Error + ? err.message + : typeof err === "object" && err !== null && "message" in err + ? String((err as { message: unknown }).message) + : String(err); if ( - err instanceof Error && - (err.message.includes("Cannot find module") || - err.message.includes("Cannot find package") || - err.message.includes("MODULE_NOT_FOUND") || - err.message.includes("ERR_MODULE_NOT_FOUND")) + message.includes("Cannot find module") || + message.includes("Cannot find package") || + message.includes("MODULE_NOT_FOUND") || + message.includes("ERR_MODULE_NOT_FOUND") ) { throw new Error(`Policy "${source}" not found. Install it with: npm install ${source}`); } diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 726a2ef..f87a18c 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -1,3 +1,4 @@ +import { constants as fsConstants } from "fs"; import fs from "fs/promises"; import path from "path"; @@ -13,22 +14,261 @@ export async function safeWriteFile( force: boolean ): Promise { const resolved = path.resolve(filePath); + const noFollowFlag = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW; + + if (await hasSymlinkAncestor(resolved)) { + return { wrote: false, reason: "symlink" }; + } + + await fs.mkdir(path.dirname(resolved), { recursive: true }); + if (await hasSymlinkAncestor(resolved)) { + return { wrote: false, reason: "symlink" }; + } + + if (process.platform === "win32") { + try { + const stat = await fs.lstat(resolved); + if (stat.isSymbolicLink()) { + return { wrote: false, reason: "symlink" }; + } + if (!force) { + return { wrote: false, reason: "exists" }; + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + throw error; + } + } + } + + if (process.platform === "win32" && force) { + return replaceFileWindows(resolved, content); + } + + const flags = force + ? fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | noFollowFlag + : fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollowFlag; - // Reject symlinks to prevent writing through them to unintended locations try { - const stat = await fs.lstat(resolved); - if (stat.isSymbolicLink()) { + const handle = await fs.open(resolved, flags, 0o666); + try { + await handle.writeFile(content, "utf8"); + } finally { + await handle.close(); + } + return { wrote: true }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST") { + try { + const stat = await fs.lstat(resolved); + if (stat.isSymbolicLink()) { + return { wrote: false, reason: "symlink" }; + } + } catch { + // Ignore stat errors and fall through to generic exists handling + } + return { wrote: false, reason: "exists" }; + } + if (code === "ELOOP") { return { wrote: false, reason: "symlink" }; } - if (!force) { + throw error; + } +} + +async function replaceFileWindows(targetPath: string, content: string): Promise { + const parentDir = path.dirname(targetPath); + const tempPath = path.join( + parentDir, + `.primer-tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + const backupPath = path.join( + parentDir, + `.primer-backup-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + + const tempHandle = await fs.open( + tempPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, + 0o666 + ); + try { + await tempHandle.writeFile(content, "utf8"); + } finally { + await tempHandle.close(); + } + + let movedOriginal = false; + let placedReplacement = false; + let restoredOriginal = false; + let restoreFailed = false; + try { + try { + const stat = await fs.lstat(targetPath); + if (stat.isSymbolicLink()) { + await fs.rm(tempPath, { force: true }); + return { wrote: false, reason: "symlink" }; + } + if (stat.isDirectory()) { + await fs.rm(tempPath, { force: true }); + return { wrote: false, reason: "exists" }; + } + await fs.rename(targetPath, backupPath); + movedOriginal = true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + throw error; + } + } + + await fs.rename(tempPath, targetPath); + placedReplacement = true; + return { wrote: true }; + } catch (error) { + await fs.rm(tempPath, { force: true }); + + if (movedOriginal) { + try { + await fs.rename(backupPath, targetPath); + restoredOriginal = true; + } catch { + restoreFailed = true; + } + } + + if (restoreFailed) { + throw new Error( + `Failed to restore original file after replacement failure; backup retained at ${backupPath}` + ); + } + + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST") { + try { + const stat = await fs.lstat(targetPath); + if (stat.isSymbolicLink()) { + return { wrote: false, reason: "symlink" }; + } + } catch { + // Ignore lstat errors and fall through + } return { wrote: false, reason: "exists" }; } - } catch { - // File does not exist — safe to create + + throw error; + } finally { + if (movedOriginal && (placedReplacement || restoredOriginal)) { + await fs.rm(backupPath, { force: true }); + } + } +} + +async function hasSymlinkAncestor(filePath: string): Promise { + const parentDir = path.dirname(filePath); + const closestExistingAncestor = await findClosestExistingAncestor(parentDir); + const closestAncestorStat = await fs.lstat(closestExistingAncestor); + if (closestAncestorStat.isSymbolicLink()) { + return true; + } + + const realClosestAncestor = await fs.realpath(closestExistingAncestor); + if ( + realClosestAncestor !== closestExistingAncestor && + !isAllowedSystemAlias(closestExistingAncestor, realClosestAncestor) + ) { + // On Windows, 8.3 short filenames (e.g. RUNNER~1 → runneradmin) cause + // realpath to differ without any symlinks. Walk each ancestor component + // to check for actual symlinks before concluding. + if (process.platform === "win32") { + const parsed = path.parse(closestExistingAncestor); + const relative = path.relative(parsed.root, closestExistingAncestor); + const components = relative.split(path.sep).filter(Boolean); + let current = parsed.root; + let foundSymlink = false; + for (const component of components) { + current = path.join(current, component); + try { + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + foundSymlink = true; + break; + } + } catch { + break; + } + } + if (foundSymlink) { + return true; + } + } else { + return true; + } + } + + const relativeParent = path.relative(closestExistingAncestor, parentDir); + const segments = relativeParent.split(path.sep).filter(Boolean); + let currentPath = closestExistingAncestor; + + for (const segment of segments) { + currentPath = path.join(currentPath, segment); + try { + const stat = await fs.lstat(currentPath); + if (stat.isSymbolicLink()) { + return true; + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + break; + } + throw error; + } } - await fs.writeFile(resolved, content, "utf8"); - return { wrote: true }; + return false; +} + +async function findClosestExistingAncestor(targetDir: string): Promise { + let currentDir = targetDir; + + while (true) { + try { + await fs.lstat(currentDir); + return currentDir; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + throw error; + } + + const nextDir = path.dirname(currentDir); + if (nextDir === currentDir) { + return currentDir; + } + currentDir = nextDir; + } + } +} + +function isAllowedSystemAlias(originalPath: string, realPath: string): boolean { + if (process.platform !== "darwin") { + return false; + } + + const allowsVarAlias = + (originalPath === "/var" || originalPath.startsWith("/var/")) && + (realPath === "/private/var" || realPath.startsWith("/private/var/")) && + originalPath.slice("/var".length) === realPath.slice("/private/var".length); + + const allowsTmpAlias = + (originalPath === "/tmp" || originalPath.startsWith("/tmp/")) && + (realPath === "/private/tmp" || realPath.startsWith("/private/tmp/")) && + originalPath.slice("/tmp".length) === realPath.slice("/private/tmp".length); + + return allowsVarAlias || allowsTmpAlias; } /**