Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
85 changes: 85 additions & 0 deletions .github/scripts/check-public-refs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env node
//
// Every repository named in this repo must be fetchable by an anonymous reader.
//
// This repo is public. Naming a repository here discloses that it exists, who owns it and
// roughly what is in it — and a prohibition discloses exactly as much as a recommendation:
// "do not re-host to acme/secret-notes, it is private" publishes the name either way. So the
// rule is about the mention, not the sentiment attached to it.
//
// The check is a request, not a list. An owner allowlist looked cheaper and was wrong on its
// first run: it cleared nothing useful and flagged `nock/nock` and `phishfort/phishfort-lists`,
// because "is this owner well known" is not the property that matters. The property is whether
// a reader who is not you can open the link — which an unauthenticated request answers exactly.
// 404 means private or absent; both are unresolvable for a public reader, and both are defects.
//
// Deliberately unauthenticated: a token would see private repos and pass them, which is the
// failure this exists to prevent.
//
// 0 every referenced repository resolves anonymously
// 1 one or more do not
// 2 could not run (offline) — reported, not silently passed
import { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = process.env.SKILLS_LINT_ROOT
? path.resolve(process.env.SKILLS_LINT_ROOT)
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');

// `orgs/`, `sponsors/` and friends are github.com paths that are not repositories.
const NOT_A_REPO = new Set(['orgs', 'sponsors', 'users', 'settings', 'apps', 'topics', 'features', 'pricing']);
// Org-internal repos are private to the public but readable by colleagues, and naming them is a
// deliberate call: they are load-bearing context for the audience this repo is written for. The
// rule being enforced is about *personal* repos, which are unreachable by colleagues too.
const INTERNAL_OWNERS = new Set(['MetaMask', 'Consensys']);
// Template placeholders in contributor docs are meant to be substituted, not resolved.
const PLACEHOLDER = /^(YOUR|MY|<|\$\{)/u;
const REPO_REF = /https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*)/gu;
const TEXT = /\.(md|sh|py|mjs|js|ya?ml|json|tsx?)$/u;

function walk(dir, out = []) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === '.git' || e.name === 'node_modules') continue;
const full = path.join(dir, e.name);
if (e.isDirectory()) walk(full, out);
else if (TEXT.test(e.name)) out.push(full);
}
return out;
}

const refs = new Map(); // "owner/repo" -> Set of relative paths
for (const file of walk(ROOT)) {
let text;
try { text = readFileSync(file, 'utf8'); } catch { continue; }
for (const [, owner, repo] of text.matchAll(REPO_REF)) {
if (NOT_A_REPO.has(owner) || INTERNAL_OWNERS.has(owner) || PLACEHOLDER.test(owner)) continue;
const key = `${owner}/${repo.replace(/\.git$/u, '')}`;
if (!refs.has(key)) refs.set(key, new Set());
refs.get(key).add(path.relative(ROOT, file));
}
}

if (refs.size === 0) { console.log('check-public-refs: no repository references found'); process.exit(0); }

let bad = 0, unknown = 0;
for (const [key, files] of [...refs].sort()) {
let status;
try {
const res = await fetch(`https://github.com/${key}`, { method: 'HEAD', redirect: 'follow' });
status = res.status;
} catch {
console.error(` ???? ${key} — request failed; cannot conclude`);
unknown += 1;
continue;
}
if (status === 200) continue;
bad += 1;
console.error(` FAIL ${key} — HTTP ${status} anonymously; a public reader cannot open this`);
for (const f of files) console.error(` ${f}`);
}

console.log(`\ncheck-public-refs: ${refs.size} repository reference(s) checked`);
if (unknown > 0 && bad === 0) { console.error(`${unknown} could not be checked — offline?`); process.exit(2); }
if (bad > 0) { console.error(`${bad} unresolvable. Cite an org-owned location, or state the rule without the example.`); process.exit(1); }
console.log('every referenced repository resolves anonymously');
81 changes: 79 additions & 2 deletions .github/scripts/lint-skill-entry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// Run against the repo: node .github/scripts/lint-skill-entry.mjs
// Run against another tree: SKILLS_LINT_ROOT=/path node .github/scripts/lint-skill-entry.mjs

import { readFileSync, readdirSync, statSync } from 'node:fs';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

