Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
slug: codeassembly-content-specification
description: The declaration contract for CodeAssembly skills, subagents, rulebooks, and collections -- frontmatter fields, dependency blocks, and invocation tokens.
delivery: skill
version: 6
version: 7
---

# CodeAssembly content specification
Expand All @@ -13,7 +13,7 @@ The declaration contract for CodeAssembly artifacts -- skills, subagents, rulebo

Every rule below belongs to one of three classes, marked where it appears.

**Validated on parse.** A malformed `slug` or `skill-name`, a `delivery` value outside `ambient`/`skill`, an unknown artifact-type key, a non-list value under one, and a `members:` block on anything but a collection each fail the run with an error naming the source file. Four more fail outside the parser: a token naming an artifact that does not exist fails the run with an error naming the slug and the directories searched, a rulebook link target outside a linkable root fails the run before anything is written, a rulebook token naming a target that deploys no skill to invoke fails the same pre-write pass, and a harness that declares no sigil is a type error at its `HarnessConfig` literal, so the build fails.
**Validated on parse.** A malformed `slug` or `skill-name`, a `delivery` value outside `ambient`/`skill`, an unknown artifact-type key, a non-list value under one, and a `members:` block on anything but a collection each fail the run with an error naming the source file. Six more fail outside the parser: a token naming an artifact that does not exist fails the run with an error naming the slug and the directories searched, a rulebook link target outside a linkable root fails the run before anything is written, a rulebook token naming a target that deploys no skill to invoke fails the same pre-write pass, an anchor-only link target that names no heading in its own body fails wherever that body is rendered or shipped, so does a code fence nothing closes, and a harness that declares no sigil is a type error at its `HarnessConfig` literal, so the build fails.

**Enforced by test.** The suites in `packages/agents/src/__tests__/` read the shipped library and assert its conventions hold. A rule one of them guards names its test.

Expand Down Expand Up @@ -59,10 +59,22 @@ A rulebook may link only into `skills/` and `scripts/`, the two trees whose sour

A link to a sibling rulebook is rejected too, and its error names the `{rulebook:<slug>}` token that addresses it instead. A rulebook is invoked rather than read: the skill it deploys is discovered by name, so an invocation resolves wherever it was deployed, while a path would be right in one domain and dead in the other. _(Validated on parse.)_

A target that is rooted correctly but names a file that has moved or been deleted is caught separately, by `content-link-resolution.test.ts`, which also resolves every anchor fragment to exactly one heading. _(Enforced by test.)_
A target that is rooted correctly but names a file that has moved or been deleted is caught separately, by `content-link-resolution.test.ts`, which also resolves a fragment carried on such a target to exactly one heading in the file it points into. _(Enforced by test.)_

One limitation is worth knowing before writing a rulebook that documents linking: rewriting runs over the whole body, so a Markdown link inside a code fence or an inline code span is rewritten along with the rest. A rulebook cannot show a relative link verbatim as an example, and must describe the target instead. Invocation tokens rewrite the same way, so an example token keeps the `<slug>` placeholder rather than naming a real artifact.

## Anchor links

An anchor-only link addresses the body it appears in, so its fragment must name exactly one heading there. Naming none fails the run, and so does naming two: a locator that resolves by accident is not a locator. The rule covers every rulebook, skill, and subagent, and the guidance files `install` ships. _(Validated on parse.)_

Where the pipeline expands includes -- skills, subagents, and harness guidance -- the body checked is the expanded one, so an anchor authored in a `_partials/` file resolves against each artifact that inlines it, and the error names that artifact rather than the partial. A rulebook body and a shared guidance file are checked as authored, since neither inlines a partial.

Frontmatter, fenced code blocks (backtick or tilde), and inline code spans are exempt on both sides: a heading inside one offers no anchor, and a link inside one requests none. A code span _within_ a heading is the opposite case: it is part of that heading's text, so the heading still anchors, with the backticks dropped as punctuation -- ``### The `respond-to-review` path`` answers to `#the-respond-to-review-path`. An indented code block is not exempt, because telling one from a nested list item would take block-level parsing, so show an example anchor in a fence or a code span. An anchor-only target is never rewritten, so unlike a relative one it survives either intact.

A fence nothing closes fails the run in its own right. Everything below it reads as code, so no anchor there can be checked, and a silent pass over an unchecked remainder is worse than a rejection. A closing fence repeats the opening character at least as many times, which is the rule a four-backtick example wrapping a three-backtick one depends on. _(Validated on parse.)_

