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
62 changes: 61 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <selection-id> --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.
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
15 changes: 2 additions & 13 deletions docs/implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down Expand Up @@ -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 <report> --select <id...>` 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 <path>` → `publish --select <id> --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)

Expand Down
13 changes: 13 additions & 0 deletions src/cli/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -30,6 +35,14 @@ export function createDefaultAnalyzeDependencies(): AnalyzeDependencies {
};
}

export async function createDefaultPublishDependencies(): Promise<PublishDependencies> {
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<void>;
Expand Down
169 changes: 157 additions & 12 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,6 +22,7 @@ import {
import {
exceedsFailOnThreshold,
renderMarkdown,
renderPublicationTerminal,
renderTerminal,
} from "./render.js";

Expand All @@ -27,7 +33,10 @@ export interface CliIo {

export interface RunCliOptions {
dependencies: AnalyzeDependencies;
publishDependencies?: PublishDependencies;
createPublishDependencies?: () => Promise<PublishDependencies>;
io?: CliIo;
confirm?: (summary: string) => Promise<boolean>;
}

const PACKAGE_VERSION = "0.1.0";
Expand All @@ -38,6 +47,7 @@ export async function runCli(
options: RunCliOptions,
): Promise<number> {
const io = options.io ?? { stdout: process.stdout, stderr: process.stderr };
let exitCode = EXIT_SUCCESS;
const program = new Command();

program
Expand All @@ -53,8 +63,7 @@ export async function runCli(
.option("--output <path>", "Write report output to a file")
.option("--fail-on <criticality>", "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
Expand All @@ -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("<report>", "Path to an analysis report JSON file")
.requiredOption("--select <id...>", "Finding selection IDs to publish")
.option("--format <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);
}
Expand All @@ -99,6 +110,12 @@ interface AnalyzeFlags {
failOn?: string;
}

interface PublishFlags {
select: string[];
format?: string;
yes?: boolean;
}

async function runAnalyzeCommand(
path: string,
flags: AnalyzeFlags,
Expand Down Expand Up @@ -159,7 +176,133 @@ async function runAnalyzeCommand(
}
}

async function runPublishCommand(
reportPath: string,
flags: PublishFlags,
options: RunCliOptions,
io: CliIo,
): Promise<number> {
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<boolean> {
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;
Expand Down Expand Up @@ -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;
}
Loading