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);
}
144 changes: 142 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,142 @@
// Stub — replaced with full action in Task 7
export type { ComplianceReport, ActionInputs } from "./types.js";
import * as core from "@actions/core";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { runSpecLinter } from "./spec-linter.js";
import { runInjectionScanner } from "./injection-scanner.js";
import {
extractDeclaredScope,
checkScopeCompliance,
} from "./scope-checker.js";
import {
extractCriteria,
extractTestDescriptions,
matchCriteria,
} from "./criteria-checker.js";
import { buildComment } from "./comment-builder.js";
import { postOrUpdateComment, getPrChangedFiles } from "./github.js";
import { ActionInputs, CheckStatus, ComplianceReport } from "./types.js";

function getInputs(): ActionInputs {
return {
specFile: core.getInput("spec-file") || "CLAUDE.md",
failOn: (core.getInput("fail-on") || "errors") as ActionInputs["failOn"],
postComment: core.getInput("post-comment") !== "false",
injectionScannerVersion:
core.getInput("injection-scanner-version") || "v0.0.1",
};
}

function findTestFiles(dir: string): Map<string, string[]> {
const testFiles = new Map<string, string[]>();

function walk(currentDir: string): void {
for (const entry of readdirSync(currentDir)) {
const fullPath = join(currentDir, entry);
const stat = statSync(fullPath);

if (
stat.isDirectory() &&
entry !== "node_modules" &&
entry !== "dist"
) {
walk(fullPath);
} else if (entry.match(/\.(test|spec)\.(ts|js|tsx|jsx)$/)) {
const content = readFileSync(fullPath, "utf-8");
const descriptions = extractTestDescriptions(content);
testFiles.set(fullPath, descriptions);
}
}
}

walk(dir);
return testFiles;
}

function worstStatus(statuses: CheckStatus[]): CheckStatus {
if (statuses.includes("fail")) return "fail";
if (statuses.includes("warn")) return "warn";
return "pass";
}

async function run(): Promise<void> {
try {
const inputs = getInputs();
const specPath = resolve(inputs.specFile);
const specContent = readFileSync(specPath, "utf-8");

core.info(`Checking spec file: ${specPath}`);

// 1. Run spec-linter
const specResult = await runSpecLinter(specPath);
core.info(`Spec validation: ${specResult.status}`);

// 2. Run injection-scanner
const scanResult = await runInjectionScanner(
specPath,
inputs.injectionScannerVersion,
);
core.info(`Security scan: ${scanResult.status}`);

// 3. Scope check
const token =
core.getInput("github-token") || process.env.GITHUB_TOKEN || "";
const changedFiles = token ? await getPrChangedFiles(token) : [];
const declaredScope = extractDeclaredScope(specContent);
const scopeResult = checkScopeCompliance(declaredScope, changedFiles);
core.info(
`Scope compliance: ${scopeResult.compliant ? "pass" : "fail"}`,
);

// 4. Criteria check
const criteria = extractCriteria(specContent);
const testFiles = findTestFiles(".");
const criteriaMatches = matchCriteria(criteria, testFiles);
const matchedCount = criteriaMatches.filter((c) => c.matched).length;
core.info(`Criteria coverage: ${matchedCount}/${criteria.length}`);

// 5. Build report
const scopeStatus: CheckStatus =
declaredScope.length === 0
? "warn"
: scopeResult.compliant
? "pass"
: "fail";

const report: ComplianceReport = {
specFile: inputs.specFile,
checks: [specResult, scanResult],
scopeResult,
criteriaMatches,
overallStatus: worstStatus([
specResult.status,
scanResult.status,
scopeStatus,
]),
};

// 6. Post comment
if (inputs.postComment && token) {
const comment = buildComment(report);
await postOrUpdateComment(token, comment);
core.info("PR comment posted/updated");
}

// 7. Set exit code
if (inputs.failOn === "errors" && report.overallStatus === "fail") {
core.setFailed("Spec compliance check failed with errors");
} else if (
inputs.failOn === "warnings" &&
report.overallStatus !== "pass"
) {
core.setFailed("Spec compliance check failed with warnings");
}

core.setOutput("status", report.overallStatus);
core.setOutput("report", JSON.stringify(report));
} catch (error: unknown) {
const err = error as Error;
core.setFailed(err.message);
}
}

run();
Loading
Loading