Expand Down Expand Up @@ -41,8 +41,10 @@ export function lintSkill(skill) {
const dirName = skill.id.slice(skill.domain.length + 1);

let raw;
let source = '';
try {
raw = parseFrontmatter(readFileSync(path.join(skill.path, 'skill.md'), 'utf8'));
source = readFileSync(path.join(skill.path, 'skill.md'), 'utf8');
raw = parseFrontmatter(source);
} catch (error) {
return { errors: [`could not read skill.md: ${error.message}`], warnings };
}
Expand Down Expand Up @@ -122,9 +124,84 @@ export function lintSkill(skill) {
}
}

crossReferenceChecks(skill, raw, source, errors, warnings);

return { errors, warnings };
}

// Lane IDs (`B7`, `C4`) are addresses into evidence-catalog.md, not names. They carry no
// meaning to a reader who has not opened the catalog, and a `description` cannot link out
// to it — frontmatter is plain text. So: never in a description, and in the body only on a
// line that also links the catalog. The catalog's own skill is exempt: it defines them.
const LANE_ID = /(?<![\w-])[A-G]\d(?![\w-])/u;
const CATALOG = 'evidence-catalog';

// `## Related` is by convention a list of sibling skills, so every backticked kebab-case
// token in it must name one. This is what catches a rename that swept the owning branch
// and left every branch that referenced it pointing at a name that no longer resolves.
// `$(?![\s\S])`, not `\z` — JS has no absolute-end anchor, and under `m` a bare `$` would
// stop the section at its own first line break.
const RELATED_SECTION = /^#{1,4}\s+Related\s*$([\s\S]*?)(?=^#{1,4}\s|$(?![\s\S]))/imu;
const BACKTICKED = /`([a-z][a-z0-9]*(?:-[a-z0-9]+)+)`/gu;

