357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
import {
|
|
CiMetadata,
|
|
CommitDecision,
|
|
CommitEscalation,
|
|
CommitRequirements,
|
|
CommitCompoundMeta,
|
|
parseCommitScope,
|
|
formatCommitScope,
|
|
CommitScope,
|
|
} from "../types/commit-meta.js";
|
|
import {
|
|
extractCiBlock,
|
|
parseCiBlock,
|
|
parseCommitMessage,
|
|
} from "./commit-parser.js";
|
|
|
|
const SAMPLE_INIT_COMMIT = `docs(init): initialize task-api (4 phases)
|
|
|
|
---ci---
|
|
phase: 0
|
|
milestone: v1.0
|
|
status: specify
|
|
decisions:
|
|
- id: D-001
|
|
decision: Node.js with Express for REST API
|
|
rationale: Spec requires Node.js; Express is minimal and well-supported
|
|
confidence: 0.95
|
|
alternatives: [Fastify, Hono]
|
|
- id: D-002
|
|
decision: PostgreSQL for persistence
|
|
rationale: ACID compliance required by spec
|
|
confidence: 0.90
|
|
alternatives: [MongoDB, SQLite]
|
|
---/ci---
|
|
|
|
Specification: Build a REST API for task management with JWT auth, CRUD
|
|
operations, real-time notifications via WebSocket, PostgreSQL database.
|
|
|
|
Requirements: AUTH-01 through AUTH-04, TASK-01 through TASK-05, NOTIF-01
|
|
Constraints: Node.js, production-ready, no Docker
|
|
Out of scope: Admin dashboard, payment integration, mobile apps`;
|
|
|
|
const SAMPLE_TASK_COMMIT = `feat(P01-01-02): create user registration endpoint
|
|
|
|
---ci---
|
|
phase: 1
|
|
milestone: v1.0
|
|
plan: 01-01
|
|
task: 01-01-02
|
|
status: execute
|
|
decisions:
|
|
- id: D-003
|
|
decision: Use bcrypt with 12 rounds for password hashing
|
|
rationale: Industry standard; argon2 not available in target env
|
|
confidence: 0.88
|
|
alternatives: [argon2, scrypt]
|
|
requirements:
|
|
covered: [AUTH-01]
|
|
---/ci---
|
|
|
|
- POST /auth/register validates email and password
|
|
- Checks for duplicate users
|
|
- Returns JWT token on success`;
|
|
|
|
const SAMPLE_PHASE_COMPLETE_COMMIT = `docs(P01): complete authentication phase
|
|
|
|
---ci---
|
|
phase: 1
|
|
milestone: v1.0
|
|
status: complete
|
|
decisions:
|
|
- id: D-005
|
|
decision: Session JWTs with 1hr expiry + opaque refresh token
|
|
rationale: Balances security and UX; refresh rotation prevents replay
|
|
confidence: 0.92
|
|
alternatives: [Stateless JWT only, session cookies]
|
|
requirements:
|
|
covered: [AUTH-01, AUTH-02, AUTH-03, AUTH-04]
|
|
lessons:
|
|
- bcrypt async is 10x faster than sync in Node; always use bcrypt.compare()
|
|
- JWT expiry must be checked before signature verification to prevent edge cases
|
|
---/ci---
|
|
|
|
Tasks completed: 4/4`;
|
|
|
|
const SAMPLE_COMPOUND_COMMIT = `compound(auth): JWT refresh token rotation pattern
|
|
|
|
---ci---
|
|
phase: 1
|
|
milestone: v1.0
|
|
status: complete
|
|
compound:
|
|
category: auth
|
|
problem: Refresh tokens can be replayed if stolen; naive implementation allows token reuse
|
|
solution: Implement refresh token rotation — each use invalidates old token and issues new one. Store token family ID to detect replay attempts. On replay detection, revoke entire family.
|
|
lessons:
|
|
- Refresh token rotation is not optional for production auth
|
|
- Token family detection prevents silent takeover
|
|
---/ci---
|
|
|
|
Discovered during AUTH-04 implementation.`;
|
|
|
|
const SAMPLE_ESCALATION_COMMIT = `escalation(P03): deploy to staging requires approval
|
|
|
|
---ci---
|
|
phase: 3
|
|
milestone: v1.0
|
|
status: execute
|
|
escalations:
|
|
- id: E-001
|
|
type: irreversible_action
|
|
description: Phase 3 requires deployment to staging environment
|
|
resolution: pending
|
|
---/ci---
|
|
|
|
All tests pass. Awaiting deploy approval.`;
|
|
|
|
const SAMPLE_PROJECT_COMMIT = `feat(task-api/P01-01-02): create registration endpoint
|
|
|
|
---ci---
|
|
phase: 1
|
|
milestone: v1.0
|
|
project: task-api
|
|
plan: 01-01
|
|
task: 01-01-02
|
|
status: execute
|
|
---/ci---
|
|
|
|
Registration endpoint for task-api project.`;
|
|
|
|
describe("extractCiBlock", () => {
|
|
it("extracts ---ci--- block from commit message", () => {
|
|
const block = extractCiBlock(SAMPLE_INIT_COMMIT);
|
|
expect(block).toBeTruthy();
|
|
expect(block).toContain("phase: 0");
|
|
expect(block).toContain("milestone: v1.0");
|
|
});
|
|
|
|
it("returns null when no ---ci--- block exists", () => {
|
|
const block = extractCiBlock("docs: some regular commit\n\nNo CI block here");
|
|
expect(block).toBeNull();
|
|
});
|
|
|
|
it("returns null for unclosed ---ci--- block", () => {
|
|
const block = extractCiBlock("docs: bad\n---ci---\nphase: 1\nno end marker");
|
|
expect(block).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("parseCiBlock", () => {
|
|
it("parses init commit ci block", () => {
|
|
const block = extractCiBlock(SAMPLE_INIT_COMMIT)!;
|
|
const meta = parseCiBlock(block)!;
|
|
|
|
expect(meta.phase).toBe(0);
|
|
expect(meta.milestone).toBe("v1.0");
|
|
expect(meta.status).toBe("specify");
|
|
expect(meta.decisions).toHaveLength(2);
|
|
expect(meta.decisions![0].id).toBe("D-001");
|
|
expect(meta.decisions![0].decision).toBe("Node.js with Express for REST API");
|
|
expect(meta.decisions![0].confidence).toBe(0.95);
|
|
expect(meta.decisions![0].alternatives).toEqual(["Fastify", "Hono"]);
|
|
});
|
|
|
|
it("parses task commit ci block", () => {
|
|
const block = extractCiBlock(SAMPLE_TASK_COMMIT)!;
|
|
const meta = parseCiBlock(block)!;
|
|
|
|
expect(meta.phase).toBe(1);
|
|
expect(meta.plan).toBe("01-01");
|
|
expect(meta.task).toBe("01-01-02");
|
|
expect(meta.status).toBe("execute");
|
|
expect(meta.decisions).toHaveLength(1);
|
|
expect(meta.decisions![0].id).toBe("D-003");
|
|
expect(meta.requirements).toBeDefined();
|
|
expect(meta.requirements!.covered).toEqual(["AUTH-01"]);
|
|
});
|
|
|
|
it("parses phase completion with lessons", () => {
|
|
const block = extractCiBlock(SAMPLE_PHASE_COMPLETE_COMMIT)!;
|
|
const meta = parseCiBlock(block)!;
|
|
|
|
expect(meta.phase).toBe(1);
|
|
expect(meta.status).toBe("complete");
|
|
expect(meta.lessons).toHaveLength(2);
|
|
expect(meta.lessons![0]).toContain("bcrypt async");
|
|
expect(meta.requirements!.covered).toEqual(["AUTH-01", "AUTH-02", "AUTH-03", "AUTH-04"]);
|
|
});
|
|
|
|
it("parses compound commit", () => {
|
|
const block = extractCiBlock(SAMPLE_COMPOUND_COMMIT)!;
|
|
const meta = parseCiBlock(block)!;
|
|
|
|
expect(meta.compound).toBeDefined();
|
|
expect(meta.compound!.category).toBe("auth");
|
|
expect(meta.compound!.problem).toContain("Refresh tokens can be replayed");
|
|
expect(meta.compound!.solution).toContain("refresh token rotation");
|
|
expect(meta.lessons).toHaveLength(2);
|
|
});
|
|
|
|
it("parses escalation commit", () => {
|
|
const block = extractCiBlock(SAMPLE_ESCALATION_COMMIT)!;
|
|
const meta = parseCiBlock(block)!;
|
|
|
|
expect(meta.escalations).toHaveLength(1);
|
|
expect(meta.escalations![0].id).toBe("E-001");
|
|
expect(meta.escalations![0].type).toBe("irreversible_action");
|
|
expect(meta.escalations![0].resolution).toBe("pending");
|
|
});
|
|
|
|
it("parses project field", () => {
|
|
const block = extractCiBlock(SAMPLE_PROJECT_COMMIT)!;
|
|
const meta = parseCiBlock(block)!;
|
|
expect(meta.project).toBe("task-api");
|
|
expect(meta.phase).toBe(1);
|
|
expect(meta.plan).toBe("01-01");
|
|
});
|
|
|
|
it("returns null for empty block", () => {
|
|
const meta = parseCiBlock("");
|
|
expect(meta).toBeNull();
|
|
});
|
|
|
|
it("returns null for block missing required fields", () => {
|
|
const meta = parseCiBlock("something: true\nother: false");
|
|
expect(meta).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("parseCommitMessage", () => {
|
|
it("parses init commit subject line", () => {
|
|
const parsed = parseCommitMessage("abc123", SAMPLE_INIT_COMMIT);
|
|
|
|
expect(parsed.hash).toBe("abc123");
|
|
expect(parsed.type).toBe("docs");
|
|
expect(parsed.scope).toBe("init");
|
|
expect(parsed.subject).toBe("initialize task-api (4 phases)");
|
|
expect(parsed.ci).not.toBeNull();
|
|
expect(parsed.ci!.phase).toBe(0);
|
|
});
|
|
|
|
it("parses task commit with scope", () => {
|
|
const parsed = parseCommitMessage("def456", SAMPLE_TASK_COMMIT);
|
|
|
|
expect(parsed.type).toBe("feat");
|
|
expect(parsed.scope).toBe("P01-01-02");
|
|
expect(parsed.ci!.plan).toBe("01-01");
|
|
expect(parsed.ci!.task).toBe("01-01-02");
|
|
});
|
|
|
|
it("parses compound commit type", () => {
|
|
const parsed = parseCommitMessage("ghi789", SAMPLE_COMPOUND_COMMIT);
|
|
expect(parsed.type).toBe("compound");
|
|
expect(parsed.ci!.compound!.category).toBe("auth");
|
|
});
|
|
|
|
it("parses escalation commit type", () => {
|
|
const parsed = parseCommitMessage("jkl012", SAMPLE_ESCALATION_COMMIT);
|
|
expect(parsed.type).toBe("escalation");
|
|
expect(parsed.ci!.escalations![0].id).toBe("E-001");
|
|
});
|
|
|
|
it("handles commit without ci block", () => {
|
|
const msg = "feat: some regular feature\n\nJust a normal commit.";
|
|
const parsed = parseCommitMessage("mno345", msg);
|
|
|
|
expect(parsed.type).toBe("feat");
|
|
expect(parsed.ci).toBeNull();
|
|
expect(parsed.body).toContain("Just a normal commit");
|
|
});
|
|
|
|
it("extracts body text outside ci block", () => {
|
|
const parsed = parseCommitMessage("pqr678", SAMPLE_TASK_COMMIT);
|
|
expect(parsed.body).toContain("POST /auth/register validates email and password");
|
|
});
|
|
|
|
it("parses commit with project-prefixed scope", () => {
|
|
const parsed = parseCommitMessage("stu901", SAMPLE_PROJECT_COMMIT);
|
|
expect(parsed.type).toBe("feat");
|
|
expect(parsed.scope).toBe("task-api/P01-01-02");
|
|
expect(parsed.ci!.project).toBe("task-api");
|
|
});
|
|
});
|
|
|
|
describe("parseCommitScope", () => {
|
|
it("parses init scope", () => {
|
|
const scope = parseCommitScope("init");
|
|
expect(scope.isInit).toBe(true);
|
|
expect(scope.phase).toBe(0);
|
|
});
|
|
|
|
it("parses milestone scope", () => {
|
|
const scope = parseCommitScope("milestone");
|
|
expect(scope.isMilestone).toBe(true);
|
|
expect(scope.phase).toBe(0);
|
|
});
|
|
|
|
it("parses simple phase scope", () => {
|
|
const scope = parseCommitScope("P01");
|
|
expect(scope.phase).toBe(1);
|
|
expect(scope.isInit).toBe(false);
|
|
expect(scope.isMilestone).toBe(false);
|
|
});
|
|
|
|
it("parses task scope with plan and task", () => {
|
|
const scope = parseCommitScope("P01-01-02");
|
|
expect(scope.phase).toBe(1);
|
|
expect(scope.plan).toBe("01-01");
|
|
expect(scope.task).toBe("01-01-02");
|
|
});
|
|
|
|
it("parses project-prefixed scope", () => {
|
|
const scope = parseCommitScope("task-api/P01-01-02");
|
|
expect(scope.project).toBe("task-api");
|
|
expect(scope.phase).toBe(1);
|
|
expect(scope.plan).toBe("01-01");
|
|
expect(scope.task).toBe("01-01-02");
|
|
});
|
|
|
|
it("does not treat P-prefixed scope as project-prefixed", () => {
|
|
const scope = parseCommitScope("P01-auth");
|
|
expect(scope.project).toBeUndefined();
|
|
expect(scope.phase).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("formatCommitScope", () => {
|
|
it("formats init scope", () => {
|
|
const scope: CommitScope = { phase: 0, isInit: true, isMilestone: false };
|
|
expect(formatCommitScope(scope)).toBe("init");
|
|
});
|
|
|
|
it("formats milestone scope", () => {
|
|
const scope: CommitScope = { phase: 0, isInit: false, isMilestone: true };
|
|
expect(formatCommitScope(scope)).toBe("milestone");
|
|
});
|
|
|
|
it("formats simple phase scope", () => {
|
|
const scope: CommitScope = { phase: 1, isInit: false, isMilestone: false };
|
|
expect(formatCommitScope(scope)).toBe("P01");
|
|
});
|
|
|
|
it("formats task scope", () => {
|
|
const scope: CommitScope = { phase: 1, plan: "01-01", task: "01-01-02", isInit: false, isMilestone: false };
|
|
expect(formatCommitScope(scope)).toBe("P01-01-02");
|
|
});
|
|
|
|
it("formats project-prefixed scope", () => {
|
|
const scope: CommitScope = { phase: 1, project: "task-api", plan: "01-01", task: "01-01-02", isInit: false, isMilestone: false };
|
|
expect(formatCommitScope(scope)).toBe("task-api/P01-01-02");
|
|
});
|
|
|
|
it("formats project-prefixed phase scope without plan/task", () => {
|
|
const scope: CommitScope = { phase: 2, project: "auth-svc", isInit: false, isMilestone: false };
|
|
expect(formatCommitScope(scope)).toBe("auth-svc/P02");
|
|
});
|
|
}); |