Skip to content
Open
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
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ domains/<area>/
knowledge/ # Optional: shared domain reference
```

Domain `knowledge/` is copied **beside every skill in the domain**, so an installed skill
body reaches it as `knowledge/<file>.md`. That is a different shape from this repo, where
`knowledge/` sits two levels above a skill. **Cite knowledge files by name, or by the
installed-relative path — never by a repo-relative one.** See
[Referring to domain knowledge from a skill](README.md#referring-to-domain-knowledge-from-a-skill).

### `skill.md` Format

Your `skill.md` should include YAML frontmatter plus body content:
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,30 @@ tools/
.targets.local.example # template for maintainer config
```

### Referring to domain knowledge from a skill

`knowledge/` is copied **beside every skill in the domain**, so the installed tree is
flatter than this repo's:

```
repo domains/<area>/knowledge/x.md domains/<area>/skills/<s>/skill.md
installed .claude/skills/mms-<s>/knowledge/x.md .claude/skills/mms-<s>/SKILL.md
```

A skill body therefore reaches its knowledge as **`knowledge/x.md`** once installed, but as
`../../knowledge/x.md` in the repo — and from a `references/` file the two are `../knowledge/x.md`
and `../../../knowledge/x.md`. **A repo-relative path is broken in the delivered output**, and
nothing reports it. Cite by name, or by the installed-relative form:

```markdown
See the `selector-anti-patterns` knowledge file. <!-- safe anywhere -->
See [x](knowledge/selector-anti-patterns.md) <!-- correct from an installed skill body -->
See [x](../../knowledge/selector-anti-patterns.md) <!-- repo-relative: 404 once installed -->
```

`test/cli.test.mjs` guards this: every `knowledge/…` reference in an emitted skill must
resolve on disk after install.

## Domains today

| Domain | Audience | Examples |
Expand Down
225 changes: 224 additions & 1 deletion test/cli.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -240,3 +240,226 @@ describe('managed skill pruning', () => {
assert.equal(existsSync(stale), true);
});
});

describe('installed knowledge references resolve', () => {
let root;
let source;
let target;

before(() => {
root = mkdtempSync(path.join(os.tmpdir(), 'mms-knowledge-refs-'));
source = path.join(root, 'source');
target = path.join(root, 'target');
mkdirSync(path.join(source, 'tools'), { recursive: true });
symlinkSync(INSTALL, path.join(source, 'tools', 'install'));
mkdirSync(target, { recursive: true });

// The fixture carries a real CONSUMER: a skill body that cites a knowledge file
// the way shipped skills actually do — skill-relative `knowledge/<file>`. A fixture
// without one cannot exhibit a layout regression, which is how MetaMask/skills#87
// shipped a change that stranded 12 such references while every test passed.
const knowledge = path.join(source, 'domains', 'testing', 'knowledge');
mkdirSync(knowledge, { recursive: true });
writeFileSync(path.join(knowledge, 'alpha.md'), '# Alpha\n');
const dir = path.join(source, 'domains', 'testing', 'skills', 'consumer');
mkdirSync(dir, { recursive: true });
writeFileSync(
path.join(dir, 'skill.md'),
[
'---',
'name: consumer',
'description: Cites a domain knowledge file',
'maturity: stable',
'---',
'Read [alpha](knowledge/alpha.md) before starting.',
].join('\n'),
);
});

after(() => {
rmSync(root, { recursive: true, force: true });
});

test('every knowledge/ reference in an emitted skill resolves on disk', () => {
const result = spawnSync(
'bash',
[INSTALL, '--target', target, '--repo', 'core', '--source', source],
{ encoding: 'utf8' },
Comment on lines +285 to +287
);
assert.equal(result.status, 0, result.stderr);

const emitted = [
['.claude/skills', 'mms-consumer', 'SKILL.md'],
['.cursor/rules', 'mms-consumer', 'RULE.md'],
['.agents/skills', 'mms-consumer', 'SKILL.md'],
];
for (const [base, name, file] of emitted) {
const skillDir = path.join(target, base, name);
const body = readFileSync(path.join(skillDir, file), 'utf8');
const refs = [...body.matchAll(/\]\((knowledge\/[\w.-]+)\)/gu)].map((m) => m[1]);
assert.ok(refs.length > 0, `${base}/${name}: expected a knowledge reference in the emitted body`);
Comment on lines +298 to +300
for (const ref of refs) {
assert.ok(
existsSync(path.join(skillDir, ref)),
`${base}/${name}: dangling knowledge reference ${ref} — the body cites it but install did not place it there`,
);
}
}
});
});

describe('corpus: knowledge citations resolve within their own domain', () => {
// `knowledge/` is copied per DOMAIN, so a skill can only cite files from its own
// domain's knowledge dir. A citation naming another domain's file can never resolve
// for any consumer or operator — the skill installs fine and the reference dangles.
//
// Known-unresolved, tracked separately; the list must only ever shrink. Each entry is
// a cross-domain citation of testing/knowledge/testing-layers.md, which the installer
// has no way to deliver into these domains.
const KNOWN_UNRESOLVED = new Set([
'domains/coding/skills/coding-guidelines/repos/metamask-mobile.md → knowledge/testing-layers.md',
'domains/perps/skills/perps-review-pr/skill.md → knowledge/testing-layers.md',
'domains/pr-workflow/skills/pr-guidelines/repos/metamask-mobile.md → knowledge/testing-layers.md',
'domains/pr-workflow/skills/pr-readiness-check/repos/metamask-mobile.md → knowledge/testing-layers.md',
]);

function collectCitations(domainsDir) {
const found = [];
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.isFile() && entry.name.endsWith('.md')) {
const rel = path.relative(path.join(domainsDir, '..'), full).split(path.sep).join('/');
if (!rel.includes('/skills/')) continue;
const domain = rel.split('/')[1];
const body = readFileSync(full, 'utf8');
for (const m of body.matchAll(/\]\((knowledge\/[\w.-]+\.md)\)|`(knowledge\/[\w.-]+\.md)`/gu)) {
const ref = m[1] || m[2];
found.push({ rel, domain, ref, key: `${rel} → ${ref}` });
}
}
}
};
walk(domainsDir);
return found;
}

