Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e77a3b7
plan 52: mark in-progress
claude Apr 20, 2026
a9dbec7
plan 52: add archetype template library
claude Apr 20, 2026
484f199
plan 52: raise patch coverage on archetype paths
claude Apr 20, 2026
5fd7d3b
plan 52: address Copilot review
claude Apr 20, 2026
808350a
plan 52: rework scope to user-supplied archetypes + CLI
claude Apr 21, 2026
cc8b3c8
plan 52: replace embedded archetypes with user-supplied + CLI
claude Apr 21, 2026
1d01cce
plan 52: raise patch coverage on archetypes paths
claude Apr 21, 2026
8b5298a
plan 52: broaden e2e coverage of archetypes CLI
claude Apr 21, 2026
f956950
plan 52: address Copilot review
claude Apr 21, 2026
eef4693
plan 52: review fixes — reserved names, unified errors, fixtures
claude Apr 21, 2026
cf7e767
plan 52: cover loadArchetype raw-os fallback path
claude Apr 21, 2026
fb2f316
plan 52: reject escaping archetype-roots + doc accuracy
claude Apr 21, 2026
6802276
plan 52: constrain CLI resolver to project root
claude Apr 21, 2026
c9afab8
fix: handle empty archetype roots and dot-root schema detection
Copilot Apr 21, 2026
1efcf8c
test(cmd/mdsmith): add unit tests for all functions in main package
Copilot Apr 22, 2026
8d535cc
test: clarify parallel-unsafe comment on capture helpers
Copilot Apr 22, 2026
02cae35
docs(cli): add --no-follow-symlinks to check/fix table, document metr…
Copilot Apr 22, 2026
39493bb
plan 52: address review — rename e2e test, add defaults to CLI flag t…
claude Apr 22, 2026
d6ec7ff
plan 52: tighten schema-source matching to one level deep
claude Apr 22, 2026
5905a99
plan 52: reuse archetypes.DefaultRoot + surface List errors
claude Apr 22, 2026
5c00f8b
plan 52: reject path-injection archetype names
claude Apr 22, 2026
9efb0dc
plan 52: validate init dir, drop stale comment, close pipe read ends
claude Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .mdsmith.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ rules:
- "demo/**"
- "internal/rules/**"
- "internal/metrics/**"
- "internal/archetypes/**"
- ".claude/**"
- ".github/**"
catalog: true
Expand Down Expand Up @@ -245,3 +246,4 @@ ignore:
- "plan/proto.md"
- "internal/rules/proto.md"
- "docs/security/proto.md"
- "internal/archetypes/*.md"
Comment thread
jeduden marked this conversation as resolved.
Outdated
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ footer: |
|-----|--------|-----------------------------------------------------------------------------------------------------------------|
| 50 | 🔲 | [Redundancy / Duplication Detection](plan/50_redundancy-duplication-detection.md) |
| 51 | ✅ | [Section-Level Size Limits](plan/51_section-level-size-limits.md) |
| 52 | 🔲 | [Archetype / Template Library for Agentic Patterns](plan/52_archetype-template-library.md) |
| 52 | 🔳 | [Archetype / Template Library for Agentic Patterns](plan/52_archetype-template-library.md) |
Comment thread
jeduden marked this conversation as resolved.
Outdated
| 53 | ⛔ | [Conciseness Scoring](plan/53_conciseness-scoring.md) |
| 54 | ⛔ | [Conciseness Metrics Design and Implementation](plan/54_metrics-guide-tradeoffs.md) |
| 56 | ⛔ | [Spike Ollama for Weasel Detection](plan/56_spike-ollama-weasel-detection.md) |
Expand Down
20 changes: 20 additions & 0 deletions docs/guides/directives/enforcing-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,26 @@ directive has no effect`.
For full reference, see
[MDS020 required-structure](../../../internal/rules/MDS020-required-structure/README.md).

## Built-in archetypes

Instead of authoring a schema, select one of the
built-in archetype schemas for common agentic document
types:

```yaml
overrides:
- files: ["stories/**/*.md"]
rules:
required-structure:
archetype: story-file
```

Available archetypes: `story-file`, `prd`,
`agent-definition`, `claude-md`. Archetypes are
mutually exclusive with `schema`. Built-in archetypes
cannot reference on-disk `<?include?>` fragments; for
composition, ship a local schema file instead.

## Allowing intentional empty sections

Some sections are intentionally left empty (for
Expand Down
14 changes: 14 additions & 0 deletions internal/archetypes/agent-definition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
name: 'string & != ""'
description: 'string & != ""'
"tools?": '[...string]'
---
# ?

## Purpose

## Inputs

## Outputs

## ...
54 changes: 54 additions & 0 deletions internal/archetypes/archetypes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Package archetypes ships built-in required-structure schemas
// for common agentic Markdown document types.
package archetypes

import (
"embed"
"errors"
"fmt"
"io/fs"
"sort"
"strings"
)

//go:embed *.md
var files embed.FS

// Lookup returns the bytes of the built-in archetype schema with the
// given name (for example "story-file"). The name is the basename
// without extension. An unknown name returns an error whose message
// lists the available archetypes; other read errors are wrapped and
// returned as-is.
func Lookup(name string) ([]byte, error) {
if name == "" {
return nil, fmt.Errorf("archetype name must not be empty")
}
data, err := files.ReadFile(name + ".md")
if err != nil {
return nil, classifyLookupError(name, err, List())
}
return data, nil
}

// classifyLookupError turns a ReadFile error into a user-facing
// error. Missing entries surface as "unknown archetype" with the
// available list; other errors are wrapped verbatim.
func classifyLookupError(name string, err error, available []string) error {
if errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf(
"unknown archetype %q: available: %s",
name, strings.Join(available, ", "))
}
return fmt.Errorf("reading archetype %q: %w", name, err)
}

// List returns the names of all built-in archetypes, sorted.
func List() []string {
entries, _ := files.ReadDir(".")
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, strings.TrimSuffix(e.Name(), ".md"))
}
sort.Strings(names)
return names
}
59 changes: 59 additions & 0 deletions internal/archetypes/archetypes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package archetypes

import (
"errors"
"io/fs"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestList_ContainsBuiltins(t *testing.T) {
names := List()
assert.Contains(t, names, "story-file")
assert.Contains(t, names, "prd")
assert.Contains(t, names, "agent-definition")
assert.Contains(t, names, "claude-md")
}

func TestList_Sorted(t *testing.T) {
names := List()
for i := 1; i < len(names); i++ {
assert.Less(t, names[i-1], names[i])
}
}

func TestLookup_ReturnsSchemaBytes(t *testing.T) {
data, err := Lookup("story-file")
require.NoError(t, err)
assert.Contains(t, string(data), "## Background")
assert.Contains(t, string(data), "## Acceptance Criteria")
}

func TestLookup_EmptyName(t *testing.T) {
_, err := Lookup("")
require.Error(t, err)
}

func TestLookup_UnknownNameListsAvailable(t *testing.T) {
_, err := Lookup("not-real")
require.Error(t, err)
assert.Contains(t, err.Error(), "story-file")
assert.Contains(t, err.Error(), "unknown archetype")
}

func TestClassifyLookupError_MissingEntry(t *testing.T) {
err := classifyLookupError("foo", fs.ErrNotExist, []string{"a", "b"})
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown archetype")
assert.Contains(t, err.Error(), "a, b")
}

func TestClassifyLookupError_UnexpectedError(t *testing.T) {
boom := errors.New("io failure")
err := classifyLookupError("foo", boom, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "reading archetype")
assert.True(t, errors.Is(err, boom))
}
5 changes: 5 additions & 0 deletions internal/archetypes/claude-md.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# ?

## Project

## ...
16 changes: 16 additions & 0 deletions internal/archetypes/prd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: 'string & != ""'
"status?": '"draft" | "review" | "approved"'
"owner?": 'string & != ""'
---
# ?

## Problem

## Goals

## Non-Goals

## Requirements

## ...
13 changes: 13 additions & 0 deletions internal/archetypes/story-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
as: 'string & != ""'
i-want: 'string & != ""'
so-that: 'string & != ""'
"status?": '"draft" | "in-progress" | "done"'
---
# ?

## Background

## Acceptance Criteria

## ...
45 changes: 38 additions & 7 deletions internal/rules/MDS020-required-structure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,35 @@ schema.

## Settings

| Setting | Type | Default | Description |
|----------|--------|---------|---------------------|
| `schema` | string | `""` | Path to schema file |
| Setting | Type | Default | Description |
|-------------|--------|---------|-------------------------------------|
| `schema` | string | `""` | Path to schema file |
| `archetype` | string | `""` | Name of a built-in schema archetype |

When `schema` is empty the rule skips structure and
front matter validation, but still warns on misplaced
`<?require?>` directives. Use overrides to apply
schemas to specific file groups.
When both `schema` and `archetype` are empty the rule
skips structure and front matter validation, but still
warns on misplaced `<?require?>` directives. Use
overrides to apply schemas to specific file groups.

`schema` and `archetype` are mutually exclusive; set
only one.

### Archetypes

Built-in archetype schemas ship ready-to-use for
common agentic Markdown patterns:

| Name | Use case |
|--------------------|---------------------------------------|
| `story-file` | Agile user story |
| `prd` | Product Requirements Document |
| `agent-definition` | AI agent / persona definition |
| `claude-md` | `CLAUDE.md` project instructions file |

Built-in archetypes cannot reference on-disk
`<?include?>` fragments. For schemas that compose
across files, ship a local schema file and use
`schema:` instead.

Schema front matter may embed a CUE schema that
validates document front matter:
Expand Down Expand Up @@ -106,6 +127,16 @@ overrides:
schema: internal/rules/proto.md
```

Apply a built-in archetype to all story files:

```yaml
overrides:
- files: ["stories/**/*.md"]
rules:
required-structure:
archetype: story-file
```

Disable:

```yaml
Expand Down
63 changes: 53 additions & 10 deletions internal/rules/requiredstructure/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"cuelang.org/go/cue"
"cuelang.org/go/cue/cuecontext"
"github.com/jeduden/mdsmith/internal/archetypes"
"github.com/jeduden/mdsmith/internal/fieldinterp"
"github.com/jeduden/mdsmith/internal/lint"
"github.com/jeduden/mdsmith/internal/rule"
Expand All @@ -25,7 +26,8 @@ func init() {

// Rule checks that a document's heading structure matches a schema.
type Rule struct {
Schema string // path to schema file
Schema string // path to schema file
Archetype string // name of a built-in archetype schema
}

// ID implements rule.Rule.
Expand All @@ -47,17 +49,28 @@ func (r *Rule) ApplySettings(settings map[string]any) error {
return fmt.Errorf("required-structure: schema must be a string, got %T", v)
}
r.Schema = s
case "archetype":
s, ok := v.(string)
if !ok {
return fmt.Errorf("required-structure: archetype must be a string, got %T", v)
}
r.Archetype = s
default:
return fmt.Errorf("required-structure: unknown setting %q", k)
}
}
if r.Schema != "" && r.Archetype != "" {
return fmt.Errorf(
"required-structure: schema and archetype are mutually exclusive")
}
return nil
}

// DefaultSettings implements rule.Configurable.
func (r *Rule) DefaultSettings() map[string]any {
return map[string]any{
"schema": "",
"schema": "",
"archetype": "",
}
}

Expand All @@ -75,24 +88,23 @@ func (r *Rule) Check(f *lint.File) []lint.Diagnostic {
}
}

if r.Schema == "" {
if r.Schema == "" && r.Archetype == "" {
Comment thread
jeduden marked this conversation as resolved.
return diags
}

schData, err := readSchemaFile(f, r.Schema)
schData, schPath, err := r.loadSchema(f)
if err != nil {
return append(diags, r.diag(f.Path, 1,
fmt.Sprintf("cannot read schema %q: %v", r.Schema, err)))
return append(diags, r.diag(f.Path, 1, err.Error()))
}

sch, err := parseSchema(schData, r.Schema, f.MaxInputBytes)
sch, err := parseSchema(schData, schPath, f.MaxInputBytes)
if err != nil {
return append(diags, r.diag(f.Path, 1,
fmt.Sprintf("invalid schema %q: %v", r.Schema, err)))
fmt.Sprintf("invalid schema %q: %v", r.schemaSource(), err)))
}

// Skip the schema file itself.
if isSchemaFile(f.Path, r.Schema) {
// Skip the schema file itself when schemas come from disk.
if r.Schema != "" && isSchemaFile(f.Path, r.Schema) {
return diags
}

Expand All @@ -118,6 +130,37 @@ func (r *Rule) Check(f *lint.File) []lint.Diagnostic {
return diags
}

// loadSchema returns the schema bytes and resolution path. When the rule
// selects a built-in archetype, the returned path is empty because such
// schemas cannot reference on-disk include fragments.
func (r *Rule) loadSchema(f *lint.File) ([]byte, string, error) {
if r.Schema != "" && r.Archetype != "" {
return nil, "", fmt.Errorf(
"schema and archetype are mutually exclusive")
}
if r.Archetype != "" {
data, err := archetypes.Lookup(r.Archetype)
if err != nil {
return nil, "", err
}
return data, "", nil
}
data, err := readSchemaFile(f, r.Schema)
if err != nil {
return nil, "", fmt.Errorf("cannot read schema %q: %v", r.Schema, err)
}
return data, r.Schema, nil
}

// schemaSource returns the user-facing identifier of the configured
// schema, either the file path or "archetype:<name>".
func (r *Rule) schemaSource() string {
if r.Archetype != "" {
return "archetype:" + r.Archetype
}
return r.Schema
}

func (r *Rule) diag(file string, line int, msg string) lint.Diagnostic {
return lint.Diagnostic{
File: file,
Expand Down
Loading
Loading