skilld matched fixed text patterns in SKILL.md and file names. Patterns miss obfuscated code.
diff --git a/layers/registry/server/api/skill-asset/[...slug].get.ts b/layers/registry/server/api/skill-asset/[...slug].get.ts
index 983b41b2..9f84bb9e 100644
--- a/layers/registry/server/api/skill-asset/[...slug].get.ts
+++ b/layers/registry/server/api/skill-asset/[...slug].get.ts
@@ -4,9 +4,9 @@ import { normalizeSkillAssetFilePath } from '#shared/skill-asset-path'
import { resolveRepoSourceIdentityFromRow } from '../../utils/repo-source-identity'
import { skillImagePolicyForEvent } from '../../utils/skill-image-policy'
import { parseSkillMd } from '../../utils/skill-md-render'
+import { loadStoredSkillRow, parseStoredAssets, resolveReferencedFileTarget } from '../../utils/skill-stored-source'
import { findSkill } from '../../utils/skills-registry'
import { fetchUpstreamText } from '../../utils/upstream-text'
-import { fetchUpstreamTree } from '../../utils/upstream-tree'
const ASSET_CACHE_TTL = 60 * 60 * 24 * 7
const ASSET_MISSING_TTL = 60 * 60
@@ -22,14 +22,6 @@ interface AssetCache {
skillPath: string | null
}
-interface SkillAssetRow {
- default_branch: string | null
- assets: string | null
- source_owner: string | null
- source_repo: string | null
- source_resolved: number | null
-}
-
interface RepoSkillNameRow {
name: string
}
@@ -79,12 +71,7 @@ export default defineApiHandler({
throw createError({ statusCode: 404, message: 'Skill not found' })
const [row, repoSkillRows] = await Promise.all([
- platform.db
- .prepare(`SELECT r.default_branch, r.source_owner, r.source_repo, s.assets, s.source_resolved
- FROM skills s JOIN repos r ON r.owner = s.owner AND r.repo = s.repo
- WHERE s.owner = ? AND s.repo = ? AND s.name = ?`)
- .bind(skill.owner, skill.repo, skill.name)
- .first
(),
+ loadStoredSkillRow(platform.db, skill),
platform.db
.prepare(`SELECT name FROM skills WHERE owner = ? AND repo = ? ORDER BY name`)
.bind(skill.owner, skill.repo)
@@ -101,21 +88,7 @@ export default defineApiHandler({
throw createError({ statusCode: 410, message: 'Skill source is gone upstream' })
const source = resolveRepoSourceIdentityFromRow(skill, row)
- let registered: RegisteredAsset[] = []
- if (row.assets) {
- try {
- const parsed = JSON.parse(row.assets) as unknown
- if (Array.isArray(parsed)) {
- registered = parsed.filter((a): a is RegisteredAsset =>
- Boolean(a) && typeof a === 'object' && typeof (a as { path: unknown }).path === 'string',
- )
- }
- }
- catch {
- // Treat as empty list.
- }
- }
-
+ const registered = parseStoredAssets(row.assets)
// Any file inside the resolved skillDir is fair game — the path is
// already constrained below so this stays scoped to the skill folder.
// Registration is used only to pick up the recorded size/type when
@@ -124,8 +97,17 @@ export default defineApiHandler({
const asset: RegisteredAsset = registeredAsset
?? { path: filePath, size: 0, type: classifyAsset(filePath) }
- const branch = row.default_branch || 'main'
- const cacheKey = `skills:asset:v4:${source.owner}/${source.repo}/${skill.name}:${filePath}:${branch}`
+ const target = resolveReferencedFileTarget(skill, row, filePath)
+ if (target._tag === 'missing')
+ throw createError({ statusCode: 404, message: 'Skill source not found' })
+ if (target._tag === 'unavailable') {
+ setHeader(event, 'retry-after', ASSET_RETRY_AFTER)
+ throw createError({ statusCode: 503, message: 'Skill source commit is unavailable. Try again after the next sync.' })
+ }
+ const branch = row.rendered_commit_sha!
+ const skillMdPath = row.rendered_skill_path!
+ const skillDir = skillMdPath.replace(/(?:^|\/)SKILL\.md$/i, '')
+ const cacheKey = `skills:asset:v5:${source.owner}/${source.repo}/${skill.name}:${filePath}:${branch}`
const cached = await readCache(useStorage('edge-cache'), cacheKey)
if (cached) {
if (cached.status === 'missing')
@@ -133,47 +115,7 @@ export default defineApiHandler({
return cached
}
- // Resolve the skill directory by re-finding the SKILL.md path.
- const treeResult = await fetchUpstreamTree(source, branch, { operation: 'skill-asset-tree-fetch' })
-
- if (treeResult._tag === 'gone') {
- // The registry has not recorded this deletion yet. The next sync flips
- // `source_resolved` and short-circuits earlier.
- throw createError({ statusCode: 410, message: 'Skill source is gone upstream' })
- }
-
- const treeUnavailable = treeResult._tag === 'unavailable'
- const treeFiles = treeResult._tag === 'available' ? treeResult.files : []
- const slugifiedName = skill.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
- const skillMdPath = treeFiles.find(f =>
- f.path.toLowerCase().endsWith(`/${slugifiedName}/skill.md`)
- || f.path.toLowerCase() === `${slugifiedName}/skill.md`
- || f.path.toLowerCase().endsWith(`/${skill.name.toLowerCase()}/skill.md`),
- )?.path
-
- if (!skillMdPath) {
- // A tree-listing outage must not leave a "missing" marker behind, or the
- // asset reads as deleted for the rest of the cache window.
- if (treeUnavailable) {
- setHeader(event, 'retry-after', ASSET_RETRY_AFTER)
- throw createError({ statusCode: 503, message: 'Skill source is unavailable upstream' })
- }
- await writeCache(useStorage('edge-cache'), cacheKey, {
- status: 'missing',
- raw: null,
- html: null,
- size: asset.size,
- type: asset.type,
- branch,
- skillPath: null,
- } satisfies AssetCache, { ttl: ASSET_MISSING_TTL })
- throw createError({ statusCode: 404, message: 'Skill source not found' })
- }
-
- const skillDir = skillMdPath.replace(/\/SKILL\.md$/, '')
- const fullPath = `${skillDir}/${filePath}`
- const rawUrl = `https://raw.githubusercontent.com/${source.owner}/${source.repo}/${branch}/${fullPath}`
- const upstream = await fetchUpstreamText(rawUrl)
+ const upstream = await fetchUpstreamText(target.url)
if (upstream._tag === 'missing') {
emitOperationalEvent(createWideEvent({ 'operation': 'skill-asset-fetch', 'outcome': 'missing', 'upstream.status': upstream.status }))
diff --git a/layers/registry/server/api/skills-raw/[...slug].get.ts b/layers/registry/server/api/skills-raw/[...slug].get.ts
index 15c53306..b388646d 100644
--- a/layers/registry/server/api/skills-raw/[...slug].get.ts
+++ b/layers/registry/server/api/skills-raw/[...slug].get.ts
@@ -76,6 +76,10 @@ export default defineApiHandler({
// A file beside SKILL.md (e.g. `references/foo.md`) is not stored, so it
// is the one read that still reaches GitHub. Its directory comes from D1.
const target = resolveReferencedFileTarget(skill, row, filePath)
+ if (target._tag === 'unavailable') {
+ setHeader(event, 'retry-after', RAW_RETRY_AFTER)
+ throw createError({ statusCode: 503, message: 'Skill source commit is unavailable. Try again after the next sync.' })
+ }
if (target._tag === 'missing')
throw createError({ statusCode: 404, message: 'Referenced file not found in repository' })
diff --git a/layers/registry/server/api/skills/[...slug].get.ts b/layers/registry/server/api/skills/[...slug].get.ts
index 35336ed4..4515edd7 100644
--- a/layers/registry/server/api/skills/[...slug].get.ts
+++ b/layers/registry/server/api/skills/[...slug].get.ts
@@ -2,7 +2,7 @@ import type { H3Event } from 'h3'
import type { z } from 'zod'
import type { Platform } from '#shared/server/platform'
-import { cached } from '#shared/server/cache'
+import { cached, readThroughCache } from '#shared/server/cache'
import { defineApiHandler } from '#shared/server/handler'
import { githubSkillFileUrl } from '#shared/skill-file-url'
import { selectSkillFiles } from '#shared/skill-files'
@@ -17,6 +17,7 @@ import { skillPageBehaviors } from '../../utils/skill-behaviors'
import { findSkillDemo, presentSkillDemo } from '../../utils/skill-demos'
import { getGeneratedKinds } from '../../utils/skill-generated'
import { skillImagePolicyForEvent } from '../../utils/skill-image-policy'
+import { readRepositoryLicense } from '../../utils/skill-license'
import { parseSkillMd } from '../../utils/skill-md-render'
import { findDuplicateGroupForSkill, findSkillWithRow } from '../../utils/skills-registry'
import { tagLinkPath } from '../../utils/tag-quality'
@@ -145,13 +146,14 @@ const DETAIL_COLUMNS_SQL = `r.forks, r.repo_created_at,
s.seo_index_reasons, s.seo_index_synced_at,
s.curator_count, s.curator_reason_count, s.approved_social_count, s.author_social_count,
s.trust_source, s.trust_reasons, s.trust_synced_at,
- s.rendered_status, s.rendered_raw, s.rendered_frontmatter, s.rendered_html,
+ s.rendered_status, s.rendered_commit_sha, s.rendered_raw, s.rendered_frontmatter, s.rendered_html,
(SELECT sr.sha FROM skill_revisions sr
WHERE sr.owner = s.owner AND sr.repo = s.repo AND sr.name = s.name
ORDER BY sr.modified_at DESC LIMIT 1) AS latest_revision_sha,
${SKILL_TRENDING_AWARDS_SQL}`
interface SkillDetailRow {
+ rendered_commit_sha: string | null
// repo meta
stars: number | null
forks: number | null
@@ -218,7 +220,7 @@ const skillDetailHandler = defineApiHandler({
return cached({
storage: useStorage('edge-cache'),
- key: `skills:detail:v4:${slug.toLowerCase()}`,
+ key: `skills:detail:v5:${slug.toLowerCase()}`,
ttlSeconds: DETAIL_CACHE_TTL,
staleSeconds: DETAIL_CACHE_STALE_TTL,
compute: () => loadSkillDetail(event, platform, slug),
@@ -255,7 +257,7 @@ async function loadSkillDetail(event: H3Event, platform: Platform, slug: string)
const source = resolveRepoSourceIdentityFromRow(skill, row)
const githubUrl = `https://github.com/${source.owner}/${source.repo}`
- const branch = row.default_branch || 'main'
+ const branch = row.rendered_commit_sha || row.default_branch || 'main'
const repoSkillNames = (repoSkillRows.results ?? []).map(candidate => candidate.name)
// Warm path: render is in D1. Cold path (no usable stored render): render
@@ -271,7 +273,7 @@ async function loadSkillDetail(event: H3Event, platform: Platform, slug: string)
repo: source.repo,
name: skill.name,
branch,
- skillDir: row.rendered_skill_path?.replace(/\/SKILL\.md$/, '') ?? '',
+ skillDir: row.rendered_skill_path?.replace(/(?:^|\/)SKILL\.md$/i, '') ?? '',
filePath: '',
skillNames: repoSkillNames,
registryOwner: skill.owner,
@@ -316,7 +318,29 @@ async function loadSkillDetail(event: H3Event, platform: Platform, slug: string)
const keywords = rawAiTags.filter(t => !knownTagSlugs.has(t))
const description = frontmatterString(rendered.frontmatter, 'description') ?? skill.description ?? null
- const license = frontmatterString(rendered.frontmatter, 'license')
+ let license = frontmatterString(rendered.frontmatter, 'license')
+ let licenseSource: 'skill' | 'repository' | null = license ? 'skill' : null
+ if (!license && rendered.skillPath === 'SKILL.md' && row.rendered_commit_sha && row.source_resolved !== 0) {
+ const commit = row.rendered_commit_sha
+ const repositoryLicense = await readThroughCache(
+ useStorage('edge-cache'),
+ `skill:license:v1:${source.owner}/${source.repo}@${commit}`,
+ async () => {
+ const answer = await readRepositoryLicense({ ...source, commit }, resolveGithubBindings(event.context.platform?.env))
+ if (answer._tag === 'unavailable')
+ throw new Error(`Repository license unavailable (${answer.status})`)
+ return answer
+ },
+ { ttl: 60 * 60 * 24 * 7 },
+ ).catch((error: unknown) => {
+ emitOperationalEvent(createWideEvent({ operation: 'skill-license', outcome: 'failed', reason: error instanceof Error ? error.message : String(error) }))
+ return null
+ })
+ if (repositoryLicense?._tag === 'known') {
+ license = repositoryLicense.license
+ licenseSource = 'repository'
+ }
+ }
let assets: { path: string, size: number, type: string }[] = []
if (row.assets) {
try {
@@ -352,7 +376,7 @@ async function loadSkillDetail(event: H3Event, platform: Platform, slug: string)
source: { owner: source.owner, repo: source.repo, branch, skillPath: sourceGone ? null : rendered.skillPath },
})
// `current_sha` is the blob sha of SKILL.md, not a commit, so it cannot pin a link.
- const sourceCommitSha = row.latest_revision_sha ?? null
+ const sourceCommitSha = row.rendered_commit_sha ?? null
const skillDemo = findSkillDemo(skill.owner, skill.repo, skill.name)
const pushedAtIso = epochToIso(row.pushed_at)
const createdAtIso = epochToIso(row.repo_created_at)
@@ -380,10 +404,11 @@ async function loadSkillDetail(event: H3Event, platform: Platform, slug: string)
frontmatter: rendered.frontmatter,
raw: rendered.raw,
assets: selectedAssets.files,
- assetCount: selectedAssets.total,
+ assetCount: Math.max(row.references_count ?? 0, selectedAssets.total),
curators,
description,
license,
+ licenseSource,
stars: row.stars ?? 0,
forks: row.forks ?? 0,
pushedAt: pushedAtIso,
@@ -402,7 +427,7 @@ async function loadSkillDetail(event: H3Event, platform: Platform, slug: string)
createdAt: createdAtIso,
stars: row.stars ?? 0,
forks: row.forks ?? 0,
- defaultBranch: branch,
+ defaultBranch: row.default_branch || 'main',
},
source: {
resolved: sourceResolved,
@@ -585,7 +610,7 @@ async function renderLive(
for (const path of candidates) {
const raw = await readRaw(path, 'skill-detail-candidate-fetch')
if (raw) {
- const skillDir = path.replace(/\/SKILL\.md$/, '')
+ const skillDir = path.replace(/(?:^|\/)SKILL\.md$/i, '')
const parsed = await parseSkillMd(raw, {
owner: sourceOwner,
repo: sourceRepo,
@@ -631,7 +656,7 @@ async function renderLive(
if (match) {
const raw = await readRaw(match.path, 'skill-detail-matched-fetch')
if (raw) {
- const skillDir = match.path.replace(/\/SKILL\.md$/, '')
+ const skillDir = match.path.replace(/(?:^|\/)SKILL\.md$/i, '')
const parsed = await parseSkillMd(raw, {
owner: sourceOwner,
repo: sourceRepo,
diff --git a/layers/registry/server/presenters/skill-detail-v1.ts b/layers/registry/server/presenters/skill-detail-v1.ts
index e8563de8..32216bd8 100644
--- a/layers/registry/server/presenters/skill-detail-v1.ts
+++ b/layers/registry/server/presenters/skill-detail-v1.ts
@@ -1,4 +1,5 @@
import type { OperationResult, skillsV1 } from 'skilld-sdk/contract'
+import { createError } from 'h3'
import { epochSecondsToIso, presentCount } from '#shared/server/skill-cards'
import { skillInstallCmd, skillRunCmd } from '#shared/skill-commands'
@@ -31,7 +32,7 @@ export interface LegacySkillDetail {
label: string
locations: { path: string, line: number | null }[]
total: number
- }[]
+ }[] | null
}
provenance: {
sourceCommitSha: string | null
@@ -44,6 +45,8 @@ export interface LegacySkillDetail {
const SITE_ORIGIN = 'https://skilld.dev'
export function presentSkillDetail(detail: LegacySkillDetail): OperationResult {
+ if (detail.sourceFacts.behaviors === null)
+ throw createError({ statusCode: 503, message: 'Skill behaviors are unavailable. Try again later.' })
return {
owner: detail.owner,
repository: detail.repo,
diff --git a/layers/registry/server/utils/github-client.ts b/layers/registry/server/utils/github-client.ts
index f62ad779..fbdb4c33 100644
--- a/layers/registry/server/utils/github-client.ts
+++ b/layers/registry/server/utils/github-client.ts
@@ -380,6 +380,7 @@ export interface RepoSummary {
*/
repositoryId: number
headTreeSha: string | null
+ headCommitSha: string | null
}
/**
@@ -405,7 +406,7 @@ function repoSummaryFromGql(r: RepoSummaryGql): RepoSummary | null {
archived: r.isArchived,
fork: r.isFork,
}
- return { meta, repositoryId: r.databaseId, headTreeSha: r.defaultBranchRef?.target?.tree.oid ?? null }
+ return { meta, repositoryId: r.databaseId, headTreeSha: r.defaultBranchRef?.target?.tree.oid ?? null, headCommitSha: r.defaultBranchRef?.target?.oid ?? null }
}
/**
diff --git a/layers/registry/server/utils/skill-behaviors.ts b/layers/registry/server/utils/skill-behaviors.ts
index 966a86dd..98b470e4 100644
--- a/layers/registry/server/utils/skill-behaviors.ts
+++ b/layers/registry/server/utils/skill-behaviors.ts
@@ -13,7 +13,7 @@ export type SkillPageBehavior = Omit & {
* file content, so only those inputs reach the shared rules. `skilld run`
* applies the same rules to every file.
*
- * The panel is optional, so a rules failure returns no behaviors and reports
+ * The panel is optional, so a rules failure returns unavailable and reports
* itself. On 2026-10-05 the bundled rules failed their own parse, and the
* throw took every Skill page down with a 503.
*/
@@ -21,7 +21,7 @@ export function skillPageBehaviors(input: {
raw: string | null
assetPaths: readonly string[]
source: { owner: string, repo: string, branch: string | null, skillPath: string | null }
-}, detect: typeof detectBehaviors = detectBehaviors): SkillPageBehavior[] {
+}, detect: typeof detectBehaviors = detectBehaviors): SkillPageBehavior[] | null {
const files = [
...(input.raw === null ? [] : [{ path: 'SKILL.md', text: input.raw }]),
...input.assetPaths.map(path => ({ path })),
@@ -51,7 +51,7 @@ export function skillPageBehaviors(input: {
outcome: 'failed',
reason: error instanceof Error ? error.message : String(error),
}))
- return []
+ return null
}
return detected.map(behavior => ({
...behavior,
diff --git a/layers/registry/server/utils/skill-license.ts b/layers/registry/server/utils/skill-license.ts
new file mode 100644
index 00000000..fb128d71
--- /dev/null
+++ b/layers/registry/server/utils/skill-license.ts
@@ -0,0 +1,30 @@
+import type { FetchOutcome, GithubBindings } from './github-client'
+import { z } from 'zod'
+import { getGithubJson } from './github-client'
+
+const RepositoryLicenseSchema = z.object({ license: z.object({ spdx_id: z.string() }).nullable() })
+
+export type RepositoryLicense
+ = | { _tag: 'known', license: string }
+ | { _tag: 'missing' }
+ | { _tag: 'unavailable', status: number }
+
+/** Only a root Skill inherits the Repository license. Nested Skills may have their own terms. */
+export async function readRepositoryLicense(
+ source: { owner: string, repo: string, commit: string },
+ bindings: GithubBindings,
+ read: (path: `/${string}`, bindings: GithubBindings) => Promise> = getGithubJson,
+): Promise {
+ const answer = await read(`/repos/${source.owner}/${source.repo}/license?ref=${encodeURIComponent(source.commit)}`, bindings)
+ if (answer.status === 404)
+ return { _tag: 'missing' }
+ if (!answer.data)
+ return { _tag: 'unavailable', status: answer.status }
+ const parsed = RepositoryLicenseSchema.safeParse(answer.data)
+ if (!parsed.success)
+ return { _tag: 'unavailable', status: 502 }
+ const license = parsed.data.license?.spdx_id.trim()
+ return !license || license === 'NOASSERTION'
+ ? { _tag: 'missing' }
+ : { _tag: 'known', license }
+}
diff --git a/layers/registry/server/utils/skill-md-render.spec.ts b/layers/registry/server/utils/skill-md-render.spec.ts
index b5ec408e..9eaed43c 100644
--- a/layers/registry/server/utils/skill-md-render.spec.ts
+++ b/layers/registry/server/utils/skill-md-render.spec.ts
@@ -5,6 +5,18 @@ import { importImageProxyKey, signImageProxyUrl } from '#server/utils/image-prox
import { parseSkillMd } from './skill-md-render'
describe('parseSkillMd', () => {
+ it('opens a reference beside a root SKILL.md without dropping its first character', async () => {
+ const { html } = await parseSkillMd('[Onboarding](onboarding.md)', {
+ owner: 'acme',
+ repo: 'playbook',
+ name: 'playbook',
+ branch: 'c'.repeat(40),
+ skillDir: '',
+ filePath: '',
+ })
+ const doc = new DOMParser().parseFromString(html, 'text/html')
+ expect(doc.querySelector('a')?.getAttribute('href')).toBe('/gh/acme/playbook/playbook/-/onboarding.md')
+ })
it('keeps archived text without links to removed supporting files', async () => {
const parsed = await parseSkillMd('[**Guide**](references/guide.md) and [site](https://example.com).', {
owner: 'acme',
diff --git a/layers/registry/server/utils/skill-md-render.ts b/layers/registry/server/utils/skill-md-render.ts
index 935b7593..38fa3ecc 100644
--- a/layers/registry/server/utils/skill-md-render.ts
+++ b/layers/registry/server/utils/skill-md-render.ts
@@ -86,10 +86,10 @@ function rewriteHref(href: string, kind: 'link' | 'image', ctx?: SkillRenderCont
? joinPath(ctx.skillDir, ctx.filePath.split('/').slice(0, -1).join('/'))
: ctx.skillDir
const resolved = joinPath(baseDir, path)
- const isInsideSkillDir = resolved === ctx.skillDir || resolved.startsWith(`${ctx.skillDir}/`)
+ const isInsideSkillDir = ctx.skillDir === '' || resolved === ctx.skillDir || resolved.startsWith(`${ctx.skillDir}/`)
const isMarkdown = /\.(?:md|markdown)$/i.test(resolved)
if (kind === 'link' && isInsideSkillDir && isMarkdown) {
- const rel = resolved.slice(ctx.skillDir.length + 1)
+ const rel = ctx.skillDir ? resolved.slice(ctx.skillDir.length + 1) : resolved
if (!rel)
return `/gh/${ctx.owner}/${ctx.repo}/${ctx.name}${suffix}`
return `/gh/${ctx.owner}/${ctx.repo}/${ctx.name}/-/${rel}${suffix}`
diff --git a/layers/registry/server/utils/skill-stored-source.ts b/layers/registry/server/utils/skill-stored-source.ts
index 29de3122..6b4e12f1 100644
--- a/layers/registry/server/utils/skill-stored-source.ts
+++ b/layers/registry/server/utils/skill-stored-source.ts
@@ -15,9 +15,11 @@ export interface StoredSkillRow {
source_resolved: number | null
rendered_status: string | null
rendered_raw: string | null
+ rendered_commit_sha: string | null
rendered_skill_path: string | null
/** JSON array of `{ path, size, type }`, written by the sync. */
assets: string | null
+ references_count: number
}
export type SkillFileType = 'markdown' | 'code' | 'image' | 'data' | 'other'
@@ -42,7 +44,7 @@ export async function loadStoredSkillRow(
return db
.prepare(`
SELECT r.default_branch, r.source_owner, r.source_repo,
- s.source_resolved, s.rendered_status, s.rendered_raw, s.rendered_skill_path, s.assets
+ s.source_resolved, s.rendered_status, s.rendered_raw, s.rendered_skill_path, s.rendered_commit_sha, s.assets, s.references_count
FROM skills s JOIN repos r ON r.owner = s.owner AND r.repo = s.repo
WHERE s.owner = ? AND s.repo = ? AND s.name = ?
`)
@@ -68,7 +70,7 @@ export function readStoredSkillMd(skill: RepoIdentity, row: StoredSkillRow): Sto
return {
_tag: 'ok',
body: row.rendered_raw,
- source: `${source.owner}/${source.repo}@${row.default_branch || 'main'}/${row.rendered_skill_path}`,
+ source: `${source.owner}/${source.repo}@${row.rendered_commit_sha ?? 'unknown'}/${row.rendered_skill_path}`,
}
}
@@ -112,25 +114,25 @@ export type StoredSkillFiles
| { _tag: 'gone' }
/**
- * The file list as of the last sync. A Skill whose SKILL.md sits at the
- * repository root has no stored assets, so its list is empty but its
- * `skillPath` is set. A Skill with no resolved SKILL.md has `skillPath: null`.
+ * The file list as of the last sync, including a root Skill's supporting files.
+ * A Skill with no resolved SKILL.md has `skillPath: null`.
*/
export function readStoredSkillFiles(row: StoredSkillRow): StoredSkillFiles {
if (row.source_resolved === 0)
return { _tag: 'gone' }
- const branch = row.default_branch || 'main'
+ const branch = row.rendered_commit_sha || row.default_branch || 'main'
if (row.rendered_status !== 'ok' || !row.rendered_skill_path)
return { _tag: 'ok', payload: { skillPath: null, branch, files: [], total: 0 } }
const selected = selectSkillFiles(parseStoredAssets(row.assets))
return {
_tag: 'ok',
- payload: { skillPath: row.rendered_skill_path, branch, files: selected.files, total: selected.total },
+ payload: { skillPath: row.rendered_skill_path, branch, files: selected.files, total: Math.max(row.references_count, selected.total) },
}
}
export type ReferencedFileTarget
= | { _tag: 'ok', url: string, path: string, source: string }
+ | { _tag: 'unavailable' }
| { _tag: 'missing' }
/**
@@ -146,7 +148,9 @@ export function resolveReferencedFileTarget(
if (!row.rendered_skill_path)
return { _tag: 'missing' }
const source = resolveRepoSourceIdentityFromRow(skill, row)
- const branch = row.default_branch || 'main'
+ if (!row.rendered_commit_sha)
+ return { _tag: 'unavailable' }
+ const branch = row.rendered_commit_sha
const skillDir = row.rendered_skill_path.replace(/(?:^|\/)skill\.md$/i, '')
const path = skillDir ? `${skillDir}/${filePath}` : filePath
return {
diff --git a/layers/registry/server/utils/sync-repo.ts b/layers/registry/server/utils/sync-repo.ts
index b24a31bb..f50ed82a 100644
--- a/layers/registry/server/utils/sync-repo.ts
+++ b/layers/registry/server/utils/sync-repo.ts
@@ -3,6 +3,7 @@
import type { GithubBindings, RepoMeta } from './github-client'
import type { RepositoryMovePlan, RepositoryName } from './repository-move'
import type { SkillTrustTier } from './skill-trust'
+import { selectSkillFiles } from '#shared/skill-files'
import { canonicalSkillPaths, isRegistrySkillPath, isSkilldCacheSkill } from '#shared/skill-path'
import { isCategoryPinned } from '../data/clusters'
import { getBlobsBatch, getCommitsBatch, getRepoSummary, getTree, logRateLimit } from './github-client'
@@ -53,6 +54,7 @@ interface ExistingSkill {
last_synced_at: number | null
references_count: number
rendered_skill_path: string | null
+ rendered_commit_sha: string | null
rendered_status: string | null
owner_verified: number
}
@@ -253,7 +255,7 @@ function classifyAsset(path: string): SkillAsset['type'] {
return 'other'
}
-const ASSET_IGNORE = /(?:^|\/)(?:LICENSE(?:\.[^/]+)?|\.DS_Store|\.gitignore|\.gitattributes)$/i
+const ASSET_IGNORE = /(?:^|\/)(?:\.DS_Store|\.gitignore|\.gitattributes)$/i
/**
* Bucket a repo's blobs by the skill directories that own them, in one pass.
@@ -277,6 +279,9 @@ function collectAssetsByDir(
for (const e of tree) {
if (e.type !== 'blob')
continue
+ const root = byDir.get('')
+ if (root && e.path !== 'SKILL.md' && !ASSET_IGNORE.test(e.path))
+ root.push({ path: e.path, size: e.size ?? 0, type: classifyAsset(e.path) })
// An asset belongs to every skill directory above it, which keeps the
// prefix semantics intact when skills nest inside one another.
for (let slash = e.path.indexOf('/'); slash !== -1; slash = e.path.indexOf('/', slash + 1)) {
@@ -302,7 +307,7 @@ export async function loadExistingSkillSummaries(
const res = await db
.prepare(
`SELECT name, current_sha, modified_at, first_seen_at, last_synced_at,
- references_count, rendered_skill_path, rendered_status,
+ references_count, rendered_skill_path, rendered_commit_sha, rendered_status,
owner_verified
FROM skills WHERE owner = ? AND repo = ?`,
)
@@ -405,10 +410,10 @@ export async function refreshRepoAssets(
}
const existing = [...(await loadExistingSkillSummaries(db, owner, repo)).values()]
- const skillPaths = new Set(
+ const skillPaths = new Map(
treeRes.data.tree
.filter(entry => entry.type === 'blob' && isRegistrySkillPath(entry.path))
- .map(entry => entry.path),
+ .map(entry => [entry.path, entry.sha]),
)
let skillsChanged = 0
let pathsMissing = 0
@@ -421,7 +426,7 @@ export async function refreshRepoAssets(
)
const withPath = slice.flatMap((skill) => {
const path = skill.rendered_skill_path
- if (!path || !skillPaths.has(path)) {
+ if (!path || skillPaths.get(path) !== skill.current_sha) {
pathsMissing += 1
return []
}
@@ -431,15 +436,15 @@ export async function refreshRepoAssets(
const writes: D1PreparedStatement[] = []
for (const { skill, dirPath } of withPath) {
const assets = assetsByDir.get(dirPath) ?? []
- const assetsJson = JSON.stringify(assets)
- if (skill.references_count === assets.length && existingAssets.get(skill.name) === assetsJson)
+ const assetsJson = JSON.stringify(selectSkillFiles(assets).files)
+ if (skill.references_count === assets.length && existingAssets.get(skill.name) === assetsJson && skill.rendered_commit_sha === repoRes.data.headCommitSha)
continue
skillsChanged += 1
writes.push(db.prepare(
`UPDATE skills
- SET references_count = ?, assets = ?
+ SET references_count = ?, assets = ?, rendered_commit_sha = ?
WHERE owner = ? AND repo = ? AND name = ?`,
- ).bind(assets.length, assetsJson, owner, repo, skill.name))
+ ).bind(assets.length, assetsJson, repoRes.data.headCommitSha ?? null, owner, repo, skill.name))
writes.push(db.prepare(
`INSERT INTO skill_dirty (owner, repo, name, reason, queued_at, attempts)
VALUES (?, ?, ?, 'references_changed', ?, 0)
@@ -847,6 +852,7 @@ export async function syncRepo(
const sourceOwner = meta.owner.login
const sourceRepo = meta.name
const headTreeSha = repoRes.data.headTreeSha
+ const sourceCommit = repoRes.data.headCommitSha ?? null
const branch = meta.default_branch || 'main'
const repoPushedAt = epoch(meta.pushed_at)
const checkedAt = opts.continuation?.checkedAt ?? nowSec()
@@ -937,10 +943,8 @@ export async function syncRepo(
// Naming follows the convention `skill-mention-verify.ts` already uses for
// the same case: the repository name is the skill name.
//
- // `dirPath` is deliberately empty. `collectAssetsByDir` only ever looks up
- // paths that contain a slash, so a root skill collects no assets rather
- // than claiming every file in the repository as its own, which is the
- // failure this would otherwise invite on a large repo.
+ // The root Skill owns its folder, just as Artifact delivery does.
+ // Supporting files must reach the file viewer and Agent Markdown too.
if (entry.path === 'SKILL.md') {
skillFiles.push({
path: entry.path,
@@ -1104,7 +1108,7 @@ export async function syncRepo(
const assets = assetsByDir.get(file.dirPath) ?? []
const refsCount = assets.length
- const assetsJson = JSON.stringify(assets)
+ const assetsJson = JSON.stringify(selectSkillFiles(assets).files)
const referencesChanged = prev.references_count !== refsCount || existingAssets.get(prev.name) !== assetsJson
const ownerVerificationChanged = opts.ownerVerified === true && prev.owner_verified !== 1
@@ -1114,6 +1118,7 @@ export async function syncRepo(
`UPDATE skills
SET references_count = ?,
assets = ?,
+ rendered_commit_sha = ?,
last_synced_at = ?,
sync_status = 'ok',
source_resolved = 1,
@@ -1122,6 +1127,7 @@ export async function syncRepo(
[
refsCount,
assetsJson,
+ sourceCommit,
now,
opts.ownerVerified ? 1 : 0,
owner,
@@ -1158,7 +1164,7 @@ export async function syncRepo(
const blobs = new Map()
const unreadablePaths = new Set()
if (contentFiles.length > 0) {
- const blobsRes = await getBlobsBatch(sourceOwner, sourceRepo, branch, contentFiles.map(file => file.path), bindings)
+ const blobsRes = await getBlobsBatch(sourceOwner, sourceRepo, sourceCommit ?? branch, contentFiles.map(file => file.path), bindings)
logRateLimit(`blobs ${owner}/${repo}`, blobsRes.rateLimit)
trackRateLimit(blobsRes.rateLimit)
if (!blobsRes.data) {
@@ -1334,12 +1340,12 @@ export async function syncRepo(
indexability,
} = file
const firstSeenAt = prev?.first_seen_at ?? now
- const skillDir = file.path.replace(/\/SKILL\.md$/, '')
+ const skillDir = file.path.replace(/(?:^|\/)SKILL\.md$/i, '')
const rendered = await parseSkillMd(raw, {
owner: sourceOwner,
repo: sourceRepo,
name: parsed.name,
- branch,
+ branch: sourceCommit ?? branch,
skillDir,
filePath: '',
})
@@ -1375,12 +1381,12 @@ export async function syncRepo(
is_official, source_resolved, seo_index_score, seo_indexable,
seo_index_reasons, seo_index_synced_at,
trust_tier, trust_source, trust_score, trust_reasons, trust_synced_at,
- rendered_skill_path, rendered_status, rendered_raw, rendered_raw_sha256, rendered_frontmatter, rendered_html, rendered_at,
+ rendered_skill_path, rendered_commit_sha, rendered_status, rendered_raw, rendered_raw_sha256, rendered_frontmatter, rendered_html, rendered_at,
owner_verified
) VALUES (
?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, 'ok',
?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
- 'ok', ?, ?, ?, ?, ?, ?
+ ?, 'ok', ?, ?, ?, ?, ?, ?
)
ON CONFLICT(owner, repo, name) DO UPDATE SET
display_name = excluded.display_name,
@@ -1404,6 +1410,7 @@ export async function syncRepo(
trust_reasons = CASE WHEN skills.trust_synced_at IS NULL THEN excluded.trust_reasons ELSE skills.trust_reasons END,
trust_synced_at = COALESCE(skills.trust_synced_at, excluded.trust_synced_at),
rendered_skill_path = excluded.rendered_skill_path,
+ rendered_commit_sha = excluded.rendered_commit_sha,
rendered_status = excluded.rendered_status,
rendered_raw = excluded.rendered_raw,
rendered_raw_sha256 = excluded.rendered_raw_sha256,
@@ -1422,7 +1429,7 @@ export async function syncRepo(
modifiedAt,
firstSeenAt,
refsCount,
- JSON.stringify(assets),
+ JSON.stringify(selectSkillFiles(assets).files),
now,
isOfficial ? 1 : 0,
indexability.score,
@@ -1435,6 +1442,7 @@ export async function syncRepo(
JSON.stringify(trust.reasons),
now,
file.path,
+ sourceCommit,
raw,
renderedRawSha256,
JSON.stringify(rendered.frontmatter),
diff --git a/migrations/0148_skill_snapshot_commit.sql b/migrations/0148_skill_snapshot_commit.sql
new file mode 100644
index 00000000..5905d015
--- /dev/null
+++ b/migrations/0148_skill_snapshot_commit.sql
@@ -0,0 +1,11 @@
+-- A Skill revision names its instructions, not its supporting file inventory.
+-- Leave old snapshots unpinned until sync captures both at the same commit.
+ALTER TABLE skills ADD COLUMN rendered_commit_sha TEXT;
+
+-- Clear both skip cursors for every Skill repository, including dormant ones.
+-- Root inventories and legacy snapshots then refresh without another push.
+UPDATE repos SET last_tree_sha = NULL, pushed_at = NULL, repo_meta_synced_at = NULL
+WHERE EXISTS (
+ SELECT 1 FROM skills s
+ WHERE s.owner = repos.owner AND s.repo = repos.repo
+);
diff --git a/test/unit/skill-asset-tree-outage.test.ts b/test/unit/skill-asset-tree-outage.test.ts
index 1c3f496b..e0a6a42c 100644
--- a/test/unit/skill-asset-tree-outage.test.ts
+++ b/test/unit/skill-asset-tree-outage.test.ts
@@ -20,6 +20,7 @@ beforeEach(() => {
`INSERT INTO skills (owner, repo, name, slug, display_name, source_resolved)
VALUES ('owner', 'repo', 'skill', 'owner/repo/skill', 'Skill', 1)`,
).run()
+ fixture.raw.prepare('UPDATE skills SET rendered_status = \'ok\', rendered_skill_path = \'SKILL.md\', rendered_commit_sha = ?').run('c'.repeat(40))
cache = {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
@@ -41,9 +42,9 @@ afterEach(() => {
vi.unstubAllGlobals()
})
-describe('skill-asset tree outage', () => {
+describe('skill-asset source outage', () => {
it('returns a retryable error without caching the asset as missing', async () => {
- vi.stubGlobal('$fetch', vi.fn().mockRejectedValue(new Error('tree unavailable')))
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 400 })))
const handler = (await import('../../layers/registry/server/api/skill-asset/[...slug].get')).default
@@ -68,17 +69,32 @@ describe('skill-asset source gone', () => {
expect(cache.setItem).not.toHaveBeenCalled()
})
- it('classifies an upstream 404 tree as gone rather than an outage', async () => {
- // ungh answers 404 for a deleted repository. A 503 with retry-after tells
- // an agent to retry a permanent condition, which is what raised SKILLD-11.
- const error = Object.assign(new Error('404 Not Found'), { status: 404, statusCode: 404 })
- vi.stubGlobal('$fetch', vi.fn().mockRejectedValue(error))
-
+ it('reports a missing reference without claiming that the Skill is gone', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 404 })))
const handler = (await import('../../layers/registry/server/api/skill-asset/[...slug].get')).default
+ await expect(handler(event())).rejects.toMatchObject({ statusCode: 404 })
+ expect(responseHeaders.has('retry-after')).toBe(false)
+ })
- await expect(handler(event())).rejects.toMatchObject({ statusCode: 410 })
+ it('opens a root reference at its recorded commit without a tree request', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(new Response('Pinned reference'))
+ vi.stubGlobal('fetch', fetchMock)
+ const handler = (await import('../../layers/registry/server/api/skill-asset/[...slug].get')).default
+ const body = await handler(event()) as { raw: string, branch: string }
+ expect(body.raw).toBe('Pinned reference')
+ expect(body.branch).toBe('c'.repeat(40))
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(`https://raw.githubusercontent.com/owner/repo/${'c'.repeat(40)}/references/guide.md`)
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
- expect(responseHeaders.has('retry-after')).toBe(false)
+ it('keeps an unknown commit retryable without reading a mutable branch', async () => {
+ fixture.raw.prepare('UPDATE skills SET rendered_commit_sha = NULL').run()
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+ const handler = (await import('../../layers/registry/server/api/skill-asset/[...slug].get')).default
+ await expect(handler(event())).rejects.toMatchObject({ statusCode: 503 })
+ expect(fetchMock).not.toHaveBeenCalled()
+ expect(cache.setItem).not.toHaveBeenCalled()
})
})
diff --git a/test/unit/skill-behaviors-panel.nuxt.test.ts b/test/unit/skill-behaviors-panel.nuxt.test.ts
index cbc7e293..33fba722 100644
--- a/test/unit/skill-behaviors-panel.nuxt.test.ts
+++ b/test/unit/skill-behaviors-panel.nuxt.test.ts
@@ -33,6 +33,13 @@ const reading: BehaviorReading = {
}
describe('skill behaviors panel', () => {
+ it('does not report no match when the rules are unavailable', async () => {
+ const wrapper = await mountSuspended(SkillBehaviors, { props: { behaviors: null } })
+ expect(wrapper.text()).toContain('Skill behaviors are unavailable.')
+ expect(wrapper.text()).not.toContain('No rule matched.')
+ wrapper.unmount()
+ })
+
it('shows a model reading under the match it reads, and says a language model wrote it', async () => {
const wrapper = await mountSuspended(SkillBehaviors, { props: { behaviors, readings: [reading] } })
const text = wrapper.text().replace(/\s+/g, ' ')
diff --git a/test/unit/skill-detail-behaviors.test.ts b/test/unit/skill-detail-behaviors.test.ts
index fec7a0d4..a322a698 100644
--- a/test/unit/skill-detail-behaviors.test.ts
+++ b/test/unit/skill-detail-behaviors.test.ts
@@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite'
const NOW_SEC = Math.floor(Date.now() / 1000)
+const repositoryLicense = vi.hoisted(() => vi.fn().mockResolvedValue({ _tag: 'known', license: 'MIT' }))
+vi.mock('../../layers/registry/server/utils/skill-license', () => ({ readRepositoryLicense: repositoryLicense }))
vi.stubGlobal('defineEventHandler', (handler: unknown) => handler)
vi.stubGlobal('defineCachedEventHandler', (handler: unknown) => handler)
@@ -32,6 +34,8 @@ function seed(sourceResolved: number) {
}
interface Body {
+ license: string | null
+ licenseSource: 'skill' | 'repository' | null
sourceFacts: { behaviors: Array<{ id: string, tier: string, locations: Array<{ path: string, line: number | null, url: string | null }> }> }
}
@@ -46,10 +50,28 @@ async function detail(): Promise {
beforeEach(() => {
vi.resetModules()
cacheMap.clear()
+ repositoryLicense.mockClear()
harness = createSqliteD1(allMigrations())
})
describe('skill detail behaviors', () => {
+ it('uses the root Repository license at the snapshot commit', async () => {
+ seed(1)
+ harness.raw.prepare('UPDATE skills SET rendered_skill_path = \'SKILL.md\', rendered_commit_sha = ?').run('c'.repeat(40))
+ const body = await detail()
+ expect(body.license).toBe('MIT')
+ expect(body.licenseSource).toBe('repository')
+ expect(repositoryLicense.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ owner: 'acme', repo: 'skills', commit: 'c'.repeat(40) }))
+ })
+
+ it('keeps declared Skill terms above the Repository license', async () => {
+ seed(1)
+ harness.raw.prepare('UPDATE skills SET rendered_skill_path = \'SKILL.md\', rendered_commit_sha = ?, rendered_raw = ?').run('c'.repeat(40), '---\nname: setup\nlicense: BSD-3-Clause\n---\n\nUse this Skill.')
+ const body = await detail()
+ expect(body.license).toBe('BSD-3-Clause')
+ expect(body.licenseSource).toBe('skill')
+ expect(repositoryLicense).not.toHaveBeenCalled()
+ })
it('names each behavior in SKILL.md and the stored file names, with a link to where it appears', async () => {
seed(1)
diff --git a/test/unit/skill-detail-d1-cost.test.ts b/test/unit/skill-detail-d1-cost.test.ts
index b937300a..b2c1da5a 100644
--- a/test/unit/skill-detail-d1-cost.test.ts
+++ b/test/unit/skill-detail-d1-cost.test.ts
@@ -69,6 +69,7 @@ function seed(options: { maximumQueries?: number, renderedAt?: number | null, re
status ? renderedAt : null,
)
}
+ raw.prepare('UPDATE skills SET rendered_commit_sha = ?').run('c'.repeat(40))
raw.prepare(`INSERT INTO skill_revisions (owner, repo, name, sha, modified_at) VALUES (?, ?, 'alpha', 'old', 1), (?, ?, 'alpha', 'newest', 2)`)
.run(OWNER, REPO, OWNER, REPO)
const generated = raw.prepare(`INSERT INTO skill_generated (owner, repo, name, kind, sha, payload, generated_at) VALUES (?, ?, 'alpha', ?, 'sha', ?, '2026-09-01')`)
@@ -115,7 +116,7 @@ describe('uncached skill detail render', () => {
faqs: [{ question: 'Why?', answer: 'Because.' }],
summary: { text: 'A summary.' },
keywords: ['scandinavian'],
- provenance: { sourceCommitSha: 'newest' },
+ provenance: { sourceCommitSha: 'c'.repeat(40) },
})
expect(body.tags.map((tag: { slug: string }) => tag.slug)).toEqual(['frontend'])
expect(body.dependencies).toEqual([])
diff --git a/test/unit/skill-detail-swr.test.ts b/test/unit/skill-detail-swr.test.ts
index c557f513..62148405 100644
--- a/test/unit/skill-detail-swr.test.ts
+++ b/test/unit/skill-detail-swr.test.ts
@@ -14,7 +14,7 @@ import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite'
describe('skill detail SWR cache', () => {
const NOW_SEC = Math.floor(Date.now() / 1000)
const slug = 'ericzakariasson/scandinavian-design/alpha'
- const cacheKey = `skills:detail:v4:${slug}`
+ const cacheKey = `skills:detail:v5:${slug}`
let harness: SqliteD1
let handler: (event: H3Event) => Promise>
let prepareCalls: number
diff --git a/test/unit/skill-detail-v1.test.ts b/test/unit/skill-detail-v1.test.ts
index 1a8bc45a..9600eed5 100644
--- a/test/unit/skill-detail-v1.test.ts
+++ b/test/unit/skill-detail-v1.test.ts
@@ -45,6 +45,9 @@ const detail: LegacySkillDetail = {
}
describe('presentSkillDetail', () => {
+ it('refuses an unavailable behavior check instead of reporting no matches', () => {
+ expect(() => presentSkillDetail({ ...detail, sourceFacts: { ...detail.sourceFacts, behaviors: null } })).toThrow('Skill behaviors are unavailable.')
+ })
it('returns each behavior with its path and line, without the page links', () => {
expect(presentSkillDetail(detail).behaviors).toEqual([
{ id: 'remote-code', tier: 'ask', label: 'Runs code downloaded from the network', locations: [{ path: 'SKILL.md', line: 7 }], total: 1 },
diff --git a/test/unit/skill-file-tree-root.nuxt.test.ts b/test/unit/skill-file-tree-root.nuxt.test.ts
new file mode 100644
index 00000000..0a64fa23
--- /dev/null
+++ b/test/unit/skill-file-tree-root.nuxt.test.ts
@@ -0,0 +1,21 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import SkillFileTree from '../../layers/registry/app/components/SkillFileTree.vue'
+
+describe('root Skill file links', () => {
+ it('links the license at the stored commit without a SKILL.md directory', async () => {
+ const commit = 'c'.repeat(40)
+ const wrapper = await mountSuspended(SkillFileTree, {
+ props: {
+ assets: [{ path: 'LICENSE', size: 10, type: 'other' }],
+ owner: 'acme',
+ repo: 'skills',
+ name: 'setup',
+ registryPath: '/gh/acme/skills/setup',
+ branch: commit,
+ skillPath: 'SKILL.md',
+ },
+ })
+ expect(wrapper.get('a[href*="LICENSE"]').attributes('href')).toBe(`https://github.com/acme/skills/blob/${commit}/LICENSE`)
+ })
+})
diff --git a/test/unit/skill-license.test.ts b/test/unit/skill-license.test.ts
new file mode 100644
index 00000000..b9965d6b
--- /dev/null
+++ b/test/unit/skill-license.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it, vi } from 'vitest'
+import { readRepositoryLicense } from '../../layers/registry/server/utils/skill-license'
+
+const source = { owner: 'heyimjames', repo: 'nikita-bier-consumer-apps', commit: 'c'.repeat(40) }
+
+describe('repository license', () => {
+ it('reads a detected license at the exact snapshot commit', async () => {
+ const read = vi.fn().mockResolvedValue({ status: 200, data: { license: { spdx_id: 'MIT' } } })
+ expect(await readRepositoryLicense(source, {}, read)).toEqual({ _tag: 'known', license: 'MIT' })
+ expect(read).toHaveBeenCalledWith(`/repos/heyimjames/nikita-bier-consumer-apps/license?ref=${source.commit}`, {})
+ })
+
+ it.each([null, { spdx_id: 'NOASSERTION' }])('keeps undetected terms unknown', async (license) => {
+ expect(await readRepositoryLicense(source, {}, vi.fn().mockResolvedValue({ status: 200, data: { license } }))).toEqual({ _tag: 'missing' })
+ })
+
+ it.each([503, 403, 0])('does not turn a %s outage into a missing license', async (status) => {
+ expect(await readRepositoryLicense(source, {}, vi.fn().mockResolvedValue({ status, data: null }))).toEqual({ _tag: 'unavailable', status })
+ })
+})
diff --git a/test/unit/skill-page-behaviors.test.ts b/test/unit/skill-page-behaviors.test.ts
index c08bd97f..ad096411 100644
--- a/test/unit/skill-page-behaviors.test.ts
+++ b/test/unit/skill-page-behaviors.test.ts
@@ -11,7 +11,7 @@ describe('skillPageBehaviors', () => {
source,
})
- expect(behaviors.map(behavior => [behavior.id, behavior.tier, behavior.locations])).toEqual([
+ expect(behaviors?.map(behavior => [behavior.id, behavior.tier, behavior.locations])).toEqual([
['privilege', 'ask', [{ path: 'SKILL.md', line: 7, url: 'https://github.com/acme/skills/blob/main/skills/setup/SKILL.md?plain=1#L7' }]],
['shell', 'show', [{ path: 'SKILL.md', line: 6, url: 'https://github.com/acme/skills/blob/main/skills/setup/SKILL.md?plain=1#L6' }]],
['scripts', 'show', [{ path: 'scripts/install.sh', line: null, url: 'https://github.com/acme/skills/blob/main/skills/setup/scripts/install.sh' }]],
@@ -20,13 +20,13 @@ describe('skillPageBehaviors', () => {
})
it('links a Skill at the Repository root without a directory prefix', () => {
- const [scripts] = skillPageBehaviors({
+ const result = skillPageBehaviors({
raw: null,
assetPaths: ['run.py'],
source: { ...source, skillPath: 'SKILL.md' },
})
- expect(scripts?.locations[0]?.url).toBe('https://github.com/acme/skills/blob/main/run.py')
+ expect(result?.[0]?.locations[0]?.url).toBe('https://github.com/acme/skills/blob/main/run.py')
})
it('leaves locations unlinked when the SKILL.md path is unknown', () => {
@@ -36,11 +36,11 @@ describe('skillPageBehaviors', () => {
})
// 2026-10-05: the bundled rules failed their own parse, and every Skill page answered 503.
- it('returns no behaviors, and keeps the page up, when the rules fail', () => {
+ it('reports unavailable, and keeps the page up, when the rules fail', () => {
const failing = () => {
throw new Error('patterns are lowercase')
}
- expect(skillPageBehaviors({ raw: '---\nname: setup\n---\n', assetPaths: ['run.py'], source }, failing)).toEqual([])
+ expect(skillPageBehaviors({ raw: '---\nname: setup\n---\n', assetPaths: ['run.py'], source }, failing)).toBeNull()
})
})
diff --git a/test/unit/skill-snapshot-migration.test.ts b/test/unit/skill-snapshot-migration.test.ts
new file mode 100644
index 00000000..af15077f
--- /dev/null
+++ b/test/unit/skill-snapshot-migration.test.ts
@@ -0,0 +1,37 @@
+import type { ExistingRepo } from '../../layers/registry/server/utils/sync-repo'
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { loadStoredSkillRow, resolveReferencedFileTarget } from '../../layers/registry/server/utils/skill-stored-source'
+import { GENERAL_SYNC_CANDIDATES_SQL } from '../../layers/registry/server/utils/sync-candidates'
+import { unchangedRepoStatus } from '../../layers/registry/server/utils/sync-repo'
+import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite'
+
+const migration = 'migrations/0148_skill_snapshot_commit.sql'
+
+describe('skill snapshot migration', () => {
+ it.each([true, false])('refreshes dormant nested Skills, recorded revision: %s', async (hasRevision) => {
+ const fixture = createSqliteD1(allMigrations().filter(path => path !== migration))
+ const skill = { owner: 'acme', repo: 'skills', name: 'setup' }
+ fixture.raw.exec(`
+ INSERT INTO repos (owner, repo, default_branch, last_tree_sha, pushed_at, repo_meta_synced_at)
+ VALUES ('acme', 'skills', 'main', 'tree', 100, 100);
+ INSERT INTO skills (owner, repo, name, slug, display_name, source_resolved,
+ rendered_status, rendered_raw, rendered_skill_path, assets, references_count)
+ VALUES ('acme', 'skills', 'setup', 'acme/skills/setup', 'Setup', 1,
+ 'ok', '# Skill', 'skills/setup/SKILL.md', '[{"path":"new.md","size":10,"type":"markdown"}]', 1);
+ `)
+ if (hasRevision) {
+ fixture.raw.prepare(`INSERT INTO skill_revisions (owner, repo, name, sha, modified_at)
+ VALUES ('acme', 'skills', 'setup', ?, 50)`).run('a'.repeat(40))
+ }
+ fixture.raw.exec(readFileSync(migration, 'utf8'))
+ expect(fixture.raw.prepare(GENERAL_SYNC_CANDIDATES_SQL).all(1, 10)).toEqual([
+ { owner: 'acme', repo: 'skills', ls: null, owner_verified: 0 },
+ ])
+ const existing = fixture.raw.prepare('SELECT last_tree_sha, pushed_at, source_owner, source_repo FROM repos').get() as unknown as ExistingRepo
+ expect(unchangedRepoStatus({ existing, hasAdmittedSkills: true, headTreeSha: 'tree', repoPushedAt: 100 })).toBeNull()
+ const row = await loadStoredSkillRow(fixture.db, skill)
+ expect(resolveReferencedFileTarget(skill, row!, 'new.md')).toEqual({ _tag: 'unavailable' })
+ fixture.close()
+ })
+})
diff --git a/test/unit/skill-stored-source.test.ts b/test/unit/skill-stored-source.test.ts
index 38582fec..7e3343ad 100644
--- a/test/unit/skill-stored-source.test.ts
+++ b/test/unit/skill-stored-source.test.ts
@@ -23,6 +23,7 @@ function seed(fields: { raw?: string | null, status?: string, path?: string | nu
fields.path === undefined ? 'skills/skill/SKILL.md' : fields.path,
JSON.stringify(fields.assets ?? []),
)
+ fixture.raw.prepare('UPDATE skills SET rendered_commit_sha = ?').run('c'.repeat(40))
}
beforeEach(() => {
@@ -42,7 +43,7 @@ describe('stored SKILL.md', () => {
expect(readStoredSkillMd(skill, row!)).toEqual({
_tag: 'ok',
body: '# Stored body',
- source: 'owner/repo@trunk/skills/skill/SKILL.md',
+ source: `owner/repo@${'c'.repeat(40)}/skills/skill/SKILL.md`,
})
})
@@ -78,7 +79,7 @@ describe('stored file list', () => {
_tag: 'ok',
payload: {
skillPath: 'skills/skill/SKILL.md',
- branch: 'trunk',
+ branch: 'c'.repeat(40),
files: [
{ path: 'references/a.md', size: 10, type: 'markdown' },
{ path: 'run.py', size: 5, type: 'code' },
@@ -88,13 +89,13 @@ describe('stored file list', () => {
})
})
- it('lists no files for a root SKILL.md, because the sync stores none', async () => {
+ it('returns an empty inventory for a root Skill with no supporting files', async () => {
seed({ path: 'SKILL.md', assets: [] })
const row = await loadStoredSkillRow(fixture.db, skill)
expect(readStoredSkillFiles(row!)).toEqual({
_tag: 'ok',
- payload: { skillPath: 'SKILL.md', branch: 'trunk', files: [], total: 0 },
+ payload: { skillPath: 'SKILL.md', branch: 'c'.repeat(40), files: [], total: 0 },
})
})
@@ -104,7 +105,7 @@ describe('stored file list', () => {
expect(readStoredSkillFiles(row!)).toEqual({
_tag: 'ok',
- payload: { skillPath: null, branch: 'trunk', files: [], total: 0 },
+ payload: { skillPath: null, branch: 'c'.repeat(40), files: [], total: 0 },
})
})
@@ -126,7 +127,7 @@ describe('referenced file', () => {
const target = resolveReferencedFileTarget(skill, row!, 'references/a.md')
expect(target).toMatchObject({
_tag: 'ok',
- url: 'https://raw.githubusercontent.com/owner/repo/trunk/skills/skill/references/a.md',
+ url: `https://raw.githubusercontent.com/owner/repo/${'c'.repeat(40)}/skills/skill/references/a.md`,
})
const fetchText = vi.fn().mockResolvedValue({ _tag: 'ok', body: '# A' })
@@ -134,12 +135,13 @@ describe('referenced file', () => {
expect(fetchText).toHaveBeenCalledTimes(1)
})
- it('resolves a root Skill file at the repository root', async () => {
+ it('pins a root Skill reference to the stored commit, even when the default branch moves', async () => {
seed({ path: 'SKILL.md' })
+ fixture.raw.prepare('UPDATE skills SET rendered_commit_sha = ?').run('c'.repeat(40))
const row = await loadStoredSkillRow(fixture.db, skill)
expect(resolveReferencedFileTarget(skill, row!, 'a.md')).toMatchObject({
- url: 'https://raw.githubusercontent.com/owner/repo/trunk/a.md',
+ url: `https://raw.githubusercontent.com/owner/repo/${'c'.repeat(40)}/a.md`,
})
})
diff --git a/test/unit/skills-raw-stored.test.ts b/test/unit/skills-raw-stored.test.ts
index 977a42f8..9f157694 100644
--- a/test/unit/skills-raw-stored.test.ts
+++ b/test/unit/skills-raw-stored.test.ts
@@ -14,6 +14,7 @@ function seed(resolved: number, raw: string | null) {
`INSERT INTO skills (owner, repo, name, slug, display_name, source_resolved, rendered_status, rendered_raw, rendered_skill_path, assets)
VALUES ('owner', 'repo', 'skill', 'owner/repo/skill', 'Skill', ?, 'ok', ?, 'skills/skill/SKILL.md', '[{"path":"references/a.md","size":3,"type":"markdown"}]')`,
).run(resolved, raw)
+ fixture.raw.prepare('UPDATE skills SET rendered_commit_sha = ?').run('c'.repeat(40))
}
function event(): H3Event {
@@ -53,7 +54,7 @@ describe('skills-raw handler', () => {
expect(await handler(event())).toBe('# Stored')
expect(headers.get('content-type')).toBe('text/markdown; charset=utf-8')
expect(headers.get('cache-control')).toBe('public, max-age=300')
- expect(headers.get('x-skilld-source')).toBe('owner/repo@main/skills/skill/SKILL.md')
+ expect(headers.get('x-skilld-source')).toBe(`owner/repo@${'c'.repeat(40)}/skills/skill/SKILL.md`)
expect(fetchMock).not.toHaveBeenCalled()
})
@@ -81,7 +82,7 @@ describe('skill-files handler', () => {
expect(await handler(event())).toEqual({
skillPath: 'skills/skill/SKILL.md',
- branch: 'main',
+ branch: 'c'.repeat(40),
files: [{ path: 'references/a.md', size: 3, type: 'markdown' }],
total: 1,
})
diff --git a/test/unit/sync-repo-correctness.test.ts b/test/unit/sync-repo-correctness.test.ts
index 23cdc3ec..a87f35b0 100644
--- a/test/unit/sync-repo-correctness.test.ts
+++ b/test/unit/sync-repo-correctness.test.ts
@@ -168,27 +168,6 @@ describe('syncRepo content acknowledgement', () => {
expect(sqlite.prepare(`SELECT name FROM skills`).pluck().get()).toBe('skills')
})
- it('gives a root skill no assets rather than the whole repository', async () => {
- // `dirPath` is empty for a root skill. Were assets resolved by prefix
- // against that, every file in the repository would become an asset of it.
- github.getTree.mockResolvedValue(tree([
- { path: 'SKILL.md', sha: 'root-sha' },
- { path: 'src/index.ts', sha: 'code-sha' },
- { path: 'docs/guide.md', sha: 'doc-sha' },
- ]))
- github.getBlobsBatch.mockResolvedValue({
- status: 200,
- data: new Map([['SKILL.md', rawSkill('Sounds Human')]]),
- unreadable: new Set(),
- rateLimit: null,
- notModified: false,
- })
-
- await syncRepo('acme', 'skills', {}, db, { ownerVerified: true })
-
- expect(sqlite.prepare(`SELECT assets FROM skills`).pluck().get()).toBe('[]')
- })
-
it('never reports a zero-upsert trust rejection as indexed or ok', async () => {
github.getTree.mockResolvedValue(tree([{ path: 'skills/one/SKILL.md', sha: 'one-new' }]))
github.getBlobsBatch.mockResolvedValue({
@@ -728,7 +707,7 @@ function createDatabase(): Database.Database {
seo_index_reasons TEXT NOT NULL DEFAULT '[]', seo_index_synced_at INTEGER,
trust_tier TEXT NOT NULL DEFAULT 'untrusted', trust_source TEXT NOT NULL DEFAULT 'computed',
trust_score INTEGER NOT NULL DEFAULT 0, trust_reasons TEXT NOT NULL DEFAULT '[]', trust_synced_at INTEGER,
- rendered_skill_path TEXT, rendered_status TEXT, rendered_raw TEXT, rendered_raw_sha256 TEXT, rendered_frontmatter TEXT,
+ rendered_skill_path TEXT, rendered_commit_sha TEXT, rendered_status TEXT, rendered_raw TEXT, rendered_raw_sha256 TEXT, rendered_frontmatter TEXT,
rendered_html TEXT, rendered_at INTEGER, owner_verified INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (owner, repo, name)
);
diff --git a/test/unit/sync-repo-streaming.test.ts b/test/unit/sync-repo-streaming.test.ts
index 1b2ee452..5b4d734f 100644
--- a/test/unit/sync-repo-streaming.test.ts
+++ b/test/unit/sync-repo-streaming.test.ts
@@ -23,6 +23,7 @@ function repoSummary() {
data: {
repositoryId: 1,
headTreeSha: 'new-tree',
+ headCommitSha: 'c'.repeat(40),
meta: {
name: 'skills',
full_name: 'acme/skills',
@@ -225,8 +226,71 @@ describe('syncRepo bounded working set', () => {
references_count: number
assets: string
}
- expect(row.references_count).toBe(2)
- expect(JSON.parse(row.assets).map((a: { path: string }) => a.path)).toEqual(['reference.md', 'run.py'])
+ expect(row.references_count).toBe(3)
+ expect(JSON.parse(row.assets).map((a: { path: string }) => a.path)).toEqual(['LICENSE', 'reference.md', 'run.py'])
+ })
+
+ it('includes root Skill references and records the commit used for their bytes', async () => {
+ insertRepo(sqlite, 'old-tree')
+ github.getTree.mockResolvedValue({
+ status: 200,
+ data: { sha: 'new-tree', tree: [
+ { path: 'SKILL.md', sha: 'root', type: 'blob' },
+ { path: 'onboarding.md', sha: 'ref', type: 'blob', size: 12 },
+ { path: 'references/deeper.md', sha: 'deep', type: 'blob', size: 34 },
+ { path: 'LICENSE', sha: 'lic', type: 'blob', size: 5 },
+ { path: '.gitignore', sha: 'ignore', type: 'blob', size: 1 },
+ ] },
+ rateLimit: null,
+ })
+ github.getBlobsBatch.mockResolvedValue({ status: 200, data: new Map([['SKILL.md', rawSkill('skills')]]), unreadable: new Set() })
+
+ await syncRepo('acme', 'skills', {}, db, { ownerVerified: true })
+
+ const row = sqlite.prepare('SELECT assets FROM skills').get() as { assets: string }
+ expect(JSON.parse(row.assets).map((file: { path: string }) => file.path)).toEqual(['LICENSE', 'onboarding.md', 'references/deeper.md'])
+ expect(github.getBlobsBatch.mock.calls[0]?.[2]).toBe('c'.repeat(40))
+ })
+
+ it('advances the snapshot commit when only a supporting file changes', async () => {
+ insertRepo(sqlite, 'old-tree')
+ insertSkill(sqlite, 'skills', 'root')
+ sqlite.prepare('UPDATE skills SET rendered_status = \'ok\', rendered_skill_path = \'SKILL.md\', rendered_commit_sha = ?').run('b'.repeat(40))
+ github.getTree.mockResolvedValue({
+ status: 200,
+ data: { sha: 'new-tree', tree: [
+ { path: 'SKILL.md', sha: 'root', type: 'blob' },
+ { path: 'onboarding.md', sha: 'changed-reference', type: 'blob', size: 42 },
+ ] },
+ rateLimit: null,
+ })
+
+ await syncRepo('acme', 'skills', {}, db, { ownerVerified: true })
+
+ expect(sqlite.prepare('SELECT rendered_commit_sha FROM skills').pluck().get()).toBe('c'.repeat(40))
+ expect(github.getBlobsBatch).not.toHaveBeenCalled()
+ })
+
+ it('bounds stored root metadata and keeps the full supporting file count', async () => {
+ insertRepo(sqlite, 'old-tree')
+ github.getTree.mockResolvedValue({
+ status: 200,
+ data: { sha: 'new-tree', tree: [
+ { path: 'SKILL.md', sha: 'root', type: 'blob' },
+ ...Array.from({ length: 300 }, (_, i) => ({ path: `references/${i}.md`, sha: `ref-${i}`, type: 'blob', size: 12 })),
+ { path: 'onboarding.md', sha: 'top', type: 'blob', size: 12 },
+ ] },
+ rateLimit: null,
+ })
+ github.getBlobsBatch.mockResolvedValue({ status: 200, data: new Map([['SKILL.md', rawSkill('skills')]]), unreadable: new Set() })
+
+ await syncRepo('acme', 'skills', {}, db, { ownerVerified: true })
+
+ const row = sqlite.prepare('SELECT references_count, assets FROM skills').get() as { references_count: number, assets: string }
+ const files = JSON.parse(row.assets) as { path: string }[]
+ expect(row.references_count).toBe(301)
+ expect(files).toHaveLength(250)
+ expect(files.some(file => file.path === 'onboarding.md')).toBe(true)
})
// Same root cause, opposite symptom: a bare name can match an unrelated
@@ -297,7 +361,7 @@ function createDatabase(): Database.Database {
seo_index_reasons TEXT NOT NULL DEFAULT '[]', seo_index_synced_at INTEGER,
trust_tier TEXT NOT NULL DEFAULT 'untrusted', trust_source TEXT NOT NULL DEFAULT 'computed',
trust_score INTEGER NOT NULL DEFAULT 0, trust_reasons TEXT NOT NULL DEFAULT '[]', trust_synced_at INTEGER,
- rendered_skill_path TEXT, rendered_status TEXT, rendered_raw TEXT, rendered_raw_sha256 TEXT, rendered_frontmatter TEXT,
+ rendered_skill_path TEXT, rendered_commit_sha TEXT, rendered_status TEXT, rendered_raw TEXT, rendered_raw_sha256 TEXT, rendered_frontmatter TEXT,
rendered_html TEXT, rendered_at INTEGER, owner_verified INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (owner, repo, name)
);
diff --git a/test/unit/sync-repo-unreadable-blob.test.ts b/test/unit/sync-repo-unreadable-blob.test.ts
index d89f1fd2..616259b4 100644
--- a/test/unit/sync-repo-unreadable-blob.test.ts
+++ b/test/unit/sync-repo-unreadable-blob.test.ts
@@ -179,7 +179,7 @@ function createDatabase(): Database.Database {
seo_index_reasons TEXT NOT NULL DEFAULT '[]', seo_index_synced_at INTEGER,
trust_tier TEXT NOT NULL DEFAULT 'untrusted', trust_source TEXT NOT NULL DEFAULT 'computed',
trust_score INTEGER NOT NULL DEFAULT 0, trust_reasons TEXT NOT NULL DEFAULT '[]', trust_synced_at INTEGER,
- rendered_skill_path TEXT, rendered_status TEXT, rendered_raw TEXT, rendered_raw_sha256 TEXT, rendered_frontmatter TEXT,
+ rendered_skill_path TEXT, rendered_commit_sha TEXT, rendered_status TEXT, rendered_raw TEXT, rendered_raw_sha256 TEXT, rendered_frontmatter TEXT,
rendered_html TEXT, rendered_at INTEGER, owner_verified INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (owner, repo, name)
);