A heading carrying a token cannot be anchored: it renders to a different slug on each harness, so no one fragment reaches it everywhere. Give such a heading a token-free title where a link must address it. _(Validated on parse.)_

## Collections

A collection's only payload is a `members:` block -- the constituents it pulls into the deployed closure. List them per type (the same shape `dependencies:` uses), or use the computed token `'@library'` for every rulebook, skill, and subagent in the content root the collection belongs to -- the built-in library, or the owning source for a source collection:
Expand Down
222 changes: 110 additions & 112 deletions packages/agents/src/__tests__/content-link-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path';

import { beforeAll, describe, expect, it } from 'vitest';

import { collectHeadingSlugs, findUnterminatedFence, normalizeForAnchorScan } from '../lib/anchor-resolution.ts';
import { expandIncludes } from '../lib/directive-expander.ts';
import type { RulebookInvocationCatalog } from '../lib/invocation-tokens.ts';
import { isRewritableLinkTarget, MARKDOWN_LINK_REGEX } from '../lib/path-rewriter.ts';
Expand All @@ -21,6 +22,16 @@ import { renderRulebookBody } from '../lib/rulebook-transform.ts';
// file it points into. Skills reach their inlined output-shaping specs through in-file anchors, so an unvalidated
// fragment is a dead locator repeated across every consumer.
//
// A host that leaves a code fence open is reported before either check runs. Everything below such a fence is read as
// code, so the links and headings the host appears to carry are not the ones it carries, and a clean result over it
// would be indistinguishable from a checked one. The render gate throws on the same condition.
//
// The render pass rejects a same-body anchor on its own, over content from any source. This suite still covers it,
// because it reports every violation across the tree at once where the render pass throws on the first artifact, and
// because a fragment on a cross-file target is checked nowhere else: the deployed tree unions library content with
// each declared source's, so the render pass cannot resolve one from the content root at hand. Both share one slug
// algorithm through `anchor-resolution`.
//
// Host roots only. A `_partials/` file is never installed standalone, and its links are authored against the host that
// inlines it — checking one in isolation would misresolve every `../` it carries. Include expansion below reaches them
// through each host, which is the only context where they mean anything.
Expand All @@ -41,26 +52,89 @@ const HOST_ROOTS: ReadonlyArray<string> = [RULEBOOK_ROOT, 'skills', 'subagents']

const CONTENT_ROOT = new URL('../../content/', import.meta.url).pathname;

const HEADING_REGEX = /^#{1,6}\s+(.+?)\s*$/gm;

type Reason = 'ambiguous-anchor' | 'dead-anchor' | 'missing-file';
type Reason = 'ambiguous-anchor' | 'dead-anchor' | 'missing-file' | 'unterminated-fence';

interface Violation {
readonly file: string;
readonly target: string;
readonly reason: Reason;
}