function crossReferenceChecks(skill, raw, source, errors, warnings) {
const ownsCatalog = existsSync(path.join(skill.path, 'references', `${CATALOG}.md`));

if (!ownsCatalog) {
if (raw.description && LANE_ID.test(raw.description)) {
errors.push(
`\`description\` cites a bare lane id (${raw.description.match(LANE_ID)[0]}); frontmatter cannot link ${CATALOG}.md, so name the category instead of indexing it`,
);
}
// `skill.body` is frontmatter-stripped, so its indices are not the line numbers a reader
// sees when they open the file. Offset by the stripped prefix — a lint message that
// points at the wrong line is the same unresolvable-reference defect this rule exists
// to catch.
const bodyLines = skill.body.split('\n');
// Locate the body in the source rather than subtracting line counts: the two differ by
// trailing-newline handling, which silently shifts every reported line by one.
const start = source.indexOf(skill.body);
const offset = start < 0 ? 0 : source.slice(0, start).split('\n').length - 1;
for (const [index, line] of bodyLines.entries()) {
const hit = line.match(LANE_ID);
if (hit && !line.includes(CATALOG)) {
warnings.push(
`line ${index + 1 + offset} cites lane ${hit[0]} without linking ${CATALOG}.md; an unresolvable index reads as noise`,
);
}
}
}

// `[[snake_case]]` is wiki-link syntax from a private authoring vault. It renders as
// literal brackets on GitHub and resolves nowhere for any reader here. Underscores are
// what separate it from JS array literals (`[[signer1.address, …]]`), which are common
// in workflow snippets and must not trip this.
for (const [, link] of skill.body.matchAll(/\[\[([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\]\]/gu)) {
errors.push(`\`[[${link}]]\` is a private-vault wiki link; it resolves for no reader here — use a real path or URL`);
}

const related = skill.body.match(RELATED_SECTION);
if (related) {
const known = knownSkillNames();
for (const [, name] of related[1].matchAll(BACKTICKED)) {
if (!known.has(name) && name !== skill.name) {
// Warning, not error: the gate runs on the PR's own branch, where a sibling skill
// that ships in a concurrent PR does not exist yet. Blocking would fail a PR for a
// forward reference that resolves on merge.
warnings.push(`\`## Related\` links \`${name}\`, which is not a skill on this branch (renamed, removed, or still in an open PR?)`);
}
}
}
}

let nameCache;
function knownSkillNames() {
// `sources` is an array of roots; passing a bare string iterates its characters and
// silently yields zero skills, which would make every check below vacuously pass.
nameCache ??= new Set(collectSkills([ROOT]).map((skill) => skill.name));
return nameCache;
}

// Restrict to skills touched by the given file paths (the CI gate passes the
// PR's changed files, so pre-existing drift in untouched skills never blocks an
// unrelated change). With no paths, every skill is linted (a full audit).
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/lint-skill-entry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ on:
- 'domains/**'
- 'tools/**'
- '.github/scripts/lint-skill-entry.mjs'
- '.github/scripts/check-public-refs.mjs'
- '.github/workflows/lint-skill-entry.yml'
- 'test/**'
- 'CONTRIBUTING.md'
- 'README.md'

permissions:
contents: read
Expand Down Expand Up @@ -64,3 +68,13 @@ jobs:
run: |
mapfile -d '' -t files < changed-skill-files.bin
node .github/scripts/lint-skill-entry.mjs "${files[@]}"

# Runs on the WHOLE tree, not the changed files: a private-repo reference is a
# property of what this repository publishes, and a PR that touches nothing can
# still be the moment someone notices one. Unauthenticated by construction — a
# token would see private repos and pass them, which is the failure it prevents.
- name: Every referenced repository resolves anonymously
env:
GH_TOKEN: ''
GITHUB_TOKEN: ''
run: node .github/scripts/check-public-refs.mjs
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,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 @@ -137,6 +137,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
4 changes: 2 additions & 2 deletions domains/perps/skills/perps-write-ticket/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,9 @@ line.

## References (read installed, don't duplicate)

- `../../knowledge/screens.md` — screen/area names, if you want to use precise
- `knowledge/screens.md` — screen/area names, if you want to use precise
surface labels (optional; plain words are fine for a product ticket).
- `../../knowledge/formatting-rules.md` — number semantics, to describe expected
- `knowledge/formatting-rules.md` — number semantics, to describe expected
values correctly without prescribing decimals.
- Related skills: `perps-breakdown-tickets` (engineering split + routing — the
next pass), `recipe-fix-ticket` (implement a fix).
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Extension testing layers — see domain knowledge

**Canonical policy:** [`../../../knowledge/extension-testing-layers.md`](../../../knowledge/extension-testing-layers.md)
**Canonical policy:** [`knowledge/extension-testing-layers.md`](knowledge/extension-testing-layers.md)

When installed beside testing skills, open `knowledge/extension-testing-layers.md`.

Expand Down
2 changes: 1 addition & 1 deletion domains/testing/skills/extension-testing/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Mobile layers, and cross-layer placement audits (phase 2). Keep those separate.
### 1. Choose the layer

Read installed `knowledge/extension-testing-layers.md` (source:
[`../../knowledge/extension-testing-layers.md`](../../knowledge/extension-testing-layers.md))
[`knowledge/extension-testing-layers.md`](knowledge/extension-testing-layers.md))
before writing any test. `references/layers.md` is only a redirect stub.

### 2. Open only the matching reference
Expand Down
2 changes: 1 addition & 1 deletion domains/testing/skills/mobile-testing/references/layers.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Mobile testing layers — see domain knowledge

**Canonical policy:** [`../../../knowledge/testing-layers.md`](../../../knowledge/testing-layers.md)
**Canonical policy:** `knowledge/testing-layers.md`

When installed beside testing skills, open `knowledge/testing-layers.md`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Before classifying or writing tests, load as needed (do not reinvent layer rules

| Concern | Open |
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Layer decision tree | installed `knowledge/testing-layers.md` (source [`../../../knowledge/testing-layers.md`](../../../knowledge/testing-layers.md); stub [`layers.md`](layers.md)) |
| Layer decision tree | installed `knowledge/testing-layers.md` (stub [`layers.md`](layers.md)) |
| Screen UI via Redux | [`component-view.md`](component-view.md) |
| Pure helpers / CV fallback | [`unit.md`](unit.md) |
| App↔controller seam | [`integration.md`](integration.md) |
Expand Down
3 changes: 1 addition & 2 deletions domains/testing/skills/mobile-testing/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ those separate.

## First step — choose the layer

Read installed `knowledge/testing-layers.md` (source:
[`../../knowledge/testing-layers.md`](../../knowledge/testing-layers.md))
Read installed `knowledge/testing-layers.md`
before writing any test. `references/layers.md` is only a redirect stub.

## Open next
Expand Down
Loading