diff --git a/README.md b/README.md index a34991a..cd928af 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,70 @@ The goal is simple: make technical debt visible, actionable, and steadily smalle ## Project status -Architecture and initial delivery contracts are specified; implementation proceeds through tested vertical slices beginning with deterministic local vulnerability analysis and selected GitHub Finding Issue publication. +The first tracer slice delivers deterministic local vulnerability analysis and user-selected GitHub Finding Issue publication. - [Specification](SPEC.md) - [Architecture](docs/architecture.md) - [Domain language](CONTEXT.md) - [Architecture decisions](docs/adr/) - [Implementation plan](docs/implementation-plan.md) + +## Prerequisites + +- Node.js 22 or newer +- [GitHub CLI](https://cli.github.com/) authenticated for publication (`gh auth login`) +- [Trivy](https://trivy.dev/) on `PATH` within the supported version range + +## Install + +Project-local (recommended): + +```bash +npm install techdebtter +``` + +One-off: + +```bash +npx techdebtter --help +``` + +## Usage + +Analyze a local checkout and write a versioned JSON report: + +```bash +techdebtter analyze . --format json --output /tmp/report.json +``` + +Review findings in the terminal (default), JSON, or Markdown. Each finding has a stable `selectionId` for publication. + +Publish selected findings to GitHub Finding Issues: + +```bash +techdebtter publish /tmp/report.json --select --yes +``` + +Omit `--yes` in an interactive terminal to review the intended issue writes before confirming. In non-interactive environments, `--yes` is required. + +Inspect CLI capabilities for skill or automation negotiation: + +```bash +techdebtter capabilities --json +``` + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | Success | +| `2` | Invalid input, policy, report, or publication selection | +| `3` | Missing prerequisite or authentication failure | +| `4` | Operational failure or cancelled publication | +| `10` | `--fail-on` threshold met during analyze | + +Machine-readable errors are written to `stderr` as JSON so `stdout` remains valid JSON when `--format json` is used. + +## Non-reproducible reports + +Dirty worktrees are rejected by default. Pass `--include-uncommitted` to analyze uncommitted changes; the resulting report is marked non-reproducible and **cannot be published** until changes are committed and the repository is reanalyzed. diff --git a/SPEC.md b/SPEC.md index a88b254..98051b2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -72,7 +72,7 @@ Continuously turn concrete repository debt into prioritized, auditable GitHub wo |---|---|---|---| | T1 | done | domain + policy + fingerprint + Criticality + report contracts | I6,I7,I9,V1-V15,V24 | | T2 | done | local Git/Trivy analysis + KEV/EPSS enrichment | I1,I4,I9,V1-V15,V24-V25 | -| T3 | wip | interactive selection + idempotent Finding Issue publication | I2,I5,I8,V4-V12,V16,V19,V28 | +| T3 | done | interactive selection + idempotent Finding Issue publication | I2,I5,I8,V4-V12,V16,V19,V28 | | T4 | pending | installable thin `/techdebtter` skill | I3,I10,V28 | | T5 | pending | database-free GitHub Actions controller + App auth | I11,V16-V18,V24-V27,V30 | | T6 | pending | npm + `package-lock.json` Remediator + draft PR/CI verification | V17-V23,V30 | diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 1ac4e54..3feed03 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -8,7 +8,7 @@ This document tracks delivery progress, repository layout, and acceptance criter |---|---|---|---| | Domain, policy, fingerprints, Criticality, report contracts | T1 | done | `src/domain/`, `schemas/policy.schema.json`, `schemas/analysis-report.schema.json` | | Local Git + Trivy analysis with KEV/EPSS enrichment | T2 | done | `src/adapters/{git,trivy,kev,epss,fs-cache}.ts`, `src/application/analyze.ts`, `analyze` CLI | -| Interactive selection + idempotent Finding Issue publication | T3 | in progress | `publish()` and GitHub adapter implemented; `publish` CLI and full tracer acceptance pending | +| Interactive selection + idempotent Finding Issue publication | T3 | done | `publish()` CLI, GitHub adapter, tracer acceptance test | | Installable `/techdebtter` skill | T4 | pending | `skills/techdebtter/SKILL.md` placeholder only | | Database-free GitHub Actions controller | T5 | pending | `src/action/` not started | | npm + `package-lock.json` Remediator | T6 | pending | | @@ -42,18 +42,7 @@ Key interfaces: ## Remaining first-tracer work (T3 completion) -### CLI `publish` and `capabilities` - -**Files:** `src/cli/main.ts`, `test/cli/publish.test.ts`, `test/cli/capabilities.test.ts`, `test/cli/tracer.test.ts`, `README.md` - -**Acceptance criteria:** - -- `techdebtter publish --select ` wires Task 7 `publish()`; rejects unknown IDs, duplicates, non-reproducible reports, schema failures, unverified Organization Policy, and out-of-scope owner/repo. -- Interactive mode summarizes intended issue writes before confirmation; `--yes` only for already explicit selections. -- `--format json` emits `PublicationResult` on stdout; operational errors stay on stderr. -- `techdebtter capabilities --json` reports CLI version, supported report schema versions, commands, detectors, and publication support. -- Full tracer integration test: temp Git repo + fake Trivy/fetch/GitHub → `analyze --format json --output ` → `publish --select --yes` twice → one Finding Issue created then reconciled on second run. -- README documents prerequisites, install/`npx` usage, analyze/report/select/publish flow, exit codes, and non-reproducible report restriction. +T3 is complete. Next slice: installable skill (T4). ## Next slice: installable skill (T4) diff --git a/src/cli/bootstrap.ts b/src/cli/bootstrap.ts index 41e1f83..70dc4ab 100644 --- a/src/cli/bootstrap.ts +++ b/src/cli/bootstrap.ts @@ -1,13 +1,18 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { Octokit } from "@octokit/rest"; +import { activeGhToken } from "../adapters/gh-auth.js"; import { LocalGitRepositorySource } from "../adapters/git.js"; +import { OctokitGitHubGateway } from "../adapters/github.js"; import { readRepositoryPolicyFile } from "../adapters/local-policy.js"; import { FileSystemCache } from "../adapters/fs-cache.js"; import { CisaKevProvider } from "../adapters/kev.js"; import { FirstEpssProvider } from "../adapters/epss.js"; +import { execProcessRunner } from "../adapters/process.js"; import { TrivyVulnerabilityDetector } from "../adapters/trivy.js"; import type { AnalyzeDependencies } from "../application/analyze.js"; +import type { PublishDependencies } from "../application/publish.js"; let defaultCacheRoot: string | undefined; @@ -30,6 +35,14 @@ export function createDefaultAnalyzeDependencies(): AnalyzeDependencies { }; } +export async function createDefaultPublishDependencies(): Promise { + const token = await activeGhToken(execProcessRunner); + const octokit = new Octokit({ auth: token }); + return { + gateway: new OctokitGitHubGateway({ octokit }), + }; +} + export async function createCliTestCacheRoot(): Promise<{ path: string; cleanup: () => Promise; diff --git a/src/cli/main.ts b/src/cli/main.ts index 160b590..27546c8 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,12 +1,17 @@ -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; +import { createInterface } from "node:readline/promises"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { stdin as input, stdout as output } from "node:process"; import { Command } from "commander"; import type { Writable } from "node:stream"; -import { analyze, type AnalyzeDependencies } from "../application/analyze.js"; +import { GhAuthError } from "../adapters/gh-auth.js"; import { PrerequisiteError } from "../adapters/errors.js"; +import { analyze, type AnalyzeDependencies } from "../application/analyze.js"; +import { publish, type PublishDependencies } from "../application/publish.js"; +import { PublishError } from "../application/publish-error.js"; import { PolicyError } from "../domain/policy-error.js"; -import type { Criticality } from "../domain/model.js"; +import type { AnalysisReport, Criticality, OperatingScope } from "../domain/model.js"; import { EXIT_FAIL_ON, EXIT_INVALID, @@ -17,6 +22,7 @@ import { import { exceedsFailOnThreshold, renderMarkdown, + renderPublicationTerminal, renderTerminal, } from "./render.js"; @@ -27,7 +33,10 @@ export interface CliIo { export interface RunCliOptions { dependencies: AnalyzeDependencies; + publishDependencies?: PublishDependencies; + createPublishDependencies?: () => Promise; io?: CliIo; + confirm?: (summary: string) => Promise; } const PACKAGE_VERSION = "0.1.0"; @@ -38,6 +47,7 @@ export async function runCli( options: RunCliOptions, ): Promise { const io = options.io ?? { stdout: process.stdout, stderr: process.stderr }; + let exitCode = EXIT_SUCCESS; const program = new Command(); program @@ -53,8 +63,7 @@ export async function runCli( .option("--output ", "Write report output to a file") .option("--fail-on ", "Exit with code 10 when findings meet threshold") .action(async (path: string, flags: AnalyzeFlags) => { - const exitCode = await runAnalyzeCommand(path, flags, options.dependencies, io); - process.exitCode = exitCode; + exitCode = await runAnalyzeCommand(path, flags, options.dependencies, io); }); program @@ -77,16 +86,18 @@ export async function runCli( program .command("publish") - .description("Publish selected findings to GitHub (implemented in a later slice)") - .action(() => { - writeStructuredError(io.stderr, "publish is not implemented yet", "not-implemented"); - process.exitCode = EXIT_OPERATIONAL; + .description("Publish selected findings to GitHub Finding Issues") + .argument("", "Path to an analysis report JSON file") + .requiredOption("--select ", "Finding selection IDs to publish") + .option("--format ", "Output format", "terminal") + .option("--yes", "Skip confirmation prompt") + .action(async (reportPath: string, flags: PublishFlags) => { + exitCode = await runPublishCommand(reportPath, flags, options, io); }); try { await program.parseAsync(argv); - const exitCode = process.exitCode ?? EXIT_SUCCESS; - return typeof exitCode === "number" ? exitCode : EXIT_OPERATIONAL; + return exitCode; } catch (error) { return mapErrorToExitCode(error, io); } @@ -99,6 +110,12 @@ interface AnalyzeFlags { failOn?: string; } +interface PublishFlags { + select: string[]; + format?: string; + yes?: boolean; +} + async function runAnalyzeCommand( path: string, flags: AnalyzeFlags, @@ -159,7 +176,133 @@ async function runAnalyzeCommand( } } +async function runPublishCommand( + reportPath: string, + flags: PublishFlags, + options: RunCliOptions, + io: CliIo, +): Promise { + const format = flags.format ?? "terminal"; + if (!["terminal", "json"].includes(format)) { + writeStructuredError(io.stderr, `Unsupported format: ${format}`, "invalid-format"); + return EXIT_INVALID; + } + + let report: AnalysisReport; + try { + const raw = await readFile(resolve(reportPath), "utf8"); + report = JSON.parse(raw) as AnalysisReport; + } catch (error) { + writeStructuredError( + io.stderr, + error instanceof Error ? error.message : "Failed to read analysis report", + "invalid-report", + ); + return EXIT_INVALID; + } + + const scope = scopeFromReport(report); + const selectedFindings = flags.select.map((selectionId) => { + const finding = report.findings.find((entry) => entry.selectionId === selectionId); + if (!finding) { + return { selectionId, title: selectionId }; + } + return { selectionId, title: finding.title }; + }); + + const summary = buildPublishSummary(report, selectedFindings); + if (!flags.yes) { + if (!input.isTTY && !options.confirm) { + writeStructuredError( + io.stderr, + "Refusing to publish without --yes in non-interactive mode", + "confirmation-required", + ); + return EXIT_INVALID; + } + const confirmed = await confirmPublication(summary, options.confirm, io); + if (!confirmed) { + writeStructuredError(io.stderr, "Publication cancelled", "cancelled"); + return EXIT_OPERATIONAL; + } + } + + try { + const publishDependencies = + options.publishDependencies ?? + (options.createPublishDependencies + ? await options.createPublishDependencies() + : undefined); + if (!publishDependencies) { + writeStructuredError( + io.stderr, + "Publish dependencies are not configured", + "not-configured", + ); + return EXIT_OPERATIONAL; + } + + const result = await publish(report, flags.select, scope, publishDependencies); + const rendered = + format === "json" + ? `${JSON.stringify(result, null, 2)}\n` + : renderPublicationTerminal(result); + io.stdout.write(rendered); + return EXIT_SUCCESS; + } catch (error) { + return mapErrorToExitCode(error, io); + } +} + +function scopeFromReport(report: AnalysisReport): OperatingScope { + return { + organization: report.snapshot.owner, + repositories: [report.snapshot.repo], + localPath: "", + includeUncommitted: false, + }; +} + +function buildPublishSummary( + report: AnalysisReport, + selectedFindings: Array<{ selectionId: string; title: string }>, +): string { + const lines = [ + `Publish ${selectedFindings.length} finding(s) to ${report.snapshot.owner}/${report.snapshot.repo}:`, + ]; + for (const finding of selectedFindings) { + lines.push(` - ${finding.selectionId}: ${finding.title}`); + } + return lines.join("\n"); +} + +async function confirmPublication( + summary: string, + confirm: RunCliOptions["confirm"], + io: CliIo, +): Promise { + io.stdout.write(`${summary}\nProceed? [y/N] `); + if (confirm) { + return confirm(summary); + } + const rl = createInterface({ input, output }); + try { + const answer = await rl.question(""); + return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes"; + } finally { + rl.close(); + } +} + function mapErrorToExitCode(error: unknown, io: CliIo): number { + if (error instanceof PublishError) { + writeStructuredError(io.stderr, error.message, error.code); + return EXIT_INVALID; + } + if (error instanceof GhAuthError) { + writeStructuredError(io.stderr, error.message, error.code); + return EXIT_PREREQUISITE; + } if (error instanceof PrerequisiteError) { writeStructuredError(io.stderr, error.message, error.code); return EXIT_PREREQUISITE; @@ -193,9 +336,11 @@ function isCriticality(value: string): value is Criticality { const entryPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : ""; if (import.meta.url === entryPath) { - const { createDefaultAnalyzeDependencies } = await import("./bootstrap.js"); + const { createDefaultAnalyzeDependencies, createDefaultPublishDependencies } = + await import("./bootstrap.js"); const exitCode = await runCli(process.argv, { dependencies: createDefaultAnalyzeDependencies(), + createPublishDependencies: createDefaultPublishDependencies, }); process.exitCode = exitCode; } diff --git a/src/cli/render.ts b/src/cli/render.ts index 02d1ed6..69718fc 100644 --- a/src/cli/render.ts +++ b/src/cli/render.ts @@ -1,4 +1,9 @@ -import type { AnalysisReport, Criticality, Finding } from "../domain/model.js"; +import type { + AnalysisReport, + Criticality, + Finding, + PublicationResult, +} from "../domain/model.js"; export function renderTerminal(report: AnalysisReport): string { const lines: string[] = [ @@ -82,3 +87,22 @@ export function exceedsFailOnThreshold( (finding) => rank[finding.effectiveCriticality] <= minimumRank, ); } + +export function renderPublicationTerminal(result: PublicationResult): string { + const lines: string[] = [`Published ${result.published.length} finding(s):`]; + + for (const entry of result.published) { + lines.push( + ` ${entry.selectionId} -> #${entry.issueNumber} (${entry.action}) ${entry.issueUrl}`, + ); + } + + if (result.warnings.length > 0) { + lines.push("", "Warnings:"); + for (const warning of result.warnings) { + lines.push(`- ${warning}`); + } + } + + return `${lines.join("\n")}\n`; +} diff --git a/src/index.ts b/src/index.ts index ddb3382..5ec7f25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -123,6 +123,7 @@ export type { CliIo, RunCliOptions } from "./cli/main.js"; export { exceedsFailOnThreshold, renderMarkdown, + renderPublicationTerminal, renderTerminal, } from "./cli/render.js"; diff --git a/test/cli/capabilities.test.ts b/test/cli/capabilities.test.ts new file mode 100644 index 0000000..3d5d590 --- /dev/null +++ b/test/cli/capabilities.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { EXIT_SUCCESS } from "../../src/cli/exit-codes.js"; +import { runCli } from "../../src/cli/main.js"; +import { captureIo } from "./helpers.js"; + +describe("runCli capabilities", () => { + it("emits JSON capabilities on stdout with --json", async () => { + const captured = captureIo(); + const exitCode = await runCli(["node", "techdebtter", "capabilities", "--json"], { + dependencies: createAnalyzeDependencies(), + io: captured.io, + }); + + expect(exitCode).toBe(EXIT_SUCCESS); + const payload = JSON.parse(captured.stdout) as { + cliVersion: string; + reportSchemaVersions: string[]; + commands: string[]; + detectors: string[]; + publicationSupported: boolean; + }; + + expect(payload.cliVersion).toBe("0.1.0"); + expect(payload.reportSchemaVersions).toEqual(["1.0.0"]); + expect(payload.commands).toEqual(["analyze", "capabilities", "publish"]); + expect(payload.detectors).toEqual(["trivy-vulnerability"]); + expect(payload.publicationSupported).toBe(true); + }); +}); + +function createAnalyzeDependencies() { + return { + repositorySource: { + async snapshot() { + return { + owner: "acme", + repo: "api", + commitSha: "a".repeat(40), + dirty: false, + }; + }, + }, + detectors: [], + enrichmentProviders: [], + readOrganizationPolicy: async () => ({ state: "absent" as const }), + readRepositoryPolicy: async () => ({ state: "absent" as const }), + clock: { now: () => new Date("2026-08-31T12:00:00.000Z") }, + }; +} diff --git a/test/cli/helpers.ts b/test/cli/helpers.ts new file mode 100644 index 0000000..0e18ac0 --- /dev/null +++ b/test/cli/helpers.ts @@ -0,0 +1,63 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { Writable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { withReportHash } from "../../src/application/report-hash.js"; +import type { AnalysisReport } from "../../src/domain/model.js"; + +const fixturePath = join( + dirname(fileURLToPath(import.meta.url)), + "../fixtures/reports/v1.json", +); + +export function loadVerifiedReportFixture(): AnalysisReport { + const baseReport = JSON.parse(readFileSync(fixturePath, "utf8")) as AnalysisReport; + const { reportHash: _fixtureHash, ...baseReportBody } = baseReport; + return withReportHash({ + ...baseReportBody, + policy: { + verified: true, + sources: ["product-defaults", "organization-absent", "repository-absent"], + }, + warnings: [], + }); +} + +export function writeReportFixture(report: AnalysisReport): string { + const directory = mkdtempSync(join(tmpdir(), "techdebtter-report-")); + const filePath = join(directory, "report.json"); + writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + return filePath; +} + +export function captureIo(): { + stdout: string; + stderr: string; + io: { stdout: Writable; stderr: Writable }; +} { + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + return { + get stdout() { + return stdoutChunks.join(""); + }, + get stderr() { + return stderrChunks.join(""); + }, + io: { + stdout: createWritable(stdoutChunks), + stderr: createWritable(stderrChunks), + }, + }; +} + +function createWritable(chunks: string[]): Writable { + return new Writable({ + write(chunk, _encoding, callback) { + chunks.push(String(chunk)); + callback(); + }, + }); +} diff --git a/test/cli/publish.test.ts b/test/cli/publish.test.ts new file mode 100644 index 0000000..ed7d315 --- /dev/null +++ b/test/cli/publish.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from "vitest"; +import { withReportHash } from "../../src/application/report-hash.js"; +import type { PublicationResult } from "../../src/domain/model.js"; +import type { GitHubGateway } from "../../src/domain/ports.js"; +import { + EXIT_INVALID, + EXIT_OPERATIONAL, + EXIT_SUCCESS, +} from "../../src/cli/exit-codes.js"; +import { runCli } from "../../src/cli/main.js"; +import { captureIo, loadVerifiedReportFixture, writeReportFixture } from "./helpers.js"; + +describe("runCli publish", () => { + it("requires explicit selection IDs", async () => { + const captured = captureIo(); + const reportPath = writeReportFixture(loadVerifiedReportFixture()); + + const exitCode = await runCli( + ["node", "techdebtter", "publish", reportPath, "--yes"], + { + dependencies: createAnalyzeDependencies(), + publishDependencies: { gateway: createGateway() }, + io: captured.io, + }, + ); + + expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(captured.stdout).toBe(""); + }); + + it("publishes selected findings with --yes and emits JSON on stdout", async () => { + const captured = captureIo(); + const report = loadVerifiedReportFixture(); + const reportPath = writeReportFixture(report); + const gateway = createGateway(); + + const exitCode = await runCli( + [ + "node", + "techdebtter", + "publish", + reportPath, + "--select", + report.findings[0]!.selectionId, + "--yes", + "--format", + "json", + ], + { + dependencies: createAnalyzeDependencies(), + publishDependencies: { gateway }, + io: captured.io, + }, + ); + + expect(exitCode).toBe(EXIT_SUCCESS); + const result = JSON.parse(captured.stdout) as PublicationResult; + expect(result.published).toHaveLength(1); + expect(result.published[0]?.action).toBe("created"); + expect(vi.mocked(gateway.reconcileFinding)).toHaveBeenCalledOnce(); + }); + + it("summarizes intended writes and requires confirmation without --yes", async () => { + const captured = captureIo(); + const report = loadVerifiedReportFixture(); + const reportPath = writeReportFixture(report); + const gateway = createGateway(); + const confirm = vi.fn(async () => false); + + const exitCode = await runCli( + [ + "node", + "techdebtter", + "publish", + reportPath, + "--select", + report.findings[0]!.selectionId, + ], + { + dependencies: createAnalyzeDependencies(), + publishDependencies: { gateway }, + confirm, + io: captured.io, + }, + ); + + expect(exitCode).toBe(EXIT_OPERATIONAL); + expect(captured.stdout).toContain("Publish 1 finding(s) to acme/api"); + expect(captured.stdout).toContain(report.findings[0]!.selectionId); + expect(confirm).toHaveBeenCalledOnce(); + expect(vi.mocked(gateway.reconcileFinding)).not.toHaveBeenCalled(); + }); + + it("rejects unknown selection IDs with structured stderr", async () => { + const captured = captureIo(); + const reportPath = writeReportFixture(loadVerifiedReportFixture()); + + const exitCode = await runCli( + [ + "node", + "techdebtter", + "publish", + reportPath, + "--select", + "missing-id", + "--yes", + ], + { + dependencies: createAnalyzeDependencies(), + publishDependencies: { gateway: createGateway() }, + io: captured.io, + }, + ); + + expect(exitCode).toBe(EXIT_INVALID); + expect(captured.stdout).toBe(""); + expect(JSON.parse(captured.stderr)).toMatchObject({ + code: "unknown-selection", + }); + }); + + it("rejects non-reproducible reports", async () => { + const captured = captureIo(); + const report = loadVerifiedReportFixture(); + const { reportHash: _, ...reportBody } = report; + const nonReproducible = writeReportFixture( + withReportHash({ + ...reportBody, + reproducible: false, + snapshot: { ...report.snapshot, dirty: true }, + }), + ); + + const exitCode = await runCli( + [ + "node", + "techdebtter", + "publish", + nonReproducible, + "--select", + report.findings[0]!.selectionId, + "--yes", + ], + { + dependencies: createAnalyzeDependencies(), + publishDependencies: { gateway: createGateway() }, + io: captured.io, + }, + ); + + expect(exitCode).toBe(EXIT_INVALID); + expect(JSON.parse(captured.stderr)).toMatchObject({ + code: "non-reproducible", + }); + }); + + it("refuses to publish without --yes in non-interactive mode", async () => { + const captured = captureIo(); + const report = loadVerifiedReportFixture(); + const reportPath = writeReportFixture(report); + + const exitCode = await runCli( + [ + "node", + "techdebtter", + "publish", + reportPath, + "--select", + report.findings[0]!.selectionId, + ], + { + dependencies: createAnalyzeDependencies(), + publishDependencies: { gateway: createGateway() }, + io: captured.io, + }, + ); + + expect(exitCode).toBe(EXIT_INVALID); + expect(JSON.parse(captured.stderr)).toMatchObject({ + code: "confirmation-required", + }); + }); +}); + +function createGateway(overrides: Partial = {}): GitHubGateway { + const reconcileFinding = vi.fn(async (_snapshot, finding) => + publishedEntry(finding.selectionId, 42, "created"), + ); + return { + readOrganizationPolicy: vi.fn(), + readRepositoryPolicy: vi.fn(), + reconcileFinding, + ...overrides, + }; +} + +function publishedEntry( + selectionId: string, + issueNumber: number, + action: PublicationResult["published"][number]["action"], +): PublicationResult["published"][number] { + return { + selectionId, + issueNumber, + issueUrl: `https://github.com/acme/api/issues/${issueNumber}`, + action, + }; +} + +function createAnalyzeDependencies() { + return { + repositorySource: { async snapshot() { return { owner: "acme", repo: "api", commitSha: "a".repeat(40), dirty: false }; } }, + detectors: [], + enrichmentProviders: [], + readOrganizationPolicy: async () => ({ state: "absent" as const }), + readRepositoryPolicy: async () => ({ state: "absent" as const }), + clock: { now: () => new Date("2026-08-31T12:00:00.000Z") }, + }; +} diff --git a/test/cli/tracer.test.ts b/test/cli/tracer.test.ts new file mode 100644 index 0000000..6a97527 --- /dev/null +++ b/test/cli/tracer.test.ts @@ -0,0 +1,191 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AnalyzeDependencies } from "../../src/application/analyze.js"; +import { createAnalysisReportValidator } from "../../src/application/report-schema.js"; +import type { + Detection, + Evidence, + PublicationResult, + RepositorySnapshot, +} from "../../src/domain/model.js"; +import type { GitHubGateway } from "../../src/domain/ports.js"; +import { EXIT_SUCCESS } from "../../src/cli/exit-codes.js"; +import { runCli } from "../../src/cli/main.js"; +import { captureIo } from "./helpers.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("local analyze-to-issue tracer", () => { + it("analyzes, publishes once, and reconciles on the second publish", async () => { + const dir = await mkdtemp(join(tmpdir(), "techdebtter-tracer-")); + tempDirs.push(dir); + const reportPath = join(dir, "report.json"); + const gateway = createTrackingGateway(); + const dependencies = createTracerAnalyzeDependencies(); + const captured = captureIo(); + + const analyzeExit = await runCli( + [ + "node", + "techdebtter", + "analyze", + ".", + "--format", + "json", + "--output", + reportPath, + ], + { + dependencies, + io: captured.io, + }, + ); + + expect(analyzeExit).toBe(EXIT_SUCCESS); + const report = JSON.parse(await readFile(reportPath, "utf8")) as { + findings: Array<{ selectionId: string }>; + policy: { verified: boolean }; + }; + expect(createAnalysisReportValidator()(report)).toBe(true); + expect(report.policy.verified).toBe(true); + expect(report.findings).toHaveLength(1); + + const selectionId = report.findings[0]!.selectionId; + const publishArgs = [ + "node", + "techdebtter", + "publish", + reportPath, + "--select", + selectionId, + "--yes", + "--format", + "json", + ] as const; + + const firstPublishExit = await runCli([...publishArgs], { + dependencies, + publishDependencies: { gateway }, + io: captureIo().io, + }); + expect(firstPublishExit).toBe(EXIT_SUCCESS); + + const secondPublishExit = await runCli([...publishArgs], { + dependencies, + publishDependencies: { gateway }, + io: captureIo().io, + }); + expect(secondPublishExit).toBe(EXIT_SUCCESS); + + expect(vi.mocked(gateway.reconcileFinding)).toHaveBeenCalledTimes(2); + expect(gateway.invocations[0]?.action).toBe("created"); + expect(gateway.invocations[1]?.action).toBe("updated"); + expect(gateway.invocations[0]?.issueNumber).toBe(gateway.invocations[1]?.issueNumber); + }); +}); + +function createTrackingGateway(): GitHubGateway & { + invocations: PublicationResult["published"]; +} { + const invocations: PublicationResult["published"] = []; + const reconcileFinding = vi.fn(async (_snapshot, finding) => { + const entry = + invocations.length === 0 + ? publishedEntry(finding.selectionId, 101, "created") + : { + ...invocations[0]!, + action: "updated" as const, + }; + invocations.push(entry); + return entry; + }); + + return { + readOrganizationPolicy: vi.fn(), + readRepositoryPolicy: vi.fn(), + reconcileFinding, + invocations, + }; +} + +function createTracerAnalyzeDependencies(): AnalyzeDependencies { + const snapshot: RepositorySnapshot = { + owner: "acme", + repo: "api", + commitSha: "a".repeat(40), + dirty: false, + }; + + return { + repositorySource: { + async snapshot() { + return snapshot; + }, + }, + detectors: [ + { + id: "trivy-vulnerability", + async detect() { + return [baseDetection()]; + }, + }, + ], + enrichmentProviders: [ + { + id: "cisa-kev", + async enrich() { + return { evidenceByVulnerability: new Map(), warnings: [] }; + }, + }, + ], + readOrganizationPolicy: async () => ({ state: "absent" }), + readRepositoryPolicy: async () => ({ state: "absent" }), + clock: { + now: () => new Date("2026-08-31T12:00:00.000Z"), + }, + }; +} + +function baseDetection(): Detection { + return { + fingerprint: "det-1", + detector: "trivy-vulnerability", + detectorVersion: "0.60.0", + class: "vulnerability", + packageEcosystem: "npm", + packageName: "lodash", + installedVersion: "4.17.21", + fixedVersions: ["4.17.22"], + vulnerabilityIds: ["CVE-2026-0001"], + target: "package-lock.json", + severity: "high", + evidence: [ + { + kind: "detector", + source: "trivy-vulnerability", + observedAt: "2026-08-31T12:00:00.000Z", + subject: "raw-result", + value: "fixture-hash", + }, + ], + }; +} + +function publishedEntry( + selectionId: string, + issueNumber: number, + action: PublicationResult["published"][number]["action"], +): PublicationResult["published"][number] { + return { + selectionId, + issueNumber, + issueUrl: `https://github.com/acme/api/issues/${issueNumber}`, + action, + }; +}