test('no skill cites a knowledge file its own domain does not ship', () => {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const domainsDir = path.join(repoRoot, 'domains');
const unresolved = collectCitations(domainsDir).filter(
(c) => !existsSync(path.join(domainsDir, c.domain, c.ref)),
);

const unexpected = unresolved.filter((c) => !KNOWN_UNRESOLVED.has(c.key));
assert.deepEqual(
unexpected.map((c) => c.key),
[],
'new dangling knowledge citation(s) — a skill may only cite its own domain\'s knowledge',
);
});

test('the known-unresolved list has no stale entries', () => {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const domainsDir = path.join(repoRoot, 'domains');
const stillBroken = new Set(
collectCitations(domainsDir)
.filter((c) => !existsSync(path.join(domainsDir, c.domain, c.ref)))
.map((c) => c.key),
);
const fixed = [...KNOWN_UNRESOLVED].filter((k) => !stillBroken.has(k));
assert.deepEqual(fixed, [], 'these citations now resolve — remove them from KNOWN_UNRESOLVED');
});
});

describe('corpus: content is safe to publish and links stay current', () => {
function allSkillDocs() {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const domainsDir = path.join(repoRoot, 'domains');
const out = [];
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.isFile() && /\.(md|py|sh|ts|mjs)$/u.test(entry.name)) {
out.push({ rel: path.relative(repoRoot, full).split(path.sep).join('/'), body: readFileSync(full, 'utf8') });
}
}
};
walk(domainsDir);
return out;
}

// Who is running this, from whatever the environment knows. Short or generic values are
// dropped: a two-letter git username matches everywhere and would fail every file.
function whoAmI() {
const raw = [
process.env.GITHUB_ACTOR,
process.env.USER,
(() => {
const r = spawnSync('git', ['config', 'user.name'], { encoding: 'utf8' });
return r.status === 0 ? r.stdout.trim() : '';
})(),
];
const GENERIC = new Set(['root', 'runner', 'ubuntu', 'admin', 'user', 'ci', 'build', 'node']);
return [...new Set(raw.filter(Boolean).map((v) => v.trim()))]
.filter((v) => v.length >= 4 && !GENERIC.has(v.toLowerCase()) && !v.includes(' '));
}

// This repo is public. A personal path, handle, or private-repo name in a skill is
// both a leak and a dead reference for every reader but its author.
//
// The specific names are NOT listed here. A denylist of private identifiers, committed to a
// public repo, publishes every identifier it protects — the guard discloses what it guards,
// and this test previously named five. Structural patterns that describe a *shape* are safe
// and stay inline; anything that names a particular person, host or repo comes from the
// environment. Set SKILLS_PRIVATE_PATTERNS to a newline-separated list of regex sources
// (CI secret, or an untracked local file) to extend this locally.
test('no personal paths, handles, or private-repo references', () => {
const PERSONAL = [
[/(^|[\s"'`(])\/(home|Users)\/[a-z][a-z0-9_.-]*/u, 'absolute personal path'],
[/\bgit@[a-z0-9.-]+:[^\s]+/u, 'ssh remote'],
// Derived, not listed. The identifiers most likely to leak are the ones belonging to
// whoever is running — so ask the environment who that is instead of committing a
// denylist. In CI that is GITHUB_ACTOR; locally it is the git identity. This fires by
// default: an env-var-only version was inert everywhere, which is a check that cannot
// fail dressed as a check.
...whoAmI().map((who) => [
new RegExp(`\\b${who.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}\\b`, 'iu'),
'your own handle or identity',
]),
...(process.env.SKILLS_PRIVATE_PATTERNS ?? '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((src) => [new RegExp(src, 'u'), 'configured private identifier']),
];
const hits = [];
for (const { rel, body } of allSkillDocs()) {
body.split('\n').forEach((line, i) => {
for (const [re, label] of PERSONAL) {
if (re.test(line)) hits.push(`${rel}:${i + 1} (${label})`);
}
});
}
assert.deepEqual(hits, [], 'personal or private references must not ship in a public skill');
});

// A frozen branch is worse than a deleted one: the link loads, and the reader gets
// stale source with no signal. `metamask-extension` moved to `main`; `develop` still
// exists but stopped receiving commits in January 2026.
const FROZEN_BRANCHES = ['develop'];
test('no links into a known-frozen branch', () => {
const hits = [];
for (const { rel, body } of allSkillDocs()) {
body.split('\n').forEach((line, i) => {
for (const branch of FROZEN_BRANCHES) {
const re = new RegExp(`github\\.com/[^\\s)]+/(blob|tree)/${branch}/`, 'u');
if (re.test(line)) hits.push(`${rel}:${i + 1} (→ ${branch})`);
}
});
}
assert.deepEqual(hits, [], 'link points into a frozen branch — use the repo\'s default branch, or pin a SHA');
});
});