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
20 changes: 20 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ program
}
});

// ── Layer 4: Patterns ──
const patternCmd = program
.command("pattern")
.description("Manage pattern files");

patternCmd
.command("add <name>")
.description("Create a new pattern file and add it to the index")
.action(async (name) => {
try {
const config = findConfig();
const { runPatternAdd } = await import("./pattern/index.js");
await runPatternAdd(config, name);
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
});

// ── Git Hook ──
program
.command("watch")
Expand Down Expand Up @@ -116,6 +135,7 @@ program
console.log(" mex sync --warnings Include warning-only files in sync");
console.log(" mex init Pre-scan codebase, build brief for AI");
console.log(" mex init --json Scanner brief as JSON");
console.log(" mex pattern add <name> Create a new pattern file");
console.log(" mex watch Install post-commit hook for auto drift score");
console.log(" mex watch --uninstall Remove the post-commit hook");
console.log();
Expand Down
68 changes: 68 additions & 0 deletions src/pattern/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { join } from "node:path";
import { existsSync, writeFileSync, appendFileSync, mkdirSync, readFileSync } from "node:fs";
import chalk from "chalk";
import type { MexConfig } from "../types.js";

export async function runPatternAdd(config: MexConfig, name: string) {
if (!/^[a-z0-9-]+$/i.test(name)) {
throw new Error(`Invalid pattern name '${name}'. Use only letters, numbers, and hyphens.`);
}

const patternsDir = join(config.scaffoldRoot, "patterns");
const patternPath = join(patternsDir, `${name}.md`);
const indexPath = join(patternsDir, "INDEX.md");

if (existsSync(patternPath)) {
throw new Error(`Pattern '${name}' already exists at ${patternPath}`);
}

const today = new Date().toISOString().split("T")[0];

const template = `---
name: ${name}
description: [one line — what this pattern covers and when to use it]
triggers:
- "[keyword that should trigger loading this file]"
edges:
- target: "context/conventions.md"
condition: "when verifying this task"
last_updated: ${today}
---

# ${name}

## Context
[What to load or know before starting this task type]

## Steps
[The workflow — what to do, in what order]

## Gotchas
[The things that go wrong. What to watch out for.]

## Verify
[Checklist to run after completing this task type]

## Debug
[What to check when this task type breaks]

## Update Scaffold
- [ ] Update \`ROUTER.md\` "Current Project State" if what's working/not built has changed
- [ ] Update any \`context/\` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in \`patterns/\` and add to \`INDEX.md\`
`;

mkdirSync(patternsDir, { recursive: true });
writeFileSync(patternPath, template, "utf8");

if (existsSync(indexPath)) {
const currentIndex = readFileSync(indexPath, "utf8");
const newlinePrefix = currentIndex.length === 0 || currentIndex.endsWith("\n") ? "" : "\n";
const entry = `${newlinePrefix}| [${name}.md](${name}.md) | [description] |\n`;
appendFileSync(indexPath, entry, "utf8");
}

console.log(chalk.green(`✓ Created pattern ${name}.md`));
console.log(chalk.dim(` Added entry to patterns/INDEX.md`));
console.log(chalk.yellow(`! Remember to edit patterns/INDEX.md and replace [description] with a real use case.`));
}
66 changes: 66 additions & 0 deletions test/pattern.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { runPatternAdd } from "../src/pattern/index.js";

let tmpDir: string;

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "mex-pattern-"));
mkdirSync(join(tmpDir, "patterns"));
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

describe("runPatternAdd", () => {
it("creates a new pattern file and index entry", async () => {
writeFileSync(join(tmpDir, "patterns", "INDEX.md"), "| Pattern | Use when |\n|---|---|\n", "utf8");

await runPatternAdd({ projectRoot: tmpDir, scaffoldRoot: tmpDir }, "my-pattern");

const patternContent = readFileSync(join(tmpDir, "patterns", "my-pattern.md"), "utf8");
expect(patternContent).toContain("name: my-pattern");
expect(patternContent).toContain("# my-pattern");
expect(patternContent).toContain("## Verify");

const indexContent = readFileSync(join(tmpDir, "patterns", "INDEX.md"), "utf8");
expect(indexContent).toContain("| [my-pattern.md](my-pattern.md) |");
});

it("throws an error if pattern already exists", async () => {
writeFileSync(join(tmpDir, "patterns", "my-pattern.md"), "existing content", "utf8");

await expect(
runPatternAdd({ projectRoot: tmpDir, scaffoldRoot: tmpDir }, "my-pattern")
).rejects.toThrow("already exists");
});

it("creates pattern even if INDEX.md is missing", async () => {
await runPatternAdd({ projectRoot: tmpDir, scaffoldRoot: tmpDir }, "my-pattern");

const patternContent = readFileSync(join(tmpDir, "patterns", "my-pattern.md"), "utf8");
expect(patternContent).toContain("name: my-pattern");
});

it("throws an error for invalid pattern names", async () => {
await expect(
runPatternAdd({ projectRoot: tmpDir, scaffoldRoot: tmpDir }, "my pattern")
).rejects.toThrow("Invalid pattern name");

await expect(
runPatternAdd({ projectRoot: tmpDir, scaffoldRoot: tmpDir }, "pattern!")
).rejects.toThrow("Invalid pattern name");
});

it("appends to INDEX.md with a newline if it does not end with one", async () => {
writeFileSync(join(tmpDir, "patterns", "INDEX.md"), "| Pattern | Use when |", "utf8");

await runPatternAdd({ projectRoot: tmpDir, scaffoldRoot: tmpDir }, "newline-pattern");

const indexContent = readFileSync(join(tmpDir, "patterns", "INDEX.md"), "utf8");
expect(indexContent).toBe("| Pattern | Use when |\n| [newline-pattern.md](newline-pattern.md) | [description] |\n");
});
});
Loading