@rulemetric/skills-registry 0.12.2 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Identity and metadata derivation for registry entries.
3
+ *
4
+ * Extracted from github-fetcher.ts so it can be tested without the network.
5
+ * Everything here is pure: (repo, path, content) in, entry metadata out.
6
+ *
7
+ * These four functions decide, for every markdown file in 379 source repos,
8
+ * what it is called, what it claims to be about, whether it is a skill at all,
9
+ * and whether it survives at all. Before this module existed the package had
10
+ * no tests, and each of them was wrong in a way that reached real projects.
11
+ */
12
+ /** Frontmatter fields the skill formats agree on. */
13
+ export interface SkillFrontmatter {
14
+ name?: string;
15
+ description?: string;
16
+ }
17
+ /**
18
+ * Parse the leading YAML frontmatter block for `name` and `description`.
19
+ *
20
+ * Deliberately not a general YAML parser: it reads the two scalar fields the
21
+ * Anthropic/OpenCode skill formats define, including block scalars (`|`, `>`),
22
+ * and ignores everything else. A skill file whose frontmatter is malformed
23
+ * should degrade to prose extraction, not throw.
24
+ */
25
+ export declare function parseFrontmatter(content: string): SkillFrontmatter;
26
+ export declare function deriveName(filePath: string, frontmatter?: SkillFrontmatter): string;
27
+ /**
28
+ * A one-line description of what the file is about.
29
+ *
30
+ * Order of authority:
31
+ * 1. frontmatter `description` — the author said it outright;
32
+ * 2. the first line of real prose;
33
+ * 3. nothing. An empty string is honest; the file name dressed up as a
34
+ * sentence is not, and downstream scoring can see the difference.
35
+ *
36
+ * The prose scan skips what is not prose. The previous version skipped only
37
+ * the opening ``` fence and not the code inside it, so the first line of the
38
+ * first code block became the description — which is how a Python skill was
39
+ * published to real projects described as `def append_to(item, target=[]):`.
40
+ */
41
+ export declare function extractDescription(content: string, frontmatter?: SkillFrontmatter): string;
42
+ export type EntryKind = 'skill' | 'supporting' | 'furniture';
43
+ export interface EntryClassification {
44
+ kind: EntryKind;
45
+ /** Why, for the build log. Never discard something without saying why. */
46
+ reason: string;
47
+ }
48
+ /**
49
+ * Decide whether a matched file is a skill, supporting material for one, or
50
+ * repo furniture.
51
+ *
52
+ * The patterns in sources.ts are as broad as `**\/*.md` for most repos, so this
53
+ * is the only thing standing between "every markdown file in 379 repos" and
54
+ * the catalog the adoption loop proposes from. Measured 2026-08-25: 3,099 of
55
+ * 5,850 entries lived in a support directory and 543 of trailofbits/skills'
56
+ * 543 entries were reference files, eval graders and agent definitions — not
57
+ * one of its actual skills.
58
+ */
59
+ export declare function classifyEntry(filePath: string, content: string, frontmatter?: SkillFrontmatter): EntryClassification;
60
+ /**
61
+ * The stable half of an entry's id: `<repo-short>--<basename>`.
62
+ *
63
+ * Kept exactly as it was. 5,850 live ids have this shape, they are embedded in
64
+ * `instructions.name` as `skills--<id>` and in on-disk skill directories, so
65
+ * moving them would orphan every adopted skill. Collisions are resolved by
66
+ * ADDING qualified ids for the files that currently lose, never by renaming
67
+ * the file that currently wins.
68
+ */
69
+ export declare function baseId(repo: string, filePath: string): string;
70
+ /**
71
+ * Progressively more qualified ids for the same file, most-preferred first.
72
+ *
73
+ * For a directory-shaped skill (`skills/lang-python/SKILL.md`) the directory
74
+ * is the identity: every such file otherwise asks for `<repo>--skill`, which
75
+ * names nothing and which only one file per repo can hold.
76
+ */
77
+ export declare function idCandidates(repo: string, filePath: string): string[];
78
+ /**
79
+ * Rank for deciding which file keeps the unqualified id when several want it.
80
+ *
81
+ * Lower wins. Assignment must not depend on which network request returned
82
+ * first: ids reached production through a `Promise.all` race, so a skill's id
83
+ * — and therefore the identity of any instruction row adopted from it — could
84
+ * change between two builds that saw identical repos.
85
+ */
86
+ export declare function preferenceRank(kind: EntryKind, filePath: string, frontmatter: SkillFrontmatter): number;
87
+ //# sourceMappingURL=entry-identity.d.ts.map
@@ -0,0 +1,293 @@
1
+ import * as path from 'node:path';
2
+ /**
3
+ * Parse the leading YAML frontmatter block for `name` and `description`.
4
+ *
5
+ * Deliberately not a general YAML parser: it reads the two scalar fields the
6
+ * Anthropic/OpenCode skill formats define, including block scalars (`|`, `>`),
7
+ * and ignores everything else. A skill file whose frontmatter is malformed
8
+ * should degrade to prose extraction, not throw.
9
+ */
10
+ export function parseFrontmatter(content) {
11
+ const match = /^?---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(content);
12
+ if (!match)
13
+ return {};
14
+ const lines = match[1].split(/\r?\n/);
15
+ const out = {};
16
+ for (let i = 0; i < lines.length; i++) {
17
+ const field = /^(name|description):\s*(.*)$/.exec(lines[i]);
18
+ if (!field)
19
+ continue;
20
+ const key = field[1];
21
+ if (out[key] !== undefined)
22
+ continue;
23
+ let value = field[2].trim();
24
+ // Block scalar: the value is the indented lines that follow, not the
25
+ // `|`/`>` marker. Reading the marker as the value is how a skill ended up
26
+ // described as "|".
27
+ if (value === '|' || value === '>' || /^[|>][-+]?\d*$/.test(value)) {
28
+ const collected = [];
29
+ for (let j = i + 1; j < lines.length; j++) {
30
+ if (lines[j].trim() === '') {
31
+ collected.push('');
32
+ continue;
33
+ }
34
+ if (!/^\s/.test(lines[j]))
35
+ break;
36
+ collected.push(lines[j].trim());
37
+ }
38
+ value = collected.join(' ').replace(/\s+/g, ' ').trim();
39
+ }
40
+ else {
41
+ value = unquote(value);
42
+ // Plain multi-line scalar: continuation lines are indented and carry no
43
+ // `key:` of their own.
44
+ const continued = [value];
45
+ for (let j = i + 1; j < lines.length; j++) {
46
+ if (!/^\s+\S/.test(lines[j]))
47
+ break;
48
+ if (/^\s*(name|description|allowed-tools|license|version|model|tools):/.test(lines[j]))
49
+ break;
50
+ continued.push(lines[j].trim());
51
+ }
52
+ value = continued.join(' ').replace(/\s+/g, ' ').trim();
53
+ }
54
+ if (value)
55
+ out[key] = value;
56
+ }
57
+ return out;
58
+ }
59
+ function unquote(value) {
60
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(value.trim());
61
+ return quoted ? quoted[2] : value.trim();
62
+ }
63
+ export function deriveName(filePath, frontmatter) {
64
+ const declared = frontmatter?.name?.trim();
65
+ if (declared)
66
+ return declared.slice(0, 120);
67
+ const name = path.basename(filePath, path.extname(filePath));
68
+ const titled = name.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).trim();
69
+ // A directory-shaped skill is named by its directory: every one of them is
70
+ // called "SKILL", "README" or "index" otherwise.
71
+ if (/^(skill|readme|index|agents)$/i.test(name)) {
72
+ const parent = path.basename(path.dirname(filePath));
73
+ if (parent && parent !== '.' && parent !== '/') {
74
+ return parent.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).trim();
75
+ }
76
+ }
77
+ return titled;
78
+ }
79
+ /**
80
+ * A one-line description of what the file is about.
81
+ *
82
+ * Order of authority:
83
+ * 1. frontmatter `description` — the author said it outright;
84
+ * 2. the first line of real prose;
85
+ * 3. nothing. An empty string is honest; the file name dressed up as a
86
+ * sentence is not, and downstream scoring can see the difference.
87
+ *
88
+ * The prose scan skips what is not prose. The previous version skipped only
89
+ * the opening ``` fence and not the code inside it, so the first line of the
90
+ * first code block became the description — which is how a Python skill was
91
+ * published to real projects described as `def append_to(item, target=[]):`.
92
+ */
93
+ export function extractDescription(content, frontmatter) {
94
+ const declared = (frontmatter ?? parseFrontmatter(content)).description?.trim();
95
+ if (declared)
96
+ return declared.slice(0, 300);
97
+ const body = stripFrontmatter(content);
98
+ let inFence = false;
99
+ let fenceMarker = '';
100
+ for (const raw of body.split('\n')) {
101
+ const trimmed = raw.trim();
102
+ const fence = /^(`{3,}|~{3,})/.exec(trimmed);
103
+ if (fence) {
104
+ if (!inFence) {
105
+ inFence = true;
106
+ fenceMarker = fence[1][0];
107
+ }
108
+ else if (fence[1][0] === fenceMarker) {
109
+ inFence = false;
110
+ }
111
+ continue;
112
+ }
113
+ if (inFence)
114
+ continue;
115
+ // A list item is often the first real sentence in a reference file. Judge
116
+ // the text, not the bullet.
117
+ const unlisted = trimmed.replace(/^(?:[-*+]|\d+[.)])\s+/, '');
118
+ if (!isProse(trimmed, unlisted))
119
+ continue;
120
+ if (unlisted.length > 10 && unlisted.length < 300)
121
+ return unlisted.slice(0, 200);
122
+ }
123
+ return '';
124
+ }
125
+ function stripFrontmatter(content) {
126
+ return content.replace(/^?---\r?\n[\s\S]*?\r?\n---\s*(?:\r?\n|$)/, '');
127
+ }
128
+ /**
129
+ * Is this line a sentence a human wrote about the subject?
130
+ *
131
+ * Everything rejected here was observed as a live description in the 5,850-row
132
+ * registry: markdown tables ("| Error | Resolution |"), link indexes
133
+ * ("- [Overview](...)"), shell and import lines, HTML, badges, block quotes.
134
+ */
135
+ function isProse(trimmed, unlisted = trimmed) {
136
+ if (!trimmed)
137
+ return false;
138
+ if (trimmed.startsWith('#'))
139
+ return false;
140
+ if (trimmed.startsWith('---') || trimmed.startsWith('===') || /^\*{3,}$/.test(trimmed))
141
+ return false;
142
+ if (trimmed.startsWith('|') || /^\+?[-:| ]+\+?$/.test(trimmed))
143
+ return false;
144
+ if (trimmed.startsWith('<'))
145
+ return false;
146
+ if (trimmed.startsWith('>'))
147
+ return false;
148
+ if (/^!\[/.test(trimmed))
149
+ return false;
150
+ if (/^[-*+]\s*\[/.test(trimmed))
151
+ return false;
152
+ if (/^\d+\.\s*\[/.test(trimmed))
153
+ return false;
154
+ // Code that happens to sit outside a fence.
155
+ if (/^(import|from|const|let|var|function|class|def|return|export|package|using|#include)\b/.test(trimmed))
156
+ return false;
157
+ if (/^(npm|npx|pnpm|yarn|pip|pip3|uv|cargo|go|git|docker|kubectl|brew|apt|curl|bash|sh|make)\s/.test(trimmed))
158
+ return false;
159
+ if (/^[$>][\s]/.test(trimmed))
160
+ return false;
161
+ if (/^[\w.]+\s*[:=]\s*[[{]/.test(trimmed))
162
+ return false;
163
+ if (/[;{}]\s*$/.test(trimmed))
164
+ return false;
165
+ if (/^@\w/.test(trimmed))
166
+ return false;
167
+ // Markup that survived outside a fence: JSX/HTML attributes, template
168
+ // expressions, arrow functions. `}} src={logo.lightSrc} alt={logo.name} />`
169
+ // was a live description.
170
+ if (/=>|=\{|\/>|\}\}|\$\{/.test(trimmed))
171
+ return false;
172
+ // A section label rather than a sentence: "**PowerShell:**", "JS-specific:",
173
+ // "**function.json:**". Ends in a colon and says nothing after it.
174
+ if (/:$/.test(unlisted) && unlisted.replace(/[*`_:]/g, '').trim().split(/\s+/).length <= 4)
175
+ return false;
176
+ // A bolded key with a code value — "**SDK/package**: `azure-ai-contentsafety`"
177
+ // is a fact about the file, not a description of it.
178
+ if (/^\*\*[^*]{1,40}\*\*:\s*[`<]/.test(unlisted))
179
+ return false;
180
+ // Mostly punctuation: a sentence needs words.
181
+ const words = unlisted.replace(/[`*_[\]()<>{}|#]/g, ' ').trim().split(/\s+/).filter((w) => /[a-z]{2}/i.test(w));
182
+ if (words.length < 4)
183
+ return false;
184
+ // A bare key: value line — frontmatter that escaped a malformed block, or a
185
+ // config sample. "name: Backend Developer" was a live description on 1,894
186
+ // entries.
187
+ if (/^[a-z][\w-]{0,30}:\s*\S/i.test(trimmed) && !/\s/.test(trimmed.split(':')[0]))
188
+ return false;
189
+ return true;
190
+ }
191
+ /** Directories whose contents support a skill rather than being one. */
192
+ const SUPPORT_DIR = /(^|\/)(references?|resources?|examples?|assets?|templates?|schemas?|fixtures?|img|images|screenshots?)\//i;
193
+ /** Directories that are machinery, not publishable content. */
194
+ const FURNITURE_DIR = /(^|\/)(evals?|tests?|__tests__|node_modules|\.github\/workflows|dist|build|coverage)\//i;
195
+ /** Repo furniture: present in every repo, about the repo, never a skill. */
196
+ const FURNITURE_FILE = /^(license|licence|changelog|contributing|code_of_conduct|security|notice|authors|maintainers|governance|support|funding|history|upgrading|migration|todo)$/i;
197
+ /**
198
+ * Decide whether a matched file is a skill, supporting material for one, or
199
+ * repo furniture.
200
+ *
201
+ * The patterns in sources.ts are as broad as `**\/*.md` for most repos, so this
202
+ * is the only thing standing between "every markdown file in 379 repos" and
203
+ * the catalog the adoption loop proposes from. Measured 2026-08-25: 3,099 of
204
+ * 5,850 entries lived in a support directory and 543 of trailofbits/skills'
205
+ * 543 entries were reference files, eval graders and agent definitions — not
206
+ * one of its actual skills.
207
+ */
208
+ export function classifyEntry(filePath, content, frontmatter) {
209
+ const base = path.basename(filePath, path.extname(filePath));
210
+ const fm = frontmatter ?? parseFrontmatter(content);
211
+ if (FURNITURE_FILE.test(base))
212
+ return { kind: 'furniture', reason: `repo furniture (${base})` };
213
+ if (FURNITURE_DIR.test(filePath))
214
+ return { kind: 'furniture', reason: 'build/test/eval machinery' };
215
+ // A declared skill is a skill wherever it lives. The format's whole point is
216
+ // that the author says so; second-guessing a file that carries both fields
217
+ // is how a real skill gets thrown away.
218
+ if (fm.name && fm.description)
219
+ return { kind: 'skill', reason: 'declares name + description' };
220
+ if (/^skill$/i.test(base))
221
+ return { kind: 'skill', reason: 'SKILL.md' };
222
+ if (SUPPORT_DIR.test(filePath))
223
+ return { kind: 'supporting', reason: 'lives in a support directory' };
224
+ return { kind: 'skill', reason: 'standalone document' };
225
+ }
226
+ /**
227
+ * The stable half of an entry's id: `<repo-short>--<basename>`.
228
+ *
229
+ * Kept exactly as it was. 5,850 live ids have this shape, they are embedded in
230
+ * `instructions.name` as `skills--<id>` and in on-disk skill directories, so
231
+ * moving them would orphan every adopted skill. Collisions are resolved by
232
+ * ADDING qualified ids for the files that currently lose, never by renaming
233
+ * the file that currently wins.
234
+ */
235
+ export function baseId(repo, filePath) {
236
+ const repoShort = repo.split('/').pop() ?? repo;
237
+ return `${repoShort}--${slug(path.basename(filePath, path.extname(filePath)))}`;
238
+ }
239
+ /** Files whose own name says nothing — the directory is the skill's name. */
240
+ const DIRECTORY_SHAPED = /^(skill|readme|index|agents|main)$/i;
241
+ /**
242
+ * Progressively more qualified ids for the same file, most-preferred first.
243
+ *
244
+ * For a directory-shaped skill (`skills/lang-python/SKILL.md`) the directory
245
+ * is the identity: every such file otherwise asks for `<repo>--skill`, which
246
+ * names nothing and which only one file per repo can hold.
247
+ */
248
+ export function idCandidates(repo, filePath) {
249
+ const [owner, name] = splitRepo(repo);
250
+ const short = name;
251
+ const rawBase = path.basename(filePath, path.extname(filePath));
252
+ const parentDir = slug(path.basename(path.dirname(filePath)));
253
+ const base = DIRECTORY_SHAPED.test(rawBase) && parentDir ? parentDir : slug(rawBase);
254
+ const parent = base === parentDir ? '' : parentDir;
255
+ const candidates = [`${short}--${base}`];
256
+ if (parent && parent !== base && parent !== '.')
257
+ candidates.push(`${short}--${parent}-${base}`);
258
+ candidates.push(`${slug(owner)}-${short}--${base}`);
259
+ if (parent && parent !== base && parent !== '.')
260
+ candidates.push(`${slug(owner)}-${short}--${parent}-${base}`);
261
+ candidates.push(`${slug(owner)}-${short}--${slug(stripExt(filePath))}`);
262
+ return candidates.map((c) => c.slice(0, 160));
263
+ }
264
+ function splitRepo(repo) {
265
+ const parts = repo.split('/');
266
+ return parts.length > 1 ? [parts[0], parts[parts.length - 1]] : ['', repo];
267
+ }
268
+ function stripExt(filePath) {
269
+ return filePath.slice(0, filePath.length - path.extname(filePath).length);
270
+ }
271
+ function slug(value) {
272
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
273
+ }
274
+ /**
275
+ * Rank for deciding which file keeps the unqualified id when several want it.
276
+ *
277
+ * Lower wins. Assignment must not depend on which network request returned
278
+ * first: ids reached production through a `Promise.all` race, so a skill's id
279
+ * — and therefore the identity of any instruction row adopted from it — could
280
+ * change between two builds that saw identical repos.
281
+ */
282
+ export function preferenceRank(kind, filePath, frontmatter) {
283
+ if (kind === 'furniture')
284
+ return 4;
285
+ if (kind === 'supporting')
286
+ return 3;
287
+ if (frontmatter.name && frontmatter.description)
288
+ return 0;
289
+ if (/^skill$/i.test(path.basename(filePath, path.extname(filePath))))
290
+ return 1;
291
+ return 2;
292
+ }
293
+ //# sourceMappingURL=entry-identity.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=entry-identity.test.d.ts.map
@@ -0,0 +1,229 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseFrontmatter, extractDescription, deriveName, classifyEntry, baseId, idCandidates, preferenceRank, } from './entry-identity.js';
3
+ /**
4
+ * Every case below was taken from the live 5,850-entry registry on 2026-08-25.
5
+ * The package had no tests until this file; each of these shipped.
6
+ */
7
+ describe('parseFrontmatter', () => {
8
+ it('reads the description an author declared', () => {
9
+ const fm = parseFrontmatter('---\nname: Backend Developer\ndescription: FastAPI/Python specialist\n---\n\n# Hi\n');
10
+ expect(fm.name).toBe('Backend Developer');
11
+ expect(fm.description).toBe('FastAPI/Python specialist');
12
+ });
13
+ it('reads a block scalar rather than its marker', () => {
14
+ // Live entry skills--create-foundry-project was described as "|".
15
+ const fm = parseFrontmatter('---\nname: foundry-create-project\ndescription: |\n Scaffold a Foundry project\n with the standard layout.\n---\n');
16
+ expect(fm.description).toBe('Scaffold a Foundry project with the standard layout.');
17
+ });
18
+ it('strips quotes', () => {
19
+ expect(parseFrontmatter('---\ndescription: "Quoted thing"\n---\n').description).toBe('Quoted thing');
20
+ });
21
+ it('returns nothing when there is no frontmatter', () => {
22
+ expect(parseFrontmatter('# Title\n\nSome prose.\n')).toEqual({});
23
+ });
24
+ it('does not throw on malformed frontmatter', () => {
25
+ expect(() => parseFrontmatter('---\n: : :\n')).not.toThrow();
26
+ });
27
+ });
28
+ describe('extractDescription', () => {
29
+ it('prefers the declared description over the first prose line', () => {
30
+ const content = '---\nname: x\ndescription: The declared one\n---\n\nThe first prose line, which is not it.\n';
31
+ expect(extractDescription(content)).toBe('The declared one');
32
+ });
33
+ it('never returns a line from inside a fenced code block', () => {
34
+ // skills--lang-python shipped to real projects described as
35
+ // "def append_to(item, target=[]):".
36
+ const content = [
37
+ '# Python Sharp Edges',
38
+ '',
39
+ '## Mutable Default Arguments',
40
+ '',
41
+ '```python',
42
+ '# DANGEROUS: Default is shared across all calls',
43
+ 'def append_to(item, target=[]):',
44
+ ' target.append(item)',
45
+ '```',
46
+ '',
47
+ 'Mutable default arguments are evaluated once at definition time.',
48
+ ].join('\n');
49
+ expect(extractDescription(content)).toBe('Mutable default arguments are evaluated once at definition time.');
50
+ });
51
+ it('does not return a markdown table row', () => {
52
+ const content = '# Errors\n\n| Error | Resolution |\n| --- | --- |\n| ENOENT | check the path |\n\nCommon failures and how to recover from them.\n';
53
+ expect(extractDescription(content)).toBe('Common failures and how to recover from them.');
54
+ });
55
+ it('does not return a link index entry', () => {
56
+ const content = '# Home\n\n- [Overview](https://agentskills.io/home.md): A simple format\n\nAn index of the available guides.\n';
57
+ expect(extractDescription(content)).toBe('An index of the available guides.');
58
+ });
59
+ it('does not return a bare key: value line', () => {
60
+ // 1,894 entries were described with their own frontmatter "name:" line.
61
+ const content = '---\nname: Backend Developer\n---\n\nname: Backend Developer\n\nA specialist for backend work.\n';
62
+ expect(extractDescription(content)).toBe('A specialist for backend work.');
63
+ });
64
+ it('does not return a shell or import line that sits outside a fence', () => {
65
+ const content = '# Setup\n\nnpm i @microsoft/applicationinsights-react-js history\n\nWire the SDK into a React application.\n';
66
+ expect(extractDescription(content)).toBe('Wire the SDK into a React application.');
67
+ });
68
+ it('returns empty rather than inventing a description from the file name', () => {
69
+ expect(extractDescription('# Title\n\n```\ncode\n```\n')).toBe('');
70
+ });
71
+ it('does not return JSX or template markup that escaped a fence', () => {
72
+ const content = '# Logos\n\n}} src={logo.lightSrc} alt={logo.name} />\n\nRenders the brand logo for the current theme.\n';
73
+ expect(extractDescription(content)).toBe('Renders the brand logo for the current theme.');
74
+ });
75
+ it('does not return a bare section label', () => {
76
+ const content = '# Functions\n\n**PowerShell:**\n\nBindings are declared in function.json for each language.\n';
77
+ expect(extractDescription(content)).toBe('Bindings are declared in function.json for each language.');
78
+ });
79
+ it('does not return a bolded key with a code value', () => {
80
+ const content = '# SDK\n\n**SDK/package**: `azure-ai-contentsafety`\n\nDetect harmful content in text and images.\n';
81
+ expect(extractDescription(content)).toBe('Detect harmful content in text and images.');
82
+ });
83
+ it('keeps a sentence that happens to be a list item, without its bullet', () => {
84
+ const content = '# Auth\n\n- Auth uses a subscription key string rather than a credential object.\n';
85
+ expect(extractDescription(content)).toBe('Auth uses a subscription key string rather than a credential object.');
86
+ });
87
+ it('requires a sentence, not a handful of symbols', () => {
88
+ expect(extractDescription('# X\n\n`a` | `b`\n')).toBe('');
89
+ });
90
+ });
91
+ describe('deriveName', () => {
92
+ it('prefers the declared name', () => {
93
+ expect(deriveName('skills/x/SKILL.md', { name: 'azure-kusto-graph' })).toBe('azure-kusto-graph');
94
+ });
95
+ it('names a directory-shaped skill after its directory, not "Skill"', () => {
96
+ expect(deriveName('plugins/p/skills/sharp-edges/SKILL.md')).toBe('Sharp Edges');
97
+ });
98
+ it('titles a flat file from its basename', () => {
99
+ expect(deriveName('skills/lang-python.md')).toBe('Lang Python');
100
+ });
101
+ });
102
+ describe('classifyEntry', () => {
103
+ it('treats a reference sub-file as supporting material', () => {
104
+ const p = 'plugins/sharp-edges/skills/sharp-edges/references/lang-python.md';
105
+ expect(classifyEntry(p, '# Python Sharp Edges\n').kind).toBe('supporting');
106
+ });
107
+ it('treats eval graders as furniture', () => {
108
+ const p = 'plugins/audit-context-building/evals/contract-continuity/graders/unchecked-credit-path.md';
109
+ expect(classifyEntry(p, '# Grader\n').kind).toBe('furniture');
110
+ });
111
+ it('treats repo furniture as furniture', () => {
112
+ expect(classifyEntry('CHANGELOG.md', '# Changelog\n').kind).toBe('furniture');
113
+ expect(classifyEntry('docs/CONTRIBUTING.md', '# Contributing\n').kind).toBe('furniture');
114
+ });
115
+ it('keeps a declared skill even inside a support directory', () => {
116
+ const p = 'skills/a/references/real-skill.md';
117
+ const content = '---\nname: real-skill\ndescription: Does a real thing\n---\n';
118
+ expect(classifyEntry(p, content).kind).toBe('skill');
119
+ });
120
+ it('keeps SKILL.md', () => {
121
+ expect(classifyEntry('plugins/p/skills/s/SKILL.md', '# S\n').kind).toBe('skill');
122
+ });
123
+ it('keeps a standalone document', () => {
124
+ expect(classifyEntry('skills/tdd-workflow.md', '# TDD\n').kind).toBe('skill');
125
+ });
126
+ });
127
+ describe('id assignment', () => {
128
+ it('keeps the historical id shape', () => {
129
+ expect(baseId('trailofbits/skills', 'a/b/lang-python.md')).toBe('skills--lang-python');
130
+ });
131
+ it('offers the historical id first so existing entries never move', () => {
132
+ expect(idCandidates('trailofbits/skills', 'a/b/lang-python.md')[0]).toBe('skills--lang-python');
133
+ });
134
+ it('identifies a directory-shaped skill by its directory', () => {
135
+ // Every SKILL.md in every repo named "skills" wanted the single id
136
+ // "skills--skill"; all but the first were dropped with no log line.
137
+ const a = idCandidates('trailofbits/skills', 'plugins/p/skills/sharp-edges/SKILL.md');
138
+ const b = idCandidates('microsoft/skills', 'plugins/q/skills/kusto/SKILL.md');
139
+ expect(a[0]).toBe('skills--sharp-edges');
140
+ expect(b[0]).toBe('skills--kusto');
141
+ expect(a[0]).not.toBe(b[0]);
142
+ });
143
+ it('still resolves a genuine collision between two repos', () => {
144
+ // Same skill name, two of the four repos called "skills".
145
+ const a = idCandidates('trailofbits/skills', 'skills/testing/SKILL.md');
146
+ const b = idCandidates('microsoft/skills', 'skills/testing/SKILL.md');
147
+ expect(a[0]).toBe(b[0]);
148
+ const fallbacks = [...a.slice(1), ...b.slice(1)];
149
+ expect(new Set(fallbacks).size).toBe(fallbacks.length);
150
+ });
151
+ it('distinguishes two files in one repo that share a basename', () => {
152
+ const a = idCandidates('x/skills', 'skills/alpha/SKILL.md');
153
+ const b = idCandidates('x/skills', 'skills/beta/SKILL.md');
154
+ expect(a[1]).not.toBe(b[1]);
155
+ });
156
+ it('ranks a declared skill above supporting material for the plain id', () => {
157
+ const skill = preferenceRank('skill', 'skills/s/SKILL.md', { name: 'n', description: 'd' });
158
+ const support = preferenceRank('supporting', 'skills/s/references/r.md', {});
159
+ expect(skill).toBeLessThan(support);
160
+ });
161
+ it('ranks SKILL.md above a standalone document', () => {
162
+ expect(preferenceRank('skill', 'a/SKILL.md', {})).toBeLessThan(preferenceRank('skill', 'a/notes.md', {}));
163
+ });
164
+ });
165
+ describe('assignIds (via the shape it must guarantee)', () => {
166
+ it('gives every colliding file an id instead of dropping all but one', async () => {
167
+ const { assignIds } = await import('./github-fetcher.js');
168
+ const draft = (repo, p, rank) => ({
169
+ id: '', name: '', description: '', category: 'other', tags: [], tools: ['claude_code'],
170
+ source: { repo, path: p, url: '', license: 'MIT' }, content: '', updatedAt: '',
171
+ _idCandidates: idCandidates(repo, p), _rank: rank,
172
+ });
173
+ // All four repos are named "skills" and all four ship a "testing" skill.
174
+ const drafts = [
175
+ draft('trailofbits/skills', 'plugins/a/skills/testing/SKILL.md', 1),
176
+ draft('microsoft/skills', 'plugins/b/skills/testing/SKILL.md', 1),
177
+ draft('browser-act/skills', 'skills/testing/SKILL.md', 1),
178
+ draft('wondelai/skills', 'skills/testing/SKILL.md', 1),
179
+ ];
180
+ const { entries, collisions } = assignIds(drafts);
181
+ expect(entries).toHaveLength(4);
182
+ expect(new Set(entries.map((e) => e.id)).size).toBe(4);
183
+ expect(collisions).toBe(3);
184
+ });
185
+ it('is deterministic regardless of the order sources returned', async () => {
186
+ const { assignIds } = await import('./github-fetcher.js');
187
+ const draft = (repo, p, rank) => ({
188
+ id: '', name: '', description: '', category: 'other', tags: [], tools: ['claude_code'],
189
+ source: { repo, path: p, url: '', license: 'MIT' }, content: '', updatedAt: '',
190
+ _idCandidates: idCandidates(repo, p), _rank: rank,
191
+ });
192
+ const a = draft('trailofbits/skills', 'shared/SKILL.md', 1);
193
+ const b = draft('microsoft/skills', 'shared/SKILL.md', 1);
194
+ const forward = assignIds([a, b]).entries.map((e) => `${e.source.repo}=${e.id}`).sort();
195
+ const reverse = assignIds([b, a]).entries.map((e) => `${e.source.repo}=${e.id}`).sort();
196
+ expect(forward).toEqual(reverse);
197
+ });
198
+ it('gives the plain id to the real skill, not to its reference file', async () => {
199
+ const { assignIds } = await import('./github-fetcher.js');
200
+ const mk = (p, rank) => ({
201
+ id: '', name: '', description: '', category: 'other', tags: [], tools: ['claude_code'],
202
+ source: { repo: 'trailofbits/skills', path: p, url: '', license: 'MIT' }, content: '', updatedAt: '',
203
+ _idCandidates: idCandidates('trailofbits/skills', p), _rank: rank,
204
+ });
205
+ const skill = mk('plugins/p/skills/lang-python/SKILL.md', 0);
206
+ const reference = mk('plugins/p/skills/sharp-edges/references/lang-python.md', 3);
207
+ const { entries } = assignIds([reference, skill]);
208
+ const byPath = new Map(entries.map((e) => [e.source.path, e.id]));
209
+ expect(byPath.get('plugins/p/skills/lang-python/SKILL.md')).toBe('skills--lang-python');
210
+ expect(byPath.get('plugins/p/skills/sharp-edges/references/lang-python.md')).not.toBe('skills--lang-python');
211
+ });
212
+ });
213
+ describe('scoreRecency', () => {
214
+ it('scores the source commit date, not the build timestamp', async () => {
215
+ const { computeQualityScore } = await import('./scoring.js');
216
+ const base = {
217
+ id: 'x', name: 'x', description: 'a description long enough to count', category: 'other',
218
+ tags: ['a'], tools: ['claude_code'], source: { repo: 'a/b', path: 'c.md', url: '', license: 'MIT' },
219
+ content: '# x\n\nsome content here\n',
220
+ };
221
+ const old = new Date(Date.now() - 800 * 86_400_000).toISOString();
222
+ const fresh = new Date().toISOString();
223
+ // Same build, very different source ages — the factor must separate them.
224
+ const stale = computeQualityScore({ ...base, updatedAt: fresh, sourceUpdatedAt: old });
225
+ const current = computeQualityScore({ ...base, updatedAt: fresh, sourceUpdatedAt: fresh });
226
+ expect(stale.factors.recency).toBeLessThan(current.factors.recency);
227
+ });
228
+ });
229
+ //# sourceMappingURL=entry-identity.test.js.map
@@ -67,7 +67,7 @@ async function main() {
67
67
  console.log(`Fetching skills from ${sources.length} sources${includeAll ? ' (Tier 1 + 2)' : ' (Tier 1)'}...\n`);
68
68
  const previousShas = loadPreviousShas();
69
69
  const previousEntriesByRepo = loadPreviousEntriesByRepo();
70
- const { entries: freshEntries, treeShas } = await fetchAllSources(sources, {
70
+ const { entries: freshEntries, treeShas, collisions, dropped } = await fetchAllSources(sources, {
71
71
  previousShas,
72
72
  onProgress: (source, count, skipped) => {
73
73
  if (skipped) {
@@ -165,6 +165,31 @@ async function main() {
165
165
  }
166
166
  console.log('\nBy tool:', Object.fromEntries(byTool));
167
167
  console.log('By category:', Object.fromEntries(byCat));
168
+ const byKind = new Map();
169
+ for (const s of entries)
170
+ byKind.set(s.kind ?? 'skill', (byKind.get(s.kind ?? 'skill') ?? 0) + 1);
171
+ console.log('By kind:', Object.fromEntries(byKind));
172
+ // What this build refused, and why. Both numbers were previously invisible:
173
+ // a colliding entry was dropped by `if (!seenIds.has(id))` with no counter,
174
+ // which is how the registry came to hold 5,850 entries and 18 SKILL.md files
175
+ // while reporting a clean run every time.
176
+ console.log(`\nId collisions resolved with a qualified id: ${collisions}`);
177
+ if (dropped.length > 0) {
178
+ const byReason = new Map();
179
+ for (const d of dropped)
180
+ byReason.set(d.reason, (byReason.get(d.reason) ?? 0) + 1);
181
+ console.log(`Rejected as machinery: ${dropped.length}`);
182
+ for (const [reason, n] of [...byReason].sort((a, b) => b[1] - a[1])) {
183
+ console.log(` ${String(n).padStart(5)} ${reason}`);
184
+ }
185
+ }
186
+ const undescribed = entries.filter((s) => !s.description).length;
187
+ if (undescribed > 0) {
188
+ // Reported rather than papered over. An entry with no describable prose is
189
+ // a fact about the source file; inventing a description from its file name
190
+ // is what produced `def append_to(item, target=[]):`.
191
+ console.log(`\nEntries with no extractable description: ${undescribed}`);
192
+ }
168
193
  }
169
194
  main().catch(err => {
170
195
  console.error('Fatal:', err);
@@ -19,9 +19,26 @@ export declare function shouldRetrySecondaryRateLimit(retryAfterSeconds: number)
19
19
  */
20
20
  export declare function _resetOctokitForTests(): void;
21
21
  export interface FetchSourceResult {
22
- entries: SkillEntry[];
22
+ entries: DraftEntry[];
23
23
  treeSha: string | null;
24
24
  skipped: boolean;
25
+ /** Files matched by the patterns but rejected as machinery, with the reason.
26
+ * Counted rather than silently skipped — a pattern that starts sweeping up
27
+ * a repo's CI directory should be visible in the build log. */
28
+ dropped: Array<{
29
+ path: string;
30
+ reason: string;
31
+ }>;
32
+ }
33
+ /**
34
+ * An entry before its id is final.
35
+ *
36
+ * `_idCandidates` and `_rank` exist only between fetch and id assignment; they
37
+ * are stripped before the entry is written to the registry.
38
+ */
39
+ export interface DraftEntry extends SkillEntry {
40
+ _idCandidates: string[];
41
+ _rank: number;
25
42
  }
26
43
  /**
27
44
  * Fetch all skills from a single registry source.
@@ -42,7 +59,43 @@ export interface FetchAllResult {
42
59
  entries: SkillEntry[];
43
60
  /** Map of repo → current tree SHA, for caching across runs. */
44
61
  treeShas: Map<string, string>;
62
+ /** How many entries needed a qualified id. Reported, never silent. */
63
+ collisions: number;
64
+ /** Files rejected as machinery, with the reason. */
65
+ dropped: Array<{
66
+ path: string;
67
+ reason: string;
68
+ }>;
45
69
  }
46
- /** Fetch skills from multiple sources, in parallel, deduplicating by id. */
70
+ /**
71
+ * Assign a final, unique id to every drafted entry.
72
+ *
73
+ * Two properties matter, and neither held before:
74
+ *
75
+ * 1. **Deterministic.** Ids used to be handed out first-come inside a
76
+ * `Promise.all` race, so which of two colliding files kept the plain id
77
+ * depended on which HTTP response landed first. An entry's id is the
78
+ * identity of the `instructions` row adopted from it, so a build could
79
+ * silently re-point an adopted skill. Sorting by (rank, repo, path) makes
80
+ * the outcome a function of the inputs alone.
81
+ *
82
+ * 2. **Total.** The loser of a collision used to be dropped — no entry, no
83
+ * log. `deriveId` was `<repo-short>--<basename>`, which ignores the
84
+ * directory AND the repo owner, so all four repos named `skills` and all
85
+ * nine named `cursorrules` shared one namespace, and every `SKILL.md`
86
+ * anywhere in a repo wanted the single id `<repo>--skill`. Measured
87
+ * 2026-08-25: the registry held 5,850 entries and just 18 SKILL.md files;
88
+ * trailofbits/skills contributed 543 entries, of which none were its
89
+ * actual skills — they had all collided into one id and been discarded,
90
+ * leaving only the `references/` files behind them.
91
+ *
92
+ * Ranking puts declared skills ahead of supporting material, so where a real
93
+ * skill and one of its reference files compete, the skill keeps the plain id.
94
+ */
95
+ export declare function assignIds(drafts: DraftEntry[]): {
96
+ entries: SkillEntry[];
97
+ collisions: number;
98
+ };
99
+ /** Fetch skills from multiple sources in parallel. */
47
100
  export declare function fetchAllSources(sources: RegistrySource[], options?: FetchAllOptions): Promise<FetchAllResult>;
48
101
  //# sourceMappingURL=github-fetcher.d.ts.map
@@ -3,6 +3,7 @@ import { Octokit } from 'octokit';
3
3
  import { retry } from '@octokit/plugin-retry';
4
4
  import { throttling } from '@octokit/plugin-throttling';
5
5
  import pLimit from 'p-limit';
6
+ import { parseFrontmatter, extractDescription, deriveName, classifyEntry, idCandidates, preferenceRank, } from './entry-identity.js';
6
7
  // ── HTTP layer ───────────────────────────────────────────────────────────────
7
8
  const GITHUB_RAW = 'https://raw.githubusercontent.com';
8
9
  const USER_AGENT = 'rulemetric-skills-registry';
@@ -222,29 +223,6 @@ function patternToRegex(pattern) {
222
223
  .replace(/<<GLOBSTAR>>/g, '.*');
223
224
  return new RegExp(`^${escaped}$`);
224
225
  }
225
- function deriveId(repo, filePath) {
226
- const repoShort = repo.split('/').pop() ?? repo;
227
- const name = path.basename(filePath, path.extname(filePath))
228
- .toLowerCase()
229
- .replace(/[^a-z0-9]+/g, '-')
230
- .replace(/^-|-$/g, '');
231
- return `${repoShort}--${name}`;
232
- }
233
- function deriveName(filePath) {
234
- const name = path.basename(filePath, path.extname(filePath));
235
- return name.replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).trim();
236
- }
237
- function extractDescription(content) {
238
- const lines = content.split('\n');
239
- for (const line of lines) {
240
- const trimmed = line.trim();
241
- if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('---') || trimmed.startsWith('```'))
242
- continue;
243
- if (trimmed.length > 10 && trimmed.length < 300)
244
- return trimmed.slice(0, 200);
245
- }
246
- return deriveName(lines[0] ?? '');
247
- }
248
226
  function inferCategory(filePath, content) {
249
227
  const lower = (filePath + ' ' + content.slice(0, 500)).toLowerCase();
250
228
  if (/\btest|tdd|vitest|jest|pytest\b/.test(lower))
@@ -311,9 +289,9 @@ const FILE_CONCURRENCY = pLimit(8);
311
289
  export async function fetchSource(source, previousTreeSha) {
312
290
  const meta = await fetchRepoMeta(source.repo);
313
291
  if (!meta)
314
- return { entries: [], treeSha: null, skipped: false };
292
+ return { entries: [], treeSha: null, skipped: false, dropped: [] };
315
293
  if (previousTreeSha && previousTreeSha === meta.treeSha) {
316
- return { entries: [], treeSha: meta.treeSha, skipped: true };
294
+ return { entries: [], treeSha: meta.treeSha, skipped: true, dropped: [] };
317
295
  }
318
296
  const branch = source.branch ?? meta.defaultBranch;
319
297
  const tree = await fetchTree(source.repo, meta.treeSha);
@@ -325,15 +303,24 @@ export async function fetchSource(source, previousTreeSha) {
325
303
  return false;
326
304
  return matchesPatterns(item.path, source.patterns);
327
305
  });
306
+ const dropped = [];
328
307
  const entries = await Promise.all(matchedFiles.map(file => FILE_CONCURRENCY(async () => {
329
308
  const url = `${GITHUB_RAW}/${source.repo}/${branch}/${file.path}`;
330
309
  const result = await fetchRaw(url);
331
310
  if (!result.body || result.body.trim().length < 20)
332
311
  return null;
333
312
  const content = result.body;
334
- const id = deriveId(source.repo, file.path);
335
- const name = deriveName(file.path);
336
- const description = extractDescription(content);
313
+ const frontmatter = parseFrontmatter(content);
314
+ const classification = classifyEntry(file.path, content, frontmatter);
315
+ // Machinery eval graders, CI config, CHANGELOG — is not content
316
+ // anybody would adopt. Dropped here rather than scored low, because a
317
+ // low score still leaves it reachable by search and by nomination.
318
+ if (classification.kind === 'furniture') {
319
+ dropped.push({ path: file.path, reason: classification.reason });
320
+ return null;
321
+ }
322
+ const name = deriveName(file.path, frontmatter);
323
+ const description = extractDescription(content, frontmatter);
337
324
  const category = inferCategory(file.path, content);
338
325
  const tags = inferTags(file.path, content);
339
326
  const languages = inferLanguages(content);
@@ -342,7 +329,12 @@ export async function fetchSource(source, previousTreeSha) {
342
329
  // error), so the field is populated even on a degraded run.
343
330
  const sourceUpdatedAt = (await fetchFileCommitDate(source.repo, file.path)) ?? meta.pushedAt ?? undefined;
344
331
  return {
345
- id,
332
+ // Provisional. Final ids are assigned across all sources at once so
333
+ // two repos racing for the same id cannot decide it between them.
334
+ id: idCandidates(source.repo, file.path)[0],
335
+ _idCandidates: idCandidates(source.repo, file.path),
336
+ _rank: preferenceRank(classification.kind, file.path, frontmatter),
337
+ kind: classification.kind,
346
338
  name,
347
339
  description,
348
340
  category: category !== 'other' ? category : source.defaultCategory,
@@ -365,26 +357,77 @@ export async function fetchSource(source, previousTreeSha) {
365
357
  entries: entries.filter((e) => e !== null),
366
358
  treeSha: meta.treeSha,
367
359
  skipped: false,
360
+ dropped,
368
361
  };
369
362
  }
370
363
  const SOURCE_CONCURRENCY = pLimit(4);
371
- /** Fetch skills from multiple sources, in parallel, deduplicating by id. */
364
+ /**
365
+ * Assign a final, unique id to every drafted entry.
366
+ *
367
+ * Two properties matter, and neither held before:
368
+ *
369
+ * 1. **Deterministic.** Ids used to be handed out first-come inside a
370
+ * `Promise.all` race, so which of two colliding files kept the plain id
371
+ * depended on which HTTP response landed first. An entry's id is the
372
+ * identity of the `instructions` row adopted from it, so a build could
373
+ * silently re-point an adopted skill. Sorting by (rank, repo, path) makes
374
+ * the outcome a function of the inputs alone.
375
+ *
376
+ * 2. **Total.** The loser of a collision used to be dropped — no entry, no
377
+ * log. `deriveId` was `<repo-short>--<basename>`, which ignores the
378
+ * directory AND the repo owner, so all four repos named `skills` and all
379
+ * nine named `cursorrules` shared one namespace, and every `SKILL.md`
380
+ * anywhere in a repo wanted the single id `<repo>--skill`. Measured
381
+ * 2026-08-25: the registry held 5,850 entries and just 18 SKILL.md files;
382
+ * trailofbits/skills contributed 543 entries, of which none were its
383
+ * actual skills — they had all collided into one id and been discarded,
384
+ * leaving only the `references/` files behind them.
385
+ *
386
+ * Ranking puts declared skills ahead of supporting material, so where a real
387
+ * skill and one of its reference files compete, the skill keeps the plain id.
388
+ */
389
+ export function assignIds(drafts) {
390
+ const ordered = [...drafts].sort((a, b) => a._rank - b._rank
391
+ || a.source.repo.localeCompare(b.source.repo)
392
+ || a.source.path.localeCompare(b.source.path));
393
+ const taken = new Set();
394
+ const entries = [];
395
+ let collisions = 0;
396
+ for (const draft of ordered) {
397
+ const candidates = draft._idCandidates;
398
+ let id = candidates.find((candidate) => !taken.has(candidate));
399
+ if (!id) {
400
+ // Every qualified form is taken too — fall back to the full path, which
401
+ // is unique within a repo by construction.
402
+ collisions++;
403
+ let n = 2;
404
+ const stem = candidates[candidates.length - 1];
405
+ while (taken.has(`${stem}-${n}`))
406
+ n++;
407
+ id = `${stem}-${n}`;
408
+ }
409
+ else if (id !== candidates[0]) {
410
+ collisions++;
411
+ }
412
+ taken.add(id);
413
+ const { _idCandidates, _rank, ...entry } = draft;
414
+ entries.push({ ...entry, id });
415
+ }
416
+ return { entries, collisions };
417
+ }
418
+ /** Fetch skills from multiple sources in parallel. */
372
419
  export async function fetchAllSources(sources, options = {}) {
373
- const allEntries = [];
374
- const seenIds = new Set();
420
+ const drafts = [];
375
421
  const treeShas = new Map();
422
+ const dropped = [];
376
423
  await Promise.all(sources.map(source => SOURCE_CONCURRENCY(async () => {
377
424
  try {
378
425
  const previous = options.previousShas?.get(source.repo);
379
426
  const result = await fetchSource(source, previous);
380
427
  if (result.treeSha)
381
428
  treeShas.set(source.repo, result.treeSha);
382
- for (const entry of result.entries) {
383
- if (!seenIds.has(entry.id)) {
384
- seenIds.add(entry.id);
385
- allEntries.push(entry);
386
- }
387
- }
429
+ drafts.push(...result.entries);
430
+ dropped.push(...result.dropped);
388
431
  options.onProgress?.(source.repo, result.entries.length, result.skipped);
389
432
  }
390
433
  catch (err) {
@@ -392,6 +435,7 @@ export async function fetchAllSources(sources, options = {}) {
392
435
  options.onProgress?.(source.repo, 0, false);
393
436
  }
394
437
  })));
395
- return { entries: allEntries, treeShas };
438
+ const { entries, collisions } = assignIds(drafts);
439
+ return { entries, treeShas, collisions, dropped };
396
440
  }
397
441
  //# sourceMappingURL=github-fetcher.js.map
package/dist/index.d.ts CHANGED
@@ -4,4 +4,5 @@ export { fetchSource, fetchAllSources, shouldRetryRateLimit, shouldRetrySecondar
4
4
  export { TIER_1_SOURCES, TIER_2_SOURCES } from './sources.js';
5
5
  export { loadDiscoveredSources } from './discovered.js';
6
6
  export { computeQualityScore, type QualityScore } from './scoring.js';
7
+ export { parseFrontmatter, extractDescription, deriveName, classifyEntry, baseId, idCandidates, preferenceRank, type SkillFrontmatter, type EntryKind, type EntryClassification, } from './entry-identity.js';
7
8
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -3,4 +3,5 @@ export { fetchSource, fetchAllSources, shouldRetryRateLimit, shouldRetrySecondar
3
3
  export { TIER_1_SOURCES, TIER_2_SOURCES } from './sources.js';
4
4
  export { loadDiscoveredSources } from './discovered.js';
5
5
  export { computeQualityScore } from './scoring.js';
6
+ export { parseFrontmatter, extractDescription, deriveName, classifyEntry, baseId, idCandidates, preferenceRank, } from './entry-identity.js';
6
7
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=recompute.d.ts.map
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Re-derive entry metadata from content already in registry.json.
3
+ *
4
+ * A fix to `extractDescription` or `classifyEntry` only reaches the catalog on
5
+ * the next full crawl, which needs GitHub API budget and a clean rate-limit
6
+ * window. But `registry.json` already stores every file's `content`, so the
7
+ * derivation can simply be run again offline — no network, no quota, and the
8
+ * result is byte-comparable against what shipped.
9
+ *
10
+ * IDS ARE NEVER TOUCHED HERE. An entry's id is the identity of the
11
+ * `instructions` row synced from it and of any skill directory adopted from
12
+ * that row; re-assigning ids without the full crawl would orphan adopted
13
+ * skills to fix a description. Collision repair belongs to the crawl, which
14
+ * can see the files that were dropped.
15
+ *
16
+ * pnpm --filter @rulemetric/skills-registry recompute [--write]
17
+ *
18
+ * Without `--write` it reports what would change and exits.
19
+ */
20
+ import * as fs from 'node:fs';
21
+ import * as path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { parseFrontmatter, extractDescription, deriveName, classifyEntry } from './entry-identity.js';
24
+ import { computeQualityScore } from './scoring.js';
25
+ const here = path.dirname(fileURLToPath(import.meta.url));
26
+ const REGISTRY_PATH = path.resolve(here, '..', 'registry.json');
27
+ const INDEX_PATH = path.resolve(here, '..', 'registry-index.json');
28
+ function main() {
29
+ const write = process.argv.includes('--write');
30
+ const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf-8'));
31
+ const entries = registry.skills;
32
+ let descriptionsChanged = 0;
33
+ let namesChanged = 0;
34
+ let kindsSet = 0;
35
+ let nowUndescribed = 0;
36
+ const furniture = [];
37
+ const samples = [];
38
+ for (const entry of entries) {
39
+ const content = entry.content ?? '';
40
+ const filePath = entry.source?.path ?? '';
41
+ const frontmatter = parseFrontmatter(content);
42
+ const description = extractDescription(content, frontmatter);
43
+ if (description !== entry.description) {
44
+ descriptionsChanged++;
45
+ if (!description)
46
+ nowUndescribed++;
47
+ if (samples.length < 12 && description) {
48
+ samples.push(` ${entry.id}\n was: ${JSON.stringify((entry.description ?? '').slice(0, 70))}\n now: ${JSON.stringify(description.slice(0, 70))}`);
49
+ }
50
+ entry.description = description;
51
+ }
52
+ const name = deriveName(filePath, frontmatter);
53
+ if (name !== entry.name) {
54
+ namesChanged++;
55
+ entry.name = name;
56
+ }
57
+ const classification = classifyEntry(filePath, content, frontmatter);
58
+ // Machinery is recorded but NOT removed here. Deleting rows is the crawl's
59
+ // job: the API sync upserts by id and has no delete path, so dropping an
60
+ // entry from this file would leave the row in the database with no way to
61
+ // notice. Reported so the next crawl's removal is expected.
62
+ if (classification.kind === 'furniture') {
63
+ furniture.push(entry.id);
64
+ entry.kind = 'supporting';
65
+ }
66
+ else if (entry.kind !== classification.kind) {
67
+ entry.kind = classification.kind;
68
+ }
69
+ if (entry.kind)
70
+ kindsSet++;
71
+ entry.qualityScore = computeQualityScore(entry);
72
+ }
73
+ const byKind = new Map();
74
+ for (const entry of entries)
75
+ byKind.set(entry.kind ?? 'skill', (byKind.get(entry.kind ?? 'skill') ?? 0) + 1);
76
+ console.log(`entries : ${entries.length}`);
77
+ console.log(`descriptions changed : ${descriptionsChanged}`);
78
+ console.log(` of which now empty : ${nowUndescribed} (no extractable prose — honest, and scored as such)`);
79
+ console.log(`names changed : ${namesChanged}`);
80
+ console.log(`kind assigned : ${kindsSet}`);
81
+ console.log(`by kind : ${JSON.stringify(Object.fromEntries(byKind))}`);
82
+ console.log(`machinery (flagged, removed on next crawl): ${furniture.length}`);
83
+ if (samples.length > 0)
84
+ console.log(`\nsample description changes:\n${samples.join('\n')}`);
85
+ if (!write) {
86
+ console.log('\nDry run. Pass --write to update registry.json and registry-index.json.');
87
+ return;
88
+ }
89
+ registry.generatedAt = new Date().toISOString();
90
+ fs.writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2));
91
+ const index = {
92
+ version: registry.version,
93
+ generatedAt: registry.generatedAt,
94
+ skills: entries.map(({ content, ...rest }) => rest),
95
+ };
96
+ fs.writeFileSync(INDEX_PATH, JSON.stringify(index, null, 2));
97
+ console.log(`\nWrote ${REGISTRY_PATH}\nWrote ${INDEX_PATH}`);
98
+ }
99
+ main();
100
+ //# sourceMappingURL=recompute.js.map
package/dist/scoring.js CHANGED
@@ -83,10 +83,24 @@ function scoreCompleteness(skill) {
83
83
  score += 3;
84
84
  return Math.min(25, score);
85
85
  }
86
+ /**
87
+ * How recently the skill itself changed.
88
+ *
89
+ * `updatedAt` is the build timestamp — every entry in a build shares it, so
90
+ * scoring against it measures when we last scraped, not when the author last
91
+ * touched the file, and the factor drifts uniformly as the registry ages
92
+ * instead of discriminating between entries. `sourceUpdatedAt` is the file's
93
+ * most recent commit date and is populated on every entry (5,850/5,850,
94
+ * measured 2026-08-25). Prefer it; fall back only for entries built before
95
+ * that field existed.
96
+ */
86
97
  function scoreRecency(skill) {
87
- if (!skill.updatedAt)
98
+ const stamp = skill.sourceUpdatedAt ?? skill.updatedAt;
99
+ if (!stamp)
100
+ return 0;
101
+ const updated = new Date(stamp);
102
+ if (Number.isNaN(updated.getTime()))
88
103
  return 0;
89
- const updated = new Date(skill.updatedAt);
90
104
  const now = new Date();
91
105
  const daysSince = (now.getTime() - updated.getTime()) / (1000 * 60 * 60 * 24);
92
106
  if (daysSince <= 7)
package/dist/types.d.ts CHANGED
@@ -22,6 +22,17 @@ export interface SkillEntry {
22
22
  frameworks?: string[];
23
23
  /** Where the skill content came from */
24
24
  source: SkillSource;
25
+ /**
26
+ * What this file is: a skill, or material that supports one.
27
+ *
28
+ * `supporting` marks reference files, examples and resources that live
29
+ * beside a skill — `references/lang-python.md` under the `sharp-edges`
30
+ * skill, say. They are worth keeping and searching, but they are not
31
+ * standalone skills, and proposing one for adoption puts a fragment of
32
+ * another skill's documentation into a real project. Absent on entries
33
+ * built before this field existed; treat undefined as 'skill'.
34
+ */
35
+ kind?: 'skill' | 'supporting';
25
36
  /** Raw content in available formats */
26
37
  content: string;
27
38
  /** Popularity metrics for ranking */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulemetric/skills-registry",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -25,6 +25,10 @@
25
25
  "./types": {
26
26
  "types": "./dist/types.d.ts",
27
27
  "default": "./dist/types.js"
28
+ },
29
+ "./entry-identity": {
30
+ "types": "./dist/entry-identity.d.ts",
31
+ "default": "./dist/entry-identity.js"
28
32
  }
29
33
  },
30
34
  "dependencies": {
@@ -46,6 +50,8 @@
46
50
  "type-check": "tsc --noEmit",
47
51
  "fetch": "tsx src/fetch-registry.ts",
48
52
  "discover": "tsx src/discover-sources.ts",
49
- "sync:api": "node scripts/sync-to-api.mjs"
53
+ "sync:api": "node scripts/sync-to-api.mjs",
54
+ "test": "vitest run",
55
+ "recompute": "tsx src/recompute.ts"
50
56
  }
51
57
  }