From 74811139a8db9212c9dc1372f6f603df939292a9 Mon Sep 17 00:00:00 2001 From: 1norahc Date: Sun, 13 Sep 2026 12:46:34 +0200 Subject: [PATCH] feat: add MAP document and project schemas --- .github/workflows/ci.yml | 2 + .github/workflows/release-patterns.yml | 7 +- .map/map.config.json | 1 + library/README.md | 2 + library/docs/README.md | 1 + library/docs/schemas/README.md | 13 + library/docs/schemas/document.md | 58 ++++ library/docs/schemas/project.md | 65 +++++ library/schemas/document.schema.json | 63 ++++ .../fixtures/document/invalid/bad-id.json | 6 + .../fixtures/document/invalid/missing-id.json | 5 + .../document/invalid/unknown-field.json | 7 + .../fixtures/document/valid/decision.json | 11 + .../project/invalid/empty-targets.json | 11 + .../project/invalid/missing-project.json | 5 + .../project/invalid/path-traversal.json | 10 + .../project/invalid/unknown-field.json | 11 + .../schemas/fixtures/project/valid/full.json | 31 ++ .../fixtures/project/valid/minimal.json | 15 + library/schemas/project.schema.json | 152 ++++++++++ library/scripts/validate-schemas.ts | 273 ++++++++++++++++++ .../packages/cli/src/compiler/frontmatter.ts | 59 +++- tooling/packages/cli/src/compiler/index.ts | 2 +- tooling/packages/cli/src/config/config.ts | 157 +++++++++- tooling/packages/cli/src/config/index.ts | 2 +- .../cli/templates/workspace/map.config.json | 1 + tooling/packages/cli/tests/compiler.test.ts | 23 ++ tooling/packages/cli/tests/config.test.ts | 40 +++ .../cli/tests/optimize-command.test.ts | 4 +- 29 files changed, 1024 insertions(+), 13 deletions(-) create mode 100644 library/docs/schemas/README.md create mode 100644 library/docs/schemas/document.md create mode 100644 library/docs/schemas/project.md create mode 100644 library/schemas/document.schema.json create mode 100644 library/schemas/fixtures/document/invalid/bad-id.json create mode 100644 library/schemas/fixtures/document/invalid/missing-id.json create mode 100644 library/schemas/fixtures/document/invalid/unknown-field.json create mode 100644 library/schemas/fixtures/document/valid/decision.json create mode 100644 library/schemas/fixtures/project/invalid/empty-targets.json create mode 100644 library/schemas/fixtures/project/invalid/missing-project.json create mode 100644 library/schemas/fixtures/project/invalid/path-traversal.json create mode 100644 library/schemas/fixtures/project/invalid/unknown-field.json create mode 100644 library/schemas/fixtures/project/valid/full.json create mode 100644 library/schemas/fixtures/project/valid/minimal.json create mode 100644 library/schemas/project.schema.json create mode 100644 library/scripts/validate-schemas.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3319a2e..c45fd01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: node-version: 22 - name: Validate the pattern registry run: node library/scripts/build-registry.ts --check + - name: Validate MAP schemas and fixtures + run: node library/scripts/validate-schemas.ts sdk: runs-on: ubuntu-latest diff --git a/.github/workflows/release-patterns.yml b/.github/workflows/release-patterns.yml index 7164601..f22029b 100644 --- a/.github/workflows/release-patterns.yml +++ b/.github/workflows/release-patterns.yml @@ -29,11 +29,14 @@ jobs: node-version: 22 - name: Build registry artifact if: steps.check.outputs.exists == 'false' - run: node library/scripts/build-registry.ts --out library/dist/registry.json + run: | + node library/scripts/validate-schemas.ts + node library/scripts/build-registry.ts --out library/dist/registry.json - name: Create release if: steps.check.outputs.exists == 'false' env: GH_TOKEN: ${{ github.token }} run: | TAG="v${{ steps.version.outputs.version }}" - gh release create "$TAG" --target "$GITHUB_SHA" --title "$TAG" --generate-notes library/dist/registry.json + gh release create "$TAG" --target "$GITHUB_SHA" --title "$TAG" --generate-notes \ + library/dist/registry.json library/schemas/*.schema.json diff --git a/.map/map.config.json b/.map/map.config.json index a979aa5..f282203 100644 --- a/.map/map.config.json +++ b/.map/map.config.json @@ -1,5 +1,6 @@ { "version": 3, + "specVersion": "0.1", "project": { "name": "map", "createdAt": "2026-09-10T17:17:53.644Z", diff --git a/library/README.md b/library/README.md index e51c3b1..c35e45e 100644 --- a/library/README.md +++ b/library/README.md @@ -10,6 +10,7 @@ An open, framework-agnostic knowledge base of AI Engineering patterns. [Philosophy](docs/philosophy.md) · [Patterns](patterns/) · +[Schemas](docs/schemas/) · [Roadmap](ROADMAP.md) · [Contributing](CONTRIBUTING.md) · [Pattern Template](patterns/_template/PATTERN_TEMPLATE.md) @@ -106,6 +107,7 @@ map/ reference/ Layer 3, Reference: small implementations (python, typescript) examples/ Layer 3: end-to-end examples that combine several patterns rfcs/ Design documents for changes that shape the MAP standard + schemas/ JSON Schema contracts, examples, and invalid fixtures templates/ Generic prompt.md / acceptance.md skeletons patterns start from scripts/ Registry builder: compiles the catalog into registry.json .github/ Contributor experience: templates, labels, discussions, CI diff --git a/library/docs/README.md b/library/docs/README.md index 54868a1..eb6393a 100644 --- a/library/docs/README.md +++ b/library/docs/README.md @@ -18,6 +18,7 @@ Specifications (the contracts tools build on — see the - **[Pattern Lifecycle](specs/pattern-lifecycle.md)** — claiming, maturity, deprecation, and renames. *(draft)* - **[Decision Guides](specs/decision-guides.md)** — cross-category "X or Y?" guides and their contract. *(draft)* - **[Website](specs/website.md)** — the ultra-simple registry-fed site. *(draft)* +- **[MAP Schemas](schemas/README.md)** — machine contracts paired with human guides and fixtures. For the patterns themselves, see [`../patterns/`](../patterns/). To contribute, see [`../CONTRIBUTING.md`](../CONTRIBUTING.md). The `map` CLI lives in diff --git a/library/docs/schemas/README.md b/library/docs/schemas/README.md new file mode 100644 index 0000000..ff7fec8 --- /dev/null +++ b/library/docs/schemas/README.md @@ -0,0 +1,13 @@ +# MAP schemas + +MAP schemas are contracts, not a replacement for documentation. Each schema is +published with a guide, examples, invalid fixtures, and an offline CI check. + +| Contract | Machine-readable | Human-readable | +|---|---|---| +| 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) | + +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 +migration notes. diff --git a/library/docs/schemas/document.md b/library/docs/schemas/document.md new file mode 100644 index 0000000..377864d --- /dev/null +++ b/library/docs/schemas/document.md @@ -0,0 +1,58 @@ +# MAP document envelope + +The document envelope is the YAML frontmatter shared by typed Markdown files in +`.map/`. Frontmatter lets software select and validate a document; the Markdown body +explains the contract to people and agents. + +Machine contract: [`document.schema.json`](../../schemas/document.schema.json). + +## Fields + +| Field | Required | Meaning | +|---|---:|---| +| `kind` | yes | Document type, such as `decision`, `agent`, `prompt`, or a namespaced extension. | +| `id` | yes | Stable identifier unique within `kind`; do not reuse it for different meaning. | +| `title` | yes | Short human name shown by tools. | +| `date` | no | ISO calendar date associated with the document or decision. | +| `status` | yes | Lifecycle state: `proposed`, `draft`, `active`, `accepted`, `rejected`, `superseded`, `deprecated`, or `archived`. | +| `scope` | no | One path glob or a unique list of globs where the contract applies. | +| `priority` | no | Compiler order: `high`, `normal` (default), or `low`. It is not permission or severity. | +| `targets` | no | Target adapters that receive the document. Omission means every configured target. | +| `x-*` | no | Namespaced experimental metadata preserved without becoming part of the stable contract. | + +The namespace is the pair `kind` + `id`; an ID does not need to repeat its kind. +Lowercase kebab-case keeps references and Git diffs stable. + +## Complete example + +```markdown +--- +kind: decision +id: adr-0001-human-and-ai-readable-contracts +title: Human- and AI-readable project contracts +date: 2026-09-13 +status: accepted +scope: [".map/**", "library/schemas/**"] +priority: high +targets: [agents, claude, cursor] +x-owner: architecture +--- + +# ADR-0001: Human- and AI-readable project contracts + +## Context + +People need reasoning and tools need stable fields. + +## Decision + +Keep typed YAML frontmatter and explanatory Markdown in one canonical file. +``` + +## Validation and migration + +Validation errors identify the file, JSON-style field path, reason, and expected +shape. Existing Markdown without frontmatter remains valid as legacy context and uses +compiler defaults. To make it a typed contract, add all four required fields in one +change; no body rewrite is required. Once published, change meaning through a new +document and lifecycle link rather than reusing an ID. diff --git a/library/docs/schemas/project.md b/library/docs/schemas/project.md new file mode 100644 index 0000000..1321663 --- /dev/null +++ b/library/docs/schemas/project.md @@ -0,0 +1,65 @@ +# MAP project manifest + +`.map/map.config.json` identifies a MAP project and configures analysis, registry, +context compilation, packs, and project tools. It is read by `map init`, `map doctor`, +editors, CI, and agents. + +Machine contract: [`project.schema.json`](../../schemas/project.schema.json). + +## Version fields + +- `version` is the on-disk configuration revision understood and migrated by the CLI. + The current revision is `3`; revision `2` stays valid. +- `specVersion` identifies the MAP Standard contract. It is optional for existing v2 + and v3 workspaces. New workspaces write `"0.1"` while the standard is pre-1.0. + +Changing `version` can require `map init --yes` migration. A compatible addition to +the standard changes `specVersion` without silently rewriting user configuration. + +## Top-level fields + +| Field | Required | Meaning | +|---|---:|---| +| `version` | yes | CLI configuration revision. | +| `specVersion` | no | MAP Standard version; generated for new workspaces. | +| `project` | yes | Name, creation time, and detected languages. | +| `analysis` | yes | Analyzer IDs plus project-relative include/exclude globs. | +| `registry` | yes | `default`, a URL, or a local registry source. | +| `packs` | no | Unique namespaced pack requirements and optional version ranges. | +| `tools` | no | Project tool settings, currently the token optimizer. | +| `sources` | no | Markdown globs loaded from `.map/`; defaults to `**/*.md`. | +| `targets` | no | Adapter/output mapping for generated agent context. | +| `x-*` | no | Experimental project metadata. | + +Paths and globs are project-relative. Absolute paths and `..` traversal are rejected. +Unknown stable fields fail validation; prefix experiments with `x-`. + +## Minimal example + +```json +{ + "version": 3, + "specVersion": "0.1", + "project": { + "name": "example", + "createdAt": "2026-09-13T08:00:00.000Z", + "languages": [] + }, + "analysis": { "analyzers": [], "include": [], "exclude": [] }, + "registry": { "source": "default" } +} +``` + +## Full example + +See [`fixtures/project/valid/full.json`](../../schemas/fixtures/project/valid/full.json). +It shows scoped analysis, a pack requirement, token budgeting, source selection, and +multiple generated targets. + +## Compatibility and rollback + +The schema accepts existing v2/v3 manifests without `specVersion`, `packs`, `sources`, +or `targets`. New fields are additive. Older CLI releases ignore `specVersion` and +`packs`; pack resolution is intentionally a later capability. To roll back, remove +those optional fields. The CLI never rewrites an existing manifest without the +explicit `--force` path already used by `map init`. diff --git a/library/schemas/document.schema.json b/library/schemas/document.schema.json new file mode 100644 index 0000000..194a4a5 --- /dev/null +++ b/library/schemas/document.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/rajanbor/map/main/library/schemas/document.schema.json", + "title": "MAP document frontmatter", + "description": "Shared machine-readable envelope for typed Markdown documents in .map/.", + "type": "object", + "required": ["kind", "id", "title", "status"], + "properties": { + "kind": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[/-][a-z0-9][a-z0-9-]*)*$" + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[/-][a-z0-9][a-z0-9-]*)*$" + }, + "title": { "type": "string", "minLength": 1 }, + "date": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "status": { + "enum": [ + "proposed", + "draft", + "active", + "accepted", + "rejected", + "superseded", + "deprecated", + "archived" + ] + }, + "scope": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "priority": { + "enum": ["high", "normal", "low"], + "default": "normal" + }, + "targets": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "minItems": 1, + "uniqueItems": true + } + }, + "patternProperties": { + "^x-[a-z0-9][a-z0-9-]*$": true + }, + "additionalProperties": false +} diff --git a/library/schemas/fixtures/document/invalid/bad-id.json b/library/schemas/fixtures/document/invalid/bad-id.json new file mode 100644 index 0000000..6c7e9b1 --- /dev/null +++ b/library/schemas/fixtures/document/invalid/bad-id.json @@ -0,0 +1,6 @@ +{ + "kind": "decision", + "id": "ADR 1", + "title": "An identifier with spaces", + "status": "proposed" +} diff --git a/library/schemas/fixtures/document/invalid/missing-id.json b/library/schemas/fixtures/document/invalid/missing-id.json new file mode 100644 index 0000000..ffc5383 --- /dev/null +++ b/library/schemas/fixtures/document/invalid/missing-id.json @@ -0,0 +1,5 @@ +{ + "kind": "decision", + "title": "A decision without stable identity", + "status": "proposed" +} diff --git a/library/schemas/fixtures/document/invalid/unknown-field.json b/library/schemas/fixtures/document/invalid/unknown-field.json new file mode 100644 index 0000000..d7dad79 --- /dev/null +++ b/library/schemas/fixtures/document/invalid/unknown-field.json @@ -0,0 +1,7 @@ +{ + "kind": "agent", + "id": "reviewer", + "title": "Reviewer", + "status": "active", + "vendorMagic": true +} diff --git a/library/schemas/fixtures/document/valid/decision.json b/library/schemas/fixtures/document/valid/decision.json new file mode 100644 index 0000000..9ab5349 --- /dev/null +++ b/library/schemas/fixtures/document/valid/decision.json @@ -0,0 +1,11 @@ +{ + "kind": "decision", + "id": "adr-0001-human-and-ai-readable-contracts", + "title": "Human- and AI-readable project contracts", + "date": "2026-09-13", + "status": "accepted", + "scope": [".map/**", "library/schemas/**"], + "priority": "high", + "targets": ["agents", "claude", "cursor"], + "x-owner": "architecture" +} diff --git a/library/schemas/fixtures/project/invalid/empty-targets.json b/library/schemas/fixtures/project/invalid/empty-targets.json new file mode 100644 index 0000000..27c5324 --- /dev/null +++ b/library/schemas/fixtures/project/invalid/empty-targets.json @@ -0,0 +1,11 @@ +{ + "version": 3, + "project": { + "name": "example", + "createdAt": "2026-09-13T08:00:00.000Z", + "languages": [] + }, + "analysis": { "analyzers": [], "include": [], "exclude": [] }, + "registry": { "source": "default" }, + "targets": {} +} diff --git a/library/schemas/fixtures/project/invalid/missing-project.json b/library/schemas/fixtures/project/invalid/missing-project.json new file mode 100644 index 0000000..ea5c4b8 --- /dev/null +++ b/library/schemas/fixtures/project/invalid/missing-project.json @@ -0,0 +1,5 @@ +{ + "version": 3, + "analysis": { "analyzers": [], "include": [], "exclude": [] }, + "registry": { "source": "default" } +} diff --git a/library/schemas/fixtures/project/invalid/path-traversal.json b/library/schemas/fixtures/project/invalid/path-traversal.json new file mode 100644 index 0000000..cb3ed08 --- /dev/null +++ b/library/schemas/fixtures/project/invalid/path-traversal.json @@ -0,0 +1,10 @@ +{ + "version": 3, + "project": { + "name": "example", + "createdAt": "2026-09-13T08:00:00.000Z", + "languages": [] + }, + "analysis": { "analyzers": [], "include": ["../secret/**"], "exclude": [] }, + "registry": { "source": "default" } +} diff --git a/library/schemas/fixtures/project/invalid/unknown-field.json b/library/schemas/fixtures/project/invalid/unknown-field.json new file mode 100644 index 0000000..900df11 --- /dev/null +++ b/library/schemas/fixtures/project/invalid/unknown-field.json @@ -0,0 +1,11 @@ +{ + "version": 3, + "project": { + "name": "example", + "createdAt": "2026-09-13T08:00:00.000Z", + "languages": [] + }, + "analysis": { "analyzers": [], "include": [], "exclude": [] }, + "registry": { "source": "default" }, + "magic": true +} diff --git a/library/schemas/fixtures/project/valid/full.json b/library/schemas/fixtures/project/valid/full.json new file mode 100644 index 0000000..2e5025f --- /dev/null +++ b/library/schemas/fixtures/project/valid/full.json @@ -0,0 +1,31 @@ +{ + "version": 3, + "specVersion": "0.1", + "project": { + "name": "support-agent", + "createdAt": "2026-09-13T08:00:00.000Z", + "languages": ["typescript", "python"] + }, + "analysis": { + "analyzers": ["typescript", "python"], + "include": ["src/**", "services/**"], + "exclude": ["**/node_modules/**", "**/dist/**"] + }, + "registry": { "source": "default" }, + "packs": [ + { "name": "@map/reviewer", "version": "^1.0.0" } + ], + "tools": { + "tokenOptimizer": { + "budget": 12000, + "include": ["**/*.md"], + "exclude": ["reports/**", "cache/**"] + } + }, + "sources": ["**/*.md"], + "targets": { + "agents": { "output": "AGENTS.md" }, + "cursor": { "output": ".cursor/rules/map.mdc", "adapter": "cursor" } + }, + "x-team": "platform" +} diff --git a/library/schemas/fixtures/project/valid/minimal.json b/library/schemas/fixtures/project/valid/minimal.json new file mode 100644 index 0000000..7eabbdb --- /dev/null +++ b/library/schemas/fixtures/project/valid/minimal.json @@ -0,0 +1,15 @@ +{ + "version": 3, + "specVersion": "0.1", + "project": { + "name": "example", + "createdAt": "2026-09-13T08:00:00.000Z", + "languages": [] + }, + "analysis": { + "analyzers": [], + "include": [], + "exclude": [] + }, + "registry": { "source": "default" } +} diff --git a/library/schemas/project.schema.json b/library/schemas/project.schema.json new file mode 100644 index 0000000..242ef8f --- /dev/null +++ b/library/schemas/project.schema.json @@ -0,0 +1,152 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/rajanbor/map/main/library/schemas/project.schema.json", + "title": "MAP project manifest", + "description": "Configuration contract for .map/map.config.json.", + "type": "object", + "required": ["version", "project", "analysis", "registry"], + "properties": { + "version": { "type": "integer", "minimum": 2, "maximum": 3 }, + "specVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+$", + "default": "0.1" + }, + "project": { + "type": "object", + "required": ["name", "createdAt", "languages"], + "properties": { + "name": { "type": "string", "minLength": 1, "pattern": ".*\\S.*" }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?Z$" + }, + "languages": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true, + "default": [] + } + }, + "patternProperties": { "^x-[a-z0-9][a-z0-9-]*$": true }, + "additionalProperties": false + }, + "analysis": { + "type": "object", + "required": ["analyzers", "include", "exclude"], + "properties": { + "analyzers": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true, + "default": [] + }, + "include": { + "type": "array", + "items": { "$ref": "#/$defs/relativePath" }, + "uniqueItems": true, + "default": [] + }, + "exclude": { + "type": "array", + "items": { "$ref": "#/$defs/relativePath" }, + "uniqueItems": true, + "default": [] + } + }, + "patternProperties": { "^x-[a-z0-9][a-z0-9-]*$": true }, + "additionalProperties": false + }, + "registry": { + "type": "object", + "required": ["source"], + "properties": { + "source": { "type": "string", "minLength": 1, "pattern": ".*\\S.*", "default": "default" } + }, + "patternProperties": { "^x-[a-z0-9][a-z0-9-]*$": true }, + "additionalProperties": false + }, + "packs": { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*$" + }, + "version": { "type": "string", "minLength": 1 } + }, + "patternProperties": { "^x-[a-z0-9][a-z0-9-]*$": true }, + "additionalProperties": false + }, + "uniqueItems": true, + "default": [] + }, + "tools": { + "type": "object", + "properties": { + "tokenOptimizer": { + "type": "object", + "properties": { + "budget": { "type": "integer", "minimum": 1 }, + "include": { + "type": "array", + "items": { "$ref": "#/$defs/relativePath" }, + "uniqueItems": true + }, + "exclude": { + "type": "array", + "items": { "$ref": "#/$defs/relativePath" }, + "uniqueItems": true + } + }, + "patternProperties": { "^x-[a-z0-9][a-z0-9-]*$": true }, + "additionalProperties": false + } + }, + "patternProperties": { "^x-[a-z0-9][a-z0-9-]*$": true }, + "additionalProperties": false + }, + "sources": { + "type": "array", + "items": { "$ref": "#/$defs/relativePath" }, + "minItems": 1, + "uniqueItems": true, + "default": ["**/*.md"] + }, + "targets": { + "type": "object", + "minProperties": 1, + "patternProperties": { + "^[a-z][a-z0-9-]*$": { + "type": "object", + "required": ["output"], + "properties": { + "output": { "$ref": "#/$defs/relativePath" }, + "adapter": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + }, + "patternProperties": { "^x-[a-z0-9][a-z0-9-]*$": true }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "patternProperties": { + "^x-[a-z0-9][a-z0-9-]*$": true + }, + "additionalProperties": false, + "$defs": { + "relativePath": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" + } + } +} diff --git a/library/scripts/validate-schemas.ts b/library/scripts/validate-schemas.ts new file mode 100644 index 0000000..774b24c --- /dev/null +++ b/library/scripts/validate-schemas.ts @@ -0,0 +1,273 @@ +#!/usr/bin/env node +/** Validate MAP schema fixtures and repository manifests without network access. */ + +import { readFile, readdir } from "node:fs/promises"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +type JsonSchema = boolean | Record; + +const WORKSPACE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const SCHEMA_ROOT = join(WORKSPACE_ROOT, "library/schemas"); +const DRAFT = "https://json-schema.org/draft/2020-12/schema"; + +interface Contract { + readonly name: string; + readonly schema: string; + readonly fixtures: string; +} + +const CONTRACTS: readonly Contract[] = [ + { name: "document", schema: "document.schema.json", fixtures: "document" }, + { name: "project", schema: "project.schema.json", fixtures: "project" }, +]; + +const failures: string[] = []; + +for (const contract of CONTRACTS) { + const schemaPath = join(SCHEMA_ROOT, contract.schema); + const schema = await readJson(schemaPath) as Record; + if (schema.$schema !== DRAFT) { + failures.push(`${display(schemaPath)}: $schema must be ${DRAFT}`); + continue; + } + + const fixtureRoot = join(SCHEMA_ROOT, "fixtures", contract.fixtures); + const validPaths = await jsonFiles(join(fixtureRoot, "valid")); + const invalidPaths = await jsonFiles(join(fixtureRoot, "invalid")); + if (validPaths.length === 0 || invalidPaths.length < 3) { + failures.push(`${display(fixtureRoot)}: requires valid and at least three invalid fixtures`); + } + + for (const fixturePath of validPaths) { + const errors = validate(await readJson(fixturePath), schema, schema); + if (errors.length > 0) { + failures.push(`${display(fixturePath)}: expected valid; ${withRemediation(errors[0]!)}`); + } + } + for (const fixturePath of invalidPaths) { + const errors = validate(await readJson(fixturePath), schema, schema); + if (errors.length === 0) { + failures.push(`${display(fixturePath)}: expected invalid; add a failing field`); + } + } +} + +const projectSchema = await readJson(join(SCHEMA_ROOT, "project.schema.json")) as Record; +for (const [name, value] of [ + [".map/map.config.json", await readJson(join(WORKSPACE_ROOT, ".map/map.config.json"))], + ["tooling/packages/cli/templates/workspace/map.config.json", await renderedInitConfig()], +] as const) { + const errors = validate(value, projectSchema, projectSchema); + if (errors.length > 0) failures.push(`${name}: ${withRemediation(errors[0]!)}`); +} + +if (failures.length > 0) { + failures.forEach((failure) => process.stderr.write(`error: ${failure}\n`)); + process.stderr.write(`schema validation failed with ${failures.length} error(s).\n`); + process.exit(1); +} + +process.stdout.write(`schemas OK: ${CONTRACTS.length} contracts, repository and init manifests valid.\n`); + +function validate( + value: unknown, + schema: JsonSchema, + root: Record, + path = "$", +): string[] { + if (schema === true) return []; + if (schema === false) return [`${path} is not allowed`]; + + const reference = schema.$ref; + if (typeof reference === "string") { + return validate(value, resolveReference(reference, root), root, path); + } + + const errors: string[] = []; + if (Array.isArray(schema.allOf)) { + schema.allOf.forEach((candidate) => errors.push(...validate(value, candidate as JsonSchema, root, path))); + } + for (const keyword of ["anyOf", "oneOf"] as const) { + const candidates = schema[keyword]; + if (!Array.isArray(candidates)) continue; + const matches = candidates.filter( + (candidate) => validate(value, candidate as JsonSchema, root, path).length === 0, + ).length; + if ((keyword === "anyOf" && matches === 0) || (keyword === "oneOf" && matches !== 1)) { + errors.push(`${path} must match ${keyword === "anyOf" ? "at least" : "exactly"} one allowed shape`); + } + } + + if (schema.const !== undefined && !deepEqual(value, schema.const)) { + errors.push(`${path} must equal ${JSON.stringify(schema.const)}`); + } + if (Array.isArray(schema.enum) && !schema.enum.some((item) => deepEqual(value, item))) { + errors.push(`${path} must be one of ${schema.enum.map(String).join(", ")}`); + } + + const expectedType = schema.type; + if (typeof expectedType === "string" && !matchesType(value, expectedType)) { + errors.push(`${path} must be ${expectedType}`); + return errors; + } + + if (typeof value === "string") validateString(value, schema, path, errors); + if (typeof value === "number") validateNumber(value, schema, path, errors); + if (Array.isArray(value)) validateArray(value, schema, root, path, errors); + if (isRecord(value)) validateObject(value, schema, root, path, errors); + return errors; +} + +function validateString( + value: string, + schema: Record, + path: string, + errors: string[], +): void { + if (typeof schema.minLength === "number" && value.length < schema.minLength) { + errors.push(`${path} must contain at least ${schema.minLength} character(s)`); + } + if (typeof schema.pattern === "string" && !new RegExp(schema.pattern, "u").test(value)) { + errors.push(`${path} must match ${schema.pattern}`); + } + if (schema.format === "date-time" && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value)) { + errors.push(`${path} must be an RFC 3339 UTC timestamp`); + } +} + +function validateNumber( + value: number, + schema: Record, + path: string, + errors: string[], +): void { + if (typeof schema.minimum === "number" && value < schema.minimum) { + errors.push(`${path} must be >= ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + errors.push(`${path} must be <= ${schema.maximum}`); + } +} + +function validateArray( + value: unknown[], + schema: Record, + root: Record, + path: string, + errors: string[], +): void { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${path} must contain at least ${schema.minItems} item(s)`); + } + if (schema.uniqueItems === true && new Set(value.map((item) => JSON.stringify(item))).size !== value.length) { + errors.push(`${path} must contain unique items`); + } + if (schema.items !== undefined) { + value.forEach((item, index) => { + errors.push(...validate(item, schema.items as JsonSchema, root, `${path}[${index}]`)); + }); + } +} + +function validateObject( + value: Record, + schema: Record, + root: Record, + path: string, + errors: string[], +): void { + if (typeof schema.minProperties === "number" && Object.keys(value).length < schema.minProperties) { + errors.push(`${path} must contain at least ${schema.minProperties} field(s)`); + } + + const required = Array.isArray(schema.required) ? schema.required : []; + required.forEach((key) => { + if (typeof key === "string" && !(key in value)) errors.push(`${path}.${key} is required`); + }); + + const properties = isRecord(schema.properties) ? schema.properties : {}; + const patterns = isRecord(schema.patternProperties) ? schema.patternProperties : {}; + for (const [key, item] of Object.entries(value)) { + if (key in properties) { + errors.push(...validate(item, properties[key] as JsonSchema, root, `${path}.${key}`)); + continue; + } + const matching = Object.entries(patterns).filter(([pattern]) => new RegExp(pattern, "u").test(key)); + if (matching.length > 0) { + matching.forEach(([, candidate]) => { + errors.push(...validate(item, candidate as JsonSchema, root, `${path}.${key}`)); + }); + continue; + } + if (schema.additionalProperties === false) errors.push(`${path}.${key} is not allowed`); + } +} + +function resolveReference(reference: string, root: Record): JsonSchema { + if (!reference.startsWith("#/")) throw new Error(`unsupported external schema reference: ${reference}`); + let current: unknown = root; + for (const encoded of reference.slice(2).split("/")) { + const key = encoded.replace(/~1/g, "/").replace(/~0/g, "~"); + if (!isRecord(current) || !(key in current)) throw new Error(`unresolved schema reference: ${reference}`); + current = current[key]; + } + if (typeof current !== "boolean" && !isRecord(current)) { + throw new Error(`schema reference is not a schema: ${reference}`); + } + return current; +} + +function matchesType(value: unknown, type: string): boolean { + if (type === "object") return isRecord(value); + if (type === "array") return Array.isArray(value); + if (type === "integer") return typeof value === "number" && Number.isInteger(value); + if (type === "number") return typeof value === "number" && Number.isFinite(value); + if (type === "null") return value === null; + return typeof value === type; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function deepEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")); +} + +async function jsonFiles(path: string): Promise { + return (await readdir(path, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => join(path, entry.name)) + .sort(); +} + +async function renderedInitConfig(): Promise { + const path = join(WORKSPACE_ROOT, "tooling/packages/cli/templates/workspace/map.config.json"); + const values: Readonly> = { + schemaVersion: 3, + projectName: "schema-fixture", + createdAt: "2026-09-13T08:00:00.000Z", + languages: ["typescript"], + analyzers: ["typescript"], + include: ["src/**"], + exclude: ["**/node_modules/**"], + }; + let source = await readFile(path, "utf8"); + for (const [key, value] of Object.entries(values)) { + source = source.replaceAll(`{{${key}}}`, JSON.stringify(value)); + } + return JSON.parse(source); +} + +function display(path: string): string { + return relative(WORKSPACE_ROOT, path); +} + +function withRemediation(error: string): string { + return `${error}; update that field to match the linked schema guide or prefix experimental metadata with 'x-'`; +} diff --git a/tooling/packages/cli/src/compiler/frontmatter.ts b/tooling/packages/cli/src/compiler/frontmatter.ts index 8f10e43..4af7843 100644 --- a/tooling/packages/cli/src/compiler/frontmatter.ts +++ b/tooling/packages/cli/src/compiler/frontmatter.ts @@ -15,8 +15,24 @@ import { parseYaml } from "./yaml-parse.ts"; export type Priority = "high" | "normal" | "low"; +export type DocumentStatus = + | "proposed" + | "draft" + | "active" + | "accepted" + | "rejected" + | "superseded" + | "deprecated" + | "archived"; export interface Frontmatter { + /** Typed document metadata; absent for legacy/untyped Markdown. */ + readonly kind?: string; + readonly id?: string; + readonly title?: string; + readonly date?: string; + readonly status?: DocumentStatus; + readonly scope?: string | readonly string[]; /** Targets this document is included in; `null` means "all targets". */ readonly targets: readonly string[] | null; readonly priority: Priority; @@ -48,5 +64,46 @@ export function parseFrontmatter(source: string): ParsedDocument { const priority: Priority = priorityNode === "high" || priorityNode === "low" ? priorityNode : "normal"; - return { frontmatter: { targets, priority }, body }; + const kind = optionalString(data["kind"]); + const id = optionalString(data["id"]); + const title = optionalString(data["title"]); + const date = optionalString(data["date"]); + const status = isDocumentStatus(data["status"]) ? data["status"] : undefined; + const scopeNode = data["scope"]; + const scope = typeof scopeNode === "string" + ? scopeNode + : Array.isArray(scopeNode) + ? scopeNode.filter((item): item is string => typeof item === "string") + : undefined; + + return { + frontmatter: { + targets, + priority, + ...(kind !== undefined && { kind }), + ...(id !== undefined && { id }), + ...(title !== undefined && { title }), + ...(date !== undefined && { date }), + ...(status !== undefined && { status }), + ...(scope !== undefined && { scope }), + }, + body, + }; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function isDocumentStatus(value: unknown): value is DocumentStatus { + return [ + "proposed", + "draft", + "active", + "accepted", + "rejected", + "superseded", + "deprecated", + "archived", + ].includes(value as DocumentStatus); } diff --git a/tooling/packages/cli/src/compiler/index.ts b/tooling/packages/cli/src/compiler/index.ts index a4ab9e1..94634af 100644 --- a/tooling/packages/cli/src/compiler/index.ts +++ b/tooling/packages/cli/src/compiler/index.ts @@ -20,7 +20,7 @@ export { documentsForTarget } from "./context.ts"; export type { Context, Document } from "./context.ts"; export { parseFrontmatter } from "./frontmatter.ts"; -export type { Frontmatter, Priority } from "./frontmatter.ts"; +export type { DocumentStatus, Frontmatter, Priority } from "./frontmatter.ts"; export { parseYaml } from "./yaml-parse.ts"; export type { YamlNode } from "./yaml-parse.ts"; diff --git a/tooling/packages/cli/src/config/config.ts b/tooling/packages/cli/src/config/config.ts index 390e567..fb7b54c 100644 --- a/tooling/packages/cli/src/config/config.ts +++ b/tooling/packages/cli/src/config/config.ts @@ -29,6 +29,8 @@ export const LEGACY_WORKSPACE_FILES = [ export interface MapConfig { readonly version: number; + /** MAP Standard version. Optional in legacy v2/v3 workspaces. */ + readonly specVersion?: string; readonly project: { readonly name: string; readonly createdAt: string; @@ -45,6 +47,8 @@ export interface MapConfig { /** "default" or an explicit registry URL/path. */ readonly source: string; }; + /** Declarative pack requirements; resolution is introduced separately. */ + readonly packs?: readonly PackReference[]; /** Project-level tools configured for this MAP workspace. */ readonly tools?: { readonly tokenOptimizer?: TokenOptimizerConfig; @@ -61,6 +65,13 @@ export interface MapConfig { readonly targets?: Readonly>; } +export interface PackReference { + /** Namespaced pack id, for example `@map/reviewer`. */ + readonly name: string; + /** Optional semver version or range. */ + readonly version?: string; +} + export interface TokenOptimizerConfig { /** Approximate maximum context size for the project. */ readonly budget?: number; @@ -92,18 +103,152 @@ export const DEFAULT_TARGETS: Readonly> = { /** Parse and structurally validate a map.config.json document. */ export function parseConfig(json: string): MapConfig { const data: unknown = JSON.parse(json); - if (typeof data !== "object" || data === null || Array.isArray(data)) { + if (!isRecord(data)) { throw new Error(`${CONFIG_FILE} must be a JSON object`); } - const config = data as MapConfig; - if (typeof config.version !== "number") { + if (typeof data.version !== "number" || !Number.isInteger(data.version)) { throw new Error(`${CONFIG_FILE} has no numeric 'version'`); } - if (config.version > CONFIG_SCHEMA_VERSION) { + if (data.version > CONFIG_SCHEMA_VERSION) { throw new Error( - `${CONFIG_FILE} version ${config.version} is newer than this CLI supports ` + + `${CONFIG_FILE} version ${data.version} is newer than this CLI supports ` + `(${CONFIG_SCHEMA_VERSION}); update the CLI`, ); } - return config; + if (data.version < 2) { + throw new Error(`${CONFIG_FILE}.version must be at least 2; run 'map init --yes' to migrate`); + } + + assertKeys(data, ["version", "specVersion", "project", "analysis", "registry", "packs", "tools", "sources", "targets"], "$", true); + if (data.specVersion !== undefined) { + assertString(data.specVersion, "$.specVersion", /^\d+\.\d+$/); + } + + const project = assertObject(data.project, "$.project"); + assertKeys(project, ["name", "createdAt", "languages"], "$.project", true); + assertString(project.name, "$.project.name", /\S/); + const createdAt = assertString(project.createdAt, "$.project.createdAt"); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(createdAt)) { + fail("$.project.createdAt", "must be an RFC 3339 UTC timestamp"); + } + assertStringArray(project.languages, "$.project.languages", true); + + const analysis = assertObject(data.analysis, "$.analysis"); + assertKeys(analysis, ["analyzers", "include", "exclude"], "$.analysis", true); + assertStringArray(analysis.analyzers, "$.analysis.analyzers", true); + for (const key of ["include", "exclude"] as const) { + const values = assertStringArray(analysis[key], `$.analysis.${key}`, true); + values.forEach((value, index) => assertRelativePath(value, `$.analysis.${key}[${index}]`)); + } + + const registry = assertObject(data.registry, "$.registry"); + assertKeys(registry, ["source"], "$.registry", true); + assertString(registry.source, "$.registry.source", /\S/); + + if (data.packs !== undefined) validatePacks(data.packs); + if (data.tools !== undefined) validateTools(data.tools); + if (data.sources !== undefined) { + const sources = assertStringArray(data.sources, "$.sources", false); + sources.forEach((source, index) => assertRelativePath(source, `$.sources[${index}]`)); + } + if (data.targets !== undefined) validateTargets(data.targets); + + return data as unknown as MapConfig; +} + +function validatePacks(value: unknown): void { + if (!Array.isArray(value)) fail("$.packs", "must be an array"); + const names = new Set(); + value.forEach((entry, index) => { + const path = `$.packs[${index}]`; + const pack = assertObject(entry, path); + assertKeys(pack, ["name", "version"], path, true); + const name = assertString(pack.name, `${path}.name`, /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/); + if (names.has(name)) fail(`${path}.name`, "must be unique"); + names.add(name); + if (pack.version !== undefined) assertString(pack.version, `${path}.version`, /\S/); + }); +} + +function validateTools(value: unknown): void { + const tools = assertObject(value, "$.tools"); + assertKeys(tools, ["tokenOptimizer"], "$.tools", true); + if (tools.tokenOptimizer === undefined) return; + const optimizer = assertObject(tools.tokenOptimizer, "$.tools.tokenOptimizer"); + assertKeys(optimizer, ["budget", "include", "exclude"], "$.tools.tokenOptimizer", true); + if (optimizer.budget !== undefined && + (typeof optimizer.budget !== "number" || !Number.isInteger(optimizer.budget) || optimizer.budget < 1)) { + fail("$.tools.tokenOptimizer.budget", "must be a positive integer"); + } + for (const key of ["include", "exclude"] as const) { + if (optimizer[key] === undefined) continue; + const values = assertStringArray(optimizer[key], `$.tools.tokenOptimizer.${key}`, true); + values.forEach((item, index) => assertRelativePath(item, `$.tools.tokenOptimizer.${key}[${index}]`)); + } +} + +function validateTargets(value: unknown): void { + const targets = assertObject(value, "$.targets"); + if (Object.keys(targets).length === 0) fail("$.targets", "must define at least one target"); + for (const [id, rawTarget] of Object.entries(targets)) { + if (!/^[a-z][a-z0-9-]*$/.test(id)) fail(`$.targets.${id}`, "has an invalid target id"); + const path = `$.targets.${id}`; + const target = assertObject(rawTarget, path); + assertKeys(target, ["output", "adapter"], path, true); + assertRelativePath(assertString(target.output, `${path}.output`, /\S/), `${path}.output`); + if (target.adapter !== undefined) { + assertString(target.adapter, `${path}.adapter`, /^[a-z][a-z0-9-]*$/); + } + } +} + +function assertObject(value: unknown, path: string): Record { + if (!isRecord(value)) fail(path, "must be an object"); + return value; +} + +function assertKeys( + value: Record, + allowed: readonly string[], + path: string, + allowExtensions: boolean, +): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key) && !(allowExtensions && key.startsWith("x-"))) { + fail(`${path}.${key}`, "is not a recognized field; use an 'x-' prefix for extensions"); + } + } +} + +function assertString(value: unknown, path: string, pattern?: RegExp): string { + if (typeof value !== "string" || value.length === 0) fail(path, "must be a non-empty string"); + if (pattern !== undefined && !pattern.test(value)) fail(path, "has an invalid format"); + return value; +} + +function assertStringArray(value: unknown, path: string, allowEmpty: boolean): readonly string[] { + if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) { + fail(path, allowEmpty ? "must be an array" : "must be a non-empty array"); + } + const seen = new Set(); + value.forEach((item, index) => { + const text = assertString(item, `${path}[${index}]`); + if (seen.has(text)) fail(`${path}[${index}]`, "must be unique"); + seen.add(text); + }); + return value as string[]; +} + +function assertRelativePath(value: string, path: string): void { + if (/^(?:\/|[A-Za-z]:[\\/])/.test(value) || /(?:^|[\\/])\.\.(?:[\\/]|$)/.test(value)) { + fail(path, "must be a project-relative path or glob without '..' traversal"); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function fail(path: string, reason: string): never { + throw new Error(`${CONFIG_FILE}: ${path} ${reason}`); } diff --git a/tooling/packages/cli/src/config/index.ts b/tooling/packages/cli/src/config/index.ts index 4319a31..47c006f 100644 --- a/tooling/packages/cli/src/config/index.ts +++ b/tooling/packages/cli/src/config/index.ts @@ -1,4 +1,4 @@ -export type { MapConfig, CompilerTarget, TokenOptimizerConfig } from "./config.ts"; +export type { MapConfig, CompilerTarget, TokenOptimizerConfig, PackReference } from "./config.ts"; export { CONFIG_SCHEMA_VERSION, MAP_DIR, diff --git a/tooling/packages/cli/templates/workspace/map.config.json b/tooling/packages/cli/templates/workspace/map.config.json index d4d8c04..21385b8 100644 --- a/tooling/packages/cli/templates/workspace/map.config.json +++ b/tooling/packages/cli/templates/workspace/map.config.json @@ -1,5 +1,6 @@ { "version": {{schemaVersion}}, + "specVersion": "0.1", "project": { "name": {{projectName}}, "createdAt": {{createdAt}}, diff --git a/tooling/packages/cli/tests/compiler.test.ts b/tooling/packages/cli/tests/compiler.test.ts index 79eb7bf..e5a4601 100644 --- a/tooling/packages/cli/tests/compiler.test.ts +++ b/tooling/packages/cli/tests/compiler.test.ts @@ -73,6 +73,29 @@ describe("parseFrontmatter", () => { expect(parsed.body).toBe("# Body"); }); + it("parses the canonical document envelope", () => { + const parsed = parseFrontmatter(`--- +kind: decision +id: adr-0001-example +title: Example decision +date: 2026-09-13 +status: accepted +scope: [src/**, tests/**] +targets: [agents, cursor] +--- +# Decision`); + + expect(parsed.frontmatter).toMatchObject({ + kind: "decision", + id: "adr-0001-example", + title: "Example decision", + date: "2026-09-13", + status: "accepted", + scope: ["src/**", "tests/**"], + targets: ["agents", "cursor"], + }); + }); + it("defaults to all targets and normal priority when absent", () => { const parsed = parseFrontmatter("# Just a body"); expect(parsed.frontmatter.targets).toBeNull(); diff --git a/tooling/packages/cli/tests/config.test.ts b/tooling/packages/cli/tests/config.test.ts index 76cc5d6..1382528 100644 --- a/tooling/packages/cli/tests/config.test.ts +++ b/tooling/packages/cli/tests/config.test.ts @@ -11,12 +11,14 @@ describe("parseConfig", () => { const config = parseConfig( JSON.stringify({ version: CONFIG_SCHEMA_VERSION, + specVersion: "0.1", project: { name: "demo", createdAt: "2026-01-01T00:00:00.000Z", languages: ["typescript"] }, analysis: { analyzers: ["typescript"], include: ["src/**"], exclude: [] }, registry: { source: "default" }, }), ); expect(config.version).toBe(CONFIG_SCHEMA_VERSION); + expect(config.specVersion).toBe("0.1"); expect(config.project.name).toBe("demo"); }); @@ -55,6 +57,44 @@ describe("parseConfig", () => { expect(config.targets).toBeUndefined(); }); + it("validates the complete manifest vocabulary", () => { + const base = { + version: CONFIG_SCHEMA_VERSION, + project: { name: "demo", createdAt: "2026-01-01T00:00:00.000Z", languages: [] }, + analysis: { analyzers: [], include: [], exclude: [] }, + registry: { source: "default" }, + }; + + expect(() => parseConfig(JSON.stringify({ ...base, unknown: true }))).toThrow(/\$\.unknown/); + expect(() => parseConfig(JSON.stringify({ ...base, specVersion: "draft" }))).toThrow( + /\$\.specVersion/, + ); + expect(() => + parseConfig(JSON.stringify({ ...base, analysis: { ...base.analysis, include: ["../secret"] } })), + ).toThrow(/traversal/); + expect(() => parseConfig(JSON.stringify({ ...base, targets: {} }))).toThrow(/\$\.targets/); + }); + + it("accepts extension fields and declarative packs", () => { + const config = parseConfig( + JSON.stringify({ + version: CONFIG_SCHEMA_VERSION, + specVersion: "0.1", + project: { + name: "demo", + createdAt: "2026-01-01T00:00:00.000Z", + languages: [], + "x-team": "platform", + }, + analysis: { analyzers: [], include: [], exclude: [] }, + registry: { source: "default" }, + packs: [{ name: "@map/reviewer", version: "^1.0.0" }], + "x-owner": "architecture", + }), + ); + expect(config.packs).toEqual([{ name: "@map/reviewer", version: "^1.0.0" }]); + }); + it("exposes sensible compiler defaults", () => { expect(DEFAULT_SOURCES).toContain("**/*.md"); expect(DEFAULT_TARGETS.claude?.output).toBe("CLAUDE.md"); diff --git a/tooling/packages/cli/tests/optimize-command.test.ts b/tooling/packages/cli/tests/optimize-command.test.ts index 5c9b95e..63b14f0 100644 --- a/tooling/packages/cli/tests/optimize-command.test.ts +++ b/tooling/packages/cli/tests/optimize-command.test.ts @@ -41,7 +41,7 @@ describe("token optimizer", () => { const storage = fakeStorage({ [join(root, ".map/map.config.json")]: JSON.stringify({ version: 3, - project: { name: "demo", createdAt: "2026-01-01", languages: [] }, + project: { name: "demo", createdAt: "2026-01-01T00:00:00.000Z", languages: [] }, analysis: { analyzers: [], include: [], exclude: [] }, registry: { source: "default" }, tools: { tokenOptimizer: { budget: 10, include: ["**/*.md"], exclude: [] } }, @@ -94,7 +94,7 @@ describe("token optimizer", () => { const storage = fakeStorage({ "/project/.map/map.config.json": JSON.stringify({ version: 3, - project: { name: "demo", createdAt: "2026-01-01", languages: [] }, + project: { name: "demo", createdAt: "2026-01-01T00:00:00.000Z", languages: [] }, analysis: { analyzers: [], include: [], exclude: [] }, registry: { source: "default" }, }),