From ab379a51b0695c44b500f52db413e70089e560c5 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Mon, 27 Apr 2026 11:13:46 -0400 Subject: [PATCH 1/3] feat: add weight field, fieldMappings config, and madrigal init wizard Replaces the opinionated `enforcement` field with a flexible `weight` system so any team can use their existing frontmatter vocabulary without renaming files. Adds a `madrigal init` CLI command that scans source files and generates a starter config with suggested fieldMappings and levels. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 101 ++++++- docs/field-mapping.md | 242 ++++++++++++++++ src/__tests__/bm25.test.ts | 35 ++- src/__tests__/propose.test.ts | 30 +- src/__tests__/weight.test.ts | 200 +++++++++++++ src/adapters/search.ts | 10 +- src/adapters/storage.ts | 6 +- src/cli.ts | 36 ++- src/compliance/checker.ts | 8 +- src/compliance/report.ts | 16 +- src/config.ts | 48 ++++ src/dev/server.ts | 38 +-- src/dev/ui/src/api.ts | 12 +- src/dev/ui/src/topology/types.ts | 2 +- src/enforcement.ts | 56 +--- src/formats/ai-rules-md.ts | 18 +- src/formats/json-bundle.ts | 2 +- src/formats/mesh-domain.ts | 4 +- src/formats/skill-md.ts | 20 +- src/index.ts | 34 ++- src/init.ts | 463 +++++++++++++++++++++++++++++++ src/loader.ts | 112 ++++++-- src/propose.ts | 35 ++- src/resolver.ts | 26 +- src/rules/index.ts | 6 +- src/schema/brand-taxonomy.ts | 14 +- src/schema/knowledge-unit.ts | 24 +- src/search/adapter.ts | 27 +- src/serve/mcp-server.ts | 44 ++- src/topology/generate.ts | 2 +- src/topology/types.ts | 2 +- src/weight.ts | 110 ++++++++ 32 files changed, 1497 insertions(+), 286 deletions(-) create mode 100644 docs/field-mapping.md create mode 100644 src/__tests__/weight.test.ts create mode 100644 src/init.ts create mode 100644 src/weight.ts diff --git a/README.md b/README.md index 2a55f70..5fe6442 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,13 @@ Inspired by [Style Dictionary](https://amzn.github.io/style-dictionary/), Madrig npm install madrigal ``` -Create a `madrigal.config.yaml`: +If you have existing knowledge files, scan them to generate a starter config: + +```bash +npx madrigal init +``` + +Or create a `madrigal.config.yaml` manually: ```yaml sources: @@ -38,7 +44,7 @@ Create a knowledge file at `knowledge/contrast.md`: --- title: Color Contrast Requirements domain: accessibility -severity: error +weight: must tags: - a11y - wcag @@ -66,7 +72,7 @@ for (const output of result.results) { ### Knowledge Units -The atomic unit. Each `.md` file with frontmatter becomes a `KnowledgeUnit` with an id, title, body, domain, severity, tags, and provenance tracking. +The atomic unit. Each `.md` file with frontmatter becomes a `KnowledgeUnit` with an id, title, body, domain, weight, tags, and provenance tracking. ### Domains @@ -76,9 +82,21 @@ Logical groupings of knowledge (e.g., `accessibility`, `typography`, `layout`). Organizational units that can inherit from each other. A brand can `include` other brands/groups, and brand-specific knowledge overrides globals with the same id. -### Severity +Units with `brand: shared` or `brand: global` (or no brand at all) are included for every brand — use these for org-wide rules that apply everywhere. + +### Weight + +How strongly a unit should influence decisions. By default, five levels from highest to lowest: + +| Level | Meaning | +|-------|---------| +| `must` | Required — non-compliance is a defect | +| `should` | Strong recommendation — deviate only with justification | +| `may` | Guidance — a good default, but context matters | +| `context` | Background — informational, not prescriptive | +| `deprecated` | Being phased out — avoid in new work | -Five levels: `error` > `warning` > `info` > `context` > `deprecated`. Severity controls enforcement behavior and output filtering. +You can define your own ordered levels in config (see [Field Mapping Guide](docs/field-mapping.md)). ### Formats @@ -121,8 +139,30 @@ platforms: format: json-bundle # Required: registered format name groupBy: brand # Optional: brand | domain | system destination: out/ # Optional: output path + +# Weight levels — ordered highest to lowest importance +# Defaults to: [must, should, may, context, deprecated] +levels: + - must + - should + - may + - context + - deprecated + +# Map your existing frontmatter field names to Madrigal's normalized fields +fieldMappings: + id: key # Simple rename: use "key" field as the id + domain: category # Simple rename: use "category" as domain + weight: # Complex mapping: rename + translate values + from: status + values: + active: must + draft: context + deprecated: deprecated ``` +See the [Field Mapping Guide](docs/field-mapping.md) for a full reference. + ## Knowledge File Format ```markdown @@ -130,8 +170,8 @@ platforms: title: Rule Title # Required (or id) id: custom-id # Optional, generated from filename if omitted domain: accessibility # Optional, defaults to 'default' -severity: error # Optional: error|warning|info|context|deprecated -brand: acme # Optional, omit for global rules +weight: must # Optional: must|should|may|context|deprecated +brand: acme # Optional, omit (or use 'shared') for global rules system: web # Optional tags: # Optional - a11y @@ -141,6 +181,48 @@ tags: # Optional Markdown body content here. ``` +## CLI + +```bash +# Scan your knowledge files and generate a starter config +madrigal init [--sources "**/*.md"] [--output madrigal.config.yaml] [--dry-run] + +# Build all platforms +madrigal build [--config madrigal.config.yaml] + +# Validate config without building +madrigal validate + +# Start MCP server +madrigal serve [--bundle path/to/knowledge.json] +``` + +### `madrigal init` + +Scans your existing frontmatter, detects non-standard field names and value vocabularies, and generates a `madrigal.config.yaml` with suggested `fieldMappings` and `levels`. Run it once when adopting Madrigal — you won't need to rename your files. + +``` +$ madrigal init + +Scanning sources: **/*.md, **/*.yaml + +Scanned 47 files. + +Non-standard fields found (candidates for fieldMappings): + key (47 files) → id — e.g. color-contrast, type-scale + category (47 files) → domain — e.g. accessibility, typography + status (42 files) → weight — e.g. active, draft, deprecated + +Weight field detected: "status" with values: active, draft, deprecated + No known vocabulary match — review the suggested levels in the config. + +Domains: accessibility, typography, motion +Brands: (none found) +Kinds: (none found) + +Wrote suggested config to madrigal.config.yaml +``` + ## Plugin System ### Custom Formats @@ -199,7 +281,7 @@ Load knowledge units from markdown files matching source globs. ### `resolveForBrand(options)` -Resolve knowledge units for a specific brand, applying inheritance and severity overrides. +Resolve knowledge units for a specific brand, applying inheritance and weight-based overrides. ### `validateConfig(config, formatNames?)` @@ -227,6 +309,8 @@ await serveMcp({ bundlePaths: [ resolve(baseDir, '../other-repo/publish/to-artifactory/knowledge.json'), ], + // Optional: provide an Anthropic API key to enable AI-powered review synthesis + anthropicApiKey: process.env.ANTHROPIC_API_KEY, }); ``` @@ -264,6 +348,7 @@ ln -sf "$(pwd)/skills/{skill-name}/SKILL.md" ~/.claude/skills/{skill-name}/SKILL | Resource | Description | |----------|-------------| +| [Field Mapping Guide](docs/field-mapping.md) | How to map your frontmatter to Madrigal's fields | | [CODEOWNERS](./CODEOWNERS) | Project lead(s) | | [GOVERNANCE.md](./GOVERNANCE.md) | Project governance | | [LICENSE](./LICENSE) | Apache License, Version 2.0 | diff --git a/docs/field-mapping.md b/docs/field-mapping.md new file mode 100644 index 0000000..ae26f88 --- /dev/null +++ b/docs/field-mapping.md @@ -0,0 +1,242 @@ +# Field Mapping Guide + +Madrigal works with your existing frontmatter — you don't need to rename your files to adopt it. This guide explains how to use `fieldMappings` and `levels` in your config to bridge your team's vocabulary to Madrigal's normalized fields. + +## Madrigal's Core Fields + +Every knowledge unit is normalized to these fields at load time: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | string | Yes* | Unique identifier. Auto-generated from filename if absent. | +| `title` | string | Yes* | Human-readable name for the unit. | +| `domain` | string | No | Logical grouping (e.g. `accessibility`, `typography`). | +| `kind` | string | No | Unit type (e.g. `rule`, `guideline`, `pattern`). | +| `brand` | string | No | Scope — omit or set to `shared`/`global` for org-wide units. | +| `weight` | string | No | Importance level. Defaults to the middle level if absent. | +| `tags` | string[] | No | Free-form labels for filtering and search. | +| `system` | string | No | Design system or platform scope. | + +*At least one of `id` or `title` must be present. + +### The `weight` field + +`weight` captures how strongly a unit should influence decisions. Unlike a binary required/optional flag, it's a spectrum — useful for filtering search results, surfacing the most critical rules first, and communicating intent to AI agents. + +**Default levels (highest → lowest):** + +| Level | Meaning | +|-------|---------| +| `must` | Required — non-compliance is a defect | +| `should` | Strong recommendation — deviate only with justification | +| `may` | Guidance — a good default, but context matters | +| `context` | Background — informational, not prescriptive | +| `deprecated` | Being phased out — avoid in new work | + +If `weight` is absent from a unit, Madrigal defaults to `may` (the middle level). + +### When `weight` isn't right for your team + +If your team doesn't use weight-like concepts at all (e.g. a pure reference library with no prioritization), you can omit `weight` from all your files. Madrigal will assign the default level and the field stays out of the way. + +--- + +## Using `fieldMappings` + +`fieldMappings` lets you tell Madrigal which of your frontmatter fields correspond to its normalized fields — without touching your source files. + +### Simple rename + +Map a source field directly to a Madrigal field: + +```yaml +fieldMappings: + id: key # Use your "key" field as the id + domain: category # Use your "category" field as domain + kind: type # Use your "type" field as kind +``` + +Rules: +- If the Madrigal target field is already set in the frontmatter, the mapping is skipped (your explicit value wins). +- If the source field doesn't exist on a unit, the mapping is silently skipped. + +### Complex mapping (rename + value translation) + +When your weight-equivalent field uses different value names, use the long form: + +```yaml +fieldMappings: + weight: + from: status + values: + active: must + draft: context + deprecated: deprecated +``` + +- `from`: the source field name +- `values`: a lookup table translating source values to Madrigal values +- If a value isn't in the `values` map, it passes through unchanged (so you can add custom levels alongside standard ones) + +You can also use the long form for a rename-only mapping (no value translation): + +```yaml +fieldMappings: + weight: + from: maturity +``` + +### Multiple mappings + +All mappings apply together. You can map several fields at once: + +```yaml +fieldMappings: + id: key + domain: category + weight: + from: status + values: + active: must + draft: should + review: may + archived: deprecated +``` + +--- + +## Using `levels` + +If your team has a different weight vocabulary, define it in `levels`: + +```yaml +levels: + - required # highest weight + - recommended + - optional + - deprecated # lowest weight +``` + +List them **highest to lowest** — Madrigal uses the order to determine which units take priority in conflict resolution and search ranking. + +Once you define custom levels, use those exact strings in your frontmatter (or map from your existing values using `fieldMappings`). + +### Accessing levels in code + +```typescript +import { parseWeight, isHighWeight, compareWeight, buildWeightOrder } from 'madrigal'; + +const myLevels = ['required', 'recommended', 'optional', 'deprecated']; + +parseWeight('required', myLevels) // → 'required' +parseWeight('unknown', myLevels) // → null +isHighWeight('required', myLevels) // → true +isHighWeight('optional', myLevels) // → false + +const order = buildWeightOrder(myLevels); +compareWeight('required', 'optional', order) // → negative (required is higher) +``` + +--- + +## Team Examples + +### Design system (maturity model) + +Your files use `maturity: stable | beta | experimental | deprecated`. + +```yaml +levels: + - stable + - beta + - experimental + - deprecated + +fieldMappings: + weight: + from: maturity +``` + +No value translation needed — the field values match the levels directly. + +### Compliance kit + +Your files use `key` for ID, `category` for domain, and `status: active | draft | deprecated`. + +```yaml +fieldMappings: + id: key + domain: category + weight: + from: status + values: + active: must + draft: context + deprecated: deprecated +``` + +### Content design rules + +Your files use `priority: critical | high | medium | low`. + +```yaml +levels: + - critical + - high + - medium + - low + +fieldMappings: + weight: + from: priority +``` + +### Mixed vocabulary (some files already use weight) + +If some files already have a `weight` field, Madrigal uses it as-is. The mapping only applies when `weight` is absent — so a gradual migration works without a big-bang rename. + +--- + +## Running `madrigal init` + +The init wizard scans your files and generates a starter config automatically: + +```bash +npx madrigal init +``` + +Flags: + +| Flag | Default | Description | +|------|---------|-------------| +| `--sources` | `**/*.md **/*.yaml` | Glob patterns to scan | +| `--output` | `madrigal.config.yaml` | Where to write the config | +| `--dry-run` | — | Print config to stdout instead of writing | + +The wizard: +1. Reads all matching files and counts field frequencies +2. Identifies fields that don't match Madrigal's normalized names +3. Detects your weight-equivalent field and its values +4. Matches against known vocabularies (design-system maturity, priority levels, etc.) +5. Writes a `madrigal.config.yaml` with suggested `fieldMappings` and `levels` + +Review the generated config before committing. Remove any mappings where your field name already matches Madrigal's, and adjust the `levels` ordering to match your team's convention. + +--- + +## Migration from `enforcement`/`severity` + +If you used earlier versions of Madrigal that required `enforcement` or `severity` frontmatter, those fields still work as deprecated aliases. Madrigal maps them to `weight` automatically: + +| Old value | New value | +|-----------|-----------| +| `error` | `must` | +| `warning` | `should` | +| `info` | `may` | + +You can migrate gradually — mixed files work fine. When you're ready, rename the fields in bulk: + +```bash +# Rename enforcement → weight in frontmatter +find knowledge -name "*.md" -exec sed -i '' 's/^enforcement:/weight:/g' {} \; +``` diff --git a/src/__tests__/bm25.test.ts b/src/__tests__/bm25.test.ts index b17fe5b..5efc9c3 100644 --- a/src/__tests__/bm25.test.ts +++ b/src/__tests__/bm25.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import type { Enforcement } from '../enforcement.js'; import { findRelated } from '../propose.js'; import type { KnowledgeUnit } from '../schema/index.js'; import { BM25SearchAdapter } from '../search/adapter.js'; import { BM25Index, tokenize } from '../search/bm25.js'; +import type { Weight } from '../weight.js'; function makeUnit( overrides: Partial & { id: string; title: string }, @@ -13,7 +13,7 @@ function makeUnit( domain: 'content', kind: 'rule', tags: [], - enforcement: 'should' as Enforcement, + weight: 'should' as string, attributes: {}, provenance: { origin: 'human-authored', confidence: 1.0 }, ...overrides, @@ -65,7 +65,7 @@ describe('BM25Index', () => { title: 'FDIC banking disclosure requirements', body: 'All banking interfaces must display FDIC disclosure. This is a legal compliance requirement for financial products.', tags: ['compliance', 'legal', 'banking'], - enforcement: 'must' as Enforcement, + weight: 'must' as string, }), makeUnit({ id: 'voice', @@ -208,7 +208,7 @@ describe('BM25SearchAdapter', () => { domain: 'content', kind: 'rule', tags: ['buttons', 'ux-copy'], - enforcement: 'should' as Enforcement, + weight: 'should' as string, }), makeUnit({ id: 'fdic', @@ -217,7 +217,7 @@ describe('BM25SearchAdapter', () => { domain: 'content', kind: 'rule', tags: ['compliance', 'legal'], - enforcement: 'must' as Enforcement, + weight: 'must' as string, }), makeUnit({ id: 'tokens', @@ -227,7 +227,7 @@ describe('BM25SearchAdapter', () => { kind: 'glossary', brand: 'square', tags: ['tokens', 'colors'], - enforcement: 'may' as Enforcement, + weight: 'may' as string, }), makeUnit({ id: 'voice', @@ -235,7 +235,7 @@ describe('BM25SearchAdapter', () => { body: 'Be clear and warm.', domain: 'content', tags: ['voice'], - enforcement: 'context' as Enforcement, + weight: 'context' as string, }), ]; @@ -274,9 +274,9 @@ describe('BM25SearchAdapter', () => { expect(results.some((u) => u.id === 'buttons')).toBe(true); // no brand = global }); - it('filters by enforcement', async () => { + it('filters by weight', async () => { const adapter = new BM25SearchAdapter(units); - const results = await adapter.exactMatch({ enforcement: ['must'] }); + const results = await adapter.exactMatch({ weight: ['must'] }); expect(results).toHaveLength(1); expect(results[0].id).toBe('fdic'); }); @@ -305,11 +305,11 @@ describe('BM25SearchAdapter', () => { expect(results[0].id).toBe('fdic'); }); - it('sorts by enforcement when no textQuery', async () => { + it('sorts by weight when no textQuery', async () => { const adapter = new BM25SearchAdapter(units); const results = await adapter.exactMatch({ domain: 'content' }); // must (fdic) should come before should (buttons) before context (voice) - expect(results[0].enforcement).toBe('must'); + expect(results[0].weight).toBe('must'); }); }); @@ -364,16 +364,15 @@ describe('BM25SearchAdapter', () => { expect(ids).toContain('global-rule'); }); - it('filters by minEnforcement', async () => { + it('filters by minWeight', async () => { const adapter = new BM25SearchAdapter(units); const results = await adapter.semanticSearch('copy guidelines', { - minEnforcement: 'should', + minWeight: 'should', }); // should only include must and should, not may or context expect( results.every( - (r) => - r.unit.enforcement === 'must' || r.unit.enforcement === 'should', + (r) => r.unit.weight === 'must' || r.unit.weight === 'should', ), ).toBe(true); }); @@ -423,7 +422,7 @@ describe('findRelated', () => { filename: 'cta-labels.md', title: 'CTA button label guidelines', domain: 'content', - enforcement: 'should', + weight: 'should', tags: ['labels'], body: 'Call-to-action buttons should use clear verb-object labels that tell users what will happen.', }; @@ -438,7 +437,7 @@ describe('findRelated', () => { filename: 'test.md', title: 'Test', domain: 'content', - enforcement: 'should', + weight: 'should', tags: [], body: 'test content', }; @@ -458,7 +457,7 @@ describe('findRelated', () => { filename: 'writing.md', title: 'Writing guidelines for clarity', domain: 'content', - enforcement: 'should', + weight: 'should', tags: ['writing'], body: 'Good writing improves user experience and clarity.', }; diff --git a/src/__tests__/propose.test.ts b/src/__tests__/propose.test.ts index 595ecfb..3cfd967 100644 --- a/src/__tests__/propose.test.ts +++ b/src/__tests__/propose.test.ts @@ -10,7 +10,7 @@ function makeUnit( domain: 'default', kind: 'rule', tags: [], - enforcement: 'may', + weight: 'may', attributes: {}, provenance: { origin: 'human-authored', confidence: 1.0 }, ...overrides, @@ -28,7 +28,7 @@ domain: content tags: - buttons - copy -enforcement: should +weight: should # Button Labels @@ -42,7 +42,7 @@ Use verb+object pattern for button labels. expect(units[0].filename).toBe('button-labels.md'); expect(units[0].title).toBe('Button labels — verb+object'); expect(units[0].domain).toBe('content'); - expect(units[0].enforcement).toBe('should'); + expect(units[0].weight).toBe('should'); expect(units[0].tags).toEqual(['buttons', 'copy']); expect(units[0].body).toContain('Use verb+object pattern'); }); @@ -54,7 +54,7 @@ Use verb+object pattern for button labels. title: "First rule" domain: accessibility -enforcement: must +weight: must tags: - a11y @@ -66,7 +66,7 @@ tags: title: "Second rule" domain: content -enforcement: may +weight: may tags: - writing @@ -91,7 +91,7 @@ title: "Minimal unit" const units = parseProposedUnits(response); expect(units[0].domain).toBe('content'); - expect(units[0].enforcement).toBe('should'); + expect(units[0].weight).toBe('should'); expect(units[0].tags).toEqual([]); expect(units[0].brand).toBeUndefined(); expect(units[0].system).toBeUndefined(); @@ -106,7 +106,7 @@ title: "Brand-specific rule" domain: visual brand: acme system: web -enforcement: should +weight: should tags: - branding @@ -130,7 +130,7 @@ tags: my-rule.md domain: content -enforcement: may +weight: may Body `; @@ -146,7 +146,7 @@ enforcement: may title: "Color tokens: use semantic names" domain: visual -enforcement: should +weight: should tags: - tokens @@ -191,7 +191,7 @@ describe('findRelated', () => { filename: 'f.md', title: 'New rule', domain: 'content', - enforcement: 'should', + weight: 'should', tags: ['buttons', 'copy'], body: '', }; @@ -207,7 +207,7 @@ describe('findRelated', () => { filename: 'f.md', title: 'New visual rule', domain: 'visual', - enforcement: 'should', + weight: 'should', tags: ['color', 'tokens'], body: '', }; @@ -222,7 +222,7 @@ describe('findRelated', () => { filename: 'f.md', title: 'Something unrelated', domain: 'other', - enforcement: 'may', + weight: 'may', tags: ['icons'], body: '', }; @@ -238,7 +238,7 @@ describe('findRelated', () => { filename: 'f.md', title: 'Color contrast accessibility', domain: 'accessibility', - enforcement: 'may', + weight: 'may', tags: [], body: '', }; @@ -262,7 +262,7 @@ describe('findRelated', () => { filename: 'f.md', title: 'Shared topic words here', domain: 'content', - enforcement: 'may', + weight: 'may', tags: ['shared-tag-a', 'shared-tag-b'], body: '', }; @@ -275,7 +275,7 @@ describe('findRelated', () => { filename: 'f.md', title: 'Color token naming patterns', domain: 'visual', - enforcement: 'may', + weight: 'may', tags: ['color', 'tokens', 'contrast'], body: '', }; diff --git a/src/__tests__/weight.test.ts b/src/__tests__/weight.test.ts new file mode 100644 index 0000000..e22d6f8 --- /dev/null +++ b/src/__tests__/weight.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { applyFieldMappings } from '../loader.js'; +import { + buildWeightOrder, + compareWeight, + DEFAULT_WEIGHT_LEVELS, + defaultWeight, + isHighWeight, + parseWeight, + WEIGHT_ORDER, +} from '../weight.js'; + +// --- parseWeight --- + +describe('parseWeight', () => { + it('accepts default level values', () => { + expect(parseWeight('must')).toBe('must'); + expect(parseWeight('should')).toBe('should'); + expect(parseWeight('may')).toBe('may'); + expect(parseWeight('context')).toBe('context'); + expect(parseWeight('deprecated')).toBe('deprecated'); + }); + + it('normalizes to lowercase', () => { + expect(parseWeight('MUST')).toBe('must'); + expect(parseWeight('Should')).toBe('should'); + }); + + it('maps legacy severity values', () => { + expect(parseWeight('error')).toBe('must'); + expect(parseWeight('warning')).toBe('should'); + expect(parseWeight('info')).toBe('may'); + }); + + it('returns null for unknown values', () => { + expect(parseWeight('unknown')).toBeNull(); + expect(parseWeight('')).toBeNull(); + }); + + it('accepts custom levels', () => { + const levels = ['stable', 'beta', 'experimental', 'deprecated']; + expect(parseWeight('stable', levels)).toBe('stable'); + expect(parseWeight('beta', levels)).toBe('beta'); + expect(parseWeight('unknown', levels)).toBeNull(); + }); + + it('does not accept default levels when using custom levels without them', () => { + const levels = ['stable', 'beta', 'experimental']; + expect(parseWeight('must', levels)).toBeNull(); + }); +}); + +// --- buildWeightOrder --- + +describe('buildWeightOrder', () => { + it('assigns index-based order (0 = highest)', () => { + const order = buildWeightOrder([ + 'stable', + 'beta', + 'experimental', + 'deprecated', + ]); + expect(order.stable).toBe(0); + expect(order.beta).toBe(1); + expect(order.experimental).toBe(2); + expect(order.deprecated).toBe(3); + }); + + it('default WEIGHT_ORDER matches DEFAULT_WEIGHT_LEVELS', () => { + for (let i = 0; i < DEFAULT_WEIGHT_LEVELS.length; i++) { + expect(WEIGHT_ORDER[DEFAULT_WEIGHT_LEVELS[i]]).toBe(i); + } + }); +}); + +// --- compareWeight --- + +describe('compareWeight', () => { + it('returns negative when a has higher weight than b', () => { + expect(compareWeight('must', 'should')).toBeLessThan(0); + }); + + it('returns 0 for equal weights', () => { + expect(compareWeight('may', 'may')).toBe(0); + }); + + it('sorts correctly with custom levels', () => { + const order = buildWeightOrder(['stable', 'beta', 'experimental']); + expect(compareWeight('stable', 'beta', order)).toBeLessThan(0); + expect(compareWeight('experimental', 'stable', order)).toBeGreaterThan(0); + }); +}); + +// --- isHighWeight --- + +describe('isHighWeight', () => { + it('returns true for top-half levels', () => { + // 5 levels: must(0), should(1) are in the top half (< ceil(5/2)=3) + expect(isHighWeight('must')).toBe(true); + expect(isHighWeight('should')).toBe(true); + }); + + it('returns false for bottom-half levels', () => { + expect(isHighWeight('context')).toBe(false); + expect(isHighWeight('deprecated')).toBe(false); + }); + + it('returns false for unknown values', () => { + expect(isHighWeight('unknown')).toBe(false); + }); + + it('works with custom levels', () => { + const levels = ['stable', 'beta', 'experimental', 'deprecated']; + expect(isHighWeight('stable', levels)).toBe(true); + expect(isHighWeight('beta', levels)).toBe(true); + expect(isHighWeight('experimental', levels)).toBe(false); + expect(isHighWeight('deprecated', levels)).toBe(false); + }); +}); + +// --- defaultWeight --- + +describe('defaultWeight', () => { + it('returns "may" for default levels', () => { + expect(defaultWeight()).toBe('may'); + }); + + it('returns "may" if present in custom levels', () => { + expect(defaultWeight(['required', 'may', 'deprecated'])).toBe('may'); + }); + + it('returns last level when "may" is absent', () => { + expect(defaultWeight(['stable', 'beta', 'experimental'])).toBe( + 'experimental', + ); + }); +}); + +// --- applyFieldMappings --- + +describe('applyFieldMappings', () => { + it('simple rename: copies source field to target', () => { + const raw = { key: 'unit-1', title: 'My Rule' }; + applyFieldMappings(raw, { id: 'key' }); + expect(raw.id).toBe('unit-1'); + }); + + it('simple rename: does not overwrite if target already set', () => { + const raw = { id: 'existing', key: 'from-mapping' }; + applyFieldMappings(raw as Record, { id: 'key' }); + expect(raw.id).toBe('existing'); + }); + + it('simple rename: ignores mapping if source field absent', () => { + const raw: Record = { title: 'Test' }; + applyFieldMappings(raw, { id: 'key' }); + expect(raw.id).toBeUndefined(); + }); + + it('complex mapping: renames and translates values', () => { + const raw: Record = { status: 'active' }; + applyFieldMappings(raw, { + weight: { + from: 'status', + values: { active: 'must', draft: 'context', deprecated: 'deprecated' }, + }, + }); + expect(raw.weight).toBe('must'); + }); + + it('complex mapping: passes through value when no values map provided', () => { + const raw: Record = { maturity: 'stable' }; + applyFieldMappings(raw, { weight: { from: 'maturity' } }); + expect(raw.weight).toBe('stable'); + }); + + it('complex mapping: passes through unknown value if not in values map', () => { + const raw: Record = { status: 'unknown-value' }; + applyFieldMappings(raw, { + weight: { from: 'status', values: { active: 'must' } }, + }); + expect(raw.weight).toBe('unknown-value'); + }); + + it('applies multiple mappings', () => { + const raw: Record = { + key: 'abc', + category: 'content', + status: 'active', + }; + applyFieldMappings(raw, { + id: 'key', + domain: 'category', + weight: { from: 'status', values: { active: 'must' } }, + }); + expect(raw.id).toBe('abc'); + expect(raw.domain).toBe('content'); + expect(raw.weight).toBe('must'); + }); +}); diff --git a/src/adapters/search.ts b/src/adapters/search.ts index b5e1b21..db176ad 100644 --- a/src/adapters/search.ts +++ b/src/adapters/search.ts @@ -1,5 +1,5 @@ -import type { Enforcement } from '../enforcement.js'; import type { Domain, KnowledgeUnit } from '../schema/knowledge-unit.js'; +import type { Weight } from '../weight.js'; /** * Filter for exact rule matching (deterministic search). @@ -15,8 +15,8 @@ export interface RuleFilter { */ brand?: string | null; - /** Filter by enforcement levels */ - enforcement?: Enforcement[]; + /** Filter by weight levels */ + weight?: string[]; /** Filter by kind (e.g., 'rule', 'glossary', 'rubric') */ kind?: string; @@ -38,8 +38,8 @@ export interface SemanticSearchOptions { /** Filter by brand before searching */ brand?: string; - /** Filter by minimum enforcement level */ - minEnforcement?: Enforcement; + /** Filter by minimum weight level */ + minWeight?: string; /** Maximum number of results to return */ limit?: number; diff --git a/src/adapters/storage.ts b/src/adapters/storage.ts index b61bee6..ec2fddd 100644 --- a/src/adapters/storage.ts +++ b/src/adapters/storage.ts @@ -1,10 +1,10 @@ -import type { Enforcement } from '../enforcement.js'; import type { CreateKnowledgeUnit, Domain, KnowledgeUnit, UpdateKnowledgeUnit, } from '../schema/knowledge-unit.js'; +import type { Weight } from '../weight.js'; /** * Filter options for querying knowledge units. @@ -16,8 +16,8 @@ export interface QueryFilter { /** Filter by brand (null for global rules only) */ brand?: string | null; - /** Filter by enforcement level */ - enforcement?: Enforcement; + /** Filter by weight level */ + weight?: string; /** Filter by tags (any match) */ tags?: string[]; diff --git a/src/cli.ts b/src/cli.ts index a5e36da..2839f31 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -88,6 +88,8 @@ async function main() { return runCheck(); case 'eval': return runEvalCmd(); + case 'init': + return runInit(); default: printUsage(); process.exit(command ? 1 : 0); @@ -98,6 +100,7 @@ function printUsage() { console.log(`madrigal v${pkg.version} — knowledge compiler Commands: + init Scan knowledge files and generate a starter madrigal.config.yaml build Compile knowledge units to all platforms validate Check config and knowledge units for errors propose Scaffold a new knowledge unit from rough input @@ -109,7 +112,7 @@ Commands: Options: build [--platform ] [--dry-run] validate - propose [--domain ] [--brand ] [--enforcement ] [--batch] + propose [--domain ] [--brand ] [--weight ] [--batch] dev [--port ] [--open] serve [--bundle ] check [--brand ] [--domain ] [--format markdown|json|sarif] @@ -264,19 +267,19 @@ function createAnthropicCompletion(apiKey: string): LlmCompletionFn { } /** - * madrigal propose [--domain ] [--brand ] [--enforcement ] [--batch] + * madrigal propose [--domain ] [--brand ] [--weight ] [--batch] */ async function runPropose() { const domain = parseFlag('--domain'); const brand = parseFlag('--brand'); - const enforcement = parseFlag('--enforcement'); + const weight = parseFlag('--weight'); const batch = hasFlag('--batch'); // Collect input: from args first, fall back to stdin const flagsToSkip = new Set([ '--domain', '--brand', - '--enforcement', + '--weight', '--batch', 'propose', ]); @@ -320,7 +323,7 @@ async function runPropose() { complete: createAnthropicCompletion(apiKey), domain, brand, - enforcement: enforcement as ProposeOptions['enforcement'], + weight: weight as ProposeOptions['weight'], batch, baseDir: process.cwd(), }; @@ -336,7 +339,7 @@ async function runPropose() { console.log(`\nProposed: ${result.filePath}`); console.log(` title: "${result.title}"`); console.log(` domain: ${result.domain}`); - console.log(` enforcement: ${result.enforcement}`); + console.log(` weight: ${result.weight}`); console.log(` tags: ${result.tags.join(', ')}`); if (result.related.length > 0) { console.log(`\n Related existing units:`); @@ -504,6 +507,27 @@ async function runEvalCmd() { console.log('\nAll evals passed.'); } +/** + * madrigal init [--sources ] [--output ] [--dry-run] + */ +async function runInit() { + const sourcesFlag = parseFlag('--sources'); + const output = parseFlag('--output') ?? 'madrigal.config.yaml'; + const dryRun = process.argv.includes('--dry-run'); + + const sources = sourcesFlag + ? sourcesFlag.split(',').map((s) => s.trim()) + : ['**/*.md', '**/*.yaml', '**/*.yml']; + + const { runInit: _runInit } = await import('./init.js'); + await _runInit({ + sources, + baseDir: process.cwd(), + output, + dryRun, + }); +} + function readStdin(): Promise { return new Promise((resolve, reject) => { let data = ''; diff --git a/src/compliance/checker.ts b/src/compliance/checker.ts index 40b5257..2b4ab1f 100644 --- a/src/compliance/checker.ts +++ b/src/compliance/checker.ts @@ -52,7 +52,7 @@ export async function checkCompliance( : units; // Build a search adapter over the resolved units if brand was specified - // (enforcement levels may have changed due to overrides) + // (weight levels may have changed due to overrides) let effectiveSearch = searchAdapter; if (brand) { const { BM25SearchAdapter } = await import('../search/adapter.js'); @@ -66,7 +66,7 @@ export async function checkCompliance( limit, }); - // Partition by enforcement level + // Partition by weight level const violations: ComplianceViolation[] = []; const warnings: ComplianceViolation[] = []; const info: ComplianceViolation[] = []; @@ -79,10 +79,10 @@ export async function checkCompliance( matched: true, confidence: result.score, }, - message: `${result.unit.title} [${result.unit.enforcement.toUpperCase()}]`, + message: `${result.unit.title} [${result.unit.weight.toUpperCase()}]`, }; - switch (result.unit.enforcement) { + switch (result.unit.weight) { case 'must': violations.push(violation); break; diff --git a/src/compliance/report.ts b/src/compliance/report.ts index fbd4c46..5e47990 100644 --- a/src/compliance/report.ts +++ b/src/compliance/report.ts @@ -84,7 +84,7 @@ function formatViolationMd( options: ReportOptions, ): string { const score = v.matchResult.confidence.toFixed(2); - let line = `- **${v.knowledgeUnit.title}** [${v.knowledgeUnit.enforcement.toUpperCase()}] — relevance: ${score}`; + let line = `- **${v.knowledgeUnit.title}** [${v.knowledgeUnit.weight.toUpperCase()}] — relevance: ${score}`; if (options.includeSuggestions) { // Include a brief excerpt from the rule body as guidance @@ -114,7 +114,7 @@ function violationToJson(v: ComplianceViolation) { return { unitId: v.knowledgeUnit.id, title: v.knowledgeUnit.title, - enforcement: v.knowledgeUnit.enforcement, + weight: v.knowledgeUnit.weight, confidence: v.matchResult.confidence, message: v.message, tags: v.knowledgeUnit.tags, @@ -143,17 +143,17 @@ function formatSarif(result: ComplianceResult): string { shortDescription: { text: v.knowledgeUnit.title }, fullDescription: { text: v.knowledgeUnit.body.slice(0, 500) }, defaultConfiguration: { - level: enforcementToSarifLevel(v.knowledgeUnit.enforcement), + level: weightToSarifLevel(v.knowledgeUnit.weight), }, properties: { tags: v.knowledgeUnit.tags, - enforcement: v.knowledgeUnit.enforcement, + weight: v.knowledgeUnit.weight, }, })); const results = allViolations.map((v) => ({ ruleId: v.knowledgeUnit.id, - level: enforcementToSarifLevel(v.knowledgeUnit.enforcement), + level: weightToSarifLevel(v.knowledgeUnit.weight), message: { text: v.message }, properties: { confidence: v.matchResult.confidence, @@ -181,10 +181,8 @@ function formatSarif(result: ComplianceResult): string { return JSON.stringify(sarif, null, 2); } -function enforcementToSarifLevel( - enforcement: string, -): 'error' | 'warning' | 'note' { - switch (enforcement) { +function weightToSarifLevel(weight: string): 'error' | 'warning' | 'note' { + switch (weight) { case 'must': return 'error'; case 'should': diff --git a/src/config.ts b/src/config.ts index 5db6a5f..6ff3dee 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; +import { DEFAULT_WEIGHT_LEVELS } from './weight.js'; /** * Configuration for a knowledge domain. @@ -40,6 +41,27 @@ export interface PlatformConfig { destination?: string; } +/** + * Maps a source frontmatter field to a Madrigal field, optionally with value translation. + * + * Simple form (rename only): + * fieldMappings: + * id: key # their "key" field → madrigal's "id" + * domain: category # their "category" → madrigal's "domain" + * + * Complex form (rename + value translation): + * fieldMappings: + * weight: + * from: status + * values: + * active: must + * draft: context + * deprecated: deprecated + */ +export type FieldMapping = + | string + | { from: string; values?: Record }; + /** * The root Madrigal configuration. * Loaded from madrigal.config.yaml or madrigal.config.js. @@ -55,6 +77,28 @@ export interface MadrigalConfig { brands: Record; /** Publish platform definitions (key is platform name) */ platforms: Record; + /** + * Weight levels ordered from highest to lowest importance. + * Defaults to: must, should, may, context, deprecated. + * Teams can define their own vocabulary here. + * + * Example: + * levels: [stable, beta, experimental, deprecated] + */ + levels: string[]; + /** + * Map source frontmatter field names to Madrigal's normalized fields. + * Allows teams to keep their existing metadata vocabulary without renaming files. + * + * Mappable target fields: id, title, domain, kind, brand, system, tags, weight + * + * Example: + * fieldMappings: + * id: key + * domain: category + * weight: { from: status, values: { active: must, draft: context } } + */ + fieldMappings: Record; } /** @@ -150,6 +194,10 @@ function normalizeConfig(raw: unknown, _baseDir: string): MadrigalConfig { kinds: (config.kinds as Record) || {}, brands: (config.brands as Record) || {}, platforms: (config.platforms as Record) || {}, + levels: Array.isArray(config.levels) + ? (config.levels as string[]) + : [...DEFAULT_WEIGHT_LEVELS], + fieldMappings: (config.fieldMappings as Record) || {}, }; } diff --git a/src/dev/server.ts b/src/dev/server.ts index 28dc5f3..60f550b 100644 --- a/src/dev/server.ts +++ b/src/dev/server.ts @@ -13,7 +13,7 @@ import { cors } from 'hono/cors'; import { getState, loadState } from './state.js'; import { resolveForBrand } from '../resolver.js'; import { build, buildPlatformByName } from '../pipeline.js'; -import { ENFORCEMENT_ORDER } from '../enforcement.js'; +import { WEIGHT_ORDER } from '../weight.js'; import type { KnowledgeUnit } from '../schema/index.js'; import { generateTopology, createOpenAIProvider, createVoyageProvider, createProviderFromEnv, cosineSimilarity } from '../topology/index.js'; import type { TopologyData, EmbeddingProvider } from '../topology/index.js'; @@ -60,7 +60,7 @@ export function createApp(baseDir: string): Hono { for (const u of units) { byDomain[u.domain] = (byDomain[u.domain] || 0) + 1; - byEnforcement[u.enforcement] = (byEnforcement[u.enforcement] || 0) + 1; + byEnforcement[u.weight] = (byEnforcement[u.weight] || 0) + 1; byKind[u.kind] = (byKind[u.kind] || 0) + 1; const brand = u.brand || 'global'; byBrand[brand] = (byBrand[brand] || 0) + 1; @@ -84,7 +84,7 @@ export function createApp(baseDir: string): Hono { const domain = c.req.query('domain'); const brand = c.req.query('brand'); const kind = c.req.query('kind'); - const enforcement = c.req.query('enforcement'); + const weight = c.req.query('weight'); const query = c.req.query('search'); const limit = parseInt(c.req.query('limit') || '50', 10); const offset = parseInt(c.req.query('offset') || '0', 10); @@ -98,7 +98,7 @@ export function createApp(baseDir: string): Hono { let results = scored; if (kind) results = results.filter((r) => r.unit.kind === kind); - if (enforcement) results = results.filter((r) => r.unit.enforcement === enforcement); + if (weight) results = results.filter((r) => r.unit.weight === weight); const paged = results.slice(offset, offset + limit); return c.json({ @@ -111,12 +111,12 @@ export function createApp(baseDir: string): Hono { if (domain) filtered = filtered.filter((u) => u.domain === domain); if (brand) filtered = filtered.filter((u) => !u.brand || u.brand === brand); if (kind) filtered = filtered.filter((u) => u.kind === kind); - if (enforcement) filtered = filtered.filter((u) => u.enforcement === enforcement); + if (weight) filtered = filtered.filter((u) => u.weight === weight); filtered.sort( (a, b) => - (ENFORCEMENT_ORDER[a.enforcement] ?? 99) - - (ENFORCEMENT_ORDER[b.enforcement] ?? 99), + (WEIGHT_ORDER[a.weight] ?? 99) - + (WEIGHT_ORDER[b.weight] ?? 99), ); return c.json({ @@ -136,14 +136,14 @@ export function createApp(baseDir: string): Hono { } // Build brand resolution table - const brandResolutions: Record = {}; + const brandResolutions: Record = {}; for (const brandName of Object.keys(config.brands)) { const resolved = resolveForBrand({ units, config, brand: brandName, baseDir }); const resolvedUnit = resolved.find((u) => u.id === id); if (resolvedUnit) { brandResolutions[brandName] = { - enforcement: resolvedUnit.enforcement, - overridden: resolvedUnit.enforcement !== unit.enforcement, + weight: resolvedUnit.weight, + overridden: resolvedUnit.weight !== unit.weight, }; } } @@ -212,8 +212,8 @@ export function createApp(baseDir: string): Hono { const original = units.find((u) => u.id === ru.id); return { ...ru, - _baseEnforcement: original?.enforcement ?? ru.enforcement, - _overridden: original ? original.enforcement !== ru.enforcement : false, + _baseEnforcement: original?.weight ?? ru.weight, + _overridden: original ? original.weight !== ru.weight : false, }; }); @@ -246,7 +246,7 @@ export function createApp(baseDir: string): Hono { domainMap.set(u.domain, entry); } entry.count++; - entry.byEnforcement[u.enforcement] = (entry.byEnforcement[u.enforcement] || 0) + 1; + entry.byEnforcement[u.weight] = (entry.byEnforcement[u.weight] || 0) + 1; } // Include config domains with zero units (coverage gaps) @@ -257,7 +257,7 @@ export function createApp(baseDir: string): Hono { } const domains = Array.from(domainMap.entries()).map(([domain, data]) => { - // Coverage score: weighted enforcement sum normalized + // Coverage score: weighted weight sum normalized let weightedSum = 0; for (const [enf, count] of Object.entries(data.byEnforcement)) { weightedSum += (enforcementWeights[enf] ?? 0) * count; @@ -325,7 +325,7 @@ export function createApp(baseDir: string): Hono { const serialize = (v: any) => ({ unitId: v.knowledgeUnit.id, unitTitle: v.knowledgeUnit.title, - enforcement: v.knowledgeUnit.enforcement, + weight: v.knowledgeUnit.weight, confidence: v.matchResult.confidence, message: v.message, }); @@ -529,7 +529,7 @@ export function createApp(baseDir: string): Hono { baseUrl?: string; domain?: string; brand?: string; - enforcement?: string; + weight?: string; batch?: boolean; }>(); @@ -555,7 +555,7 @@ export function createApp(baseDir: string): Hono { complete, domain: body.domain, brand: body.brand, - enforcement: body.enforcement as any, + weight: body.weight as any, batch: body.batch, }, state.config, @@ -607,7 +607,7 @@ export function createApp(baseDir: string): Hono { const serialize = (v: any) => ({ unitId: v.knowledgeUnit.id, unitTitle: v.knowledgeUnit.title, - enforcement: v.knowledgeUnit.enforcement, + weight: v.knowledgeUnit.weight, confidence: v.matchResult.confidence, message: v.message, }); @@ -638,7 +638,7 @@ export function createApp(baseDir: string): Hono { title: u.title, domain: u.domain, brand: u.brand, - enforcement: u.enforcement, + weight: u.weight, sourcePath: u.sourcePath, provenance: u.provenance, })); diff --git a/src/dev/ui/src/api.ts b/src/dev/ui/src/api.ts index 7ed0936..0212b9e 100644 --- a/src/dev/ui/src/api.ts +++ b/src/dev/ui/src/api.ts @@ -44,7 +44,7 @@ export interface KnowledgeUnit { system?: string; brand?: string; tags: string[]; - enforcement: string; + weight: string; attributes: Record; provenance: { origin: string; @@ -60,7 +60,7 @@ export interface UnitsResponse { export interface UnitDetailResponse { unit: KnowledgeUnit; - brandResolutions: Record; + brandResolutions: Record; } export interface SearchResponse { @@ -101,7 +101,7 @@ export interface ProposedUnitDTO { domain: string; brand?: string; system?: string; - enforcement: string; + weight: string; tags: string[]; body: string; related: Array<{ id: string; reason: string }>; @@ -114,7 +114,7 @@ export interface ProposeResponse { export interface ComplianceViolationDTO { unitId: string; unitTitle: string; - enforcement: string; + weight: string; confidence: number; message: string; } @@ -131,7 +131,7 @@ export interface AuditUnit { title: string; domain: string; brand?: string; - enforcement: string; + weight: string; sourcePath?: string; provenance: { origin: string; @@ -217,7 +217,7 @@ export const api = { baseUrl?: string; domain?: string; brand?: string; - enforcement?: string; + weight?: string; batch?: boolean; }) => fetchJson('/workbench/propose', { method: 'POST', body: JSON.stringify(body) }), diff --git a/src/dev/ui/src/topology/types.ts b/src/dev/ui/src/topology/types.ts index 35ad093..d73ccdb 100644 --- a/src/dev/ui/src/topology/types.ts +++ b/src/dev/ui/src/topology/types.ts @@ -5,7 +5,7 @@ export type TopologyNode = { title: string; domain: string; kind: string; - enforcement: string; + weight: string; brand?: string; tags: string[]; excerpt: string; diff --git a/src/enforcement.ts b/src/enforcement.ts index 1b2c332..2cc9b69 100644 --- a/src/enforcement.ts +++ b/src/enforcement.ts @@ -1,46 +1,16 @@ /** - * Enforcement levels for knowledge units. - * - * - must: Must be followed, will fail CI enforcement - * - should: Should be followed, may fail CI in strict mode - * - may: Optional guidance, won't fail CI - * - context: Background context, not a rule - * - deprecated: Previously valid, now superseded + * @deprecated Use weight.ts instead. + * This file is retained for backward compatibility. */ -export type Enforcement = 'must' | 'should' | 'may' | 'context' | 'deprecated'; +export type { + DefaultWeight as Enforcement, + Weight, +} from './weight.js'; -export const ENFORCEMENT_ORDER: Record = { - must: 0, - should: 1, - may: 2, - context: 3, - deprecated: 4, -}; - -export function compareEnforcement(a: Enforcement, b: Enforcement): number { - return ENFORCEMENT_ORDER[a] - ENFORCEMENT_ORDER[b]; -} - -export function isEnforceable(enforcement: Enforcement): boolean { - return enforcement === 'must' || enforcement === 'should'; -} - -/** - * Parse a string into an Enforcement value. - * Returns null if the string is not a valid enforcement level. - * Also accepts legacy severity values (error, warning, info) for backward compatibility. - */ -export function parseEnforcement(value: string): Enforcement | null { - const normalized = value.toLowerCase().trim(); - if (normalized in ENFORCEMENT_ORDER) return normalized as Enforcement; - - // Backward compatibility: accept legacy severity values - const legacyMap: Record = { - error: 'must', - warning: 'should', - info: 'may', - }; - if (normalized in legacyMap) return legacyMap[normalized]; - - return null; -} +export { + compareWeight as compareEnforcement, + DEFAULT_WEIGHT_LEVELS, + isHighWeight as isEnforceable, + parseWeight as parseEnforcement, + WEIGHT_ORDER as ENFORCEMENT_ORDER, +} from './weight.js'; diff --git a/src/formats/ai-rules-md.ts b/src/formats/ai-rules-md.ts index d1768ee..f6ec278 100644 --- a/src/formats/ai-rules-md.ts +++ b/src/formats/ai-rules-md.ts @@ -1,5 +1,5 @@ -import { compareEnforcement, isEnforceable } from '../enforcement.js'; import type { KnowledgeUnit } from '../schema/index.js'; +import { compareWeight, isHighWeight } from '../weight.js'; import type { Format, FormatOptions } from './registry.js'; /** @@ -32,16 +32,14 @@ export const aiRulesMdFormat: Format = { lines.push(''); // Filter to enforceable rules only (must, should) - const enforceableUnits = units.filter((u) => isEnforceable(u.enforcement)); + const enforceableUnits = units.filter((u) => isHighWeight(u.weight)); - // Sort by enforcement (must first) - enforceableUnits.sort((a, b) => - compareEnforcement(a.enforcement, b.enforcement), - ); + // Sort by weight (must first) + enforceableUnits.sort((a, b) => compareWeight(a.weight, b.weight)); - // Group by enforcement - const musts = enforceableUnits.filter((u) => u.enforcement === 'must'); - const shoulds = enforceableUnits.filter((u) => u.enforcement === 'should'); + // Group by weight + const musts = enforceableUnits.filter((u) => u.weight === 'must'); + const shoulds = enforceableUnits.filter((u) => u.weight === 'should'); if (musts.length > 0) { lines.push('## MUST Follow'); @@ -69,7 +67,7 @@ export const aiRulesMdFormat: Format = { // Add context rules in a collapsed section const contextUnits = units.filter( - (u) => u.enforcement === 'context' || u.enforcement === 'may', + (u) => u.weight === 'context' || u.weight === 'may', ); if (contextUnits.length > 0) { lines.push('## Additional Context'); diff --git a/src/formats/json-bundle.ts b/src/formats/json-bundle.ts index 1b412b7..12d2be1 100644 --- a/src/formats/json-bundle.ts +++ b/src/formats/json-bundle.ts @@ -48,7 +48,7 @@ export const jsonBundleFormat: Format = { system: unit.system, brand: unit.brand, tags: unit.tags, - enforcement: unit.enforcement, + weight: unit.weight, attributes: unit.attributes, provenance: unit.provenance, sourcePath: unit.sourcePath, diff --git a/src/formats/mesh-domain.ts b/src/formats/mesh-domain.ts index 23b7d2d..ab2686f 100644 --- a/src/formats/mesh-domain.ts +++ b/src/formats/mesh-domain.ts @@ -20,7 +20,7 @@ export interface MeshKnowledgeEntry { title: string; content: string; domain: string; - enforcement: string; + weight: string; tags: string[]; metadata?: Record; } @@ -47,7 +47,7 @@ export const meshDomainFormat: Format = { title: unit.title, content: unit.body, domain: unit.domain, - enforcement: unit.enforcement, + weight: unit.weight, tags: unit.tags, metadata: { kind: unit.kind, diff --git a/src/formats/skill-md.ts b/src/formats/skill-md.ts index 378ff4f..39420e1 100644 --- a/src/formats/skill-md.ts +++ b/src/formats/skill-md.ts @@ -1,5 +1,5 @@ -import { compareEnforcement } from '../enforcement.js'; import type { KnowledgeUnit } from '../schema/index.js'; +import { compareWeight } from '../weight.js'; import type { Format, FormatOptions } from './registry.js'; /** @@ -9,7 +9,7 @@ import type { Format, FormatOptions } from './registry.js'; * Output structure: * - Title and metadata header * - Grouped by domain - * - Rules sorted by enforcement (must first) + * - Rules sorted by weight (must first) * - Each rule as a markdown section with tags */ export const skillMdFormat: Format = { @@ -46,10 +46,8 @@ export const skillMdFormat: Format = { for (const domain of sortedDomains) { const domainUnits = byDomain.get(domain) ?? []; - // Sort units by enforcement (most strict first) - domainUnits.sort((a, b) => - compareEnforcement(a.enforcement, b.enforcement), - ); + // Sort units by weight (most strict first) + domainUnits.sort((a, b) => compareWeight(a.weight, b.weight)); lines.push(`## ${formatDomainTitle(domain)}`); lines.push(''); @@ -80,8 +78,8 @@ function formatDomainTitle(domain: string): string { function formatUnit(unit: KnowledgeUnit): string { const lines: string[] = []; - // Title with enforcement badge - const enforcementBadge = getEnforcementBadge(unit.enforcement); + // Title with weight badge + const enforcementBadge = getWeightBadge(unit.weight); lines.push(`### ${unit.title} ${enforcementBadge}`); lines.push(''); @@ -98,10 +96,10 @@ function formatUnit(unit: KnowledgeUnit): string { } /** - * Get an enforcement badge. + * Get an weight badge. */ -function getEnforcementBadge(enforcement: string): string { - switch (enforcement) { +function getWeightBadge(weight: string): string { + switch (weight) { case 'must': return '[MUST]'; case 'should': diff --git a/src/index.ts b/src/index.ts index 59f4409..9077497 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,14 +37,6 @@ export { loadConfig, validateConfig, } from './config.js'; -// Enforcement -export type { Enforcement } from './enforcement.js'; -export { - compareEnforcement, - ENFORCEMENT_ORDER, - isEnforceable, - parseEnforcement, -} from './enforcement.js'; // Eval export { type EvalResult, @@ -62,6 +54,13 @@ export { jsonBundleFormat } from './formats/json-bundle.js'; export { meshDomainFormat } from './formats/mesh-domain.js'; export { skillMdFormat } from './formats/skill-md.js'; export { topologyJsonFormat } from './formats/topology-json.js'; +export { + analyzeKnowledgeSources, + type FieldSummary, + generateConfig, + type InitAnalysis, + runInit, +} from './init.js'; // Loader export type { LoadError, @@ -69,7 +68,11 @@ export type { LoadResult, LoadWarning, } from './loader.js'; -export { loadKnowledge, loadKnowledgeSync } from './loader.js'; +export { + applyFieldMappings, + loadKnowledge, + loadKnowledgeSync, +} from './loader.js'; // Pipeline export type { BuildOptions, @@ -77,14 +80,12 @@ export type { PipelineResult, } from './pipeline.js'; export { build, buildPlatformByName } from './pipeline.js'; - // Preprocessors export type { Preprocessor } from './preprocessors/index.js'; export { defaultPreprocessorRegistry, PreprocessorRegistry, } from './preprocessors/index.js'; - // Propose (authoring assistant) export type { LlmCompletionFn, @@ -119,7 +120,6 @@ export { resolveForBrand, resolveUnits, } from './resolver.js'; - // Rules export type { MatchResult, OverrideConfig } from './rules/index.js'; export type { @@ -143,3 +143,13 @@ export { } from './search/index.js'; // Serve (MCP server) export { type ServeOptions, serveMcp } from './serve/index.js'; +export type { Weight } from './weight.js'; +export { + buildWeightOrder, + compareWeight, + DEFAULT_WEIGHT_LEVELS, + defaultWeight, + isHighWeight, + parseWeight, + WEIGHT_ORDER, +} from './weight.js'; diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..ff09a56 --- /dev/null +++ b/src/init.ts @@ -0,0 +1,463 @@ +/** + * Madrigal init — scans knowledge source files and generates a suggested + * madrigal.config.yaml, including fieldMappings and levels if the team's + * frontmatter doesn't already use Madrigal's default vocabulary. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import fg from 'fast-glob'; +import matter from 'gray-matter'; +import { parse as parseYaml } from 'yaml'; +import { DEFAULT_WEIGHT_LEVELS } from './weight.js'; + +/** Madrigal's normalized field names. */ +const MADRIGAL_FIELDS = new Set([ + 'id', + 'title', + 'domain', + 'kind', + 'brand', + 'system', + 'tags', + 'weight', + 'enforcement', // deprecated alias + 'severity', // deprecated alias + 'attributes', + 'provenance', + 'body', + 'entries', +]); + +/** + * Fields that map to a Madrigal concept — used for heuristic detection. + * Key = candidate field name, value = Madrigal target field. + */ +const FIELD_HEURISTICS: Record = { + // id candidates + key: 'id', + uid: 'id', + slug: 'id', + name: 'id', + // title candidates + label: 'title', + heading: 'title', + summary: 'title', + // domain candidates + category: 'domain', + section: 'domain', + area: 'domain', + topic: 'domain', + // brand candidates + product: 'brand', + team: 'brand', + owner: 'brand', + // kind candidates + type: 'kind', + format: 'kind', + // weight candidates + status: 'weight', + severity: 'weight', + priority: 'weight', + level: 'weight', + importance: 'weight', + maturity: 'weight', + // tags candidates + keywords: 'tags', + labels: 'tags', +}; + +/** Values commonly used for weight/enforcement — used for level detection. */ +const KNOWN_WEIGHT_VOCABULARIES: Record = { + 'design-system-maturity': ['stable', 'beta', 'experimental', 'deprecated'], + 'compliance-type': ['BASE', 'ADDON'], + 'status-lifecycle': ['active', 'draft', 'deprecated'], + 'priority-levels': ['critical', 'high', 'medium', 'low'], + adoption: ['required', 'recommended', 'optional', 'deprecated'], +}; + +export interface FieldSummary { + /** Field name found in frontmatter */ + name: string; + /** Number of files that use this field */ + count: number; + /** All distinct values seen */ + values: string[]; + /** Suggested Madrigal target field (if any) */ + suggestedMapping?: string; +} + +export interface InitAnalysis { + /** All frontmatter fields found, sorted by frequency */ + fields: FieldSummary[]; + /** Distinct domain values found */ + domains: string[]; + /** Distinct brand values found */ + brands: string[]; + /** Distinct kind values found */ + kinds: string[]; + /** Distinct weight/enforcement values found (from any weight-candidate field) */ + weightValues: string[]; + /** Field name that looks most like the weight field */ + weightField?: string; + /** Total files scanned */ + fileCount: number; +} + +/** + * Scan knowledge source files and return an analysis of their frontmatter. + */ +export async function analyzeKnowledgeSources( + sources: string[], + baseDir: string = process.cwd(), +): Promise { + const files = await fg(sources, { + cwd: baseDir, + absolute: true, + onlyFiles: true, + ignore: ['**/node_modules/**', '**/dist/**'], + }); + + const fieldCounts = new Map(); + const fieldValues = new Map>(); + + for (const filePath of files) { + try { + const content = readFileSync(filePath, 'utf-8'); + const ext = filePath.endsWith('.yaml') || filePath.endsWith('.yml'); + const data: Record = ext + ? ((parseYaml(content) as Record) ?? {}) + : (matter(content).data as Record); + + for (const [key, value] of Object.entries(data)) { + if (key === 'entries' && Array.isArray(value)) { + // Recurse into multi-unit YAML entries + for (const entry of value as Record[]) { + for (const [ek, ev] of Object.entries(entry)) { + addField(fieldCounts, fieldValues, ek, ev); + } + } + } else { + addField(fieldCounts, fieldValues, key, value); + } + } + } catch { + // Skip unparseable files + } + } + + const fields: FieldSummary[] = Array.from(fieldCounts.entries()) + .sort(([, a], [, b]) => b - a) + .map(([name, count]) => { + const values = Array.from(fieldValues.get(name) ?? []).slice(0, 20); + const suggestedMapping = !MADRIGAL_FIELDS.has(name) + ? FIELD_HEURISTICS[name.toLowerCase()] + : undefined; + return { name, count, values, suggestedMapping }; + }); + + // Collect concept-specific values + const domains = valuesFor(fieldValues, ['domain', 'category', 'section']); + const brands = valuesFor(fieldValues, ['brand', 'product', 'team']); + const kinds = valuesFor(fieldValues, ['kind', 'type', 'format']); + + // Detect weight field: prefer explicit 'weight'/'enforcement'/'severity', else heuristics + const weightField = detectWeightField(fields); + const weightValues = weightField + ? Array.from(fieldValues.get(weightField) ?? []) + : []; + + return { + fields, + domains, + brands, + kinds, + weightValues, + weightField, + fileCount: files.length, + }; +} + +function addField( + counts: Map, + values: Map>, + key: string, + value: unknown, +): void { + counts.set(key, (counts.get(key) ?? 0) + 1); + if (!values.has(key)) values.set(key, new Set()); + if (value !== null && value !== undefined) { + if (Array.isArray(value)) { + for (const v of value) values.get(key)!.add(String(v)); + } else { + values.get(key)!.add(String(value)); + } + } +} + +function valuesFor( + fieldValues: Map>, + candidates: string[], +): string[] { + for (const c of candidates) { + const v = fieldValues.get(c); + if (v && v.size > 0) return Array.from(v); + } + return []; +} + +function detectWeightField(fields: FieldSummary[]): string | undefined { + // Explicit weight/enforcement/severity fields take priority + for (const name of ['weight', 'enforcement', 'severity']) { + if (fields.some((f) => f.name === name)) return name; + } + // Then check heuristic candidates + for (const f of fields) { + if (FIELD_HEURISTICS[f.name.toLowerCase()] === 'weight') return f.name; + } + return undefined; +} + +/** + * Detect weight level vocabulary from a set of observed values. + * Returns the matching named vocabulary, or null if not recognized. + */ +export function detectWeightVocabulary( + values: string[], +): { name: string; levels: string[] } | null { + const lower = values.map((v) => v.toLowerCase()); + for (const [name, levels] of Object.entries(KNOWN_WEIGHT_VOCABULARIES)) { + const lowerLevels = levels.map((l) => l.toLowerCase()); + if (lower.some((v) => lowerLevels.includes(v))) { + return { name, levels }; + } + } + return null; +} + +/** + * Generate a suggested madrigal.config.yaml from an analysis. + */ +export function generateConfig( + analysis: InitAnalysis, + sourcesGlob: string = 'knowledge/**/*.md', +): string { + const lines: string[] = []; + + lines.push('# Generated by madrigal init'); + lines.push('# Review and adjust before committing.'); + lines.push(''); + lines.push(`sources:`); + lines.push(` - "${sourcesGlob}"`); + lines.push(''); + + // Domains + if (analysis.domains.length > 0) { + lines.push('domains:'); + for (const d of analysis.domains.slice(0, 20)) { + lines.push(` ${d}:`); + lines.push(` description: ""`); + } + } else { + lines.push('domains: {}'); + } + lines.push(''); + + // Brands + if (analysis.brands.length > 0) { + lines.push('brands:'); + for (const b of analysis.brands.slice(0, 10)) { + lines.push(` ${b}: {}`); + } + } else { + lines.push('brands: {}'); + } + lines.push(''); + + // Kinds + if (analysis.kinds.length > 0) { + lines.push('kinds:'); + for (const k of analysis.kinds.slice(0, 10)) { + lines.push(` ${k}:`); + lines.push(` description: ""`); + } + } else { + lines.push('kinds: {}'); + } + lines.push(''); + + lines.push('platforms: {}'); + lines.push(''); + + // Weight levels + const vocab = detectWeightVocabulary(analysis.weightValues); + const isDefaultVocab = + analysis.weightValues.length === 0 || + analysis.weightValues.every((v) => + (DEFAULT_WEIGHT_LEVELS as readonly string[]).includes(v), + ); + + if (!isDefaultVocab && analysis.weightValues.length > 0) { + lines.push( + '# Weight levels define how strongly a unit should influence decisions.', + ); + lines.push('# Listed from highest to lowest importance.'); + if (vocab) { + lines.push(`# Detected vocabulary: ${vocab.name}`); + lines.push('levels:'); + for (const l of vocab.levels) { + lines.push(` - ${l}`); + } + } else { + lines.push("# Adjust this ordering to match your team's convention."); + lines.push('levels:'); + for (const v of analysis.weightValues.slice(0, 10)) { + lines.push(` - ${v}`); + } + } + lines.push(''); + } + + // Field mappings + const mappings: Array<{ target: string; source: string }> = []; + for (const field of analysis.fields) { + if (field.suggestedMapping) { + mappings.push({ target: field.suggestedMapping, source: field.name }); + } + } + + // Add weight field mapping if needed + if ( + analysis.weightField && + analysis.weightField !== 'weight' && + !mappings.some((m) => m.target === 'weight') + ) { + mappings.push({ target: 'weight', source: analysis.weightField }); + } + + if (mappings.length > 0) { + lines.push( + "# fieldMappings: map your existing frontmatter field names to Madrigal's.", + ); + lines.push( + "# Remove any mappings where your field name already matches Madrigal's.", + ); + lines.push('fieldMappings:'); + for (const { target, source } of mappings) { + lines.push(` ${target}: ${source}`); + } + + // If the weight field has non-default values, suggest a value mapping + if ( + analysis.weightField && + analysis.weightField !== 'weight' && + !isDefaultVocab && + analysis.weightValues.length > 0 + ) { + lines.push(' # To translate your weight values, use the long form:'); + lines.push(` # weight:`); + lines.push(` # from: ${analysis.weightField}`); + lines.push(` # values:`); + for (const v of analysis.weightValues.slice(0, 8)) { + lines.push(` # ${v}: must # adjust target value`); + } + } + } + + return lines.join('\n'); +} + +/** + * Print a human-readable summary of the analysis to stdout. + */ +export function printAnalysisSummary(analysis: InitAnalysis): void { + console.log(`\nScanned ${analysis.fileCount} files.\n`); + + const nonMadrigal = analysis.fields.filter( + (f) => !MADRIGAL_FIELDS.has(f.name), + ); + + if (nonMadrigal.length > 0) { + console.log('Non-standard fields found (candidates for fieldMappings):'); + for (const f of nonMadrigal) { + const hint = f.suggestedMapping ? ` → ${f.suggestedMapping}` : ''; + const sample = f.values.slice(0, 4).join(', '); + console.log( + ` ${f.name} (${f.count} files)${hint}${sample ? ` — e.g. ${sample}` : ''}`, + ); + } + console.log(''); + } + + if (analysis.weightField && analysis.weightField !== 'weight') { + const vocab = detectWeightVocabulary(analysis.weightValues); + console.log( + `Weight field detected: "${analysis.weightField}" with values: ${analysis.weightValues.join(', ')}`, + ); + if (vocab) { + console.log(` Matched known vocabulary: ${vocab.name}`); + } else { + console.log( + ` No known vocabulary match — review the suggested levels in the config.`, + ); + } + console.log(''); + } + + if (analysis.domains.length > 0) { + console.log(`Domains: ${analysis.domains.join(', ')}`); + } + if (analysis.brands.length > 0) { + console.log(`Brands: ${analysis.brands.join(', ')}`); + } + if (analysis.kinds.length > 0) { + console.log(`Kinds: ${analysis.kinds.join(', ')}`); + } + + const existing = analysis.fields.filter((f) => MADRIGAL_FIELDS.has(f.name)); + if (existing.length > 0) { + console.log( + `\nMadrigal fields already present: ${existing.map((f) => f.name).join(', ')}`, + ); + } +} + +/** + * Run the init wizard: scan files, print summary, write suggested config. + */ +export async function runInit(options: { + sources?: string[]; + baseDir?: string; + output?: string; + dryRun?: boolean; +}): Promise { + const baseDir = options.baseDir ?? process.cwd(); + const sources = options.sources ?? ['**/*.md', '**/*.yaml', '**/*.yml']; + const output = options.output ?? 'madrigal.config.yaml'; + const outputPath = resolve(baseDir, output); + + console.log(`Scanning sources: ${sources.join(', ')}`); + + const analysis = await analyzeKnowledgeSources(sources, baseDir); + printAnalysisSummary(analysis); + + const config = generateConfig(analysis, sources[0]); + + if (options.dryRun) { + console.log('\n--- Suggested config (--dry-run) ---\n'); + console.log(config); + } else { + const { writeFileSync, existsSync } = await import('node:fs'); + if (existsSync(outputPath)) { + console.log( + `\n${output} already exists. Use --output to specify a different path, or --dry-run to preview.`, + ); + return; + } + writeFileSync(outputPath, config, 'utf-8'); + console.log(`\nWrote suggested config to ${output}`); + console.log( + 'Review it, adjust field mappings and levels, then run: madrigal build', + ); + } +} diff --git a/src/loader.ts b/src/loader.ts index f2fbf19..d152e60 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -3,11 +3,45 @@ import { basename, extname, relative } from 'node:path'; import fg from 'fast-glob'; import matter from 'gray-matter'; import { parse as parseYaml } from 'yaml'; -import type { MadrigalConfig } from './config.js'; -import type { Enforcement } from './enforcement.js'; -import { parseEnforcement } from './enforcement.js'; +import type { FieldMapping, MadrigalConfig } from './config.js'; import { createFileProvenance } from './provenance.js'; import type { KnowledgeFrontmatter, KnowledgeUnit } from './schema/index.js'; +import { defaultWeight, parseWeight } from './weight.js'; + +/** + * Apply fieldMappings from config to a raw frontmatter/YAML object. + * + * For each entry in fieldMappings: + * - Simple form ("id": "key"): copies raw["key"] → raw["id"] if not already set + * - Complex form ("weight": { from: "status", values: { active: "must" } }): + * reads raw["status"], translates via values map, writes to raw["weight"] + * + * Modifies the object in place and returns it. + */ +export function applyFieldMappings( + raw: Record, + mappings: Record, +): Record { + for (const [targetField, mapping] of Object.entries(mappings)) { + if (typeof mapping === 'string') { + // Simple rename: only apply if target not already populated + if (raw[targetField] === undefined && raw[mapping] !== undefined) { + raw[targetField] = raw[mapping]; + } + } else { + // Complex: rename + optional value translation + const sourceValue = raw[mapping.from]; + if (raw[targetField] === undefined && sourceValue !== undefined) { + const strValue = String(sourceValue); + raw[targetField] = + mapping.values?.[strValue] !== undefined + ? mapping.values[strValue] + : strValue; + } + } + } + return raw; +} /** * Options for loading knowledge units. @@ -81,6 +115,8 @@ export async function loadKnowledge(options: LoadOptions): Promise { const kindNames = new Set(Object.keys(config.kinds)); const brandNames = new Set(Object.keys(config.brands)); brandNames.add('global'); // 'global' is always valid + const levels = config.levels; + const fieldMappings = config.fieldMappings; for (const filePath of files) { try { @@ -92,6 +128,8 @@ export async function loadKnowledge(options: LoadOptions): Promise { domainNames, kindNames, brandNames, + levels, + fieldMappings, warnings, ); units.push(...parsed); @@ -102,6 +140,8 @@ export async function loadKnowledge(options: LoadOptions): Promise { domainNames, kindNames, brandNames, + levels, + fieldMappings, warnings, ); if (unit) { @@ -143,10 +183,13 @@ function parseKnowledgeFile( domainNames: Set, kindNames: Set, brandNames: Set, + levels: string[], + fieldMappings: Record, warnings: LoadWarning[], ): KnowledgeUnit | null { const content = readFileSync(filePath, 'utf-8'); const { data, content: body } = matter(content); + applyFieldMappings(data as Record, fieldMappings); const frontmatter = data as KnowledgeFrontmatter; // Validate required fields @@ -192,18 +235,20 @@ function parseKnowledgeFile( }); } - // Parse enforcement (with backward compat for 'severity' field) - let enforcement: Enforcement = 'may'; - const rawEnforcement = frontmatter.enforcement || frontmatter.severity; - if (rawEnforcement) { - const parsed = parseEnforcement(rawEnforcement); + // Parse weight (accepts weight:, weight:, severity: in that priority order) + const fallbackWeight = defaultWeight(levels); + let weight: string = fallbackWeight; + const rawWeight = + frontmatter.weight || frontmatter.weight || frontmatter.severity; + if (rawWeight) { + const parsed = parseWeight(rawWeight, levels); if (parsed) { - enforcement = parsed; + weight = parsed; } else { warnings.push({ filePath, - field: 'enforcement', - message: `Invalid enforcement "${rawEnforcement}". Using "may".`, + field: 'weight', + message: `Unknown weight "${rawWeight}". Using "${fallbackWeight}". Valid levels: ${levels.join(', ')}`, }); } } @@ -232,7 +277,7 @@ function parseKnowledgeFile( system: frontmatter.system, brand: frontmatter.brand, tags: frontmatter.tags || [], - enforcement, + weight, attributes, provenance, sourcePath: relative(baseDir, filePath), @@ -266,8 +311,9 @@ const KNOWN_YAML_KEYS = new Set([ 'system', 'brand', 'tags', - 'enforcement', - 'severity', + 'weight', + 'weight', // deprecated alias + 'severity', // deprecated alias 'provenance', 'body', 'entries', @@ -287,6 +333,8 @@ function parseKnowledgeYamlFile( domainNames: Set, kindNames: Set, brandNames: Set, + levels: string[], + fieldMappings: Record, warnings: LoadWarning[], ): KnowledgeUnit[] { const content = readFileSync(filePath, 'utf-8'); @@ -301,6 +349,9 @@ function parseKnowledgeYamlFile( } const entries = parsed.entries as Array> | undefined; + // Apply field mappings to the top-level object before parsing + applyFieldMappings(parsed, fieldMappings); + if (Array.isArray(entries)) { // Multi-unit mode: each entry becomes a unit, inheriting top-level defaults return entries.map((entry, index) => @@ -313,6 +364,7 @@ function parseKnowledgeYamlFile( domainNames, kindNames, brandNames, + levels, warnings, ), ); @@ -329,6 +381,7 @@ function parseKnowledgeYamlFile( domainNames, kindNames, brandNames, + levels, warnings, ), ]; @@ -346,6 +399,7 @@ function buildYamlUnit( domainNames: Set, kindNames: Set, brandNames: Set, + levels: string[], warnings: LoadWarning[], ): KnowledgeUnit { // Merge top-level defaults with entry overrides @@ -393,20 +447,21 @@ function buildYamlUnit( }); } - // Parse enforcement - let enforcement: Enforcement = 'may'; - const rawEnforcement = (merged.enforcement || merged.severity) as + // Parse weight (accepts weight:, weight:, severity: in that priority order) + const fallbackWeight = defaultWeight(levels); + let weight: string = fallbackWeight; + const rawWeight = (merged.weight || merged.weight || merged.severity) as | string | undefined; - if (rawEnforcement) { - const parsed = parseEnforcement(String(rawEnforcement)); - if (parsed) { - enforcement = parsed; + if (rawWeight) { + const parsedWeight = parseWeight(String(rawWeight), levels); + if (parsedWeight) { + weight = parsedWeight; } else { warnings.push({ filePath, - field: 'enforcement', - message: `Invalid enforcement "${rawEnforcement}" in YAML unit "${id}". Using "may".`, + field: 'weight', + message: `Unknown weight "${rawWeight}" in YAML unit "${id}". Using "${fallbackWeight}". Valid levels: ${levels.join(', ')}`, }); } } @@ -448,7 +503,8 @@ function buildYamlUnit( 'system', 'brand', 'tags', - 'enforcement', + 'weight', + 'weight', 'severity', 'provenance', 'body', @@ -474,7 +530,7 @@ function buildYamlUnit( system, brand, tags, - enforcement, + weight, attributes, provenance, sourcePath: relative(baseDir, filePath), @@ -502,6 +558,8 @@ export function loadKnowledgeSync(options: LoadOptions): LoadResult { const kindNames = new Set(Object.keys(config.kinds)); const brandNames = new Set(Object.keys(config.brands)); brandNames.add('global'); + const levels = config.levels; + const fieldMappings = config.fieldMappings; for (const filePath of files) { try { @@ -513,6 +571,8 @@ export function loadKnowledgeSync(options: LoadOptions): LoadResult { domainNames, kindNames, brandNames, + levels, + fieldMappings, warnings, ); units.push(...parsed); @@ -523,6 +583,8 @@ export function loadKnowledgeSync(options: LoadOptions): LoadResult { domainNames, kindNames, brandNames, + levels, + fieldMappings, warnings, ); if (unit) { diff --git a/src/propose.ts b/src/propose.ts index 1167de1..160d127 100644 --- a/src/propose.ts +++ b/src/propose.ts @@ -3,10 +3,10 @@ import { join, relative } from 'node:path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import type { MadrigalConfig } from './config.js'; import { loadConfig } from './config.js'; -import type { Enforcement } from './enforcement.js'; import { loadKnowledge } from './loader.js'; import type { KnowledgeUnit } from './schema/index.js'; import { BM25Index } from './search/bm25.js'; +import type { Weight } from './weight.js'; /** * A function that sends a prompt to an LLM and returns the text response. @@ -23,8 +23,8 @@ export interface ProposeOptions { domain?: string; /** Hint: target brand (omit for global) */ brand?: string; - /** Hint: enforcement level */ - enforcement?: Enforcement; + /** Hint: weight level */ + weight?: string; /** If true, decompose input into multiple KUs */ batch?: boolean; /** Base directory (defaults to cwd) */ @@ -35,7 +35,7 @@ export interface ProposeResult { filePath: string; title: string; domain: string; - enforcement: string; + weight: string; tags: string[]; related: Array<{ id: string; reason: string }>; skipped: boolean; @@ -47,7 +47,7 @@ export interface ProposedUnit { domain: string; brand?: string; system?: string; - enforcement: string; + weight: string; tags: string[]; body: string; } @@ -99,7 +99,7 @@ export function buildPrompt( const existingList = existingUnits .map( (u) => - `- ${u.id}: "${u.title}" (domain: ${u.domain}, enforcement: ${u.enforcement})`, + `- ${u.id}: "${u.title}" (domain: ${u.domain}, weight: ${u.weight})`, ) .join('\n'); @@ -115,8 +115,7 @@ export function buildPrompt( hints.push( `Brand hint: ${options.brand} (place in brands/${options.brand}/ directory)`, ); - if (options.enforcement) - hints.push(`Enforcement hint: ${options.enforcement}`); + if (options.weight) hints.push(`string hint: ${options.weight}`); if (!options.brand) hints.push( 'No brand specified — this should be a global rule (no brand field in frontmatter)', @@ -134,7 +133,7 @@ A knowledge unit is a single, atomic rule, guideline, or pattern stored as a mar Available domains: ${domains.join(', ')} Available brands: ${brands.join(', ')} (omit brand field for global rules) -Enforcement levels: must (must follow, blocks CI), should (should follow), may (optional guidance), context (background) +string levels: must (must follow, blocks CI), should (should follow), may (optional guidance), context (background) ${hints.join('\n')} ## Existing units in this repo @@ -163,7 +162,7 @@ tags: - tag1 - tag2 - tag3 -enforcement: ${options.enforcement || 'should'} +weight: ${options.weight || 'should'} provenance: origin: system-proposed confidence: 0.85 @@ -185,7 +184,7 @@ The body in markdown. Include: - Include Do/Don't examples when the guideline has concrete right/wrong applications - Use **Don't:** / **Do:** format (bold, with colon) for example pairs - Tags should be 3-5 specific terms, not generic -- Enforcement: use "must" only for compliance/legal/accessibility violations. Use "should" for best practices. Use "may" for suggestions and reference patterns. +- string: use "must" only for compliance/legal/accessibility violations. Use "should" for best practices. Use "may" for suggestions and reference patterns. - Do NOT duplicate an existing unit. If the input overlaps with an existing unit, note it but still create the new unit focused on the distinct aspect. ## Input to process @@ -221,7 +220,7 @@ function formatUnitAsFile(unit: KnowledgeUnit): string { if (unit.brand) frontmatter.brand = unit.brand; if (unit.system) frontmatter.system = unit.system; frontmatter.tags = unit.tags; - frontmatter.enforcement = unit.enforcement; + frontmatter.weight = unit.weight; frontmatter.provenance = unit.provenance; const yaml = stringifyYaml(frontmatter); @@ -245,8 +244,8 @@ export function parseProposedUnits(response: string): ProposedUnit[] { const domain = String(fm.domain || 'content'); const brand = fm.brand ? String(fm.brand) : undefined; const system = fm.system ? String(fm.system) : undefined; - // Support both 'enforcement' and legacy 'severity' - const enforcement = String(fm.enforcement || fm.severity || 'should'); + // Support both 'weight' and legacy 'severity' + const weight = String(fm.weight || fm.severity || 'should'); const tags = Array.isArray(fm.tags) ? fm.tags.map(String) : []; units.push({ @@ -255,7 +254,7 @@ export function parseProposedUnits(response: string): ProposedUnit[] { domain, brand, system, - enforcement, + weight, tags, body, }); @@ -301,7 +300,7 @@ function writeProposedUnit( filePath: relPath, title: unit.title, domain: unit.domain, - enforcement: unit.enforcement, + weight: unit.weight, tags: unit.tags, related: [], skipped: true, @@ -320,7 +319,7 @@ function writeProposedUnit( for (const tag of unit.tags) { frontmatterLines.push(` - ${tag}`); } - frontmatterLines.push(`enforcement: ${unit.enforcement}`); + frontmatterLines.push(`weight: ${unit.weight}`); frontmatterLines.push('provenance:'); frontmatterLines.push(' origin: system-proposed'); frontmatterLines.push(' confidence: 0.85'); @@ -339,7 +338,7 @@ function writeProposedUnit( filePath: relPath, title: unit.title, domain: unit.domain, - enforcement: unit.enforcement, + weight: unit.weight, tags: unit.tags, related, skipped: false, diff --git a/src/resolver.ts b/src/resolver.ts index 7149740..f335da4 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -2,8 +2,8 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; import type { MadrigalConfig } from './config.js'; -import type { Enforcement } from './enforcement.js'; import type { KnowledgeUnit } from './schema/index.js'; +import type { Weight } from './weight.js'; /** * Options for resolving knowledge units. @@ -29,8 +29,8 @@ export interface ResolveOptions { export interface EnforcementOverride { /** ID of the knowledge unit to override */ id: string; - /** New enforcement level */ - enforcement: Enforcement; + /** New weight level */ + weight: string; /** Reason for the override */ reason?: string; } @@ -49,7 +49,7 @@ export interface OverridesFile { * 1. Start with units matching the brand's `include` list (e.g., 'global') * 2. Layer on brand-specific units * 3. If same `id` exists in both layers, brand-specific wins (deep merge) - * 4. Apply enforcement overrides from overrides.yaml files + * 4. Apply weight overrides from overrides.yaml files * * @param options - Resolution options * @returns Resolved knowledge units for the brand @@ -108,14 +108,14 @@ export function resolveForBrand(options: ResolveOptions): KnowledgeUnit[] { } } - // Apply enforcement overrides from overrides.yaml files + // Apply weight overrides from overrides.yaml files const overrides = loadOverrides(brand, baseDir); for (const override of overrides) { const unit = unitMap.get(override.id); if (unit) { unitMap.set(override.id, { ...unit, - enforcement: override.enforcement, + weight: override.weight, }); } } @@ -141,13 +141,13 @@ function mergeUnits( } /** - * Load enforcement overrides from overrides.yaml files. + * Load weight overrides from overrides.yaml files. * * Searches for: * 1. knowledge//overrides.yaml * 2. knowledge/brands//overrides.yaml * - * Supports both 'enforcement' and legacy 'severity' field names. + * Supports both 'weight' and legacy 'severity' field names. */ function loadOverrides(brand: string, baseDir: string): EnforcementOverride[] { const overrides: EnforcementOverride[] = []; @@ -168,8 +168,8 @@ function loadOverrides(brand: string, baseDir: string): EnforcementOverride[] { const raw = entry as Record; overrides.push({ id: String(raw.id), - // Support both 'enforcement' and legacy 'severity' field - enforcement: (raw.enforcement || raw.severity) as Enforcement, + // Support both 'weight' and legacy 'severity' field + weight: (raw.weight || raw.severity) as string, reason: raw.reason ? String(raw.reason) : undefined, }); } @@ -281,13 +281,13 @@ export function filterByDomain( } /** - * Filter units by enforcement level. + * Filter units by weight level. */ export function filterByEnforcement( units: KnowledgeUnit[], - enforcement: Enforcement, + weight: string, ): KnowledgeUnit[] { - return units.filter((u) => u.enforcement === enforcement); + return units.filter((u) => u.weight === weight); } /** diff --git a/src/rules/index.ts b/src/rules/index.ts index 539b556..853f668 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -1,4 +1,4 @@ -import type { Enforcement } from '../enforcement.js'; +import type { Weight } from '../weight.js'; /** * Result of matching a code pattern against a knowledge unit rule. @@ -11,11 +11,11 @@ export interface MatchResult { } /** - * Brand-specific enforcement override configuration. + * Brand-specific weight override configuration. */ export interface OverrideConfig { brand: string; knowledgeUnitId: string; - enforcement: Enforcement; + weight: string; reason: string; } diff --git a/src/schema/brand-taxonomy.ts b/src/schema/brand-taxonomy.ts index 998dc35..4e62313 100644 --- a/src/schema/brand-taxonomy.ts +++ b/src/schema/brand-taxonomy.ts @@ -1,4 +1,4 @@ -import type { Enforcement } from '../enforcement.js'; +import type { Weight } from '../weight.js'; /** * A Brand represents a distinct brand identity within the organization. @@ -38,8 +38,8 @@ export interface CreateBrand { } /** - * Brand-specific enforcement override for a knowledge unit. - * Allows a global rule to have different enforcement levels per brand. + * Brand-specific weight override for a knowledge unit. + * Allows a global rule to have different weight levels per brand. */ export interface BrandEnforcementOverride { /** The brand this override applies to */ @@ -48,8 +48,8 @@ export interface BrandEnforcementOverride { /** The knowledge unit being overridden */ knowledgeUnitId: string; - /** The brand-specific enforcement level */ - enforcement: Enforcement; + /** The brand-specific weight level */ + weight: string; /** Reason for the override */ reason?: string; @@ -62,12 +62,12 @@ export interface BrandEnforcementOverride { } /** - * Input for creating a brand enforcement override. + * Input for creating a brand weight override. */ export interface CreateBrandEnforcementOverride { brandId: string; knowledgeUnitId: string; - enforcement: Enforcement; + weight: string; reason?: string; createdBy?: string; } diff --git a/src/schema/knowledge-unit.ts b/src/schema/knowledge-unit.ts index dde0266..cd31d2f 100644 --- a/src/schema/knowledge-unit.ts +++ b/src/schema/knowledge-unit.ts @@ -1,5 +1,5 @@ -import type { Enforcement } from '../enforcement.js'; import type { Provenance } from '../provenance.js'; +import type { Weight } from '../weight.js'; /** * Knowledge domains supported by the system. @@ -10,6 +10,7 @@ export type Domain = string; /** * Frontmatter fields expected in knowledge markdown files. + * Unknown fields are collected into `attributes` automatically. */ export interface KnowledgeFrontmatter { id?: string; @@ -19,8 +20,11 @@ export interface KnowledgeFrontmatter { system?: string; brand?: string; tags?: string[]; + /** Weight level — how strongly this unit should influence decisions. */ + weight?: string; + /** @deprecated Use weight instead */ enforcement?: string; - /** @deprecated Use enforcement instead */ + /** @deprecated Use weight instead */ severity?: string; attributes?: Record; provenance?: Partial; @@ -31,7 +35,7 @@ export interface KnowledgeFrontmatter { * It represents a single rule, guideline, pattern, or insight. */ export interface KnowledgeUnit { - /** Unique identifier (UUID) */ + /** Unique identifier */ id: string; /** Human-readable title */ @@ -49,14 +53,18 @@ export interface KnowledgeUnit { /** Design system this applies to (e.g., 'market', 'arcade', 'wave') */ system?: string; - /** Brand this applies to, null for global rules */ + /** Brand this applies to; omit or use 'shared'/'global' for universal units */ brand?: string; /** Searchable tags for categorization */ tags: string[]; - /** Enforcement level */ - enforcement: Enforcement; + /** + * How strongly this unit should influence decisions. + * Config-driven — teams define their own level vocabulary in madrigal.config.yaml. + * Default levels: must > should > may > context > deprecated. + */ + weight: Weight; /** Open metadata for domain-specific attributes (surfaces, audiences, etc.) */ attributes: Record; @@ -86,7 +94,7 @@ export interface CreateKnowledgeUnit { system?: string; brand?: string; tags: string[]; - enforcement: Enforcement; + weight: Weight; attributes?: Record; provenance: Provenance; } @@ -103,7 +111,7 @@ export interface UpdateKnowledgeUnit { system?: string; brand?: string; tags?: string[]; - enforcement?: Enforcement; + weight?: Weight; attributes?: Record; provenance?: Provenance; } diff --git a/src/search/adapter.ts b/src/search/adapter.ts index b4914c0..adef4e8 100644 --- a/src/search/adapter.ts +++ b/src/search/adapter.ts @@ -4,8 +4,8 @@ import type { SearchAdapter, SemanticSearchOptions, } from '../adapters/search.js'; -import { ENFORCEMENT_ORDER } from '../enforcement.js'; import type { KnowledgeUnit } from '../schema/index.js'; +import { WEIGHT_ORDER } from '../weight.js'; import { BM25Index } from './bm25.js'; // "shared" and "global" are sentinel values meaning always-include, @@ -31,7 +31,7 @@ export class BM25SearchAdapter implements SearchAdapter { /** * Exact-match filtering by metadata fields. * If textQuery is provided, results are ranked by BM25 score. - * Otherwise, results are sorted by enforcement (must first). + * Otherwise, results are sorted by weight (must first). */ async exactMatch(filter: RuleFilter): Promise { let results = this.applyFilters(this.units, filter); @@ -42,11 +42,10 @@ export class BM25SearchAdapter implements SearchAdapter { const scored = filtered.search(filter.textQuery, results.length); results = scored.map((s) => s.unit); } else { - // Sort by enforcement: must first + // Sort by weight: must first results.sort( (a, b) => - (ENFORCEMENT_ORDER[a.enforcement] ?? 99) - - (ENFORCEMENT_ORDER[b.enforcement] ?? 99), + (WEIGHT_ORDER[a.weight] ?? 99) - (WEIGHT_ORDER[b.weight] ?? 99), ); } @@ -82,18 +81,18 @@ export class BM25SearchAdapter implements SearchAdapter { ); } - if (options?.minEnforcement) { - const minOrder = ENFORCEMENT_ORDER[options.minEnforcement] ?? 99; + if (options?.minWeight) { + const minOrder = WEIGHT_ORDER[options.minWeight] ?? 99; results = results.filter( - (r) => (ENFORCEMENT_ORDER[r.unit.enforcement] ?? 99) <= minOrder, + (r) => (WEIGHT_ORDER[r.unit.weight] ?? 99) <= minOrder, ); } - // Apply enforcement boost: enforceable rules score slightly higher + // Apply weight boost: enforceable rules score slightly higher results = results.map((r) => { let boosted = r.score; - if (r.unit.enforcement === 'must') boosted *= 1.2; - else if (r.unit.enforcement === 'should') boosted *= 1.1; + if (r.unit.weight === 'must') boosted *= 1.2; + else if (r.unit.weight === 'should') boosted *= 1.1; return { unit: r.unit, score: boosted }; }); @@ -142,9 +141,9 @@ export class BM25SearchAdapter implements SearchAdapter { } } - if (filter.enforcement && filter.enforcement.length > 0) { - const allowed = new Set(filter.enforcement); - results = results.filter((u) => allowed.has(u.enforcement)); + if (filter.weight && filter.weight.length > 0) { + const allowed = new Set(filter.weight); + results = results.filter((u) => allowed.has(u.weight)); } if (filter.kind) { diff --git a/src/serve/mcp-server.ts b/src/serve/mcp-server.ts index 0b39211..1f1ce87 100644 --- a/src/serve/mcp-server.ts +++ b/src/serve/mcp-server.ts @@ -11,10 +11,10 @@ import { existsSync, readFileSync } from 'node:fs'; import type { MadrigalConfig } from '../config.js'; import { loadConfig } from '../config.js'; -import { ENFORCEMENT_ORDER, type Enforcement } from '../enforcement.js'; import { loadKnowledge } from '../loader.js'; import type { KnowledgeUnit } from '../schema/index.js'; import { BM25SearchAdapter } from '../search/adapter.js'; +import { WEIGHT_ORDER } from '../weight.js'; /** * Options for starting the MCP server. @@ -112,7 +112,7 @@ export async function serveMcp(options: ServeOptions = {}): Promise { // --- Tool: search_knowledge --- server.tool( 'search_knowledge', - 'Search the knowledge base by query text, tags, domain, enforcement, kind, or brand. Returns matching rules, guidelines, and patterns ranked by relevance.', + 'Search the knowledge base by query text, tags, domain, weight, kind, or brand. Returns matching rules, guidelines, and patterns ranked by relevance.', { query: z .string() @@ -120,10 +120,10 @@ export async function serveMcp(options: ServeOptions = {}): Promise { .describe('Free text search across titles and body content'), domain: z.string().optional().describe('Filter by domain'), brand: z.string().optional().describe('Filter by brand (omit for all)'), - enforcement: z + weight: z .array(z.enum(['must', 'should', 'may', 'context', 'deprecated'])) .optional() - .describe('Filter by enforcement levels'), + .describe('Filter by weight levels'), kind: z .string() .optional() @@ -131,14 +131,14 @@ export async function serveMcp(options: ServeOptions = {}): Promise { tags: z.array(z.string()).optional().describe('Filter by tags'), limit: z.number().optional().describe('Max results (default: 10)'), }, - async ({ query, domain, brand, enforcement, kind, tags, limit }) => { + async ({ query, domain, brand, weight, kind, tags, limit }) => { let results: KnowledgeUnit[]; if (query) { const scored = await search.semanticSearch(query, { domain, brand, - minEnforcement: enforcement?.[enforcement.length - 1], // least strict as min + minWeight: weight?.[weight.length - 1], // least strict as min limit: limit ?? 10, }); // Post-filter by kind and tags if specified @@ -154,7 +154,7 @@ export async function serveMcp(options: ServeOptions = {}): Promise { results = await search.exactMatch({ domain, brand, - enforcement: enforcement as Enforcement[] | undefined, + weight: weight as string[] | undefined, kind, tags, }); @@ -180,7 +180,7 @@ export async function serveMcp(options: ServeOptions = {}): Promise { .replace(/\n+/g, ' ') .trim() .slice(0, 150); - return `- [${u.enforcement.toUpperCase()}] **${u.id}**${tags}\n ${u.title}${excerpt ? `\n _${excerpt}…_` : ''}`; + return `- [${u.weight.toUpperCase()}] **${u.id}**${tags}\n ${u.title}${excerpt ? `\n _${excerpt}…_` : ''}`; }); return { @@ -218,7 +218,7 @@ export async function serveMcp(options: ServeOptions = {}): Promise { `**ID:** ${unit.id}`, `**Domain:** ${unit.domain}`, `**Kind:** ${unit.kind}`, - `**Enforcement:** ${unit.enforcement}`, + `**string:** ${unit.weight}`, `**Tags:** ${unit.tags.join(', ')}`, unit.brand ? `**Brand:** ${unit.brand}` : null, unit.system ? `**System:** ${unit.system}` : null, @@ -241,35 +241,33 @@ export async function serveMcp(options: ServeOptions = {}): Promise { // --- Tool: list_knowledge_units --- server.tool( 'list_knowledge_units', - 'List all available knowledge units with their IDs, titles, enforcement, and tags. Useful for discovering what rules exist.', + 'List all available knowledge units with their IDs, titles, weight, and tags. Useful for discovering what rules exist.', { domain: z.string().optional().describe('Filter by domain'), brand: z.string().optional().describe('Filter by brand'), - enforcement: z + weight: z .enum(['must', 'should', 'may', 'context', 'deprecated']) .optional() - .describe('Filter by enforcement level'), + .describe('Filter by weight level'), }, - async ({ domain, brand, enforcement }) => { + async ({ domain, brand, weight }) => { let filtered = [...units]; if (domain) filtered = filtered.filter((u) => u.domain === domain); if (brand) filtered = filtered.filter( (u) => isGlobal(u.brand) || u.brand === brand, ); - if (enforcement) - filtered = filtered.filter((u) => u.enforcement === enforcement); + if (weight) filtered = filtered.filter((u) => u.weight === weight); filtered.sort( (a, b) => - (ENFORCEMENT_ORDER[a.enforcement] ?? 99) - - (ENFORCEMENT_ORDER[b.enforcement] ?? 99), + (WEIGHT_ORDER[a.weight] ?? 99) - (WEIGHT_ORDER[b.weight] ?? 99), ); const text = filtered .map( (u) => - `- **${u.id}** [${u.enforcement.toUpperCase()}]: ${u.title} (${u.tags.join(', ')})`, + `- **${u.id}** [${u.weight.toUpperCase()}]: ${u.title} (${u.tags.join(', ')})`, ) .join('\n'); @@ -287,7 +285,7 @@ export async function serveMcp(options: ServeOptions = {}): Promise { // --- Tool: get_brand_rules --- server.tool( 'get_brand_rules', - 'List all knowledge units for a specific brand, sorted by enforcement. Returns an index of IDs, titles, and enforcement levels. Use get_knowledge_unit(id) to read a full unit.', + 'List all knowledge units for a specific brand, sorted by weight. Returns an index of IDs, titles, and weight levels. Use get_knowledge_unit(id) to read a full unit.', { brand: z.string().describe('Brand name'), }, @@ -311,7 +309,7 @@ export async function serveMcp(options: ServeOptions = {}): Promise { }; } - // Sort by enforcement (must first), then title + // Sort by weight (must first), then title const order: Record = { must: 0, should: 1, @@ -321,13 +319,13 @@ export async function serveMcp(options: ServeOptions = {}): Promise { }; brandUnits.sort( (a, b) => - (order[a.enforcement] ?? 9) - (order[b.enforcement] ?? 9) || + (order[a.weight] ?? 9) - (order[b.weight] ?? 9) || a.title.localeCompare(b.title), ); const lines = brandUnits.map( (u) => - `- [${u.enforcement.toUpperCase()}] **${u.id}** — ${u.title}${u.tags.length ? ` (${u.tags.slice(0, 3).join(', ')})` : ''}`, + `- [${u.weight.toUpperCase()}] **${u.id}** — ${u.title}${u.tags.length ? ` (${u.tags.slice(0, 3).join(', ')})` : ''}`, ); const text = `${brandUnits.length} knowledge unit(s) for brand "${brand}":\n\n${lines.join('\n')}\n\nUse get_knowledge_unit(id) to read the full content of any unit.`; @@ -377,7 +375,7 @@ export async function serveMcp(options: ServeOptions = {}): Promise { r.unit.body.length > EXCERPT_CHARS ? `${r.unit.body.slice(0, EXCERPT_CHARS)}\n[…truncated]` : r.unit.body; - return `### ${r.unit.title} [${r.unit.enforcement.toUpperCase()}]${brandTag}\n**ID:** ${r.unit.id}\n\n${excerpt}`; + return `### ${r.unit.title} [${r.unit.weight.toUpperCase()}]${brandTag}\n**ID:** ${r.unit.id}\n\n${excerpt}`; }) .join('\n\n---\n\n'); diff --git a/src/topology/generate.ts b/src/topology/generate.ts index 94e8782..877d261 100644 --- a/src/topology/generate.ts +++ b/src/topology/generate.ts @@ -117,7 +117,7 @@ export async function generateTopology( title: unit.title, domain: unit.domain, kind: unit.kind, - enforcement: unit.enforcement, + weight: unit.weight, brand: unit.brand, tags: unit.tags, excerpt: unit.body.slice(0, 200).replace(/\n/g, ' ').trim(), diff --git a/src/topology/types.ts b/src/topology/types.ts index ddb9382..302d802 100644 --- a/src/topology/types.ts +++ b/src/topology/types.ts @@ -5,7 +5,7 @@ export type TopologyNode = { title: string; domain: string; kind: string; - enforcement: string; + weight: string; brand?: string; tags: string[]; excerpt: string; diff --git a/src/weight.ts b/src/weight.ts new file mode 100644 index 0000000..d029887 --- /dev/null +++ b/src/weight.ts @@ -0,0 +1,110 @@ +/** + * Weight levels for knowledge units. + * + * Weight expresses how strongly a unit should influence decisions — from + * non-negotiable requirements down to deprecated guidance. The specific values + * are config-driven; teams define their own vocabulary and map it here. + * + * Default levels (used when no custom levels are configured): + * must > should > may > context > deprecated + */ + +/** Default ordered weight levels, highest weight first. */ +export const DEFAULT_WEIGHT_LEVELS = [ + 'must', + 'should', + 'may', + 'context', + 'deprecated', +] as const; + +export type DefaultWeight = (typeof DEFAULT_WEIGHT_LEVELS)[number]; + +/** Weight is any string — validated at load time against config levels. */ +export type Weight = string; + +/** + * Build a weight-order map from an ordered levels array. + * Index 0 = highest weight (most important). + */ +export function buildWeightOrder(levels: string[]): Record { + const order: Record = {}; + for (let i = 0; i < levels.length; i++) { + order[levels[i]] = i; + } + return order; +} + +/** Default WEIGHT_ORDER built from DEFAULT_WEIGHT_LEVELS. */ +export const WEIGHT_ORDER: Record = buildWeightOrder([ + ...DEFAULT_WEIGHT_LEVELS, +]); + +/** + * Compare two weight values using a given order map. + * Returns negative if a has higher weight than b (sort-ascending = highest first). + */ +export function compareWeight( + a: string, + b: string, + order: Record = WEIGHT_ORDER, +): number { + return (order[a] ?? 99) - (order[b] ?? 99); +} + +/** + * Returns true when the weight value is in the top half of the level stack + * (i.e. index < midpoint). Used for weight-style checks. + */ +export function isHighWeight( + w: string, + levels: string[] = [...DEFAULT_WEIGHT_LEVELS], +): boolean { + const idx = levels.indexOf(w); + if (idx < 0) return false; + return idx < Math.ceil(levels.length / 2); +} + +/** + * Parse a raw string into a validated weight value. + * + * Accepts: + * - Any value present in the provided levels array + * - Legacy severity aliases: error→must, warning→should, info→may + * - Legacy weight values when using default levels + * + * Returns null if the value is unrecognized. + */ +export function parseWeight( + value: string, + levels: string[] = [...DEFAULT_WEIGHT_LEVELS], +): string | null { + const normalized = value.toLowerCase().trim(); + + if (levels.includes(normalized)) return normalized; + + // Backward compatibility: legacy severity field values + const legacyMap: Record = { + error: 'must', + warning: 'should', + info: 'may', + }; + if (normalized in legacyMap) { + const mapped = legacyMap[normalized]; + // Only return if the mapped value is valid in this level set + if (levels.includes(mapped)) return mapped; + } + + return null; +} + +/** + * Return the lowest-weight (least important) level in a levels array. + * Used as the default when no weight is specified. + */ +export function defaultWeight( + levels: string[] = [...DEFAULT_WEIGHT_LEVELS], +): string { + // Use 'may' if present (familiar default), otherwise last item + return levels.includes('may') ? 'may' : (levels[levels.length - 1] ?? 'may'); +} From 6585eb226aa41d21f8e42d6f169162266e333053 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Mon, 27 Apr 2026 11:14:11 -0400 Subject: [PATCH 2/3] fix: type annotation in weight test Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/weight.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/weight.test.ts b/src/__tests__/weight.test.ts index e22d6f8..c620542 100644 --- a/src/__tests__/weight.test.ts +++ b/src/__tests__/weight.test.ts @@ -140,7 +140,7 @@ describe('defaultWeight', () => { describe('applyFieldMappings', () => { it('simple rename: copies source field to target', () => { - const raw = { key: 'unit-1', title: 'My Rule' }; + const raw: Record = { key: 'unit-1', title: 'My Rule' }; applyFieldMappings(raw, { id: 'key' }); expect(raw.id).toBe('unit-1'); }); From cf834dc9981f97b8054a48dd59c2f6256b6c4d1a Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Mon, 27 Apr 2026 11:23:54 -0400 Subject: [PATCH 3/3] feat: preserve enforcement as deprecated alias on KnowledgeUnit and SemanticSearchOptions Co-Authored-By: Claude Sonnet 4.6 --- src/adapters/search.ts | 3 +++ src/loader.ts | 2 ++ src/schema/knowledge-unit.ts | 3 +++ src/search/adapter.ts | 5 +++-- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/adapters/search.ts b/src/adapters/search.ts index db176ad..7b4aaef 100644 --- a/src/adapters/search.ts +++ b/src/adapters/search.ts @@ -41,6 +41,9 @@ export interface SemanticSearchOptions { /** Filter by minimum weight level */ minWeight?: string; + /** @deprecated Use minWeight instead */ + minEnforcement?: string; + /** Maximum number of results to return */ limit?: number; diff --git a/src/loader.ts b/src/loader.ts index d152e60..8c5d100 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -278,6 +278,7 @@ function parseKnowledgeFile( brand: frontmatter.brand, tags: frontmatter.tags || [], weight, + enforcement: weight, attributes, provenance, sourcePath: relative(baseDir, filePath), @@ -531,6 +532,7 @@ function buildYamlUnit( brand, tags, weight, + enforcement: weight, attributes, provenance, sourcePath: relative(baseDir, filePath), diff --git a/src/schema/knowledge-unit.ts b/src/schema/knowledge-unit.ts index cd31d2f..27f266f 100644 --- a/src/schema/knowledge-unit.ts +++ b/src/schema/knowledge-unit.ts @@ -66,6 +66,9 @@ export interface KnowledgeUnit { */ weight: Weight; + /** @deprecated Use weight instead */ + enforcement?: Weight; + /** Open metadata for domain-specific attributes (surfaces, audiences, etc.) */ attributes: Record; diff --git a/src/search/adapter.ts b/src/search/adapter.ts index adef4e8..a3d047c 100644 --- a/src/search/adapter.ts +++ b/src/search/adapter.ts @@ -81,8 +81,9 @@ export class BM25SearchAdapter implements SearchAdapter { ); } - if (options?.minWeight) { - const minOrder = WEIGHT_ORDER[options.minWeight] ?? 99; + if (options?.minWeight || options?.minEnforcement) { + const minOrder = + WEIGHT_ORDER[options.minWeight ?? options.minEnforcement ?? ''] ?? 99; results = results.filter( (r) => (WEIGHT_ORDER[r.unit.weight] ?? 99) <= minOrder, );