/** Counts each heading slug in a body, so a fragment matching two headings is reported rather than silently resolved. */
function collectHeadingSlugs(body: string): ReadonlyMap<string, number> {
const counts = new Map<string, number>();
for (const match of body.matchAll(HEADING_REGEX)) {
const slug = slugify(match[1] ?? '');
counts.set(slug, (counts.get(slug) ?? 0) + 1);
describe('installable-content link resolution', () => {
let violations: ReadonlyArray<Violation> = [];

beforeAll(async () => {
violations = await findViolations();
});

it('every relative Markdown link in an installable host resolves to a real file', () => {
const missing = violations.filter((v) => v.reason === 'missing-file');
expect(missing, formatViolations(missing)).toEqual([]);
});

it('every anchor fragment resolves to exactly one heading in the file it points into', () => {
const anchors = violations.filter((v) => v.reason === 'ambiguous-anchor' || v.reason === 'dead-anchor');
expect(anchors, formatViolations(anchors)).toEqual([]);
});

it('no installable host leaves a code fence open, which would hide every anchor below it', () => {
const fences = violations.filter((v) => v.reason === 'unterminated-fence');
expect(fences, formatFenceViolations(fences)).toEqual([]);
});
});

describe('shipped rulebook reference deliverability', () => {
it('every rulebook link target and invocation token names something that deploys', async () => {
const rejections = await findRulebookRejections();
expect(rejections, rejections.join('\n')).toEqual([]);
});
});

/**
* Renders every shipped rulebook the way `sync` does, collecting the error from each that names an undeliverable link
* target or an unusable `{rulebook:<slug>}` token. Every shipped rulebook stands in for the deployed set, which is the
* strictest catalog available here: a token naming one that is missing or ambient-only has nothing to invoke under any
* declaration. The root allowlist and the catalog are both harness-invariant, so one harness context stands for all.
*/
async function findRulebookRejections(): Promise<ReadonlyArray<string>> {
const rulebookFiles: Array<string> = [];
await collectHostFiles(path.join(CONTENT_ROOT, RULEBOOK_ROOT), rulebookFiles);

const parsed = await Promise.all(
rulebookFiles.map(async (file) => {
const slug = path.basename(file, '.md');
const { rulebook, body } = parseRulebookFile(await readFile(file, 'utf8'), `${slug}.md`);
return {
slug,
body,
skillName: resolveSkillName(slug, rulebook['skill-name']),
skill: rulebook.delivery.includes('skill'),
};
}),
);
const rulebooks: RulebookInvocationCatalog = new Map(
parsed.map(({ slug, skillName, skill }) => [slug, { skillName, skill }]),
);

const rejections: Array<string> = [];
for (const { slug, body } of parsed) {
try {
renderRulebookBody(body, slug, {
homeDir: '.claude',
harnessId: 'claude',
skillSigil: '/',
subagentSigil: '',
rulebooks,
});
} catch (error) {
rejections.push(error instanceof Error ? error.message : String(error));
}
}
return counts;
return rejections;
}

// region | Helpers

/** Recursively collects installable host `.md` files, skipping `_partials/` at any depth and dotfiles. */
async function collectHostFiles(dir: string, out: Array<string>): Promise<void> {
for (const entry of await readdir(dir, { withFileTypes: true })) {
Expand All @@ -87,9 +161,19 @@ async function findViolations(): Promise<ReadonlyArray<Violation>> {
const violations: Array<Violation> = [];

for (const hostFile of hostFiles) {
const body = stripFencedBlocks(await expandIncludes(hostFile, CONTENT_ROOT));
const expanded = await expandIncludes(hostFile, CONTENT_ROOT);
const file = path.relative(CONTENT_ROOT, hostFile);

// Everything below an open fence is blanked, so the anchors and links this host appears to carry are not the ones
// it carries. Reporting the fence and moving on matches the order the render gate uses for the same reason.
const unterminated = findUnterminatedFence(expanded);
if (unterminated !== undefined) {
violations.push({ file, target: unterminated, reason: 'unterminated-fence' });
continue;
}

const body = normalizeForAnchorScan(expanded);

for (const match of body.matchAll(MARKDOWN_LINK_REGEX)) {
// The rewriter's own set, plus anchor-only targets: those name no file to rewrite, but they do name a fragment
// this test resolves against the host's own headings.
Expand Down Expand Up @@ -127,6 +211,19 @@ async function findViolations(): Promise<ReadonlyArray<Violation>> {
return violations;
}

/** Renders the unterminated-fence violations, whose subject is a fence rather than a link target. */
function formatFenceViolations(violations: ReadonlyArray<Violation>): string {
if (violations.length === 0) {
return '';
}
const header =
`Found ${violations.length} unterminated code fence(s). Everything below an open fence reads as code, so no ` +
`anchor there is checked and a clean result over it carries no information. A closing fence repeats the opening ` +
`character at least as many times.`;
const lines = violations.map((v) => ` [${v.reason}] ${v.file}: opened with ${v.target}`);
return [header, ...lines].join('\n');
}

function formatViolations(violations: ReadonlyArray<Violation>): string {
if (violations.length === 0) {
return '';
Expand All @@ -148,108 +245,9 @@ async function readHeadingSlugs(
if (cached !== undefined) {
return cached;
}
const slugs = collectHeadingSlugs(stripFencedBlocks(await expandIncludes(file, CONTENT_ROOT)));
const slugs = collectHeadingSlugs(normalizeForAnchorScan(await expandIncludes(file, CONTENT_ROOT)));
cache.set(file, slugs);
return slugs;
}

/**
* Derives a heading's anchor the way GitHub does: lowercase, drop everything but letters, numbers, spaces, and
* hyphens, then map each remaining space to a hyphen. Runs of spaces are preserved rather than collapsed — stripping
* punctuation between two spaces is what yields the double hyphen in an anchor such as `#finding-scheme-fwtrs--legacy-suffix`.
*/
function slugify(heading: string): string {
return heading
.trim()
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}\s-]/gu, '')
.trim()
.replaceAll(' ', '-');
}

/**
* Blanks fenced code blocks. A fence illustrates output rather than declaring it, so a link or heading inside one is a
* sample, not a target: `review-branch` prints a `## Specification consistency` heading inside its output-format fence,
* which a naive scan would offer as a real anchor.
*/
function stripFencedBlocks(content: string): string {
let inFence = false;
return content
.split('\n')
.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence;
return '';
}
return inFence ? '' : line;
})
.join('\n');
}

describe('installable-content link resolution', () => {
let violations: ReadonlyArray<Violation> = [];

beforeAll(async () => {
violations = await findViolations();
});

it('every relative Markdown link in an installable host resolves to a real file', () => {
const missing = violations.filter((v) => v.reason === 'missing-file');
expect(missing, formatViolations(missing)).toEqual([]);
});

it('every anchor fragment resolves to exactly one heading in the file it points into', () => {
const anchors = violations.filter((v) => v.reason !== 'missing-file');
expect(anchors, formatViolations(anchors)).toEqual([]);
});
});

describe('shipped rulebook reference deliverability', () => {
it('every rulebook link target and invocation token names something that deploys', async () => {
const rejections = await findRulebookRejections();
expect(rejections, rejections.join('\n')).toEqual([]);
});
});

/**
* Renders every shipped rulebook the way `sync` does, collecting the error from each that names an undeliverable link
* target or an unusable `{rulebook:<slug>}` token. Every shipped rulebook stands in for the deployed set, which is the
* strictest catalog available here: a token naming one that is missing or ambient-only has nothing to invoke under any
* declaration. The root allowlist and the catalog are both harness-invariant, so one harness context stands for all.
*/
async function findRulebookRejections(): Promise<ReadonlyArray<string>> {
const rulebookFiles: Array<string> = [];
await collectHostFiles(path.join(CONTENT_ROOT, RULEBOOK_ROOT), rulebookFiles);

const parsed = await Promise.all(
rulebookFiles.map(async (file) => {
const slug = path.basename(file, '.md');
const { rulebook, body } = parseRulebookFile(await readFile(file, 'utf8'), `${slug}.md`);
return {
slug,
body,
skillName: resolveSkillName(slug, rulebook['skill-name']),
skill: rulebook.delivery.includes('skill'),
};
}),
);
const rulebooks: RulebookInvocationCatalog = new Map(
parsed.map(({ slug, skillName, skill }) => [slug, { skillName, skill }]),
);

const rejections: Array<string> = [];
for (const { slug, body } of parsed) {
try {
renderRulebookBody(body, slug, {
homeDir: '.claude',
harnessId: 'claude',
skillSigil: '/',
subagentSigil: '',
rulebooks,
});
} catch (error) {
rejections.push(error instanceof Error ? error.message : String(error));
}
}
return rejections;
}
// endregion | Helpers
32 changes: 18 additions & 14 deletions packages/agents/src/__tests__/content-path-conventions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ interface Violation {
readonly text: string;
}

describe('installable-content path conventions', () => {
it(`no installable Markdown file outside the allowlist contains a raw \`${FORBIDDEN_PATTERN}\` reference`, async () => {
const violations = await findViolations();
expect(violations, formatViolations(violations)).toEqual([]);
});

it('every allowlist entry resolves to a real file', async () => {
const files: Array<string> = [];
await collectMarkdownFiles(CONTENT_ROOT, CONTENT_ROOT, files);
const present = new Set(files.map((f) => f.split(path.sep).join('/')));
const missing = ALLOWLIST.filter((entry) => !present.has(entry));
expect(missing, `Stale allowlist entries (file no longer exists): ${missing.join(', ')}`).toEqual([]);
});
});

// region | Helpers

async function collectMarkdownFiles(dir: string, root: string, out: Array<string>): Promise<void> {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
Expand Down Expand Up @@ -75,17 +92,4 @@ function formatViolations(violations: ReadonlyArray<Violation>): string {
return [header, ...lines].join('\n');
}

describe('installable-content path conventions', () => {
it(`no installable Markdown file outside the allowlist contains a raw \`${FORBIDDEN_PATTERN}\` reference`, async () => {
const violations = await findViolations();
expect(violations, formatViolations(violations)).toEqual([]);
});

it('every allowlist entry resolves to a real file', async () => {
const files: Array<string> = [];
await collectMarkdownFiles(CONTENT_ROOT, CONTENT_ROOT, files);
const present = new Set(files.map((f) => f.split(path.sep).join('/')));
const missing = ALLOWLIST.filter((entry) => !present.has(entry));
expect(missing, `Stale allowlist entries (file no longer exists): ${missing.join(', ')}`).toEqual([]);
});
});
// endregion | Helpers
Loading
Loading