From e677e7cf73ffc1072d304f45c211ff6974a3ee17 Mon Sep 17 00:00:00 2001 From: hermanngeorge15 Date: Thu, 2 Apr 2026 16:23:24 +0200 Subject: [PATCH 1/7] feat: add test fixtures for spec-ci-plugin checkers --- tests/fixtures/sample-criteria.md | 6 ++++++ tests/fixtures/sample-test.ts | 12 ++++++++++++ tests/fixtures/spec-no-scope.md | 2 ++ tests/fixtures/spec-with-scope.md | 17 +++++++++++++++++ 4 files changed, 37 insertions(+) create mode 100644 tests/fixtures/sample-criteria.md create mode 100644 tests/fixtures/sample-test.ts create mode 100644 tests/fixtures/spec-no-scope.md create mode 100644 tests/fixtures/spec-with-scope.md diff --git a/tests/fixtures/sample-criteria.md b/tests/fixtures/sample-criteria.md new file mode 100644 index 0000000..eafff40 --- /dev/null +++ b/tests/fixtures/sample-criteria.md @@ -0,0 +1,6 @@ +# Sample Project + +## Acceptance Criteria +- [ ] Users can log in with email +- [ ] Dashboard loads in under 2 seconds +- [ ] Export data as CSV diff --git a/tests/fixtures/sample-test.ts b/tests/fixtures/sample-test.ts new file mode 100644 index 0000000..5de44e3 --- /dev/null +++ b/tests/fixtures/sample-test.ts @@ -0,0 +1,12 @@ +describe("auth", () => { + it("allows user login with email and password", () => {}); + it("rejects invalid credentials", () => {}); +}); + +describe("dashboard", () => { + test("loads dashboard within performance budget", () => {}); +}); + +describe("export", () => { + it("exports data as CSV format", () => {}); +}); diff --git a/tests/fixtures/spec-no-scope.md b/tests/fixtures/spec-no-scope.md new file mode 100644 index 0000000..4634e34 --- /dev/null +++ b/tests/fixtures/spec-no-scope.md @@ -0,0 +1,2 @@ +# Bad Spec +No required sections here. diff --git a/tests/fixtures/spec-with-scope.md b/tests/fixtures/spec-with-scope.md new file mode 100644 index 0000000..37082a9 --- /dev/null +++ b/tests/fixtures/spec-with-scope.md @@ -0,0 +1,17 @@ +# Test Project + +## Project Overview +A test project for CI plugin testing. + +## Constraints +- TypeScript strict mode + +## Acceptance Criteria +- [ ] Users can log in with email +- [ ] Dashboard loads in under 2 seconds +- [ ] Export data as CSV + +## Scope +- src/auth/ +- src/dashboard/ +- src/export/ From 3fda6ca5f9b110a0f28c0960561c2ddc5fb29be2 Mon Sep 17 00:00:00 2001 From: hermanngeorge15 Date: Thu, 2 Apr 2026 16:23:28 +0200 Subject: [PATCH 2/7] feat: add spec-linter integration via npx --- src/spec-linter.ts | 94 +++++++++++++++++++++++++++++++++++++++ tests/spec-linter.test.ts | 20 +++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/spec-linter.ts create mode 100644 tests/spec-linter.test.ts diff --git a/src/spec-linter.ts b/src/spec-linter.ts new file mode 100644 index 0000000..3a1bea3 --- /dev/null +++ b/src/spec-linter.ts @@ -0,0 +1,94 @@ +import { execSync } from "node:child_process"; +import { CheckResult } from "./types.js"; + +function isExecError( + err: unknown, +): err is { stdout: string; stderr: string; status: number } { + return typeof err === "object" && err !== null && "stdout" in err; +} + +export async function runSpecLinter( + specFile: string, +): Promise { + try { + const output = execSync( + `npx --yes @unityinflow/spec-linter check "${specFile}" --format json`, + { encoding: "utf-8", timeout: 30000 }, + ); + + const reports = JSON.parse(output) as Array<{ + errorCount: number; + warningCount: number; + results: Array<{ + severity: string; + message: string; + ruleId: string; + }>; + }>; + + const report = reports[0]; + if (!report) { + return { name: "Spec Validation", status: "pass", details: [] }; + } + + const details = report.results.map( + (r) => + `${r.severity === "error" ? "x" : "!"} ${r.message} (${r.ruleId})`, + ); + + if (report.errorCount > 0) { + return { name: "Spec Validation", status: "fail", details }; + } + if (report.warningCount > 0) { + return { name: "Spec Validation", status: "warn", details }; + } + return { name: "Spec Validation", status: "pass", details }; + } catch (error: unknown) { + if (!isExecError(error)) { + const message = + error instanceof Error ? error.message : "unknown error"; + return { + name: "Spec Validation", + status: "fail", + details: [`Failed to run spec-linter: ${message}`], + }; + } + + // Exit code 1 = errors found, 2 = warnings only + if (error.stdout) { + try { + const reports = JSON.parse(error.stdout) as Array<{ + errorCount: number; + warningCount: number; + results: Array<{ + severity: string; + message: string; + ruleId: string; + }>; + }>; + const report = reports[0]; + const details = + report?.results.map( + (r) => + `${r.severity === "error" ? "x" : "!"} ${r.message} (${r.ruleId})`, + ) ?? []; + + return { + name: "Spec Validation", + status: (report?.errorCount ?? 0) > 0 ? "fail" : "warn", + details, + }; + } catch { + // JSON parse failed + } + } + + return { + name: "Spec Validation", + status: "fail", + details: [ + `Failed to run spec-linter: ${error.stderr ?? "unknown error"}`, + ], + }; + } +} diff --git a/tests/spec-linter.test.ts b/tests/spec-linter.test.ts new file mode 100644 index 0000000..66b21a4 --- /dev/null +++ b/tests/spec-linter.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { runSpecLinter } from "../src/spec-linter.js"; + +describe("runSpecLinter", () => { + it("returns pass for valid spec content", async () => { + const result = await runSpecLinter("tests/fixtures/spec-with-scope.md"); + expect(result.status).toBe("pass"); + }); + + it("returns fail for spec with errors", async () => { + const result = await runSpecLinter("tests/fixtures/spec-no-scope.md"); + // spec-no-scope.md is missing required sections + expect(result.status).toBe("fail"); + }); + + it("captures error details", async () => { + const result = await runSpecLinter("tests/fixtures/spec-no-scope.md"); + expect(result.details.length).toBeGreaterThan(0); + }); +}); From f94f434a5d21633478066cd62fa4c42869ef5cd2 Mon Sep 17 00:00:00 2001 From: hermanngeorge15 Date: Thu, 2 Apr 2026 16:23:31 +0200 Subject: [PATCH 3/7] feat: add injection-scanner binary download and integration --- src/injection-scanner.ts | 111 ++++++++++++++++++++++++++++++++ tests/injection-scanner.test.ts | 31 +++++++++ 2 files changed, 142 insertions(+) create mode 100644 src/injection-scanner.ts create mode 100644 tests/injection-scanner.test.ts diff --git a/src/injection-scanner.ts b/src/injection-scanner.ts new file mode 100644 index 0000000..48e6396 --- /dev/null +++ b/src/injection-scanner.ts @@ -0,0 +1,111 @@ +import { execSync, execFileSync } from "node:child_process"; +import { existsSync, chmodSync } from "node:fs"; +import { join } from "node:path"; +import { CheckResult } from "./types.js"; + +function isExecError( + err: unknown, +): err is { stdout: string; stderr: string; status: number } { + return typeof err === "object" && err !== null && "stdout" in err; +} + +function downloadScanner(version: string): string { + const platform = + process.platform === "darwin" ? "apple-darwin" : "unknown-linux-musl"; + const arch = process.arch === "arm64" ? "aarch64" : "x86_64"; + const binaryName = "injection-scanner"; + const downloadPath = join("/tmp", binaryName); + + if (existsSync(downloadPath)) return downloadPath; + + const url = `https://github.com/UnityInFlow/injection-scanner/releases/download/${version}/${binaryName}-${arch}-${platform}`; + + execSync(`curl -fsSL -o "${downloadPath}" "${url}"`, { timeout: 30000 }); + chmodSync(downloadPath, 0o755); + + return downloadPath; +} + +export async function runInjectionScanner( + specFile: string, + version: string, +): Promise { + try { + const binaryPath = downloadScanner(version); + const output = execFileSync( + binaryPath, + ["check", specFile, "--format", "json"], + { + encoding: "utf-8", + timeout: 10000, + }, + ); + + const reports = JSON.parse(output) as Array<{ + matches: Array<{ + severity: string; + message: string; + pattern_id: string; + line: number; + }>; + critical_count: number; + high_count: number; + }>; + + const report = reports[0]; + if (!report || report.matches.length === 0) { + return { + name: "Security Scan", + status: "pass", + details: ["No injection patterns detected"], + }; + } + + const details = report.matches.map( + (m) => `${m.severity} :${m.line} ${m.message} (${m.pattern_id})`, + ); + + return { + name: "Security Scan", + status: report.critical_count > 0 ? "fail" : "warn", + details, + }; + } catch (error: unknown) { + if (isExecError(error) && error.stdout) { + try { + const reports = JSON.parse(error.stdout) as Array<{ + matches: Array<{ + severity: string; + message: string; + pattern_id: string; + line: number; + }>; + critical_count: number; + }>; + const report = reports[0]; + const details = + report?.matches.map( + (m) => + `${m.severity} :${m.line} ${m.message} (${m.pattern_id})`, + ) ?? []; + + return { + name: "Security Scan", + status: (report?.critical_count ?? 0) > 0 ? "fail" : "warn", + details, + }; + } catch { + // parse failed + } + } + + const message = + error instanceof Error ? error.message : "unknown"; + + return { + name: "Security Scan", + status: "warn", + details: [`Could not run injection-scanner: ${message}`], + }; + } +} diff --git a/tests/injection-scanner.test.ts b/tests/injection-scanner.test.ts new file mode 100644 index 0000000..f840dcc --- /dev/null +++ b/tests/injection-scanner.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { runInjectionScanner } from "../src/injection-scanner.js"; + +describe("runInjectionScanner", () => { + it("returns pass for clean file", async () => { + const result = await runInjectionScanner( + "tests/fixtures/spec-with-scope.md", + "v0.0.1", + ); + // Clean spec file should have no injection patterns + expect(["pass", "warn"]).toContain(result.status); + }); + + it("returns check result with name", async () => { + const result = await runInjectionScanner( + "tests/fixtures/spec-with-scope.md", + "v0.0.1", + ); + expect(result.name).toBe("Security Scan"); + }); + + it("handles missing binary gracefully", async () => { + const result = await runInjectionScanner( + "tests/fixtures/spec-with-scope.md", + "v999.999.999", + ); + // Should warn, not crash + expect(result.status).toBe("warn"); + expect(result.details.length).toBeGreaterThan(0); + }); +}); From c3250f0f03e65518af0fb78a80ee35aa8768cbba Mon Sep 17 00:00:00 2001 From: hermanngeorge15 Date: Thu, 2 Apr 2026 16:23:35 +0200 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20add=20scope=20checker=20=E2=80=94?= =?UTF-8?q?=20extract=20scope=20from=20spec,=20compare=20to=20PR=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scope-checker.ts | 53 ++++++++++++++++++++++++++++++++ tests/scope-checker.test.ts | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/scope-checker.ts create mode 100644 tests/scope-checker.test.ts diff --git a/src/scope-checker.ts b/src/scope-checker.ts new file mode 100644 index 0000000..84712ab --- /dev/null +++ b/src/scope-checker.ts @@ -0,0 +1,53 @@ +import { ScopeResult } from "./types.js"; + +export function extractDeclaredScope(specContent: string): string[] { + const paths: string[] = []; + + // Extract from GSD tags + const xmlRegex = /(.*?)<\/files>/gs; + for (const match of specContent.matchAll(xmlRegex)) { + paths.push( + ...match[1] + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0), + ); + } + + // Extract from ## Scope section + const scopeRegex = + /## (?:Scope|Files in scope)\s*\n([\s\S]*?)(?=\n## |$)/i; + const scopeMatch = specContent.match(scopeRegex); + if (scopeMatch) { + const lines = scopeMatch[1].split("\n"); + for (const line of lines) { + const cleaned = line.replace(/^[-*]\s+/, "").trim(); + if (cleaned.length > 0 && !cleaned.startsWith("#")) { + paths.push(cleaned); + } + } + } + + return [...new Set(paths)]; +} + +export function checkScopeCompliance( + declaredScope: string[], + changedFiles: string[], +): ScopeResult { + // No declared scope = skip check (everything is in scope) + if (declaredScope.length === 0) { + return { compliant: true, declaredScope, changedFiles, outOfScope: [] }; + } + + const outOfScope = changedFiles.filter( + (f) => !declaredScope.some((s) => f.startsWith(s)), + ); + + return { + compliant: outOfScope.length === 0, + declaredScope, + changedFiles, + outOfScope, + }; +} diff --git a/tests/scope-checker.test.ts b/tests/scope-checker.test.ts new file mode 100644 index 0000000..2ab52a4 --- /dev/null +++ b/tests/scope-checker.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { + extractDeclaredScope, + checkScopeCompliance, +} from "../src/scope-checker.js"; +import { readFileSync } from "node:fs"; + +describe("extractDeclaredScope", () => { + it("extracts paths from ## Scope section", () => { + const content = readFileSync( + "tests/fixtures/spec-with-scope.md", + "utf-8", + ); + const scope = extractDeclaredScope(content); + expect(scope).toContain("src/auth/"); + expect(scope).toContain("src/dashboard/"); + }); + + it("returns empty for spec with no scope section", () => { + const content = readFileSync( + "tests/fixtures/spec-no-scope.md", + "utf-8", + ); + const scope = extractDeclaredScope(content); + expect(scope).toHaveLength(0); + }); + + it("extracts from GSD tags", () => { + const content = + "Some text\nsrc/auth/, src/api/\nMore text"; + const scope = extractDeclaredScope(content); + expect(scope).toContain("src/auth/"); + expect(scope).toContain("src/api/"); + }); +}); + +describe("checkScopeCompliance", () => { + it("passes when all files are in scope", () => { + const result = checkScopeCompliance( + ["src/auth/", "src/dashboard/"], + ["src/auth/login.ts", "src/dashboard/index.ts"], + ); + expect(result.compliant).toBe(true); + expect(result.outOfScope).toHaveLength(0); + }); + + it("fails when files are out of scope", () => { + const result = checkScopeCompliance( + ["src/auth/"], + ["src/auth/login.ts", "src/random/file.ts"], + ); + expect(result.compliant).toBe(false); + expect(result.outOfScope).toContain("src/random/file.ts"); + }); + + it("handles empty scope (no scope declared)", () => { + const result = checkScopeCompliance([], ["src/anything.ts"]); + expect(result.compliant).toBe(true); + }); +}); From 1e696f263968509ce6c674710f4c7a704ec2fe3e Mon Sep 17 00:00:00 2001 From: hermanngeorge15 Date: Thu, 2 Apr 2026 16:23:39 +0200 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20add=20criteria=20checker=20?= =?UTF-8?q?=E2=80=94=20parse=20test=20descriptions,=20keyword=20match?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/criteria-checker.ts | 92 ++++++++++++++++++++++++++++++++++ tests/criteria-checker.test.ts | 52 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/criteria-checker.ts create mode 100644 tests/criteria-checker.test.ts diff --git a/src/criteria-checker.ts b/src/criteria-checker.ts new file mode 100644 index 0000000..9af82d7 --- /dev/null +++ b/src/criteria-checker.ts @@ -0,0 +1,92 @@ +import { CriteriaMatch } from "./types.js"; + +export function extractCriteria(specContent: string): string[] { + const criteriaRegex = + /## Acceptance Criteria\s*\n([\s\S]*?)(?=\n## |$)/i; + const match = specContent.match(criteriaRegex); + if (!match) return []; + + return match[1] + .split("\n") + .map((line) => line.replace(/^[-*]\s+(?:\[.\]\s+)?/, "").trim()) + .filter((line) => line.length > 0); +} + +export function extractTestDescriptions(testContent: string): string[] { + const descriptions: string[] = []; + const regex = /(?:describe|it|test)\s*\(\s*["'`](.*?)["'`]/g; + + for (const match of testContent.matchAll(regex)) { + descriptions.push(match[1]); + } + + return descriptions; +} + +function tokenize(text: string): string[] { + return text + .toLowerCase() + .replace(/[^a-z0-9\s]/g, "") + .split(/\s+/) + .filter((w) => w.length > 2); +} + +function fuzzyTokenMatch(a: string, b: string): boolean { + if (a === b) return true; + // Prefix match: "log" matches "login", "user" matches "users" + if (a.length >= 3 && b.length >= 3) { + if (a.startsWith(b) || b.startsWith(a)) return true; + } + return false; +} + +function similarity(a: string, b: string): number { + const tokensA = tokenize(a); + const tokensB = tokenize(b); + if (tokensA.length === 0 || tokensB.length === 0) return 0; + + const setA = new Set(tokensA); + const setB = new Set(tokensB); + + let overlap = 0; + for (const tokenA of setA) { + for (const tokenB of setB) { + if (fuzzyTokenMatch(tokenA, tokenB)) { + overlap++; + break; + } + } + } + + return overlap / Math.max(setA.size, setB.size); +} + +export function matchCriteria( + criteria: string[], + testFiles: Map, +): CriteriaMatch[] { + return criteria.map((criterion) => { + let bestMatch = { score: 0, file: "", name: "" }; + + for (const [file, descriptions] of testFiles) { + for (const desc of descriptions) { + const score = similarity(criterion, desc); + if (score > bestMatch.score) { + bestMatch = { score, file, name: desc }; + } + } + } + + const threshold = 0.3; + if (bestMatch.score >= threshold) { + return { + criterion, + matched: true, + testFile: bestMatch.file, + testName: bestMatch.name, + }; + } + + return { criterion, matched: false }; + }); +} diff --git a/tests/criteria-checker.test.ts b/tests/criteria-checker.test.ts new file mode 100644 index 0000000..0101e96 --- /dev/null +++ b/tests/criteria-checker.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { + extractCriteria, + extractTestDescriptions, + matchCriteria, +} from "../src/criteria-checker.js"; + +describe("extractCriteria", () => { + it("extracts acceptance criteria from spec", () => { + const content = + "## Acceptance Criteria\n- [ ] Users can log in\n- [ ] Dashboard loads fast\n\n## Other"; + const criteria = extractCriteria(content); + expect(criteria).toHaveLength(2); + expect(criteria[0]).toContain("Users can log in"); + }); + + it("returns empty for no criteria section", () => { + expect(extractCriteria("# Just a title")).toHaveLength(0); + }); +}); + +describe("extractTestDescriptions", () => { + it("extracts describe/it/test strings from test file", () => { + const content = `describe("auth", () => {\n it("allows user login with email", () => {});\n});`; + const descriptions = extractTestDescriptions(content); + expect(descriptions.some((d) => d.includes("login"))).toBe(true); + }); +}); + +describe("matchCriteria", () => { + it("matches criteria to test descriptions", () => { + const criteria = ["Users can log in with email"]; + const testFiles = new Map([ + [ + "tests/auth.test.ts", + ["allows user login with email and password"], + ], + ]); + const matches = matchCriteria(criteria, testFiles); + expect(matches[0].matched).toBe(true); + expect(matches[0].testFile).toBe("tests/auth.test.ts"); + }); + + it("reports unmatched criteria", () => { + const criteria = ["Export data as PDF"]; + const testFiles = new Map([ + ["tests/auth.test.ts", ["allows user login"]], + ]); + const matches = matchCriteria(criteria, testFiles); + expect(matches[0].matched).toBe(false); + }); +}); From 7a4feebada92b801db30029375ef2c8a74ebc1b5 Mon Sep 17 00:00:00 2001 From: hermanngeorge15 Date: Thu, 2 Apr 2026 16:23:42 +0200 Subject: [PATCH 6/7] feat: add PR comment builder with structured compliance report --- src/comment-builder.ts | 84 +++++++++++++++++++++ tests/comment-builder.test.ts | 136 ++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 src/comment-builder.ts create mode 100644 tests/comment-builder.test.ts diff --git a/src/comment-builder.ts b/src/comment-builder.ts new file mode 100644 index 0000000..1a4f86c --- /dev/null +++ b/src/comment-builder.ts @@ -0,0 +1,84 @@ +import { ComplianceReport } from "./types.js"; + +const MARKER = ""; + +function statusIcon(status: string): string { + if (status === "pass") return "\u2705"; + if (status === "warn") return "\u26a0\ufe0f"; + return "\u274c"; +} + +export function buildComment(report: ComplianceReport): string { + const lines: string[] = [MARKER, "## Spec Compliance Report", ""]; + + // Checks (spec-linter, injection-scanner) + for (const check of report.checks) { + lines.push(`### ${check.name}`); + lines.push( + `${statusIcon(check.status)} **${check.status.toUpperCase()}**`, + ); + if (check.details.length > 0) { + for (const detail of check.details) { + lines.push(`- ${detail}`); + } + } + lines.push(""); + } + + // Scope + if (report.scopeResult) { + lines.push("### Scope Compliance"); + if (report.scopeResult.declaredScope.length === 0) { + lines.push( + "\u26a0\ufe0f No scope declared in spec file. Consider adding a `## Scope` section.", + ); + } else if (report.scopeResult.compliant) { + lines.push( + "\u2705 All changed files are within spec-declared scope.", + ); + lines.push( + `Scope: ${report.scopeResult.declaredScope.join(", ")}`, + ); + } else { + lines.push("\u274c Files changed outside declared scope:"); + for (const f of report.scopeResult.outOfScope) { + lines.push(`- \`${f}\``); + } + } + lines.push(""); + } + + // Criteria + if (report.criteriaMatches.length > 0) { + lines.push("### Acceptance Criteria Coverage"); + const matched = report.criteriaMatches.filter((c) => c.matched).length; + const total = report.criteriaMatches.length; + + for (const cm of report.criteriaMatches) { + if (cm.matched) { + lines.push( + `\u2705 "${cm.criterion}" \u2014 test found: ${cm.testFile}${cm.testName ? ` ("${cm.testName}")` : ""}`, + ); + } else { + lines.push( + `\u26a0\ufe0f "${cm.criterion}" \u2014 no matching test found`, + ); + } + } + lines.push(""); + lines.push( + `**Coverage: ${matched}/${total} criteria matched to tests**`, + ); + lines.push(""); + } + + // Overall + lines.push("---"); + lines.push(`**Overall: ${report.overallStatus.toUpperCase()}**`); + + return lines.join("\n"); +} + +export function getCommentMarker(): string { + return MARKER; +} diff --git a/tests/comment-builder.test.ts b/tests/comment-builder.test.ts new file mode 100644 index 0000000..9dcd8eb --- /dev/null +++ b/tests/comment-builder.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { buildComment, getCommentMarker } from "../src/comment-builder.js"; +import { ComplianceReport } from "../src/types.js"; + +describe("buildComment", () => { + it("includes hidden marker for edit-in-place", () => { + const report: ComplianceReport = { + specFile: "CLAUDE.md", + checks: [], + criteriaMatches: [], + overallStatus: "pass", + }; + const comment = buildComment(report); + expect(comment).toContain(""); + }); + + it("renders check results", () => { + const report: ComplianceReport = { + specFile: "CLAUDE.md", + checks: [ + { + name: "Spec Validation", + status: "pass", + details: [], + }, + { + name: "Security Scan", + status: "warn", + details: ["Found potential issue"], + }, + ], + criteriaMatches: [], + overallStatus: "warn", + }; + const comment = buildComment(report); + expect(comment).toContain("### Spec Validation"); + expect(comment).toContain("### Security Scan"); + expect(comment).toContain("Found potential issue"); + }); + + it("renders scope compliance — all in scope", () => { + const report: ComplianceReport = { + specFile: "CLAUDE.md", + checks: [], + scopeResult: { + compliant: true, + declaredScope: ["src/auth/"], + changedFiles: ["src/auth/login.ts"], + outOfScope: [], + }, + criteriaMatches: [], + overallStatus: "pass", + }; + const comment = buildComment(report); + expect(comment).toContain("Scope Compliance"); + expect(comment).toContain("All changed files are within spec-declared scope"); + }); + + it("renders scope compliance — out of scope files", () => { + const report: ComplianceReport = { + specFile: "CLAUDE.md", + checks: [], + scopeResult: { + compliant: false, + declaredScope: ["src/auth/"], + changedFiles: ["src/auth/login.ts", "src/random/file.ts"], + outOfScope: ["src/random/file.ts"], + }, + criteriaMatches: [], + overallStatus: "fail", + }; + const comment = buildComment(report); + expect(comment).toContain("Files changed outside declared scope"); + expect(comment).toContain("`src/random/file.ts`"); + }); + + it("renders scope compliance — no scope declared", () => { + const report: ComplianceReport = { + specFile: "CLAUDE.md", + checks: [], + scopeResult: { + compliant: true, + declaredScope: [], + changedFiles: ["src/anything.ts"], + outOfScope: [], + }, + criteriaMatches: [], + overallStatus: "warn", + }; + const comment = buildComment(report); + expect(comment).toContain("No scope declared"); + }); + + it("renders criteria matches", () => { + const report: ComplianceReport = { + specFile: "CLAUDE.md", + checks: [], + criteriaMatches: [ + { + criterion: "Users can log in", + matched: true, + testFile: "tests/auth.test.ts", + testName: "allows user login", + }, + { + criterion: "Export data as CSV", + matched: false, + }, + ], + overallStatus: "warn", + }; + const comment = buildComment(report); + expect(comment).toContain("Acceptance Criteria Coverage"); + expect(comment).toContain("Users can log in"); + expect(comment).toContain("test found"); + expect(comment).toContain("no matching test found"); + expect(comment).toContain("Coverage: 1/2"); + }); + + it("renders overall status", () => { + const report: ComplianceReport = { + specFile: "CLAUDE.md", + checks: [], + criteriaMatches: [], + overallStatus: "fail", + }; + const comment = buildComment(report); + expect(comment).toContain("**Overall: FAIL**"); + }); +}); + +describe("getCommentMarker", () => { + it("returns the hidden marker string", () => { + expect(getCommentMarker()).toBe(""); + }); +}); From 011b3f85f1bb1afd5a01f4d1a3386c470501bd81 Mon Sep 17 00:00:00 2001 From: hermanngeorge15 Date: Thu, 2 Apr 2026 16:23:46 +0200 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20add=20GitHub=20API=20=E2=80=94=20po?= =?UTF-8?q?st/update=20PR=20comment,=20get=20changed=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/github.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/github.ts diff --git a/src/github.ts b/src/github.ts new file mode 100644 index 0000000..7d06fa9 --- /dev/null +++ b/src/github.ts @@ -0,0 +1,60 @@ +import { getOctokit, context } from "@actions/github"; +import { getCommentMarker } from "./comment-builder.js"; + +export async function postOrUpdateComment( + token: string, + body: string, +): Promise { + const octokit = getOctokit(token); + const { owner, repo } = context.repo; + const issueNumber = context.payload.pull_request?.number; + + if (!issueNumber) { + throw new Error("This action only works on pull_request events"); + } + + const marker = getCommentMarker(); + + // Find existing comment + const { data: comments } = await octokit.rest.issues.listComments({ + owner, + repo, + issue_number: issueNumber, + }); + + const existing = comments.find((c) => c.body?.includes(marker)); + + if (existing) { + await octokit.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body, + }); + } +} + +export async function getPrChangedFiles( + token: string, +): Promise { + const octokit = getOctokit(token); + const { owner, repo } = context.repo; + const pullNumber = context.payload.pull_request?.number; + + if (!pullNumber) return []; + + const { data: files } = await octokit.rest.pulls.listFiles({ + owner, + repo, + pull_number: pullNumber, + }); + + return files.map((f) => f.filename); +}