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
104 changes: 104 additions & 0 deletions web/src/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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<string, { meta: RuleMeta; lexicon: string }>();

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<string, Map<string, RuleMeta[]>>();
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<string, string> = {
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 {
Expand Down Expand Up @@ -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));
}
34 changes: 34 additions & 0 deletions web/src/report.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ReportView report={SAMPLE} />);
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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(<App />);
Expand Down
30 changes: 29 additions & 1 deletion web/src/report.tsx
Original file line number Diff line number Diff line change
@@ -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"];

Expand Down Expand Up @@ -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 (
<details class="tier">
<summary>🤖 LLM context <span class="muted">paste into your system prompt to catch these next time</span></summary>
<div class="llm-ctx">
<p class="llm-hint">
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.
</p>
<div class="diffwrap">
<button class="copy" onClick={copy}>{copied ? "copied!" : "copy"}</button>
<pre class="llm-pre">{ctx}</pre>
</div>
</div>
</details>
);
}

/** 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 }) {
Expand Down Expand Up @@ -139,12 +165,14 @@ export function ReportView({ report }: { report: Report }) {
<div class="downloads">
<button class="dl" onClick={() => downloadMarkdown(report)}>⬇ Markdown</button>
<button class="dl" onClick={() => downloadJson(report)}>⬇ JSON</button>
<button class="dl" onClick={() => downloadLLMContext(report)}>⬇ LLM context</button>
</div>
</div>
</div>
<QuickWins files={quickWins} />
<NeedsReview clusters={needsReview} />
<ReportOnly findings={reportOnly} />
<LLMContext report={report} />
</div>
);
}
3 changes: 3 additions & 0 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Loading