@rulemetric/skills-registry 0.15.1 → 0.17.0

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.
@@ -48,12 +48,27 @@ export interface DraftEntry extends SkillEntry {
48
48
  * expected to keep its existing entries for that source. Pass `undefined` to
49
49
  * always fetch.
50
50
  */
51
- export declare function fetchSource(source: RegistrySource, previousTreeSha?: string): Promise<FetchSourceResult>;
51
+ export declare function fetchSource(source: RegistrySource, previousTreeSha?: string, signal?: AbortSignal): Promise<FetchSourceResult>;
52
52
  export interface FetchAllOptions {
53
53
  /** Map of repo → previous tree SHA. Sources with matching SHA are skipped. */
54
54
  previousShas?: Map<string, string>;
55
55
  /** Called after each source completes (successful or not). */
56
56
  onProgress?: (source: string, count: number, skipped: boolean) => void;
57
+ /**
58
+ * Stops the crawl. Sources not yet started are not started; a source in
59
+ * flight stops at its next network hop. Entries already drafted are kept,
60
+ * so the caller decides what a partial registry is worth (`aborted` says
61
+ * it is one). Without this a caller had no way to end a crawl short of
62
+ * killing the process — which is how one kept running for ~16 hours after
63
+ * its job lease was gone (2026-09-10).
64
+ */
65
+ signal?: AbortSignal;
66
+ /**
67
+ * Test seam: replaces the per-source fetch so the scheduling contract
68
+ * (concurrency, abort, accounting) can be exercised without GitHub.
69
+ * Production callers never set it.
70
+ */
71
+ fetchSourceImpl?: typeof fetchSource;
57
72
  }
58
73
  export interface FetchAllResult {
59
74
  entries: SkillEntry[];
@@ -66,6 +81,14 @@ export interface FetchAllResult {
66
81
  path: string;
67
82
  reason: string;
68
83
  }>;
84
+ /** True when `options.signal` fired before every source had been visited.
85
+ * `entries` is then a PARTIAL registry and must not replace a full one. */
86
+ aborted: boolean;
87
+ /** Sources whose fetch ran to a result (including skipped-unchanged and
88
+ * failed-and-logged); `sourcesUnfetched` is the remainder the abort
89
+ * prevented. They sum to `sources.length`. */
90
+ sourcesFetched: number;
91
+ sourcesUnfetched: number;
69
92
  }
70
93
  /**
71
94
  * Assign a final, unique id to every drafted entry.
@@ -92,7 +92,7 @@ const rawEtagCache = new Map();
92
92
  * GETs and basic retry. Raw URLs don't count against the GitHub API quota, so
93
93
  * we don't go through octokit here — but we still want timeouts and retries.
94
94
  */
95
- async function fetchRaw(url, timeoutMs = 10_000, retries = 2) {
95
+ async function fetchRaw(url, timeoutMs = 10_000, retries = 2, signal) {
96
96
  const cached = rawEtagCache.get(url);
97
97
  const headers = { 'User-Agent': USER_AGENT };
98
98
  if (process.env.GITHUB_TOKEN)
@@ -101,8 +101,12 @@ async function fetchRaw(url, timeoutMs = 10_000, retries = 2) {
101
101
  headers['If-None-Match'] = cached.etag;
102
102
  let lastErr;
103
103
  for (let attempt = 0; attempt <= retries; attempt++) {
104
+ // A caller-side abort (lease lost, budget spent) ends the retry loop; the
105
+ // per-attempt timeout alone would let a dead crawl keep re-trying.
106
+ if (signal?.aborted)
107
+ return { status: 0, body: null, fromCache: false };
104
108
  try {
105
- const resp = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
109
+ const resp = await fetch(url, { headers, signal: withTimeout(timeoutMs, signal) });
106
110
  if (resp.status === 304 && cached) {
107
111
  return { status: 304, body: cached.body, fromCache: true };
108
112
  }
@@ -126,6 +130,8 @@ async function fetchRaw(url, timeoutMs = 10_000, retries = 2) {
126
130
  }
127
131
  catch (err) {
128
132
  lastErr = err;
133
+ if (signal?.aborted)
134
+ return { status: 0, body: null, fromCache: false };
129
135
  await sleep(250 * 2 ** attempt);
130
136
  }
131
137
  }
@@ -133,18 +139,35 @@ async function fetchRaw(url, timeoutMs = 10_000, retries = 2) {
133
139
  return { status: 0, body: null, fromCache: false };
134
140
  }
135
141
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
142
+ /**
143
+ * Per-request timeout combined with the caller's abort signal, so a crawl the
144
+ * caller has stopped does not wait out its own timeout first. `AbortSignal.any`
145
+ * is Node 20.3+; the package still declares >=18.18, so fall back to the
146
+ * timeout alone (the caller-side `aborted` checks between hops still bound
147
+ * the work — just one request later).
148
+ */
149
+ function withTimeout(timeoutMs, signal) {
150
+ const timeout = AbortSignal.timeout(timeoutMs);
151
+ if (!signal)
152
+ return timeout;
153
+ const any = AbortSignal.any;
154
+ return typeof any === 'function' ? any([timeout, signal]) : timeout;
155
+ }
136
156
  /**
137
157
  * Fetch repo metadata + tree SHA in one round trip via the API. Lets callers
138
158
  * compare treeSha against a previous run to skip unchanged repos entirely.
139
159
  */
140
- async function fetchRepoMeta(repo) {
160
+ async function fetchRepoMeta(repo, signal) {
141
161
  const [owner, name] = repo.split('/');
142
162
  if (!owner || !name)
143
163
  return null;
144
164
  try {
145
- const { data } = await getOctokit().rest.repos.get({ owner, repo: name });
165
+ const request = signal ? { signal } : undefined;
166
+ const { data } = await getOctokit().rest.repos.get({ owner, repo: name, request });
167
+ if (signal?.aborted)
168
+ return null;
146
169
  const branchData = await getOctokit().rest.repos.getBranch({
147
- owner, repo: name, branch: data.default_branch,
170
+ owner, repo: name, branch: data.default_branch, request,
148
171
  });
149
172
  return {
150
173
  defaultBranch: data.default_branch,
@@ -155,7 +178,7 @@ async function fetchRepoMeta(repo) {
155
178
  }
156
179
  catch (err) {
157
180
  const status = err.status;
158
- if (status !== 404)
181
+ if (status !== 404 && !signal?.aborted)
159
182
  console.warn(`fetchRepoMeta failed for ${repo}:`, err);
160
183
  return null;
161
184
  }
@@ -171,30 +194,30 @@ async function fetchRepoMeta(repo) {
171
194
  * Returns null on any failure (404, rate-limit give-up, network) so the caller
172
195
  * can fall back to the repo pushed_at without aborting the whole build.
173
196
  */
174
- async function fetchFileCommitDate(repo, filePath) {
197
+ async function fetchFileCommitDate(repo, filePath, signal) {
175
198
  const [owner, name] = repo.split('/');
176
- if (!owner || !name)
199
+ if (!owner || !name || signal?.aborted)
177
200
  return null;
178
201
  try {
179
202
  const { data } = await getOctokit().rest.repos.listCommits({
180
- owner, repo: name, path: filePath, per_page: 1,
203
+ owner, repo: name, path: filePath, per_page: 1, request: signal ? { signal } : undefined,
181
204
  });
182
205
  return data[0]?.commit?.committer?.date ?? data[0]?.commit?.author?.date ?? null;
183
206
  }
184
207
  catch (err) {
185
208
  const status = err.status;
186
- if (status !== 404)
209
+ if (status !== 404 && !signal?.aborted)
187
210
  console.warn(`fetchFileCommitDate failed for ${repo}/${filePath}:`, err);
188
211
  return null;
189
212
  }
190
213
  }
191
- async function fetchTree(repo, treeSha) {
214
+ async function fetchTree(repo, treeSha, signal) {
192
215
  const [owner, name] = repo.split('/');
193
- if (!owner || !name)
216
+ if (!owner || !name || signal?.aborted)
194
217
  return [];
195
218
  try {
196
219
  const { data } = await getOctokit().rest.git.getTree({
197
- owner, repo: name, tree_sha: treeSha, recursive: 'true',
220
+ owner, repo: name, tree_sha: treeSha, recursive: 'true', request: signal ? { signal } : undefined,
198
221
  });
199
222
  if (data.truncated)
200
223
  console.warn(`Tree truncated for ${repo} — large repo, some files skipped`);
@@ -207,7 +230,8 @@ async function fetchTree(repo, treeSha) {
207
230
  return out;
208
231
  }
209
232
  catch (err) {
210
- console.warn(`fetchTree failed for ${repo}@${treeSha}:`, err);
233
+ if (!signal?.aborted)
234
+ console.warn(`fetchTree failed for ${repo}@${treeSha}:`, err);
211
235
  return [];
212
236
  }
213
237
  }
@@ -286,15 +310,15 @@ const FILE_CONCURRENCY = pLimit(8);
286
310
  * expected to keep its existing entries for that source. Pass `undefined` to
287
311
  * always fetch.
288
312
  */
289
- export async function fetchSource(source, previousTreeSha) {
290
- const meta = await fetchRepoMeta(source.repo);
313
+ export async function fetchSource(source, previousTreeSha, signal) {
314
+ const meta = await fetchRepoMeta(source.repo, signal);
291
315
  if (!meta)
292
316
  return { entries: [], treeSha: null, skipped: false, dropped: [] };
293
317
  if (previousTreeSha && previousTreeSha === meta.treeSha) {
294
318
  return { entries: [], treeSha: meta.treeSha, skipped: true, dropped: [] };
295
319
  }
296
320
  const branch = source.branch ?? meta.defaultBranch;
297
- const tree = await fetchTree(source.repo, meta.treeSha);
321
+ const tree = await fetchTree(source.repo, meta.treeSha, signal);
298
322
  const matchedFiles = tree.filter(item => {
299
323
  const ext = path.extname(item.path).toLowerCase();
300
324
  if (SKIP_EXTENSIONS.has(ext))
@@ -305,8 +329,12 @@ export async function fetchSource(source, previousTreeSha) {
305
329
  });
306
330
  const dropped = [];
307
331
  const entries = await Promise.all(matchedFiles.map(file => FILE_CONCURRENCY(async () => {
332
+ // Checked per file, not just per source: a large repo is hundreds of
333
+ // raw fetches, and the caller's stop must land inside it.
334
+ if (signal?.aborted)
335
+ return null;
308
336
  const url = `${GITHUB_RAW}/${source.repo}/${branch}/${file.path}`;
309
- const result = await fetchRaw(url);
337
+ const result = await fetchRaw(url, undefined, undefined, signal);
310
338
  if (!result.body || result.body.trim().length < 20)
311
339
  return null;
312
340
  const content = result.body;
@@ -327,7 +355,7 @@ export async function fetchSource(source, previousTreeSha) {
327
355
  // Real "last updated" date: the file's most recent commit. Falls back
328
356
  // to the repo's pushed_at if the per-file lookup fails (rate limit /
329
357
  // error), so the field is populated even on a degraded run.
330
- const sourceUpdatedAt = (await fetchFileCommitDate(source.repo, file.path)) ?? meta.pushedAt ?? undefined;
358
+ const sourceUpdatedAt = (await fetchFileCommitDate(source.repo, file.path, signal)) ?? meta.pushedAt ?? undefined;
331
359
  return {
332
360
  // Provisional. Final ids are assigned across all sources at once so
333
361
  // two repos racing for the same id cannot decide it between them.
@@ -420,10 +448,20 @@ export async function fetchAllSources(sources, options = {}) {
420
448
  const drafts = [];
421
449
  const treeShas = new Map();
422
450
  const dropped = [];
451
+ const fetchOne = options.fetchSourceImpl ?? fetchSource;
452
+ const signal = options.signal;
453
+ let sourcesFetched = 0;
454
+ let sourcesUnfetched = 0;
423
455
  await Promise.all(sources.map(source => SOURCE_CONCURRENCY(async () => {
456
+ // The limiter releases queued sources one at a time after the abort;
457
+ // each must decline rather than start a fresh crawl.
458
+ if (signal?.aborted) {
459
+ sourcesUnfetched++;
460
+ return;
461
+ }
424
462
  try {
425
463
  const previous = options.previousShas?.get(source.repo);
426
- const result = await fetchSource(source, previous);
464
+ const result = await fetchOne(source, previous, signal);
427
465
  if (result.treeSha)
428
466
  treeShas.set(source.repo, result.treeSha);
429
467
  drafts.push(...result.entries);
@@ -431,11 +469,24 @@ export async function fetchAllSources(sources, options = {}) {
431
469
  options.onProgress?.(source.repo, result.entries.length, result.skipped);
432
470
  }
433
471
  catch (err) {
434
- console.error(`Failed to fetch ${source.repo}:`, err);
472
+ if (!signal?.aborted)
473
+ console.error(`Failed to fetch ${source.repo}:`, err);
435
474
  options.onProgress?.(source.repo, 0, false);
436
475
  }
476
+ // A source cut off mid-flight is counted as fetched: it ran, and its
477
+ // partial entries are in `drafts`. `aborted` is what tells the caller
478
+ // the whole result is partial.
479
+ sourcesFetched++;
437
480
  })));
438
481
  const { entries, collisions } = assignIds(drafts);
439
- return { entries, treeShas, collisions, dropped };
482
+ return {
483
+ entries,
484
+ treeShas,
485
+ collisions,
486
+ dropped,
487
+ aborted: Boolean(signal?.aborted),
488
+ sourcesFetched,
489
+ sourcesUnfetched,
490
+ };
440
491
  }
441
492
  //# sourceMappingURL=github-fetcher.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=github-fetcher.test.d.ts.map
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { fetchAllSources } from './github-fetcher.js';
3
+ /**
4
+ * The scheduling contract of fetchAllSources, exercised through the
5
+ * `fetchSourceImpl` seam so no GitHub call is made.
6
+ *
7
+ * Origin 2026-09-10: a caller had no way to end a crawl short of killing the
8
+ * process, and one ran ~16 hours past the point its job lease was gone. The
9
+ * contract: `signal` stops sources from STARTING, entries already drafted are
10
+ * kept, and the result says it is partial.
11
+ */
12
+ function source(repo) {
13
+ return { repo, patterns: ['**/*.md'], defaultTool: 'claude_code', defaultCategory: 'other' };
14
+ }
15
+ function draft(repo, n) {
16
+ const id = `${repo.replace('/', '--')}--${n}`;
17
+ return {
18
+ id,
19
+ _idCandidates: [id],
20
+ _rank: 0,
21
+ kind: 'skill',
22
+ name: `Skill ${n}`,
23
+ description: 'd',
24
+ category: 'other',
25
+ tags: [],
26
+ tools: ['claude_code'],
27
+ source: { repo, path: `${n}.md`, url: 'u' },
28
+ content: `# ${n}`,
29
+ popularity: { stars: 0 },
30
+ updatedAt: '2026-09-10T00:00:00.000Z',
31
+ };
32
+ }
33
+ function fetched(repo, count) {
34
+ return {
35
+ entries: Array.from({ length: count }, (_, i) => draft(repo, i)),
36
+ treeSha: `sha-${repo}`,
37
+ skipped: false,
38
+ dropped: [],
39
+ };
40
+ }
41
+ function deferred() {
42
+ let resolve;
43
+ const promise = new Promise((r) => { resolve = r; });
44
+ return { promise, resolve };
45
+ }
46
+ describe('fetchAllSources abort contract', () => {
47
+ it('without a signal visits every source and reports a complete result', async () => {
48
+ const sources = ['a/1', 'b/2', 'c/3'].map(source);
49
+ const impl = vi.fn(async (s) => fetched(s.repo, 2));
50
+ const result = await fetchAllSources(sources, { fetchSourceImpl: impl });
51
+ expect(impl).toHaveBeenCalledTimes(3);
52
+ expect(result.entries).toHaveLength(6);
53
+ expect(result).toMatchObject({ aborted: false, sourcesFetched: 3, sourcesUnfetched: 0 });
54
+ });
55
+ it('with an already-aborted signal starts no source at all', async () => {
56
+ const sources = ['a/1', 'b/2', 'c/3'].map(source);
57
+ const impl = vi.fn(async (s) => fetched(s.repo, 2));
58
+ const ctrl = new AbortController();
59
+ ctrl.abort();
60
+ const result = await fetchAllSources(sources, { fetchSourceImpl: impl, signal: ctrl.signal });
61
+ expect(impl).not.toHaveBeenCalled();
62
+ expect(result.entries).toEqual([]);
63
+ expect(result).toMatchObject({ aborted: true, sourcesFetched: 0, sourcesUnfetched: 3 });
64
+ });
65
+ it('aborting mid-crawl keeps what was drafted, starts nothing further, and marks the result partial', async () => {
66
+ // Six sources against the fetcher's concurrency of four: four start, two
67
+ // wait in the limiter's queue. The abort lands while all four are in
68
+ // flight; the two queued ones must decline when their turn comes.
69
+ const sources = ['a/1', 'b/2', 'c/3', 'd/4', 'e/5', 'f/6'].map(source);
70
+ const gates = new Map();
71
+ const impl = vi.fn((s, _prev, signal) => {
72
+ expect(signal).toBeInstanceOf(AbortSignal);
73
+ const gate = deferred();
74
+ gates.set(s.repo, gate);
75
+ return gate.promise;
76
+ });
77
+ const ctrl = new AbortController();
78
+ const progress = [];
79
+ const run = fetchAllSources(sources, {
80
+ fetchSourceImpl: impl,
81
+ signal: ctrl.signal,
82
+ onProgress: (repo) => progress.push(repo),
83
+ });
84
+ await vi.waitFor(() => expect(impl).toHaveBeenCalledTimes(4));
85
+ // One source completes normally, then the caller stops the crawl while
86
+ // three are still in flight.
87
+ gates.get('a/1').resolve(fetched('a/1', 3));
88
+ ctrl.abort();
89
+ // The in-flight three return whatever they had when they noticed.
90
+ gates.get('b/2').resolve(fetched('b/2', 1));
91
+ gates.get('c/3').resolve({ entries: [], treeSha: null, skipped: false, dropped: [] });
92
+ gates.get('d/4').resolve({ entries: [], treeSha: null, skipped: false, dropped: [] });
93
+ const result = await run;
94
+ expect(impl).toHaveBeenCalledTimes(4);
95
+ expect(result.entries.map((e) => e.id).sort()).toEqual(['a--1--0', 'a--1--1', 'a--1--2', 'b--2--0'].sort());
96
+ expect(result).toMatchObject({ aborted: true, sourcesFetched: 4, sourcesUnfetched: 2 });
97
+ expect(progress.sort()).toEqual(['a/1', 'b/2', 'c/3', 'd/4']);
98
+ // The two that never ran are not reported as progress either.
99
+ expect(progress).not.toContain('e/5');
100
+ });
101
+ it('a source that throws after the abort is counted as fetched and does not surface as an error', async () => {
102
+ const sources = ['a/1'].map(source);
103
+ const ctrl = new AbortController();
104
+ const impl = vi.fn(async () => {
105
+ ctrl.abort();
106
+ throw new Error('The operation was aborted');
107
+ });
108
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined);
109
+ try {
110
+ const result = await fetchAllSources(sources, { fetchSourceImpl: impl, signal: ctrl.signal });
111
+ expect(result).toMatchObject({ aborted: true, sourcesFetched: 1, sourcesUnfetched: 0, entries: [] });
112
+ expect(errors).not.toHaveBeenCalled();
113
+ }
114
+ finally {
115
+ errors.mockRestore();
116
+ }
117
+ });
118
+ });
119
+ //# sourceMappingURL=github-fetcher.test.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulemetric/skills-registry",
3
- "version": "0.15.1",
3
+ "version": "0.17.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },