Skip to content
84 changes: 84 additions & 0 deletions src/comment-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { ComplianceReport } from "./types.js";

const MARKER = "<!-- spec-ci-plugin-comment -->";

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;
}
92 changes: 92 additions & 0 deletions src/criteria-checker.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>,
): 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 };
});
}
60 changes: 60 additions & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string[]> {
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);
}
111 changes: 111 additions & 0 deletions src/injection-scanner.ts
Original file line number Diff line number Diff line change
@@ -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<CheckResult> {
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}`],
};
}
}
Loading
Loading