From a60f303be109577ae5ac6c0282ce20d4b690d926 Mon Sep 17 00:00:00 2001 From: 1norahc Date: Sun, 13 Sep 2026 15:00:32 +0200 Subject: [PATCH] feat: enforce Pattern Schema v1 (#103) --- library/docs/pattern-contract.md | 8 +- library/docs/schemas/README.md | 1 + library/docs/specs/pattern-schema.md | 22 ++-- .../fixtures/pattern/invalid/bad-id.json | 9 ++ .../pattern/invalid/bad-relation.json | 10 ++ .../pattern/invalid/incomplete-score.json | 9 ++ .../pattern/invalid/unknown-field.json | 10 ++ .../fixtures/pattern/valid/anti-pattern.json | 19 ++++ .../fixtures/pattern/valid/pattern.json | 36 +++++++ library/schemas/pattern.schema.json | 100 ++++++++++++++++++ library/scripts/validate-schemas.ts | 85 +++++++++++++++ .../packages/cli/src/compiler/yaml-parse.ts | 17 ++- tooling/packages/cli/tests/compiler.test.ts | 10 ++ 13 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 library/schemas/fixtures/pattern/invalid/bad-id.json create mode 100644 library/schemas/fixtures/pattern/invalid/bad-relation.json create mode 100644 library/schemas/fixtures/pattern/invalid/incomplete-score.json create mode 100644 library/schemas/fixtures/pattern/invalid/unknown-field.json create mode 100644 library/schemas/fixtures/pattern/valid/anti-pattern.json create mode 100644 library/schemas/fixtures/pattern/valid/pattern.json create mode 100644 library/schemas/pattern.schema.json diff --git a/library/docs/pattern-contract.md b/library/docs/pattern-contract.md index 9d6ba1b..aaa68fc 100644 --- a/library/docs/pattern-contract.md +++ b/library/docs/pattern-contract.md @@ -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 diff --git a/library/docs/schemas/README.md b/library/docs/schemas/README.md index 0f0ce6e..e72a273 100644 --- a/library/docs/schemas/README.md +++ b/library/docs/schemas/README.md @@ -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 diff --git a/library/docs/specs/pattern-schema.md b/library/docs/specs/pattern-schema.md index 30be56d..76c5d98 100644 --- a/library/docs/specs/pattern-schema.md +++ b/library/docs/specs/pattern-schema.md @@ -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 @@ -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 diff --git a/library/schemas/fixtures/pattern/invalid/bad-id.json b/library/schemas/fixtures/pattern/invalid/bad-id.json new file mode 100644 index 0000000..b98b7f1 --- /dev/null +++ b/library/schemas/fixtures/pattern/invalid/bad-id.json @@ -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."] +} diff --git a/library/schemas/fixtures/pattern/invalid/bad-relation.json b/library/schemas/fixtures/pattern/invalid/bad-relation.json new file mode 100644 index 0000000..681ecb8 --- /dev/null +++ b/library/schemas/fixtures/pattern/invalid/bad-relation.json @@ -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" }] +} diff --git a/library/schemas/fixtures/pattern/invalid/incomplete-score.json b/library/schemas/fixtures/pattern/invalid/incomplete-score.json new file mode 100644 index 0000000..5d4f051 --- /dev/null +++ b/library/schemas/fixtures/pattern/invalid/incomplete-score.json @@ -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."] +} diff --git a/library/schemas/fixtures/pattern/invalid/unknown-field.json b/library/schemas/fixtures/pattern/invalid/unknown-field.json new file mode 100644 index 0000000..caa3fe9 --- /dev/null +++ b/library/schemas/fixtures/pattern/invalid/unknown-field.json @@ -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 +} diff --git a/library/schemas/fixtures/pattern/valid/anti-pattern.json b/library/schemas/fixtures/pattern/valid/anti-pattern.json new file mode 100644 index 0000000..cc32060 --- /dev/null +++ b/library/schemas/fixtures/pattern/valid/anti-pattern.json @@ -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" } + ] +} diff --git a/library/schemas/fixtures/pattern/valid/pattern.json b/library/schemas/fixtures/pattern/valid/pattern.json new file mode 100644 index 0000000..592e06a --- /dev/null +++ b/library/schemas/fixtures/pattern/valid/pattern.json @@ -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"] +} diff --git a/library/schemas/pattern.schema.json b/library/schemas/pattern.schema.json new file mode 100644 index 0000000..b929547 --- /dev/null +++ b/library/schemas/pattern.schema.json @@ -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 } + } + } + } +} diff --git a/library/scripts/validate-schemas.ts b/library/scripts/validate-schemas.ts index b5fdee0..cf0a144 100644 --- a/library/scripts/validate-schemas.ts +++ b/library/scripts/validate-schemas.ts @@ -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[] = []; @@ -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`)); @@ -286,6 +288,89 @@ async function validateDecisionDocuments(): Promise { } } +async function validatePatternDocuments(): Promise { + const patternsRoot = join(WORKSPACE_ROOT, "library/patterns"); + const categoryEntries = await readdir(patternsRoot, { withFileTypes: true }); + const schema = schemaRegistry.get("pattern.schema.json") as Record; + const ids = new Set(); + const records: Array<{ path: string; metadata: Record }> = []; + + 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(); + // 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[] { + 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; diff --git a/tooling/packages/cli/src/compiler/yaml-parse.ts b/tooling/packages/cli/src/compiler/yaml-parse.ts index 6ed8ea3..ef5df49 100644 --- a/tooling/packages/cli/src/compiler/yaml-parse.ts +++ b/tooling/packages/cli/src/compiler/yaml-parse.ts @@ -3,9 +3,9 @@ * * The repo ships a dependency-free YAML serializer (config/yaml.ts); this is its * read-side counterpart. It covers exactly what MAP needs to read back: nested - * maps, block sequences (of scalars or maps), and scalar values. It is deliberately - * not a general-purpose YAML implementation — no anchors, flow collections, multi-line - * scalars, or tags. + * maps, block sequences (of scalars or maps), scalar values, and the folded/literal + * block strings used by MAP metadata. It is deliberately not a general-purpose YAML + * implementation — no anchors, complex keys, or tags. */ export type YamlNode = @@ -72,7 +72,16 @@ function parseMapping(lines: Line[], start: number, indent: number): [YamlNode, const key = unquote(line.text.slice(0, colon).trim()); const rest = line.text.slice(colon + 1).trim(); i += 1; - if (rest !== "") { + if ([">", ">-", "|", "|-"].includes(rest)) { + const block: string[] = []; + while (i < lines.length && lines[i]!.indent > indent) { + block.push(lines[i]!.text); + i += 1; + } + const folded = rest.startsWith(">"); + const keepTrailingNewline = !rest.endsWith("-"); + obj[key] = block.join(folded ? " " : "\n") + (keepTrailingNewline ? "\n" : ""); + } else if (rest !== "") { obj[key] = parseScalar(rest); } else if (i < lines.length && lines[i]!.indent > indent) { const [child, next] = parseBlock(lines, i, lines[i]!.indent); diff --git a/tooling/packages/cli/tests/compiler.test.ts b/tooling/packages/cli/tests/compiler.test.ts index e5a4601..c15e629 100644 --- a/tooling/packages/cli/tests/compiler.test.ts +++ b/tooling/packages/cli/tests/compiler.test.ts @@ -63,6 +63,16 @@ describe("parseYaml", () => { priority: "high", }); }); + + it("parses folded and literal block strings without consuming following fields", () => { + expect( + parseYaml("summary: >\n first line\n second line\nscore:\n cost: 2\nnote: |-\n alpha\n beta"), + ).toEqual({ + summary: "first line second line\n", + score: { cost: 2 }, + note: "alpha\nbeta", + }); + }); }); describe("parseFrontmatter", () => {