Files
ECC/tests/commands/learn-skill-discovery.test.js
T
haelyraandGitHub 85c7822c4a fix(commands): generate discoverable skills from learning workflows (#2653)
Carry #2246 forward on current main with native SKILL.md discovery, guarded writes, portable metadata, and fail-closed validation.
2026-08-02 14:39:29 -04:00

201 lines
8.6 KiB
JavaScript

'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..', '..');
const commandNames = ['learn', 'learn-eval', 'skill-create'];
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` PASS ${name}`);
passed++;
} catch (error) {
console.log(` FAIL ${name}`);
console.log(` Error: ${error.message}`);
failed++;
}
}
function readCommand(name) {
return fs.readFileSync(path.join(repoRoot, 'commands', `${name}.md`), 'utf8');
}
function extractGeneratedSkillTemplate(source) {
const match = source.match(/```markdown\r?\n(---\r?\n[\s\S]*?\r?\n---[\s\S]*?)\r?\n```/);
return match ? match[1] : '';
}
function extractVerification(source) {
const match = source.match(/\*\*Verify discoverability[^\n]*\*\*|\*\*Verification[^\n]*\*\*/i);
return match ? source.slice(match.index, match.index + 3000) : '';
}
function extractGuardedWrite(source) {
const marker = 'guarded-write requirements:';
const index = source.indexOf(marker);
return index >= 0 ? source.slice(index, index + 1800) : '';
}
function getWriteInstructionLines(source) {
const lines = source.split(/\r?\n/);
const selected = new Set();
lines.forEach((line, index) => {
if (!/\b(create|write|save)\b/i.test(line)) return;
for (let offset = 0; offset <= 3 && index + offset < lines.length; offset++) {
selected.add(index + offset);
}
});
return Array.from(selected)
.sort((left, right) => left - right)
.map(index => lines[index])
.join('\n');
}
function getTopLevelFrontmatterKeys(template) {
const frontmatter = template.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!frontmatter) return [];
return frontmatter[1]
.split(/\r?\n/)
.filter(line => /^\S[^:]*:/.test(line))
.map(line => line.slice(0, line.indexOf(':')));
}
console.log('\n=== Testing generated skill discoverability ===\n');
for (const name of commandNames) {
test(`/${name} generates a directory-based SKILL.md`, () => {
const source = readCommand(name);
const writeInstructions = getWriteInstructionLines(source);
const requiredWritePaths = {
learn: /~\/\.claude\/skills\/<pattern-name>\/SKILL\.md/,
'learn-eval': /<location>\/<pattern-name>\/SKILL\.md/,
'skill-create': /<output-dir>\/<skill-name>\/SKILL\.md/,
};
assert.match(
writeInstructions,
requiredWritePaths[name],
`Expected /${name} write instructions to require a <name>/SKILL.md path`,
);
assert.doesNotMatch(
writeInstructions,
/skills\/learned\/(?:\[[^\]]*name[^\]]*\]|<[^>]*name[^>]*>|\{[^}]*name[^}]*\})\.md/i,
`Expected /${name} not to instruct writing a flat learned skill file`,
);
});
test(`/${name} uses trigger-first generated skill metadata`, () => {
const template = extractGeneratedSkillTemplate(readCommand(name));
assert.match(template, /^---\r?\n/, `Expected /${name} template to start with frontmatter`);
assert.match(template, /\r?\n---(?:\r?\n|$)/, `Expected /${name} template to close frontmatter`);
assert.match(template, /^name:\s*\S+/m, `Expected /${name} template to define name`);
assert.match(
template,
/^description:\s*["']?Use when\b.+/m,
`Expected /${name} to generate a description beginning with "Use when"`,
);
assert.doesNotMatch(template, /^origin:/m, `Expected /${name} not to emit unsupported origin frontmatter`);
const portableKeys = new Set(['name', 'description', 'license', 'compatibility', 'metadata', 'allowed-tools']);
const unsupportedKeys = getTopLevelFrontmatterKeys(template).filter(key => !portableKeys.has(key));
assert.deepStrictEqual(unsupportedKeys, [], `Expected /${name} to emit portable Agent Skills frontmatter`);
assert.match(template, /^metadata:\r?\n(?: {2}.+\r?\n?)+/m, `Expected /${name} to nest provenance under metadata`);
});
test(`/${name} verifies discoverability and fails closed`, () => {
const verification = extractVerification(readCommand(name));
assert.ok(verification, `Expected /${name} to include an explicit discoverability check`);
assert.match(verification, /SKILL\.md/, `Expected /${name} to verify the entrypoint name`);
assert.match(verification, /---/, `Expected /${name} to verify frontmatter delimiters`);
assert.match(verification, /valid YAML|parseable YAML/i, `Expected /${name} to verify valid YAML`);
assert.match(verification, /name:/, `Expected /${name} to verify the frontmatter name`);
assert.match(verification, /description:/, `Expected /${name} to verify the description`);
assert.match(verification, /Use when/, `Expected /${name} to verify a trigger-first description`);
assert.match(verification, /remove|quarantine/i, `Expected /${name} to handle invalid output`);
assert.match(verification, /fresh\s+explicit\s+approval/i, `Expected /${name} to re-approve repaired output`);
assert.match(verification, /stop[^.]*success|do not\s+report\s+success/i, `Expected /${name} to fail closed`);
});
test(`/${name} guards generated skill writes`, () => {
const guardedWrite = extractGuardedWrite(readCommand(name));
assert.ok(guardedWrite, `Expected /${name} to define guarded-write requirements`);
assert.match(guardedWrite, /redact[^.]*secrets[^.]*PII/is, `Expected /${name} to redact sensitive content`);
assert.match(guardedWrite, /exclude[^.]*prompt-injection[^.]*untrusted\s+instructions/is, `Expected /${name} to exclude unsafe instructions`);
assert.match(guardedWrite, /validate[\s\S]*?slug[\s\S]*?reject path\s+separators[\s\S]*?path traversal/i, `Expected /${name} to reject unsafe names`);
assert.match(guardedWrite, /resolve[\s\S]*?inside[\s\S]*?approved (?:skill|export) root/i, `Expected /${name} to confine the resolved target`);
assert.match(guardedWrite, /already exists[^.]*show the diff[^.]*explicit overwrite\s+approval/is, `Expected /${name} to protect existing skills`);
assert.match(guardedWrite, /require explicit\s+approval[^.]*persistence/is, `Expected /${name} to approve content before persistence`);
});
}
test('/skill-create uses one skill-name for the directory and frontmatter', () => {
const source = readCommand('skill-create');
const template = extractGeneratedSkillTemplate(source);
assert.match(source, /skill-name[^\n]*default[^\n]*\{repo-name\}-patterns/i);
assert.match(source, /<output-dir>\/<skill-name>\/SKILL\.md/);
assert.match(template, /^name:\s*\{skill-name\}$/m);
});
test('/skill-create does not call an arbitrary custom output discoverable', () => {
const source = readCommand('skill-create');
assert.match(source, /custom[^\n]*--output|--output[^\n]*custom/i);
assert.match(source, /configured skill root/i);
assert.match(source, /export-only/i);
assert.match(source, /do not report[^.]*discoverab/i);
});
test('/skill-create normalizes repository names before path validation', () => {
const source = readCommand('skill-create');
assert.match(source, /lowercase[\s\S]*?replace[\s\S]*?spaces[\s\S]*?underscores[\s\S]*?path separators/i);
assert.match(source, /trim[^.]*hyphens[^.]*append[^.]*-patterns/is);
assert.match(source, /My Repo_API\/Client[\s\S]*?my-repo-api-client-patterns/);
assert.match(source, /validate the\s+final[^.]*skill-name/i);
});
test('/skill-create validates safely before replacing an existing skill', () => {
const source = readCommand('skill-create');
const verification = extractVerification(source);
assert.match(verification, /temporary\s+sibling/i);
assert.match(verification, /validate[^.]*before[^.]*replace/is);
assert.match(verification, /atomically\s+replace/i);
assert.match(verification, /leave[^.]*existing[^.]*unchanged/is);
});
test('/learn-eval treats comparison files as untrusted', () => {
const guardedWrite = extractGuardedWrite(readCommand('learn-eval'));
assert.match(guardedWrite, /MEMORY\.md/);
assert.match(guardedWrite, /\.claude\/skills/);
assert.match(guardedWrite, /never follow[^.]*instructions/i);
});
test('generated templates keep provenance values under metadata', () => {
for (const name of ['learn', 'learn-eval']) {
const template = extractGeneratedSkillTemplate(readCommand(name));
assert.match(template, /^metadata:\r?\n {2}origin: auto-extracted$/m);
}
const skillCreateTemplate = extractGeneratedSkillTemplate(readCommand('skill-create'));
assert.match(skillCreateTemplate, /^metadata:\r?\n {2}version: "1\.0\.0"\r?\n {2}source: local-git-analysis\r?\n {2}analyzed_commits: "\{count\}"$/m);
});
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);