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
8 changes: 6 additions & 2 deletions library/docs/pattern-contract.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
# Pattern File Contract

Structured metadata is normatively validated by
[`pattern.schema.json`](../schemas/pattern.schema.json) and documented in the
[Pattern Schema v1 guide](../../docs/specifications/PATTERN_SCHEMA.md).

MAP patterns are meant to be consumed by **humans, tools, and AI coding agents**. To make
that possible without rewriting anything, each pattern folder can hold a small, predictable
set of files. This is the contract.

Only `README.md` is required today. The rest form an **optional, extensible AI-ready
layer** — add them as a pattern matures.
A published pattern requires `README.md`, `pattern.yaml`, `prompt.md`, `acceptance.md`,
and `diagram.mmd`. Reference code and assets remain optional.

## Folder layout

Expand Down
1 change: 1 addition & 0 deletions library/docs/schemas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ published with a guide, examples, invalid fixtures, and an offline CI check.
| Typed `.map/` document frontmatter | [`document.schema.json`](../../schemas/document.schema.json) | [Document envelope](document.md) |
| `.map/map.config.json` | [`project.schema.json`](../../schemas/project.schema.json) | [Project manifest](project.md) |
| Architecture decision record | [`decision.schema.json`](../../schemas/decision.schema.json) | [Decision records](decision.md) |
| Pattern and anti-pattern metadata | [`pattern.schema.json`](../../schemas/pattern.schema.json) | [Pattern Schema v1](../../../docs/specifications/PATTERN_SCHEMA.md) |

Schemas use JSON Schema draft 2020-12. Stable fields are strict; experiments use an
`x-` prefix. A schema change that alters accepted meaning requires compatibility and
Expand Down
22 changes: 11 additions & 11 deletions library/docs/specs/pattern-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

| | |
|---|---|
| **Status** | Draft |
| **Status** | Implemented (Pattern Schema v1) |
| **Issue** | [#77](https://github.com/rajanbor/map/issues/77) |
| **Owner** | @rajanbor |

## Summary

Formalize `pattern.yaml` as a JSON Schema (`schemas/pattern.schema.json`) and enforce it
`pattern.yaml` is formalized as JSON Schema (`schemas/pattern.schema.json`) and enforced
in the registry build. Today validation is implicit in what `scripts/build-registry.ts`
happens to read; a wrong key or a misspelled list silently disappears from the registry
instead of failing the PR. The [pattern contract](../pattern-contract.md) promised a
Expand Down Expand Up @@ -47,19 +47,19 @@ The registry build already fails on score ranges, category mismatches, and dangl
- The informal shape in [`pattern-contract.md`](../pattern-contract.md) is replaced by
a link to the schema.

## Implementation plan
## Implementation

1. Add `schemas/pattern.schema.json`; verify all published `pattern.yaml` files pass.
2. Wire structural validation into `scripts/build-registry.ts` (`--check` fails on
violations); add malformed-fixture tests for the builder.
3. Update `pattern-contract.md` and the pattern template to point at the schema.
4. Attach the schema to releases alongside `registry.json` (release workflow).
The schema, valid examples, and malformed fixtures live under `library/schemas/`.
`scripts/validate-schemas.ts` validates the fixtures and every published
`pattern.yaml`, including directory identity, required files, duplicate IDs, and
relationship targets. The release workflow already attaches every `*.schema.json`
file alongside the registry.

## Acceptance criteria

- [ ] A PR with a misspelled `pattern.yaml` key fails the `registry` check with a message naming the file and key.
- [ ] All published patterns validate unchanged.
- [ ] The schema is published with each release and referenced from `pattern-contract.md`.
- [x] A PR with a misspelled `pattern.yaml` key fails the `registry` check with a message naming the file and key.
- [x] All published patterns validate unchanged.
- [x] The schema is published with each release and referenced from the schema guide.

## Compatibility & risks

Expand Down
9 changes: 9 additions & 0 deletions library/schemas/fixtures/pattern/invalid/bad-id.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "retrieval.chunking",
"name": "Chunking",
"category": "retrieval",
"summary": "This otherwise complete record uses a noncanonical pattern identifier.",
"score": { "complexity": 2, "latency": 5, "cost": 5, "accuracyImpact": 5, "productionReadiness": 5 },
"when_to_use": ["Long documents."],
"when_not_to_use": ["Short records."]
}
10 changes: 10 additions & 0 deletions library/schemas/fixtures/pattern/invalid/bad-relation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "retrieval/chunking",
"name": "Chunking",
"category": "retrieval",
"summary": "This otherwise complete record uses an unsupported relationship type.",
"score": { "complexity": 2, "latency": 5, "cost": 5, "accuracyImpact": 5, "productionReadiness": 5 },
"when_to_use": ["Long documents."],
"when_not_to_use": ["Short records."],
"relations": [{ "type": "similar_to", "target": "retrieval/reranking" }]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "retrieval/chunking",
"name": "Chunking",
"category": "retrieval",
"summary": "This otherwise complete record has an incomplete decision score object.",
"score": { "complexity": 2, "latency": 5, "cost": 5, "accuracyImpact": 5 },
"when_to_use": ["Long documents."],
"when_not_to_use": ["Short records."]
}
10 changes: 10 additions & 0 deletions library/schemas/fixtures/pattern/invalid/unknown-field.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "retrieval/chunking",
"name": "Chunking",
"category": "retrieval",
"summary": "This otherwise complete record includes undocumented vendor metadata.",
"score": { "complexity": 2, "latency": 5, "cost": 5, "accuracyImpact": 5, "productionReadiness": 5 },
"when_to_use": ["Long documents."],
"when_not_to_use": ["Short records."],
"vendorMagic": true
}
19 changes: 19 additions & 0 deletions library/schemas/fixtures/pattern/valid/anti-pattern.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"id": "security/unbounded-tool-authority",
"kind": "anti-pattern",
"name": "Unbounded Tool Authority",
"category": "security",
"summary": "An agent receives broad tool permissions without task-scoped capability limits.",
"score": {
"complexity": 1,
"latency": 5,
"cost": 3,
"accuracyImpact": 1,
"productionReadiness": 1
},
"when_to_use": ["Never as a deliberate production architecture."],
"when_not_to_use": ["Use least-privilege, sandboxed, confirmation-gated tools instead."],
"relations": [
{ "type": "conflicts_with", "target": "security/least-privilege-tool-access" }
]
}
36 changes: 36 additions & 0 deletions library/schemas/fixtures/pattern/valid/pattern.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"id": "retrieval/chunking",
"kind": "pattern",
"name": "Chunking",
"category": "retrieval",
"slug": "chunking",
"version": "1.0.0",
"status": "published",
"maturity": "established",
"also_known_as": ["Document segmentation"],
"summary": "Split source material into retrievable units while preserving context.",
"score": {
"complexity": 2,
"latency": 5,
"cost": 5,
"accuracyImpact": 5,
"productionReadiness": 5
},
"when_to_use": ["Documents exceed the useful retrieval context size."],
"when_not_to_use": ["The complete source must be reasoned over as one unit."],
"relations": [
{
"type": "works_with",
"target": "retrieval/reranking",
"note": "Reranking selects among retrieved chunks."
}
],
"references": ["https://arxiv.org/abs/2005.11401"],
"evidence": [
{
"source": "https://arxiv.org/abs/2005.11401",
"title": "Retrieval-Augmented Generation"
}
],
"files": ["README.md", "pattern.yaml", "prompt.md", "acceptance.md", "diagram.mmd"]
}
100 changes: 100 additions & 0 deletions library/schemas/pattern.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://map.dev/schemas/pattern.schema.json",
"title": "MAP Pattern Metadata v1",
"description": "Machine-readable identity, decision guidance, evidence, and relationships for a MAP pattern or anti-pattern.",
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "category", "summary", "score", "when_to_use", "when_not_to_use"],
"properties": {
"id": { "$ref": "#/$defs/patternId" },
"kind": { "enum": ["pattern", "anti-pattern"], "default": "pattern" },
"name": { "type": "string", "minLength": 1 },
"category": { "$ref": "#/$defs/category" },
"slug": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
"status": { "enum": ["planned", "in-progress", "published", "deprecated", "renamed"] },
"maturity": { "enum": ["emerging", "established", "declining"] },
"also_known_as": { "$ref": "#/$defs/nonEmptyStrings" },
"summary": { "type": "string", "minLength": 20 },
"score": { "$ref": "#/$defs/score" },
"when_to_use": { "$ref": "#/$defs/nonEmptyStrings" },
"when_not_to_use": { "$ref": "#/$defs/nonEmptyStrings" },
"related": {
"description": "Legacy compatibility relation. Consumers normalize each target to works_with.",
"type": "array",
"items": { "$ref": "#/$defs/patternId" },
"uniqueItems": true
},
"relations": {
"type": "array",
"items": { "$ref": "#/$defs/relation" },
"uniqueItems": true
},
"references": {
"type": "array",
"items": { "type": "string", "pattern": "^https://" },
"uniqueItems": true
},
"evidence": {
"type": "array",
"items": { "$ref": "#/$defs/evidence" },
"uniqueItems": true
},
"files": { "$ref": "#/$defs/nonEmptyStrings" },
"reference_implementations": { "$ref": "#/$defs/nonEmptyStrings" }
},
"$defs": {
"patternId": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*/[a-z0-9]+(?:-[a-z0-9]+)*$"
},
"category": {
"enum": ["retrieval", "memory", "agents", "security", "context", "evaluation", "performance", "routing", "tool-calling", "observability"]
},
"nonEmptyStrings": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"score": {
"type": "object",
"additionalProperties": false,
"required": ["complexity", "latency", "cost", "accuracyImpact", "productionReadiness"],
"properties": {
"complexity": { "type": "integer", "minimum": 1, "maximum": 5 },
"latency": { "type": "integer", "minimum": 1, "maximum": 5 },
"cost": { "type": "integer", "minimum": 1, "maximum": 5 },
"accuracyImpact": { "type": "integer", "minimum": 1, "maximum": 5 },
"productionReadiness": { "type": "integer", "minimum": 1, "maximum": 5 }
}
},
"relation": {
"type": "object",
"additionalProperties": false,
"required": ["type", "target"],
"properties": {
"type": { "enum": ["depends_on", "works_with", "alternative_to", "extends", "conflicts_with", "solves"] },
"target": { "$ref": "#/$defs/patternId" },
"note": { "type": "string", "minLength": 1 },
"evidence": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
}
}
},
"evidence": {
"type": "object",
"additionalProperties": false,
"required": ["source"],
"properties": {
"source": { "type": "string", "pattern": "^https://" },
"title": { "type": "string", "minLength": 1 },
"accessed": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" },
"note": { "type": "string", "minLength": 1 }
}
}
}
}
85 changes: 85 additions & 0 deletions library/scripts/validate-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const CONTRACTS: readonly Contract[] = [
{ name: "document", schema: "document.schema.json", fixtures: "document" },
{ name: "project", schema: "project.schema.json", fixtures: "project" },
{ name: "decision", schema: "decision.schema.json", fixtures: "decision" },
{ name: "pattern", schema: "pattern.schema.json", fixtures: "pattern" },
];

const failures: string[] = [];
Expand Down Expand Up @@ -72,6 +73,7 @@ for (const [name, value] of [
}

await validateDecisionDocuments();
await validatePatternDocuments();

if (failures.length > 0) {
failures.forEach((failure) => process.stderr.write(`error: ${failure}\n`));
Expand Down Expand Up @@ -286,6 +288,89 @@ async function validateDecisionDocuments(): Promise<void> {
}
}

async function validatePatternDocuments(): Promise<void> {
const patternsRoot = join(WORKSPACE_ROOT, "library/patterns");
const categoryEntries = await readdir(patternsRoot, { withFileTypes: true });
const schema = schemaRegistry.get("pattern.schema.json") as Record<string, unknown>;
const ids = new Set<string>();
const records: Array<{ path: string; metadata: Record<string, unknown> }> = [];

for (const categoryEntry of categoryEntries.sort((a, b) => a.name.localeCompare(b.name))) {
if (!categoryEntry.isDirectory() || categoryEntry.name.startsWith("_")) continue;
const categoryRoot = join(patternsRoot, categoryEntry.name);
const patternEntries = await readdir(categoryRoot, { withFileTypes: true });
for (const patternEntry of patternEntries.sort((a, b) => a.name.localeCompare(b.name))) {
if (!patternEntry.isDirectory() || patternEntry.name.startsWith("_")) continue;
const patternRoot = join(categoryRoot, patternEntry.name);
const path = join(patternRoot, "pattern.yaml");
try {
const parsed = parseYaml(await readFile(path, "utf8"));
if (!isRecord(parsed)) {
failures.push(`${display(path)}: pattern metadata must be a mapping`);
continue;
}
const errors = validate(parsed, schema, schema);
if (errors.length > 0) failures.push(`${display(path)}: ${withRemediation(errors[0]!)}`);

const expectedId = `${categoryEntry.name}/${patternEntry.name}`;
if (parsed.id !== expectedId) failures.push(`${display(path)}: $.id must equal directory id '${expectedId}'`);
if (parsed.category !== categoryEntry.name) {
failures.push(`${display(path)}: $.category must equal directory category '${categoryEntry.name}'`);
}
if (parsed.slug !== undefined && parsed.slug !== patternEntry.name) {
failures.push(`${display(path)}: $.slug must equal directory slug '${patternEntry.name}'`);
}
if (typeof parsed.id === "string") {
if (ids.has(parsed.id)) failures.push(`${display(path)}: duplicate pattern id '${parsed.id}'`);
ids.add(parsed.id);
}
for (const requiredFile of ["README.md", "prompt.md", "acceptance.md", "diagram.mmd"]) {
try {
await readFile(join(patternRoot, requiredFile), "utf8");
} catch {
failures.push(`${display(patternRoot)}: published pattern requires ${requiredFile}`);
}
}
records.push({ path, metadata: parsed });
} catch (error) {
failures.push(`${display(path)}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}

const catalogIds = new Set<string>();
// The bundled snapshot is tracked, unlike the generated library/dist artifact, and
// contains the complete roadmap catalog needed for referential validation in CI.
const registry = await readJson(
join(WORKSPACE_ROOT, "tooling/packages/cli/registry-snapshot/registry.json"),
);
if (isRecord(registry) && Array.isArray(registry.patterns)) {
for (const entry of registry.patterns.filter(isRecord)) {
if (typeof entry.id === "string") catalogIds.add(entry.id);
}
}
for (const record of records) {
for (const target of relationTargets(record.metadata)) {
if (!catalogIds.has(target) && !ids.has(target)) {
failures.push(`${display(record.path)}: relationship target '${target}' does not exist in the catalog`);
}
}
}
}

function relationTargets(metadata: Record<string, unknown>): string[] {
const legacy = Array.isArray(metadata.related)
? metadata.related.filter((value): value is string => typeof value === "string")
: [];
const typed = Array.isArray(metadata.relations)
? metadata.relations
.filter(isRecord)
.map((relation) => relation.target)
.filter((value): value is string => typeof value === "string")
: [];
return [...legacy, ...typed];
}

function hasNonEmptySection(source: string, heading: string): boolean {
const match = new RegExp(`^## ${heading}\\s*$\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, "mu").exec(source);
return match !== null && match[1]!.trim().length > 0;
Expand Down
Loading
Loading