Makes docs/AGENTS.md section structure the single source of truth for the
primary-vs-advanced agent classification and fails when docs/INVENTORY.md's
"Primary doc" column or the AGENTS.md prose counts drift from it.
The classification ("primary" = full role card, "advanced stub" = concise
stub) was hand-duplicated across three doc surfaces with no enforcement.
This drift guard derives the expected class from AGENTS.md section placement
and cross-checks the INVENTORY.md column, the prose counts, the parenthetical
advanced-agent list, and full roster completeness (21 primary / 12 advanced
/ 0 inventory-only today).
No agent frontmatter field is added (avoids the capability-registry `tier`
collision and the research-profiles.cjs ripple); the classification stays
documentary, enforced from where it is defined.
Closes #1171
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
379 lines
14 KiB
JavaScript
379 lines
14 KiB
JavaScript
// allow-test-rule: runtime-contract-is-the-product — docs/AGENTS.md section layout + docs/INVENTORY.md table ARE the classification surface being validated
|
||
'use strict';
|
||
|
||
/**
|
||
* Agent classification parity test (#1171)
|
||
*
|
||
* Makes docs/AGENTS.md section structure the single source of truth for
|
||
* primary-vs-advanced agent classification, and fails when docs/INVENTORY.md
|
||
* or the AGENTS.md prose counts drift from it.
|
||
*
|
||
* Classification rules (derived from AGENTS.md section placement):
|
||
* - ### gsd-<name> headings BEFORE "## Advanced and Specialized Agents" → "primary"
|
||
* - ### gsd-<name> headings INSIDE/AFTER that section → "advanced stub"
|
||
* - agents/gsd-*.md files with NO heading in AGENTS.md → "inventory only"
|
||
*/
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const ROOT = path.resolve(__dirname, '..');
|
||
const AGENTS_MD = path.join(ROOT, 'docs', 'AGENTS.md');
|
||
const INVENTORY_MD = path.join(ROOT, 'docs', 'INVENTORY.md');
|
||
const AGENTS_DIR = path.join(ROOT, 'agents');
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Parsing helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Parse AGENTS.md and return two arrays:
|
||
* primaryHeadings — agent names (without "gsd-" prefix) before the advanced section
|
||
* advancedHeadings — agent names inside "## Advanced and Specialized Agents"
|
||
*
|
||
* We work with the full "gsd-<name>" slug so the names are unambiguous.
|
||
*
|
||
* State machine (three-state):
|
||
* 'before' — before "## Advanced and Specialized Agents"
|
||
* 'advanced' — inside that section
|
||
* 'past' — after a subsequent ## heading that follows the advanced section
|
||
*
|
||
* A "## " heading (h2, NOT h3) that appears BEFORE the advanced section does NOT
|
||
* trigger 'past'. Only a "## " heading encountered WHILE in 'advanced' does.
|
||
* ### gsd-* headings are counted: primary if 'before', advanced if 'advanced',
|
||
* ignored if 'past'.
|
||
*/
|
||
function parseAgentsMd(raw) {
|
||
const lines = raw.split('\n');
|
||
const ADVANCED_SECTION = /^##\s+Advanced and Specialized Agents\s*$/;
|
||
// Matches an h2 heading (exactly two hashes, not three or more)
|
||
const H2 = /^##(?!#)\s+\S/;
|
||
const GSD_H3 = /^###\s+(gsd-[\w-]+)\s*$/;
|
||
|
||
const primaryHeadings = [];
|
||
const advancedHeadings = [];
|
||
// 'before' | 'advanced' | 'past'
|
||
let state = 'before';
|
||
|
||
for (const line of lines) {
|
||
if (ADVANCED_SECTION.test(line)) {
|
||
state = 'advanced';
|
||
continue;
|
||
}
|
||
// Any other h2 heading while in 'advanced' terminates the advanced region
|
||
if (state === 'advanced' && H2.test(line)) {
|
||
state = 'past';
|
||
continue;
|
||
}
|
||
const m = GSD_H3.exec(line);
|
||
if (m) {
|
||
if (state === 'before') {
|
||
primaryHeadings.push(m[1]);
|
||
} else if (state === 'advanced') {
|
||
advancedHeadings.push(m[1]);
|
||
}
|
||
// state === 'past': silently ignored
|
||
}
|
||
}
|
||
|
||
return { primaryHeadings, advancedHeadings };
|
||
}
|
||
|
||
/**
|
||
* Parse INVENTORY.md agent table.
|
||
* Returns a Map<agentSlug, primaryDocValue> e.g. "primary" | "advanced stub" | "inventory only"
|
||
*
|
||
* Scoping: only rows that fall between the "## Agents" heading and the NEXT
|
||
* "## " heading are considered. This prevents a future non-agent table that
|
||
* happens to contain a "| gsd-..." row from polluting the result.
|
||
*
|
||
* Column resolution: the header row "| Agent | ... | Primary doc |" is parsed
|
||
* to find the 0-based index of the "Primary doc" column. Rows are split on "|"
|
||
* and only the leading/trailing empty edge cells are dropped (slice(1,-1)) so
|
||
* that empty middle cells do NOT shift column positions.
|
||
*/
|
||
function parseInventoryMd(raw) {
|
||
const lines = raw.split('\n');
|
||
// Matches any h2 heading (exactly two hashes, not three or more)
|
||
const H2 = /^##(?!#)\s+\S/;
|
||
// Matches the Agents section heading (e.g. "## Agents (33 shipped)")
|
||
const AGENTS_SECTION = /^##\s+Agents\b/;
|
||
|
||
const result = new Map();
|
||
let inAgentsSection = false;
|
||
let primaryDocColIndex = -1; // column index within the trimmed, edge-stripped cell array
|
||
|
||
for (const line of lines) {
|
||
if (AGENTS_SECTION.test(line)) {
|
||
inAgentsSection = true;
|
||
primaryDocColIndex = -1; // reset in case file is re-parsed
|
||
continue;
|
||
}
|
||
// Any subsequent h2 heading ends the agents section
|
||
if (inAgentsSection && H2.test(line)) {
|
||
break;
|
||
}
|
||
if (!inAgentsSection) continue;
|
||
|
||
// Every table row starts and ends with "|"
|
||
if (!line.startsWith('|')) continue;
|
||
|
||
// Split on "|", drop the leading and trailing empty strings that result
|
||
// from the leading/trailing "|", but preserve empty middle cells so column
|
||
// indices stay stable.
|
||
const rawCells = line.split('|');
|
||
// rawCells[0] is '' (before the first |), rawCells[last] is '' (after last |)
|
||
const cells = rawCells.slice(1, -1).map((c) => c.trim());
|
||
|
||
// Detect the header row by looking for an "Agent" cell followed by a "Primary doc" cell
|
||
if (primaryDocColIndex === -1) {
|
||
const pdIdx = cells.findIndex((c) => c === 'Primary doc');
|
||
if (pdIdx !== -1 && cells[0] === 'Agent') {
|
||
primaryDocColIndex = pdIdx;
|
||
}
|
||
continue; // header row (or rows before the header is found) — not a data row
|
||
}
|
||
|
||
// Skip separator rows (---|---|...)
|
||
if (cells.every((c) => /^[-: ]+$/.test(c))) continue;
|
||
|
||
// Data rows: first cell must be a gsd-* slug
|
||
if (!cells[0].startsWith('gsd-')) continue;
|
||
if (cells.length <= primaryDocColIndex) continue;
|
||
|
||
const agentSlug = cells[0];
|
||
const primaryDoc = cells[primaryDocColIndex];
|
||
result.set(agentSlug, primaryDoc);
|
||
}
|
||
|
||
assert.ok(
|
||
primaryDocColIndex !== -1,
|
||
'INVENTORY.md: "Primary doc" column header not found in the Agents table — check the ## Agents section heading and table header row',
|
||
);
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* List all agents/gsd-*.md basenames (without .md extension).
|
||
*/
|
||
function listAgentFiles() {
|
||
return fs
|
||
.readdirSync(AGENTS_DIR)
|
||
.filter((f) => /^gsd-.*\.md$/.test(f))
|
||
.map((f) => f.replace(/\.md$/, ''))
|
||
.sort();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Load and parse
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const rawAgentsMd = fs.readFileSync(AGENTS_MD, 'utf8');
|
||
const rawInventoryMd = fs.readFileSync(INVENTORY_MD, 'utf8');
|
||
|
||
const { primaryHeadings, advancedHeadings } = parseAgentsMd(rawAgentsMd);
|
||
const inventoryMap = parseInventoryMd(rawInventoryMd);
|
||
const agentFiles = listAgentFiles();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Robustness guards — must pass before any assertion block runs
|
||
// ---------------------------------------------------------------------------
|
||
|
||
assert.ok(
|
||
primaryHeadings.length > 0,
|
||
'AGENTS.md: no ### gsd-* headings found before Advanced section — raw head:\n' + rawAgentsMd.slice(0, 300),
|
||
);
|
||
|
||
assert.ok(
|
||
advancedHeadings.length > 0,
|
||
'AGENTS.md: no ### gsd-* headings found inside Advanced section — raw head:\n' + rawAgentsMd.slice(0, 300),
|
||
);
|
||
|
||
assert.ok(
|
||
inventoryMap.size > 0,
|
||
'INVENTORY.md: no | gsd-* | rows parsed — raw head:\n' + rawInventoryMd.slice(0, 300),
|
||
);
|
||
|
||
assert.ok(
|
||
agentFiles.length > 0,
|
||
'agents/: no gsd-*.md files found — check AGENTS_DIR path: ' + AGENTS_DIR,
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Derived sets
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const primarySet = new Set(primaryHeadings);
|
||
const advancedSet = new Set(advancedHeadings);
|
||
const agentFileSet = new Set(agentFiles);
|
||
|
||
// Agents that exist on disk but have no heading in AGENTS.md
|
||
const inventoryOnly = agentFiles.filter(
|
||
(a) => !primarySet.has(a) && !advancedSet.has(a),
|
||
);
|
||
const inventoryOnlySet = new Set(inventoryOnly);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('agent-classification-parity: AGENTS.md section structure is the single source of truth', () => {
|
||
|
||
/**
|
||
* Test 1 — INVENTORY.md "Primary doc" column matches AGENTS.md-derived class.
|
||
* For every agent, the column value must equal:
|
||
* "primary" if the agent's ### heading is before ## Advanced and Specialized Agents
|
||
* "advanced stub" if the agent's ### heading is inside/after that section
|
||
* "inventory only" if the agent has no ### heading in AGENTS.md
|
||
*/
|
||
test('INVENTORY.md "Primary doc" values match AGENTS.md section placement', () => {
|
||
const mismatches = [];
|
||
|
||
for (const [slug, inventoryValue] of inventoryMap) {
|
||
let expected;
|
||
if (primarySet.has(slug)) {
|
||
expected = 'primary';
|
||
} else if (advancedSet.has(slug)) {
|
||
expected = 'advanced stub';
|
||
} else if (inventoryOnlySet.has(slug)) {
|
||
expected = 'inventory only';
|
||
} else {
|
||
// Row in INVENTORY.md for an agent with no file — handled in test 3
|
||
continue;
|
||
}
|
||
|
||
if (inventoryValue !== expected) {
|
||
mismatches.push(` ${slug}: INVENTORY.md="${inventoryValue}" but AGENTS.md section says "${expected}"`);
|
||
}
|
||
}
|
||
|
||
assert.strictEqual(
|
||
mismatches.length,
|
||
0,
|
||
'INVENTORY.md "Primary doc" column disagrees with AGENTS.md section placement:\n' + mismatches.join('\n'),
|
||
);
|
||
});
|
||
|
||
/**
|
||
* Test 2 — AGENTS.md prose counts and parenthetical list are accurate.
|
||
*
|
||
* Checks three sub-facts from line ~13:
|
||
* a) "21 primary agents" → count of primary headings
|
||
* b) "Twelve additional" → count of advanced headings
|
||
* c) The parenthetical slug list → equals the set of advanced heading names
|
||
* (slugs without the "gsd-" prefix, as the prose uses)
|
||
*/
|
||
test('AGENTS.md prose counts and advanced-agent parenthetical list are accurate', () => {
|
||
|
||
// --- (a) prose primary count ---
|
||
const primaryCountMatch = rawAgentsMd.match(/\*\*(\d+)\s+primary\s+agents?\*\*/);
|
||
assert.ok(
|
||
primaryCountMatch,
|
||
'AGENTS.md: could not find "**N primary agents**" in prose — raw head:\n' + rawAgentsMd.slice(0, 500),
|
||
);
|
||
const prosePrimaryCount = parseInt(primaryCountMatch[1], 10);
|
||
assert.strictEqual(
|
||
prosePrimaryCount,
|
||
primaryHeadings.length,
|
||
`AGENTS.md prose says "${prosePrimaryCount} primary agents" but there are ${primaryHeadings.length} ### gsd-* headings before the Advanced section`,
|
||
);
|
||
|
||
// --- (b) prose advanced count (cardinal word or digit) ---
|
||
// Look for "Twelve additional" or "12 additional" (case-insensitive cardinal)
|
||
const CARDINALS = {
|
||
one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7,
|
||
eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13,
|
||
};
|
||
const advancedCountMatch = rawAgentsMd.match(
|
||
/\b((?:\d+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen))\s+additional\s+shipped\s+agents?\b/i,
|
||
);
|
||
assert.ok(
|
||
advancedCountMatch,
|
||
'AGENTS.md: could not find "N additional shipped agents" in prose — raw head:\n' + rawAgentsMd.slice(0, 500),
|
||
);
|
||
const advancedCountRaw = advancedCountMatch[1].toLowerCase();
|
||
const proseAdvancedCount = CARDINALS[advancedCountRaw] !== undefined
|
||
? CARDINALS[advancedCountRaw]
|
||
: parseInt(advancedCountRaw, 10);
|
||
assert.strictEqual(
|
||
proseAdvancedCount,
|
||
advancedHeadings.length,
|
||
`AGENTS.md prose says "${advancedCountRaw} additional shipped agents" but there are ${advancedHeadings.length} ### gsd-* headings in the Advanced section`,
|
||
);
|
||
|
||
// --- (c) parenthetical slug list ---
|
||
// The prose lists short slugs without "gsd-" prefix, e.g.:
|
||
// (pattern-mapper, debug-session-manager, ...)
|
||
const parenMatch = rawAgentsMd.match(/\(([^)]+)\)\s+have concise stubs/);
|
||
assert.ok(
|
||
parenMatch,
|
||
'AGENTS.md: could not find parenthetical advanced-agent list "(slug, slug, ...) have concise stubs" — raw head:\n' + rawAgentsMd.slice(0, 500),
|
||
);
|
||
const proseSlugs = parenMatch[1].split(',').map((s) => s.trim().toLowerCase());
|
||
const proseSlugsSet = new Set(proseSlugs);
|
||
|
||
// Derive expected slugs from AGENTS.md headings (strip "gsd-" prefix)
|
||
const expectedSlugs = new Set(advancedHeadings.map((h) => h.replace(/^gsd-/, '')));
|
||
|
||
const missingFromProse = [...expectedSlugs].filter((s) => !proseSlugsSet.has(s));
|
||
const extraInProse = [...proseSlugsSet].filter((s) => !expectedSlugs.has(s));
|
||
|
||
assert.deepStrictEqual(
|
||
{ missingFromProse, extraInProse },
|
||
{ missingFromProse: [], extraInProse: [] },
|
||
'AGENTS.md parenthetical slug list disagrees with ### headings in the Advanced section.\n' +
|
||
` Missing from prose: ${JSON.stringify(missingFromProse)}\n` +
|
||
` Extra in prose: ${JSON.stringify(extraInProse)}`,
|
||
);
|
||
});
|
||
|
||
/**
|
||
* Test 3 — Roster completeness.
|
||
*
|
||
* Sub-checks:
|
||
* a) Every agents/gsd-*.md appears exactly once in (primary ∪ advanced ∪ inventoryOnly)
|
||
* — i.e. no agent is double-classified.
|
||
* b) INVENTORY.md has a row for every agent file.
|
||
* c) INVENTORY.md has no row for a non-existent agent file.
|
||
* d) primary.length + advanced.length + inventoryOnly.length === total agent file count
|
||
*/
|
||
test('Roster completeness: every agent file is classified exactly once', () => {
|
||
|
||
// (a) no agent appears in more than one classification bucket
|
||
const overlap = primaryHeadings.filter((a) => advancedSet.has(a));
|
||
assert.deepStrictEqual(
|
||
overlap,
|
||
[],
|
||
'Agents appear in BOTH primary and advanced heading sections: ' + JSON.stringify(overlap),
|
||
);
|
||
|
||
// (b) INVENTORY.md has a row for every agent file
|
||
const missingFromInventory = agentFiles.filter((a) => !inventoryMap.has(a));
|
||
assert.deepStrictEqual(
|
||
missingFromInventory,
|
||
[],
|
||
'agents/gsd-*.md files missing from INVENTORY.md: ' + JSON.stringify(missingFromInventory),
|
||
);
|
||
|
||
// (c) INVENTORY.md has no row for a non-existent agent file
|
||
const phantomRows = [...inventoryMap.keys()].filter((slug) => !agentFileSet.has(slug));
|
||
assert.deepStrictEqual(
|
||
phantomRows,
|
||
[],
|
||
'INVENTORY.md rows reference agents that have no agents/gsd-*.md file: ' + JSON.stringify(phantomRows),
|
||
);
|
||
|
||
// (d) counts add up
|
||
const total = primaryHeadings.length + advancedHeadings.length + inventoryOnly.length;
|
||
assert.strictEqual(
|
||
total,
|
||
agentFiles.length,
|
||
`primary(${primaryHeadings.length}) + advanced(${advancedHeadings.length}) + inventoryOnly(${inventoryOnly.length}) = ${total} ≠ ${agentFiles.length} agent files`,
|
||
);
|
||
});
|
||
|
||
});
|