diff --git a/web/src/download.ts b/web/src/download.ts index 1aa93ad..f021cb6 100644 --- a/web/src/download.ts +++ b/web/src/download.ts @@ -4,6 +4,106 @@ function authority(meta: RuleMeta): string { return meta.authority?.length ? ` (per ${meta.authority.map((a) => `${a.name}: ${a.url}`).join("; ")})` : ""; } +const LEXICON_NAMES: Record = { + github: "GitHub Actions", + gitlab: "GitLab CI", + forgejo: "Forgejo CI", + k8s: "Kubernetes", + docker: "Docker", + aws: "AWS CloudFormation", + azure: "Azure ARM Templates", + gcp: "Google Cloud", + helm: "Helm", + temporal: "Temporal", +}; + +const RULE_PREFIXES: Array<[string, string]> = [ + ["GHA", "github"], + ["WGL", "gitlab"], + ["WFJ", "forgejo"], + ["WK8", "k8s"], + ["DKRC", "docker"], + ["DKRD", "docker"], + ["AWS", "aws"], + ["AZR", "azure"], + ["WGC", "gcp"], + ["WHM", "helm"], + ["ARGO", "temporal"], +]; + +function lexiconFromRuleId(id: string): string { + for (const [prefix, lexicon] of RULE_PREFIXES) { + if (id.startsWith(prefix)) return lexicon; + } + return "other"; +} + +/** A compact rule set the user can paste into an LLM system prompt or context + * window so the assistant flags the same issues in future code. */ +export function reportToLLMContext(r: Report): string { + const ruleMap = new Map(); + + for (const qw of r.quickWins) { + for (const meta of qw.addressed) { + if (!ruleMap.has(meta.id)) ruleMap.set(meta.id, { meta, lexicon: lexiconFromRuleId(meta.id) }); + } + for (const f of qw.needsInput) { + if (!ruleMap.has(f.meta.id)) ruleMap.set(f.meta.id, { meta: f.meta, lexicon: f.lexicon }); + } + } + for (const cl of r.needsReview) { + for (const { meta, findings } of cl.rules) { + if (!ruleMap.has(meta.id)) { + ruleMap.set(meta.id, { meta, lexicon: findings[0]?.lexicon ?? lexiconFromRuleId(meta.id) }); + } + } + } + for (const f of r.reportOnly) { + if (!ruleMap.has(f.meta.id)) ruleMap.set(f.meta.id, { meta: f.meta, lexicon: f.lexicon }); + } + + if (!ruleMap.size) return ""; + + const byLexicon = new Map>(); + for (const { meta, lexicon } of ruleMap.values()) { + if (!byLexicon.has(lexicon)) byLexicon.set(lexicon, new Map()); + const byCat = byLexicon.get(lexicon)!; + if (!byCat.has(meta.category)) byCat.set(meta.category, []); + byCat.get(meta.category)!.push(meta); + } + + const CAT_LABELS: Record = { + security: "Security", + correctness: "Correctness", + "best-practice": "Best practice", + }; + + const out: string[] = [ + "# Audit rules — add to your LLM context to prevent regressions", + "", + `The following rules were violated in ${r.target}.`, + "Paste this block into your AI coding assistant's system prompt or context window", + "so it flags these issues before they reach a PR.", + "", + ]; + + for (const [lexicon, byCat] of byLexicon) { + out.push(`## ${LEXICON_NAMES[lexicon] ?? lexicon}`, ""); + for (const cat of ["security", "correctness", "best-practice"]) { + const rules = byCat.get(cat); + if (!rules?.length) continue; + out.push(`### ${CAT_LABELS[cat]}`, ""); + for (const meta of rules) { + const auth = meta.authority?.length ? ` [${meta.authority.map((a) => a.name).join(", ")}]` : ""; + out.push(`- **${meta.title}** (\`${meta.id}\`): ${meta.remediation}${auth}`); + } + out.push(""); + } + } + + return out.join("\n"); +} + /** A complete, human-readable Markdown report covering every finding in every * tier — what you'd paste into an issue or hand to a reviewer. */ export function reportToMarkdown(r: Report): string { @@ -71,3 +171,7 @@ export function downloadMarkdown(r: Report): void { export function downloadJson(r: Report): void { trigger(`blacklight-${slug(r.target)}.json`, "application/json", JSON.stringify(r, null, 2)); } + +export function downloadLLMContext(r: Report): void { + trigger(`blacklight-${slug(r.target)}-llm-context.md`, "text/markdown", reportToLLMContext(r)); +} diff --git a/web/src/report.test.tsx b/web/src/report.test.tsx index ca39fbc..9141750 100644 --- a/web/src/report.test.tsx +++ b/web/src/report.test.tsx @@ -3,6 +3,7 @@ import { renderToString } from "preact-render-to-string"; import { ReportView } from "./report"; import { App } from "./app"; import { SAMPLE } from "./sample"; +import { reportToLLMContext } from "./download"; describe("ReportView (tier-first)", () => { const html = renderToString(); @@ -41,6 +42,7 @@ describe("ReportView (tier-first)", () => { expect(html).toContain("report-only"); expect(html).toContain("Markdown"); expect(html).toContain("JSON"); + expect(html).toContain("LLM context"); }); test("report-only tier is open so every finding is visible", () => { @@ -49,6 +51,38 @@ describe("ReportView (tier-first)", () => { }); }); +describe("reportToLLMContext", () => { + const ctx = reportToLLMContext(SAMPLE); + + test("produces a non-empty block for a report with findings", () => { + expect(ctx.length).toBeGreaterThan(0); + }); + + test("includes the target repo URL", () => { + expect(ctx).toContain(SAMPLE.target); + }); + + test("groups rules by lexicon heading", () => { + expect(ctx).toContain("## GitHub Actions"); + }); + + test("includes rule IDs with remediation text", () => { + expect(ctx).toContain("GHA033"); + expect(ctx).toContain("GHA036"); + // k8s finding from report-only + expect(ctx).toContain("WK8101"); + }); + + test("includes a kubernetes section for cross-lexicon findings", () => { + expect(ctx).toContain("## Kubernetes"); + }); + + test("returns empty string for a zero-finding report", () => { + const empty = reportToLLMContext({ ...SAMPLE, quickWins: [], needsReview: [], reportOnly: [], counts: { ...SAMPLE.counts, total: 0 } }); + expect(empty).toBe(""); + }); +}); + describe("App", () => { test("idle state shows the input, trust strip, and samples", () => { const html = renderToString(); diff --git a/web/src/report.tsx b/web/src/report.tsx index 9df8ad6..0ed3661 100644 --- a/web/src/report.tsx +++ b/web/src/report.tsx @@ -1,7 +1,7 @@ import { useState } from "preact/hooks"; import type { Category, GuidanceCluster, QuickWinFile, Report, RuleMeta, Finding } from "./types"; import { ruleDocUrl } from "./types"; -import { downloadMarkdown, downloadJson } from "./download"; +import { downloadMarkdown, downloadJson, downloadLLMContext, reportToLLMContext } from "./download"; const CATS: Category[] = ["security", "correctness", "best-practice"]; @@ -101,6 +101,32 @@ function ReportOnly({ findings }: { findings: Finding[] }) { ); } +function LLMContext({ report }: { report: Report }) { + const ctx = reportToLLMContext(report); + if (!ctx) return null; + const [copied, setCopied] = useState(false); + const copy = () => { + void navigator.clipboard?.writeText(ctx); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + return ( +
+ 🤖 LLM context paste into your system prompt to catch these next time +
+

+ These are the rules that fired on this repo. Add them to your AI coding assistant's system prompt + or context window so it flags the same issues before they reach a PR. +

+
+ +
{ctx}
+
+
+
+ ); +} + /** Tier-first result view: quick-wins → needs-review → report-only, with a * category headline + filter. CLI reports share this spine. */ export function ReportView({ report }: { report: Report }) { @@ -139,12 +165,14 @@ export function ReportView({ report }: { report: Report }) {
+
+ ); } diff --git a/web/src/styles.css b/web/src/styles.css index 35ff5fa..f6be5a0 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -75,3 +75,6 @@ th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line th { color: var(--muted); font-weight: 600; } .stats { color: var(--accent); font-size: 13px; margin: 6px 0 0; letter-spacing: 0.2px; } .foot { margin-top: 64px; padding-top: 20px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; text-align: center; } +.llm-ctx { margin-top: 12px; } +.llm-hint { color: var(--muted); font-size: 13.5px; margin: 0 0 10px; } +.llm-pre { background: #010409; border: 1px solid var(--line); border-radius: 8px; padding: 12px; overflow-x: auto; font-size: 12.5px; margin: 0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }