Extend StarSpec
Use this loop when a user asks you to add a new tag, document type, or aggregated page to StarSpec. You are modifying the compiler itself β not authoring spec content. Read starspec/architecture and starspec/adding-a-tag before starting if you have not already.
Dependency check
Before starting, verify you have access to the StarSpec source:
packages/compiler/src/types.tspackages/compiler/src/core/Tag.tsandcore/tags/packages/compiler/src/core/AstWalker.tspackages/compiler/src/core/AggregateProcessor.tspackages/compiler/src/passes/agent/index.tspackages/compiler/src/passes/public/index.tspackages/compiler/src/passes/public/phases/packages/compiler/tests/agent-pass.test.tspackages/compiler/tests/public-pass.test.ts
If these files are not accessible, stop and ask the user to open the StarSpec repository.
Phase 1 β Concept
Ask:
- What real-world spec artefact does this tag model? (e.g. βa business ruleβ, βa personaβ, βa data contractβ)
- Where does it belong? Inside an existing tag (child), or as a new top-level document type (standalone
*.{type}.mdocfile)? - Who consumes it β agents, human readers, or both?
Do not proceed until you have clear answers to all three.
Phase 2 β Schema design
Design the tagβs Markdoc surface:
\{% newtag attribute="value" scope="public agent" %\}Prose body (if any).\{% /newtag %\}Document each attribute:
| Attribute | Type | Required | Notes |
|---|---|---|---|
| β¦ | β¦ | β¦ | β¦ |
Rules to confirm:
- Does scope propagate from a parent or must it be declared explicitly?
- Does the tag have a prose body, or is it attribute-only (self-closing
/%\})? - Can it appear multiple times as a sibling? Does order matter?
- Are there sibling-tag blank-line constraints (Markdoc parses blank lines as block boundaries β contiguous siblings must not have blank lines between them)?
Phase 3 β Agent bundle shape
Decide what structured data the agent pass extracts:
{ "fieldOne": "string value", "fieldTwo": ["array", "of", "strings"]}- Does it add a top-level field to
SpecBundle, or does it nest inside an existing field? - What gets skipped (scope-filtered, empty values)?
- What
bundlePathshould the processor declare? (e.g.'requirements','api.actions') - Should this tag feed an aggregate bundle? If so, what
aggregatePathandaggregateIdKey?
Bundle placement decision: Decide whether your processor owns the entire bundle shape (use merge()) or produces individual array items (use bundlePath). See the full rules:
The TagProcessor<T> interface
export interface TagProcessor<T = unknown> { tagName: string; /** Bundle property path where extracted data should be stored (e.g., 'api.actions', 'requirements') */ bundlePath?: string; /** If true, promote tag's id/title to bundle root (default: false) */ promotesIdentity?: boolean; /** If set, this tag's data will be collected into the named aggregate bundle */ aggregatePath?: string; /** Key name for the source bundle ID in the aggregate entry (e.g., 'domainId', 'featureId'). Defaults to 'sourceId' */ aggregateIdKey?: string; /** Authoritative Zod schema for this tag's attributes (set to TagClass.Schema). Used by generate-schema.ts to emit markdoc.config.mjs. */ zodSchema?: z.ZodObject<any>; /** Schema definition for editor support (attributes, descriptions, types) */ schema?: { attributes?: Record<string, SchemaAttribute>; description?: string; }; /** If true, the agent-pass walk() continues into children after this processor fires. */ isContainer?: boolean; /** * Declares where documents of this type appear in the public sidebar. * `section` is the top-level section; `group` is the optional sub-group label. * `weight` controls ordering within the section (lower = earlier). */ sidebar?: { section: 'Product' | 'Specification' | 'Implementation' | 'Journal'; group?: string; weight: number; collapsed?: boolean; groupByDomain?: boolean; }; match(node: Markdoc.Node): boolean; extract(file: string, node: Markdoc.Node, ast: Markdoc.Node, context: CompilerContext): T; render?(fm: Frontmatter, ast: Markdoc.Node, data: T, context: CompilerContext): string; merge?(bundle: Record<string, unknown>, data: T): void;}Declarative properties
Most wiring is handled automatically through declarative properties on the processor:
| Property | Purpose |
|---|---|
bundlePath | Dot-path where extract() data is pushed in the agent bundle (e.g. 'api.actions', 'requirements'). Replaces manual merge() for leaf processors. |
promotesIdentity | When true, the tagβs id and title are promoted to the bundle root. Used by root document tags (feature, domain, role, etc.). |
aggregatePath | Name of the aggregate bundle this tag feeds (e.g. 'glossary', 'policies'). Items are collected automatically from all documents. |
aggregateIdKey | Key name for the source document ID in aggregate entries. Defaults to 'sourceId'. |
zodSchema | Tag classβs static Schema (e.g. Rule.Schema). Used by generate-schema.ts to auto-generate markdoc.config.mjs β no manual schema registration needed. |
isContainer | When true, the agent-pass walker descends into children after this processor fires. Without it, child tags with processors are silently skipped. |
sidebar | Declares where this document type appears in the public Starlight sidebar (section, group, weight). |
Recommendation: For complex tags, the processor should act as a thin adapter that delegates to a Core Model (see core/tags/). This encapsulates validation, tree traversal, and rendering logic in a testable class.
extract(file: string, node: Node, ast: Node, context: CompilerContext) { return new MyTagModel(node).extract();}The extract() signature
extract(file: string, node: Markdoc.Node, ast: Markdoc.Node, context: CompilerContext): T| Parameter | Description |
|---|---|
file | Relative content file path |
node | The matched Markdoc tag node |
ast | Full document AST root β use for cross-node lookups within the same file |
context | Compiler context β tracker, cross-document index maps, entity links |
The render() signature
render(fm: Frontmatter, ast: Markdoc.Node, data: T, context: CompilerContext): string| Parameter | Description |
|---|---|
fm | Parsed frontmatter of the current document |
ast | Full document AST root β use ast.children?.find(...) to locate nodes |
data | The value returned by extract() for this document |
context | Compiler context β tracker, cross-document index maps, entity links |
ast is the full document root, not the matched node. Processors navigate back to their node inside render via ast.children?.find(n => n.tag === this.tagName).
Bundle placement β how extract() data reaches the agent bundle
The agent pass determines where extract() data goes based on the processorβs declarative properties, checked in this order:
merge()β if the processor implementsmerge(), it is called directly. Use for top-level processors that own the entire bundle shape (feature, domain, role, story, milestone, manifest).bundlePathβ if set, data is pushed into the bundle at the specified dot-path viaObjectPath.push(bundle, bundlePath, data). Use for leaf processors that produce array items (requirement, criterion, policy, action, event, term).promotesIdentityβ if true, the tagβsidandtitleare promoted to the bundle root (already handled before merge/bundlePath).- Fallback β data fields are spread directly onto the bundle (legacy path).
Rule of thumb: if extract() returns an object to be spread into the top-level bundle, implement merge. If it returns a single item to append to an array, set bundlePath to the target array path.
Registering child tag processors
For child tags whose data is parsed by their parent processor and only need schema visibility in the editor, use the shorthand helpers instead of writing a full processor:
// Preferred β derives schema from the Tag domain classregisterTagClass(MyChildTag, 'Description of the child tag');
// Legacy β manual attribute mapregistry.register(schemaOnly('childtag', { name: { type: 'string', required: true } }, 'Description'));Graceful Validation β Compiler Pass Invariant
The compiler pass must never throw or crash due to validation errors. All validation β schema checks, required attributes, naming conventions, cross-document references β is graceful. Issues are collected by the ValidationTracker singleton (tracker) and reported after the pass completes. The pass always finishes processing all files.
This invariant applies to:
extract()implementations in processors β wrap suspicious attribute reads defensively and emit viatracker.addRuntimeIssue()instead of throwing- Aggregate-building utilities β sort comparators, map/filter helpers, and any post-processing over collected bundles must guard against
undefinedfields (e.g.(a.term ?? '').localeCompare(b.term ?? '')) passes/agent/index.tsβ the agent pass sorts, filters, and maps all bundles; never assume a field is present
How to record an issue without crashing:
tracker.addRuntimeIssue(file, 'Descriptive message', 'error', 'tag:tagname.attribute');| Parameter | Values |
|---|---|
file | Relative content file path |
message | Human-readable description |
severity | 'error' or 'warning' |
path | Dot-path of the failing attribute (used for deduplication in the lint workflow) |
Never use throw inside a processor or pass for validation failures β always use tracker.addRuntimeIssue.
Phase 4 β Public render design
Describe the Starlight page or page section:
- If child tag: what Markdown section does it render inside the parent page? (heading level, table vs prose vs list)
- If new document type: what is the full page structure? (frontmatter, metadata line, sections)
- If aggregated page: create an
AggregateProcessorinprocessors/aggregates/. Implementcollect(),render(), and optionallysidebarEntry(). Register it inprocessors/aggregates/index.ts. Then add a sidebar triplet instarspec.config.jsβ seestarspec/sidebar-configfor the full entry ID reference and configuration syntax.
Sketch the expected Markdown output before writing any code.
Phase 5 β Scope filtering checklist
How scope propagates
Before each pass, the compiler walks the full AST and propagates scope top-down:
function propagateScope(node, inherited) { const resolved = node.attributes?.scope ?? inherited; node.attributes.scope = resolved; for (const child of node.children ?? []) { propagateScope(child, resolved); }}A child tag inherits its parentβs scope unless it explicitly declares its own. Multi-scope is space-separated: scope="public agent". Scope functions live in core/scope.ts (re-exported via utils/markdoc.ts):
isScoped(node, targetScope)β generic check used byAstWalkervisitorsisPublicScoped(node)β shorthand forisScoped(node, 'public')isAgentScoped(node)β shorthand forisScoped(node, 'agent')
Scope values
| Value | Visible to |
|---|---|
public | Rendered Starlight documentation site |
agent | AI agent TOON bundles |
internal | Neither β compile-time only notes |
A node may carry multiple scopes: scope="public agent" makes content appear in both outputs.
Scope filtering checklist for processors
When writing render() or extract():
- Top-level gate: return
''(render) ornull(extract) if the primary tag node is missing or not scoped to the current pass - Child tag lists: filter with
isPublicScoped(n)before iterating β do not render agent-only child tags in the public pass collectβTextcalls: passtrueas the second argument to skip note and property prose from polluting descriptions- Silent early return is correct for an expected absence (e.g. an optional tag not present)
tracker.addRuntimeIssueis correct for an unexpected absence (e.g. a required tag missing from a doc type that guarantees it)
Phase 6 β Implementation order
Follow this order exactly. Do not write production code before the fixture and expected output are agreed:
types.tsβ add interface and extendDocTypeif neededvalidation.tsβ add extract schema derived from Tag class Schema (if the processor computes fields beyond raw attributes); extendDocTypeSchemaif new doc typecore/tags/{TagName}.tsβ create Core Model class extendingTagwith staticNAME,Schema, andextract()methodcore/tags/names.tsβ add the tag name to theTagNamesconst- Fixture β
packages/compiler/tests/fixtures/{type}/example.{type}.mdocInclude at least one agent-scoped and one public-scoped instance to exercise filtering processors/{tagname}.tsβ implementTagProcessorwithmatch,extract(4 params: file, node, ast, context),render, and declarative properties (bundlePathfor leaf tags,promotesIdentityfor root tags,zodSchemafor editor support). For simple child tags, useregisterTagClass()instead.- Register β import the new processor in both
passes/agent/index.tsandpasses/public/index.ts core/rendering/utils.tsβ add toTYPE_LABELSandtypeToDirif new top-level type- Tests β add describe blocks to
agent-pass.test.tsandpublic-pass.test.ts - Instruction β add
instructions/tag/{tagname}.mdocwith schema, attributes table, rules, and rendered output description - Aggregate (if needed) β create
processors/aggregates/{name}.aggregate.tsimplementingAggregateProcessor, register inprocessors/aggregates/index.ts
Phase 7 β Verification
Every compiler change must pass all three checks before it is considered done. Run them in order β each builds on the previous.
Step 1 β Type check and unit tests
# From starspec/pnpm --filter @starspec/compiler build # TypeScript compile β zero errors requiredpnpm test # agent-pass, public-pass, instructions-pass testsAll tests must pass. Do not proceed if any test fails.
Step 2 β End-to-end compile
# From starspec/pnpm compileBuilds the compiler then runs it against content/. Inspect at least one generated file for the affected type:
- Agent bundle:
dist/bundles/{type}/{id}.toonβ verify the new or changed field is present and correct - Public page:
src/content/docs/{type}/{id}.mdβ verify the rendered section looks right - Aggregate page (if applicable):
src/content/docs/{aggregate-name}.md
Step 3 β Regression check
# From starspec/pnpm buildConfirm:
src/summary.jsonreports zero validation issues (or only pre-existing ones known before your change)- The Starlight site builds without errors
Step 4 β Automated E2E Gallery Verification
For changes affecting the Component Gallery or Screenshot Export, run the automated E2E agent. (These tests use the *.e2e.ts naming convention and are excluded from standard unit tests).
# From starspec/pnpm test:e2eThis agent verifies:
- All download buttons have deterministic IDs
- Every component variant in the gallery can be successfully captured
- Grouped component tabs are correctly handled
- Both βChromeβ and βCleanβ export modes function as expected
For interactive debugging of visual regressions:
pnpm test:e2e:uiDoβs and Donβts
Do:
- Run all steps in order β each builds on the previous
- Inspect at least one generated file for the affected type after end-to-end compile
- Run the E2E agent when changes affect the Component Gallery or Screenshot Export
Donβt:
- Skip a step or proceed if any test fails
- Treat a passing TypeScript compile as sufficient without running end-to-end compile and site build
Definition of Done
- TypeScript compile and unit tests pass
- End-to-end compile produces correct output
- Starlight site builds without errors
If the MCP server is running (pnpm serve), call get_document on the new bundle type to verify it is queryable.
Phase 8 β Draft & confirm
Before writing any files, present:
- The tag schema (Phase 2)
- The agent bundle shape +
merge()decision (Phase 3) - A sketch of the rendered Markdown output (Phase 4)
- The list of files that will be created or modified
Wait for user confirmation before proceeding to Phase 6.
Documentation update checklist
Before closing any task that touches the compiler, confirm all of the following are up to date:
| Artefact | What to check |
|---|---|
instructions/tag/{tagname}.mdoc | Attribute table, example, and agent bundle shape match the processor and Tag class Schema |
instructions/architecture.mdoc | Updated if a new doc type, pass sub-module, or aggregate page was added |
instructions/adding-a-tag.mdoc | Updated if the processor pattern or TagProcessor interface itself changed |
instructions/fragments/tags/inventory.mdoc | New tag added to the inventory table |
markdoc.config.json | Regenerated via pnpm compile and contains new tag schemas |
packages/compiler/src/types.ts | JSDoc comments accurate; DocType enum current |
tests/fixtures/ | Fixture files reflect the current schema (rename/remove attributes updated) |
.vscode/starspec.code-snippets | New doc: or tag: snippet added with correct type prefixes and include file pattern scoping |
| Markdoc escaping | All {% / %} delimiters in instruction .mdoc files are escaped as \{% / %\} β in prose, inline code spans, and fenced code blocks (frontmatter is exempt) |
| Referential integrity | Every new cross-reference attribute has a tracker.addRefIssue() call in render(). pnpm compile produces zero new link issues against valid content. |
| E2E Agent | If the Component Gallery or Export changed, pnpm test:e2e passes |
The rule: an instruction doc out of sync with its processor is a bug. Agents and developers will act on the wrong schema.
Doβs and Donβts
Do:
- Verify every item in the checklist before closing a task
- Ensure all Markdoc delimiters are escaped in instruction files
- Run
pnpm test:e2ewhen Component Gallery or Export changed
Donβt:
- Close a task with instruction docs out of sync with their processors
- Leave un-escaped
{%/%}delimiters in instruction.mdocfiles - Skip referential integrity checks for new cross-reference attributes
Definition of Done
- All checklist items verified or marked not applicable