happyskills 1.14.0 → 1.16.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.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/api/repos.js +21 -6
- package/src/commands/match.js +26 -1
- package/src/commands/match.test.js +1 -0
- package/src/integration/api_envelope.test.js +7 -1
- package/src/match/diff.js +17 -2
- package/src/match/funnel.js +161 -55
- package/src/match/funnel.test.js +222 -6
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.16.0] - 2026-07-01
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Make `happyskills match` recognise a local skill as `certified` when its **functional content matches any published version** — not just a byte-identical head. For every hash/name candidate, `match` now enumerates the repo's whole version history (`GET /refs`) and fetches each version's per-file manifest (path + blob SHA only, never content), then runs a **cross-version containment check**: a version is a match when all of its functional files (everything except `skill.json` and `CHANGELOG.md`) are present locally with the same SHA. `SKILL.md` is the identity anchor — containment can't hold unless the local `SKILL.md` equals that version's. The **earliest** contained version is certified (`match.version`) — where the content originated, so a metadata-only re-release doesn't shift the reported version — carrying `match.latest_version` + `match.is_latest` so a behind-by copy is clear. This is what lets a raw local folder with **no `skill.json` at all** — whose content reproduces v0.3.1 — report `certified @ 0.3.1` even though head is v0.3.2, instead of misreporting as `near`/`likely` against head. **Extra local files are tolerated** (a stray `__pycache__/*.pyc`, an editor swap) — they can't block identity — but are surfaced in `local_only_files` so a genuine fork isn't hidden. `skill.json`/`CHANGELOG.md` are excluded from the containment check, `differing_files`, and the similarity denominator (root-level, case-insensitive; `LICENSE` still counts). Purely client-side — no API or registry change. The text output gains "Certified to an older published version" and "Certified, with extra local-only files" lines. (Spec 260629-03 §4.9/§4.10.)
|
|
15
|
+
|
|
16
|
+
## [1.15.0] - 2026-06-30
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Make `happyskills match` **version-precise**: it now matches each local skill against **all** published versions of a skill, not just the latest. A local copy that is byte-identical to an *older* release is reported as `certified` at that exact version (the result carries `match.version`, `match.latest_version`, and `match.is_latest`) — instead of being diffed against head and misreported as `likely`/drift just because you're a few versions behind. The text output gains an "identical to an older published version (you are behind)" line listing each such skill as `name → owner/skill @ <version> (latest <latest>)`. Mechanically, the match funnel now also sends the whole-skill `tree_sha`s in the same batched `POST /repos:match` call and certifies on a cross-version tree match (with a fallback to the previous head-only behavior for older API servers). Requires API ≥ 5.18.0 (the `tree_shas` axis on `POST /repos:match`). (Spec 260629-03 §4.8.)
|
|
21
|
+
|
|
10
22
|
## [1.14.0] - 2026-06-30
|
|
11
23
|
|
|
12
24
|
### Added
|
package/package.json
CHANGED
package/src/api/repos.js
CHANGED
|
@@ -84,6 +84,18 @@ const get_refs = (owner, repo) => catch_errors(`Get refs for ${owner}/${repo} fa
|
|
|
84
84
|
return data
|
|
85
85
|
})
|
|
86
86
|
|
|
87
|
+
// Published version strings for a repo, newest-agnostic (the funnel sorts).
|
|
88
|
+
// Parses `refs/tags/v<semver>` tags from the refs list — used by `happyskills
|
|
89
|
+
// match` to scan every version for a cross-version containment match.
|
|
90
|
+
const list_versions = (owner, repo) => catch_errors(`List versions for ${owner}/${repo} failed`, async () => {
|
|
91
|
+
const [errors, data] = await client.get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/refs`)
|
|
92
|
+
if (errors) throw errors[errors.length - 1]
|
|
93
|
+
const refs = Array.isArray(data) ? data : (data && (data.results || data.refs)) || []
|
|
94
|
+
return refs
|
|
95
|
+
.map(r => { const m = String((r && r.name) || r).match(/^refs\/tags\/v?(.+)$/); return m ? m[1] : null })
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
})
|
|
98
|
+
|
|
87
99
|
const get_repo = (owner, repo, options = {}) => catch_errors(`Get repo ${owner}/${repo} failed`, async () => {
|
|
88
100
|
const [errors, data] = await client.get(`/repos/${owner}/${repo}`, { auth: options.auth || false })
|
|
89
101
|
if (errors) throw errors[errors.length - 1]
|
|
@@ -214,11 +226,14 @@ const semantic_search = (query, options = {}) => catch_errors('Semantic search f
|
|
|
214
226
|
})
|
|
215
227
|
|
|
216
228
|
// POST /repos:match — catalog matching for `happyskills match` (spec 260629-03).
|
|
217
|
-
// ONE batch call for the whole folder's hashes + names. Returns
|
|
218
|
-
// { results: [{ skill_md_sha, candidates }], name_results: [{ name, candidates }]
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
229
|
+
// ONE batch call for the whole folder's hashes + names + tree shas. Returns
|
|
230
|
+
// { results: [{ skill_md_sha, candidates }], name_results: [{ name, candidates }],
|
|
231
|
+
// tree_results: [{ tree_sha, candidates }] } — multi-key, so the client's
|
|
232
|
+
// single-key unwrap leaves it intact. `tree_results` (spec §4.8) matches the
|
|
233
|
+
// whole-skill content address across ALL versions, so a local copy identical to
|
|
234
|
+
// an older release is pinned to that exact version instead of drifting vs head.
|
|
235
|
+
const match = (hashes, names, tree_shas) => catch_errors('Catalog match lookup failed', async () => {
|
|
236
|
+
const [errors, data] = await client.post('/repos:match', { hashes, names: names || [], tree_shas: tree_shas || [] })
|
|
222
237
|
if (errors) throw errors[errors.length - 1]
|
|
223
238
|
return data
|
|
224
239
|
})
|
|
@@ -235,4 +250,4 @@ const manifests = (repo_versions) => catch_errors('Manifests lookup failed', asy
|
|
|
235
250
|
}
|
|
236
251
|
})
|
|
237
252
|
|
|
238
|
-
module.exports = { search, dispatch_search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, star, unstar, compare, get_blob, get_tree, match, manifests }
|
|
253
|
+
module.exports = { search, dispatch_search, semantic_search, resolve_dependencies, clone, push, get_refs, list_versions, get_repo, check_updates, del_repo, patch_repo, star, unstar, compare, get_blob, get_tree, match, manifests }
|
package/src/commands/match.js
CHANGED
|
@@ -67,6 +67,7 @@ const run = (args) => catch_errors('Match failed', async () => {
|
|
|
67
67
|
const deps = {
|
|
68
68
|
match: repos_api.match,
|
|
69
69
|
manifests: repos_api.manifests,
|
|
70
|
+
versions: (workspace, name) => repos_api.list_versions(workspace, name),
|
|
70
71
|
semantic_search: (q) => repos_api.semantic_search(q, { limit: 3 }),
|
|
71
72
|
}
|
|
72
73
|
const [funnel_err, funnel] = await run_funnel(scanned.skills, deps, { threshold, concurrency })
|
|
@@ -129,7 +130,31 @@ const run = (args) => catch_errors('Match failed', async () => {
|
|
|
129
130
|
const render_text = (data, warnings) => {
|
|
130
131
|
const s = data.summary
|
|
131
132
|
print_info(`Scanned ${s.total} skill${s.total === 1 ? '' : 's'}.`)
|
|
132
|
-
print_success(`✓ ${s.certified}
|
|
133
|
+
print_success(`✓ ${s.certified} certified (content matches a published version) · ≈ ${s.near} with small differences · ~ ${s.likely + s.possible} loosely matched · ✗ ${s.unmatched} not found`)
|
|
134
|
+
|
|
135
|
+
// A certified match to an OLDER version is still an exact match — surface the
|
|
136
|
+
// version gap so the principal knows it is the real skill, just behind.
|
|
137
|
+
const behind = data.certified.filter(en => en.match && en.match.latest_version && en.match.is_latest === false)
|
|
138
|
+
if (behind.length) {
|
|
139
|
+
print_info('\nCertified to an older published version (you are behind):')
|
|
140
|
+
for (const en of behind) {
|
|
141
|
+
const where = en.match ? `${en.match.workspace}/${en.match.name}` : '(catalog)'
|
|
142
|
+
print_info(` ${en.local.name} → ${where} @ ${en.match.version} (latest ${en.match.latest_version})`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Certified, but the local copy carries extra files the published version
|
|
147
|
+
// does not (build cruft like __pycache__/*.pyc, or genuine local additions).
|
|
148
|
+
// Certification stands — the skill's content is fully present — but show the
|
|
149
|
+
// extras so a real fork isn't hidden as "identical".
|
|
150
|
+
const with_extras = data.certified.filter(en => en.local_only_files && en.local_only_files.length)
|
|
151
|
+
if (with_extras.length) {
|
|
152
|
+
print_info('\nCertified, with extra local-only files (not part of the published skill):')
|
|
153
|
+
for (const en of with_extras) {
|
|
154
|
+
const where = en.match ? `${en.match.workspace}/${en.match.name}@${en.match.version}` : '(catalog)'
|
|
155
|
+
print_info(` ${en.local.name} → ${where} — local extras: ${en.local_only_files.join(', ')}`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
133
158
|
|
|
134
159
|
if (data.near.length) {
|
|
135
160
|
print_info('\nSkills with small differences:')
|
|
@@ -27,6 +27,7 @@ require.cache[repos_path] = {
|
|
|
27
27
|
exports: {
|
|
28
28
|
match: async () => [null, { results: api_state.results, name_results: api_state.name_results }],
|
|
29
29
|
manifests: async () => [null, { manifests: api_state.manifests, warnings: [] }],
|
|
30
|
+
list_versions: async (ws, name) => [null, [...new Set(api_state.manifests.filter(m => m.workspace === ws && m.name === name).map(m => m.version))]],
|
|
30
31
|
semantic_search: async () => [null, api_state.semantic],
|
|
31
32
|
},
|
|
32
33
|
}
|
|
@@ -90,7 +90,13 @@ const run_cli = (args, env_override = {}) => new Promise((resolve) => {
|
|
|
90
90
|
const has_mode_flag = args.includes('--json') || args.includes('--text')
|
|
91
91
|
const final_args = has_mode_flag ? args : [...args, '--text']
|
|
92
92
|
const child = spawn(NODE, [CLI, ...final_args], {
|
|
93
|
-
|
|
93
|
+
// Isolate the CLI config dir so the spawned binary never reads the
|
|
94
|
+
// developer's real ~/.config/happyskills credentials. A stored (expired)
|
|
95
|
+
// token would otherwise make an authed command (e.g. a search POST)
|
|
96
|
+
// attempt a token refresh against the stub's missing /auth/refresh route
|
|
97
|
+
// and fail with NETWORK_ERROR — a machine-state-dependent flake. A test
|
|
98
|
+
// that needs a specific config dir overrides this via env_override (last).
|
|
99
|
+
env: { ...process.env, NO_COLOR: '1', CI: '', XDG_CONFIG_HOME: make_tmp(), ...env_override },
|
|
94
100
|
})
|
|
95
101
|
let stdout = ''
|
|
96
102
|
let stderr = ''
|
package/src/match/diff.js
CHANGED
|
@@ -4,9 +4,17 @@
|
|
|
4
4
|
// [{ path, blob_sha }] lists. The differing-file list is the load-bearing
|
|
5
5
|
// artifact (not the single similarity %): it's what tells the principal exactly
|
|
6
6
|
// what changed (e.g. "only LICENSE differs").
|
|
7
|
+
//
|
|
8
|
+
// opts.ignore(path) → true marks a file as non-functional / platform-specific
|
|
9
|
+
// (spec §4.9): it is EXCLUDED from the functional diff and the similarity
|
|
10
|
+
// denominator, so it can never count toward "is this the same skill". Ignored
|
|
11
|
+
// files that differ are reported separately as `ignored_differing`, so the
|
|
12
|
+
// caller can flag a match as "metadata-only differs" rather than silently
|
|
13
|
+
// dropping the fact. Omit opts.ignore for the original whole-tree behavior.
|
|
7
14
|
|
|
8
15
|
// local_files / catalog_files: [{ path, blob_sha }]
|
|
9
|
-
const diff_manifests = (local_files, catalog_files) => {
|
|
16
|
+
const diff_manifests = (local_files, catalog_files, opts = {}) => {
|
|
17
|
+
const ignore = typeof opts.ignore === 'function' ? opts.ignore : () => false
|
|
10
18
|
const local = new Map((local_files || []).map(f => [f.path, f.blob_sha]))
|
|
11
19
|
const catalog = new Map((catalog_files || []).map(f => [f.path, f.blob_sha]))
|
|
12
20
|
const all = new Set([...local.keys(), ...catalog.keys()])
|
|
@@ -15,16 +23,22 @@ const diff_manifests = (local_files, catalog_files) => {
|
|
|
15
23
|
const modified = []
|
|
16
24
|
const added = [] // present locally, absent in the catalog skill
|
|
17
25
|
const removed = [] // present in the catalog skill, absent locally
|
|
26
|
+
const ignored_differing = [] // ignored files that differ (metadata-only deltas)
|
|
18
27
|
|
|
19
28
|
for (const p of all) {
|
|
20
29
|
const l = local.get(p)
|
|
21
30
|
const c = catalog.get(p)
|
|
31
|
+
if (ignore(p)) {
|
|
32
|
+
if (l !== c) ignored_differing.push(p) // present-both-modified, or added/removed
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
22
35
|
if (l !== undefined && c !== undefined) (l === c ? identical : modified).push(p)
|
|
23
36
|
else if (l !== undefined) added.push(p)
|
|
24
37
|
else removed.push(p)
|
|
25
38
|
}
|
|
26
39
|
|
|
27
|
-
|
|
40
|
+
// Similarity is over FUNCTIONAL files only (ignored files are out of the set).
|
|
41
|
+
const total = identical.length + modified.length + added.length + removed.length
|
|
28
42
|
const similarity = total === 0 ? 1 : identical.length / total
|
|
29
43
|
const differing = [...modified, ...added, ...removed].sort()
|
|
30
44
|
|
|
@@ -35,6 +49,7 @@ const diff_manifests = (local_files, catalog_files) => {
|
|
|
35
49
|
added: added.sort(),
|
|
36
50
|
removed: removed.sort(),
|
|
37
51
|
differing,
|
|
52
|
+
ignored_differing: ignored_differing.sort(),
|
|
38
53
|
}
|
|
39
54
|
}
|
|
40
55
|
|
package/src/match/funnel.js
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
-
// The match funnel + tier classification (spec 260629-03 §4.7).
|
|
2
|
+
// The match funnel + tier classification (spec 260629-03 §4.7 / §4.10).
|
|
3
3
|
//
|
|
4
4
|
// Stages, cheapest signal first, NO blob downloads to classify:
|
|
5
|
-
// 1.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
5
|
+
// 1. batch — ONE POST /repos:match for the whole folder (hashes + names +
|
|
6
|
+
// tree shas).
|
|
7
|
+
// 2. tree SHA — local.tree_sha === a candidate's tree_sha ⇒ certified,
|
|
8
|
+
// byte-identical, at that EXACT version (even an older one),
|
|
9
|
+
// zero further work.
|
|
10
|
+
// 3. versions — for every remaining hash/name candidate, enumerate ALL the
|
|
11
|
+
// repo's versions and fetch each version's per-file manifest
|
|
12
|
+
// (path + blob SHA only — never blob content; bounded + chunked).
|
|
13
|
+
// 3.5 contain — a version is a match when all its FUNCTIONAL files (everything
|
|
14
|
+
// except skill.json / CHANGELOG.md) are present locally with the
|
|
15
|
+
// same blob SHA (catalog ⊆ local). SKILL.md is the identity
|
|
16
|
+
// anchor — it is functional, so containment can't hold unless the
|
|
17
|
+
// local SKILL.md equals that version's. Extra local files (dirt
|
|
18
|
+
// like __pycache__/*.pyc) are tolerated and surfaced as
|
|
19
|
+
// `local_only_files`. Certify the EARLIEST contained version (where
|
|
20
|
+
// the content originated); else near/likely against the closest
|
|
21
|
+
// version by functional similarity.
|
|
22
|
+
// 4. semantic — skills with no hash/name match fall back to the deployed
|
|
23
|
+
// semantic search (bounded), labeled `possible`, else `unmatched`.
|
|
17
24
|
//
|
|
18
25
|
// Closed tier set: certified · near · likely · possible · unmatched.
|
|
19
26
|
|
|
@@ -21,12 +28,34 @@ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
|
21
28
|
const { bounded, DEFAULT_CONCURRENCY } = require('../utils/concurrency')
|
|
22
29
|
const { diff_manifests } = require('./diff')
|
|
23
30
|
|
|
24
|
-
// Identical-file fraction above which a
|
|
31
|
+
// Identical-file fraction above which a non-contained match counts as `near`.
|
|
25
32
|
const DEFAULT_THRESHOLD = 0.5
|
|
26
33
|
// Mirror of the server cap (MATCH_MAX_MANIFESTS) so we chunk manifest requests.
|
|
27
34
|
const MANIFEST_CHUNK = 50
|
|
35
|
+
// Safety bound on the cross-version scan — a repo with more tags than this has
|
|
36
|
+
// its newest N scanned (surfaced as a warning; never a silent cap).
|
|
37
|
+
const MAX_VERSIONS_PER_REPO = 50
|
|
28
38
|
|
|
29
|
-
|
|
39
|
+
// Non-functional / platform-specific files that do NOT count toward skill
|
|
40
|
+
// identity (spec 260629-03 §4.9). `skill.json` is HappySkills metadata — always
|
|
41
|
+
// present, and its `version` bumps on every publish; `CHANGELOG.md` is
|
|
42
|
+
// non-functional release history. Both are excluded from the containment check
|
|
43
|
+
// and the similarity denominator. Matched on the lowercased path, so only the
|
|
44
|
+
// ROOT files count (a `references/CHANGELOG.md` still counts as real content).
|
|
45
|
+
const IGNORE_FILES = new Set(['skill.json', 'changelog.md'])
|
|
46
|
+
const is_ignored_file = (p) => IGNORE_FILES.has(String(p).toLowerCase())
|
|
47
|
+
|
|
48
|
+
const repo_key = (c) => `${String(c.workspace).toLowerCase()}/${c.name}`
|
|
49
|
+
const key_of = (c) => `${repo_key(c)}@${c.version}`
|
|
50
|
+
|
|
51
|
+
// Descending semver-ish compare (numeric, dot-separated) — surfaces the NEWEST
|
|
52
|
+
// matching version first.
|
|
53
|
+
const cmp_version_desc = (a, b) => {
|
|
54
|
+
const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0)
|
|
55
|
+
const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0)
|
|
56
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pb[i] || 0) - (pa[i] || 0); if (d) return d }
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
30
59
|
|
|
31
60
|
const local_view = (skill) => ({
|
|
32
61
|
name: skill.name,
|
|
@@ -40,11 +69,18 @@ const match_view = (c) => ({
|
|
|
40
69
|
workspace: c.workspace,
|
|
41
70
|
name: c.name,
|
|
42
71
|
version: c.version,
|
|
43
|
-
tree_sha: c.tree_sha,
|
|
72
|
+
tree_sha: c.tree_sha || null,
|
|
44
73
|
is_source_of_truth: !!c.is_source_of_truth,
|
|
74
|
+
// The repo head, so a certified-but-older match can be surfaced as
|
|
75
|
+
// "certified @ 0.3.1 (latest 0.3.2)". `is_latest` compares the matched
|
|
76
|
+
// version to head. Null when the candidate came from a head-only path.
|
|
77
|
+
latest_version: c.latest_version || null,
|
|
78
|
+
is_latest: c.latest_version ? c.version === c.latest_version : null,
|
|
45
79
|
})
|
|
46
80
|
|
|
47
|
-
// deps: { match(hashes,names), manifests(repo_versions),
|
|
81
|
+
// deps: { match(hashes,names,tree_shas), manifests(repo_versions),
|
|
82
|
+
// versions?(workspace,name), semantic_search?(query) } — each returns a
|
|
83
|
+
// puffy [err, data] tuple.
|
|
48
84
|
// opts: { threshold, concurrency }
|
|
49
85
|
const run_funnel = (skills, deps, opts = {}) =>
|
|
50
86
|
catch_errors('Match funnel failed', async () => {
|
|
@@ -58,46 +94,84 @@ const run_funnel = (skills, deps, opts = {}) =>
|
|
|
58
94
|
// ── Stage 1: one batched match for the whole folder ──────────────────
|
|
59
95
|
const hashes = [...new Set(skills.map(s => s.skill_md_sha).filter(Boolean))]
|
|
60
96
|
const names = [...new Set(skills.map(s => s.name).filter(Boolean))]
|
|
61
|
-
const
|
|
97
|
+
const tree_shas = [...new Set(skills.map(s => s.tree_sha).filter(Boolean))]
|
|
98
|
+
const [m_err, match_data] = await deps.match(hashes, names, tree_shas)
|
|
62
99
|
if (m_err) throw e('Stage 1 /repos:match failed', m_err)
|
|
63
100
|
|
|
64
101
|
const by_hash = new Map((match_data.results || []).map(r => [r.skill_md_sha, r.candidates || []]))
|
|
65
102
|
const by_name = new Map((match_data.name_results || []).map(r => [String(r.name).toLowerCase(), r.candidates || []]))
|
|
103
|
+
const by_tree = new Map((match_data.tree_results || []).map(r => [r.tree_sha, r.candidates || []]))
|
|
66
104
|
|
|
67
|
-
// ── Stage 2:
|
|
68
|
-
const pending = []
|
|
69
|
-
const none = []
|
|
70
|
-
const
|
|
105
|
+
// ── Stage 2: fast-path certify (byte-exact) + collect candidate repos ─
|
|
106
|
+
const pending = [] // { skill, cand } — needs a cross-version scan
|
|
107
|
+
const none = [] // no hash/name match → semantic stage
|
|
108
|
+
const repo_needs = new Map() // "ws/name" -> { workspace, name, head_version, is_source_of_truth }
|
|
71
109
|
|
|
72
110
|
for (const skill of skills) {
|
|
111
|
+
const tree_cands = (skill.tree_sha && by_tree.get(skill.tree_sha)) || []
|
|
73
112
|
const hash_cands = (skill.skill_md_sha && by_hash.get(skill.skill_md_sha)) || []
|
|
74
113
|
const name_cands = by_name.get(String(skill.name || '').toLowerCase()) || []
|
|
75
114
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
buckets.certified.push({ local: local_view(skill), match: match_view(certified) })
|
|
115
|
+
// Byte-identical to a published version (tree probe) → certified at that
|
|
116
|
+
// exact version, zero manifest fetches. Falls back to head tree-SHA
|
|
117
|
+
// equality for an older server that returns no tree_results.
|
|
118
|
+
const exact = tree_cands[0] || [...hash_cands, ...name_cands].find(c => c.tree_sha === skill.tree_sha)
|
|
119
|
+
if (exact) {
|
|
120
|
+
buckets.certified.push({ local: local_view(skill), match: match_view(exact) })
|
|
83
121
|
continue
|
|
84
122
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
123
|
+
|
|
124
|
+
// A hash/name candidate points at a repo — scan ALL its versions for a
|
|
125
|
+
// functional-containment match. Prefer the source-of-truth repo (the
|
|
126
|
+
// API lists candidates SoT-first), which collapses cross-repo forks.
|
|
127
|
+
const cand = hash_cands[0] || name_cands[0]
|
|
128
|
+
if (cand) {
|
|
129
|
+
// `via: hash` means the local SKILL.md equals head's — a strong
|
|
130
|
+
// same-skill signal that keeps a non-contained match at `near` (not
|
|
131
|
+
// `likely`) even if a reference differs or the manifest can't be read.
|
|
132
|
+
const via = hash_cands[0] ? 'hash' : 'name'
|
|
133
|
+
repo_needs.set(repo_key(cand), { workspace: cand.workspace, name: cand.name, head_version: cand.version, is_source_of_truth: cand.is_source_of_truth })
|
|
134
|
+
pending.push({ skill, cand, via })
|
|
95
135
|
continue
|
|
96
136
|
}
|
|
97
137
|
none.push(skill)
|
|
98
138
|
}
|
|
99
139
|
|
|
100
|
-
// ── Stage
|
|
140
|
+
// ── Stage 2.5: enumerate each candidate repo's versions ──────────────
|
|
141
|
+
const versions_by_repo = new Map()
|
|
142
|
+
if (repo_needs.size && typeof deps.versions === 'function') {
|
|
143
|
+
const reqs = [...repo_needs.values()]
|
|
144
|
+
// Promise.resolve wrapper so a synchronously-throwing versions dep is
|
|
145
|
+
// caught here (→ head-only fallback) rather than escaping the thunk.
|
|
146
|
+
const results = await bounded(reqs.map(r => () =>
|
|
147
|
+
Promise.resolve().then(() => deps.versions(r.workspace, r.name)).then(res => ({ r, res })).catch(error => ({ r, error }))), concurrency)
|
|
148
|
+
for (const { r, res, error } of results) {
|
|
149
|
+
const key = repo_key(r)
|
|
150
|
+
const [verr, vs] = Array.isArray(res) ? res : [error || null, null]
|
|
151
|
+
if (verr || error || !Array.isArray(vs) || !vs.length) {
|
|
152
|
+
if (verr || error) warnings.push({ code: 'VERSIONS_FETCH_FAILED', message: `Could not list versions for ${r.workspace}/${r.name}.` })
|
|
153
|
+
versions_by_repo.set(key, [r.head_version].filter(Boolean))
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
let sorted = [...new Set(vs)].sort(cmp_version_desc)
|
|
157
|
+
if (sorted.length > MAX_VERSIONS_PER_REPO) {
|
|
158
|
+
warnings.push({ code: 'VERSIONS_TRUNCATED', message: `${r.workspace}/${r.name} has ${sorted.length} versions; scanning the newest ${MAX_VERSIONS_PER_REPO}.` })
|
|
159
|
+
sorted = sorted.slice(0, MAX_VERSIONS_PER_REPO)
|
|
160
|
+
}
|
|
161
|
+
versions_by_repo.set(key, sorted)
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
for (const [key, r] of repo_needs) versions_by_repo.set(key, [r.head_version].filter(Boolean))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Stage 3: fetch manifests for EVERY candidate (repo, version) ─────
|
|
168
|
+
const manifest_needs = new Map()
|
|
169
|
+
for (const { cand } of pending) {
|
|
170
|
+
const key = repo_key(cand)
|
|
171
|
+
for (const v of (versions_by_repo.get(key) || [])) {
|
|
172
|
+
manifest_needs.set(`${key}@${v}`, { workspace: cand.workspace, name: cand.name, version: v })
|
|
173
|
+
}
|
|
174
|
+
}
|
|
101
175
|
const manifest_by_key = new Map()
|
|
102
176
|
const needs = [...manifest_needs.values()]
|
|
103
177
|
if (needs.length) {
|
|
@@ -113,23 +187,55 @@ const run_funnel = (skills, deps, opts = {}) =>
|
|
|
113
187
|
}
|
|
114
188
|
}
|
|
115
189
|
|
|
116
|
-
// ──
|
|
190
|
+
// ── Stage 3.5: cross-version containment classification ──────────────
|
|
117
191
|
for (const { skill, cand, via } of pending) {
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
192
|
+
const key = repo_key(cand)
|
|
193
|
+
const head = cand.version
|
|
194
|
+
let contained = null // { version, tree_sha, local_only } — EARLIEST contained
|
|
195
|
+
let closest = null // { version, tree_sha, diff } — best partial
|
|
196
|
+
for (const v of (versions_by_repo.get(key) || [])) { // newest first
|
|
197
|
+
const m = manifest_by_key.get(`${key}@${v}`)
|
|
198
|
+
if (!m) continue
|
|
199
|
+
const diff = diff_manifests(skill.files, m.files, { ignore: is_ignored_file })
|
|
200
|
+
// Contained = every FUNCTIONAL catalog file present locally, same SHA
|
|
201
|
+
// (no missing, no modified). Extra local files (diff.added) are dirt
|
|
202
|
+
// and don't block it. Report the EARLIEST contained version — where
|
|
203
|
+
// this content originated; a later metadata-only re-release ships the
|
|
204
|
+
// same content under a new number but doesn't redefine its identity.
|
|
205
|
+
// Iterating newest→oldest and overwriting leaves the oldest match.
|
|
206
|
+
if (diff.removed.length === 0 && diff.modified.length === 0) {
|
|
207
|
+
contained = { version: v, tree_sha: m.tree_sha, local_only: diff.added }
|
|
208
|
+
}
|
|
209
|
+
if (!closest || diff.similarity > closest.diff.similarity) closest = { version: v, tree_sha: m.tree_sha, diff }
|
|
125
210
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
211
|
+
|
|
212
|
+
if (contained) {
|
|
213
|
+
const local_only = contained.local_only.filter(p => !is_ignored_file(p))
|
|
214
|
+
buckets.certified.push({
|
|
215
|
+
local: local_view(skill),
|
|
216
|
+
match: match_view({ workspace: cand.workspace, name: cand.name, version: contained.version, tree_sha: contained.tree_sha, is_source_of_truth: cand.is_source_of_truth, latest_version: head }),
|
|
217
|
+
...(local_only.length ? { local_only_files: local_only } : {}),
|
|
218
|
+
})
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (closest) {
|
|
223
|
+
const entry = {
|
|
224
|
+
local: local_view(skill),
|
|
225
|
+
match: match_view({ workspace: cand.workspace, name: cand.name, version: closest.version, tree_sha: closest.tree_sha, is_source_of_truth: cand.is_source_of_truth, latest_version: head }),
|
|
226
|
+
differing_files: closest.diff.differing,
|
|
227
|
+
similarity: closest.diff.similarity,
|
|
228
|
+
}
|
|
229
|
+
// A hash match (SKILL.md identical to head) stays `near` regardless of
|
|
230
|
+
// similarity; a name match needs to clear the threshold.
|
|
231
|
+
if (via === 'hash' || closest.diff.similarity >= threshold) buckets.near.push(entry)
|
|
232
|
+
else buckets.likely.push(entry)
|
|
131
233
|
} else {
|
|
132
|
-
|
|
234
|
+
// No manifest resolved at all (every fetch failed). A hash match is
|
|
235
|
+
// still `near` (SKILL.md identical); a name match degrades to `likely`.
|
|
236
|
+
const entry = { local: local_view(skill), match: match_view(cand), differing_files: null, similarity: null }
|
|
237
|
+
if (via === 'hash') buckets.near.push(entry)
|
|
238
|
+
else buckets.likely.push(entry)
|
|
133
239
|
}
|
|
134
240
|
}
|
|
135
241
|
|
|
@@ -176,4 +282,4 @@ const semantic_match_view = (hit) => ({
|
|
|
176
282
|
score: typeof hit.score === 'number' ? hit.score : null,
|
|
177
283
|
})
|
|
178
284
|
|
|
179
|
-
module.exports = { run_funnel, DEFAULT_THRESHOLD, MANIFEST_CHUNK }
|
|
285
|
+
module.exports = { run_funnel, DEFAULT_THRESHOLD, MANIFEST_CHUNK, MAX_VERSIONS_PER_REPO }
|
package/src/match/funnel.test.js
CHANGED
|
@@ -20,13 +20,23 @@ const skill = (over) => ({
|
|
|
20
20
|
})
|
|
21
21
|
const cand = (over) => ({ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote', skill_md_sha: 'h_skillmd', is_source_of_truth: true, ...over })
|
|
22
22
|
|
|
23
|
-
// A deps double that records calls.
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
// A deps double that records calls. `versions` is auto-derived from the
|
|
24
|
+
// provided manifests per repo (override with a { "ws/name": [vers] } map or a
|
|
25
|
+
// flat array) so a test just lists the version manifests it wants scanned.
|
|
26
|
+
const make_deps = ({ results = [], name_results = [], tree_results = [], manifests = [], versions = null, semantic = null } = {}) => {
|
|
27
|
+
const calls = { match: 0, manifests: 0, versions: 0, semantic: 0, blob: 0 }
|
|
28
|
+
const derive_versions = (ws, name) => {
|
|
29
|
+
if (Array.isArray(versions)) return versions
|
|
30
|
+
if (versions && typeof versions === 'object') return versions[`${String(ws).toLowerCase()}/${name}`] || versions[name] || []
|
|
31
|
+
return [...new Set(manifests
|
|
32
|
+
.filter(m => String(m.workspace).toLowerCase() === String(ws).toLowerCase() && m.name === name)
|
|
33
|
+
.map(m => m.version))]
|
|
34
|
+
}
|
|
26
35
|
return {
|
|
27
36
|
calls,
|
|
28
|
-
match: async () => { calls.match++; return [null, { results, name_results }] },
|
|
37
|
+
match: async () => { calls.match++; return [null, { results, name_results, tree_results }] },
|
|
29
38
|
manifests: async () => { calls.manifests++; return [null, { manifests, warnings: [] }] },
|
|
39
|
+
versions: async (ws, name) => { calls.versions++; return [null, derive_versions(ws, name)] },
|
|
30
40
|
...(semantic ? { semantic_search: async (q) => { calls.semantic++; return semantic(q) } } : {}),
|
|
31
41
|
}
|
|
32
42
|
}
|
|
@@ -71,15 +81,18 @@ test('name-only match with low similarity (SKILL.md differs) → likely', async
|
|
|
71
81
|
})
|
|
72
82
|
|
|
73
83
|
test('name-only match above the threshold → near', async () => {
|
|
74
|
-
|
|
84
|
+
// Above the threshold but NOT identical — one functional file differs (2 of 3
|
|
85
|
+
// match → 0.67). A truly identical manifest would (correctly) certify instead.
|
|
86
|
+
const local = skill({ skill_md_sha: 'h_local_only', files: [{ path: 'SKILL.md', blob_sha: 'x1' }, { path: 'a.md', blob_sha: 'shared' }, { path: 'b.md', blob_sha: 'b_local' }] })
|
|
75
87
|
const deps = make_deps({
|
|
76
88
|
name_results: [{ name: 'webapp-testing', candidates: [cand({ skill_md_sha: 'h_other', tree_sha: 't_remote' })] }],
|
|
77
89
|
manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
|
|
78
|
-
files: [{ path: 'SKILL.md', blob_sha: 'x1' }, { path: 'a.md', blob_sha: 'shared' }] }],
|
|
90
|
+
files: [{ path: 'SKILL.md', blob_sha: 'x1' }, { path: 'a.md', blob_sha: 'shared' }, { path: 'b.md', blob_sha: 'b_remote' }] }],
|
|
79
91
|
})
|
|
80
92
|
const [err, out] = await run_funnel([local], deps, { threshold: 0.5 })
|
|
81
93
|
assert.equal(err, null)
|
|
82
94
|
assert.equal(out.buckets.near.length, 1)
|
|
95
|
+
assert.deepEqual(out.buckets.near[0].differing_files, ['b.md'])
|
|
83
96
|
})
|
|
84
97
|
|
|
85
98
|
test('no hash/name match, semantic hit → possible', async () => {
|
|
@@ -107,6 +120,52 @@ test('Stage 1 is ONE batched call regardless of skill count (no N+1)', async ()
|
|
|
107
120
|
assert.equal(deps.calls.match, 1, 'the whole folder resolves in one /repos:match call')
|
|
108
121
|
})
|
|
109
122
|
|
|
123
|
+
// ── Tier 1: cross-version tree probe (spec 260629-03 §4.8) ───────────────────
|
|
124
|
+
|
|
125
|
+
test('local tree byte-identical to an OLDER published version → certified at that exact version', async () => {
|
|
126
|
+
// HEAD is 0.3.2 (a DIFFERENT tree — this is the drift the bug surfaced); the
|
|
127
|
+
// local copy is byte-identical to 0.3.0. The cross-version tree probe must
|
|
128
|
+
// pin it to 0.3.0 and certify it — NOT diff it against head and call it likely.
|
|
129
|
+
const local = skill({ name: 'tiny-gemini-image', skill_md_sha: 'h_old', tree_sha: 't_v030' })
|
|
130
|
+
const deps = make_deps({
|
|
131
|
+
// hash/name discovery still points at HEAD (head's tree differs from local)
|
|
132
|
+
name_results: [{ name: 'tiny-gemini-image', candidates: [cand({ name: 'tiny-gemini-image', version: '0.3.2', tree_sha: 't_v032', skill_md_sha: 'h_head' })] }],
|
|
133
|
+
tree_results: [{ tree_sha: 't_v030', candidates: [
|
|
134
|
+
{ workspace: 'nicolasdao', name: 'tiny-gemini-image', version: '0.3.0', tree_sha: 't_v030', skill_md_sha: 'h_old', is_source_of_truth: true, latest_version: '0.3.2' },
|
|
135
|
+
] }],
|
|
136
|
+
})
|
|
137
|
+
const [err, out] = await run_funnel([local], deps)
|
|
138
|
+
assert.equal(err, null)
|
|
139
|
+
assert.equal(out.buckets.certified.length, 1, 'an older byte-identical version is certified, not likely')
|
|
140
|
+
assert.equal(out.buckets.likely.length, 0)
|
|
141
|
+
assert.equal(out.buckets.near.length, 0)
|
|
142
|
+
assert.equal(out.buckets.certified[0].match.version, '0.3.0', 'pinned to the EXACT matching version, not head')
|
|
143
|
+
assert.equal(out.buckets.certified[0].match.latest_version, '0.3.2', 'carries the latest version so the caller can show "behind by"')
|
|
144
|
+
assert.equal(out.buckets.certified[0].match.is_latest, false)
|
|
145
|
+
assert.equal(deps.calls.manifests, 0, 'an exact tree match needs no manifest diff')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('the local tree probe is sent in the SAME batched match call (no extra round-trip)', async () => {
|
|
149
|
+
const deps = make_deps({ tree_results: [{ tree_sha: 't_local', candidates: [
|
|
150
|
+
{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_local', is_source_of_truth: true, latest_version: '1.0.0' },
|
|
151
|
+
] }] })
|
|
152
|
+
const [err, out] = await run_funnel([skill(), skill({ dir: '/b' })], deps)
|
|
153
|
+
assert.equal(err, null)
|
|
154
|
+
assert.equal(deps.calls.match, 1, 'hashes + names + tree_shas all ride one /repos:match call')
|
|
155
|
+
assert.equal(out.buckets.certified.length, 2)
|
|
156
|
+
assert.equal(out.buckets.certified[0].match.is_latest, true, 'matching head is is_latest:true')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('backward-compat: a server with no tree_results still certifies a head-identical skill', async () => {
|
|
160
|
+
// Older server returns no tree_results; the funnel falls back to head tree
|
|
161
|
+
// equality from the hash/name candidate so existing behavior is preserved.
|
|
162
|
+
const deps = make_deps({ results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_local' })] }] })
|
|
163
|
+
const [err, out] = await run_funnel([skill()], deps)
|
|
164
|
+
assert.equal(err, null)
|
|
165
|
+
assert.equal(out.buckets.certified.length, 1)
|
|
166
|
+
assert.equal(out.buckets.certified[0].match.version, '1.0.0')
|
|
167
|
+
})
|
|
168
|
+
|
|
110
169
|
test('a failed manifest batch becomes a warning, not a thrown error', async () => {
|
|
111
170
|
const deps = {
|
|
112
171
|
calls: {},
|
|
@@ -143,6 +202,163 @@ test('a semantic search that REJECTS still lands the skill in unmatched (never s
|
|
|
143
202
|
assert.ok(out.warnings.some(w => w.code === 'SEMANTIC_SEARCH_FAILED'))
|
|
144
203
|
})
|
|
145
204
|
|
|
205
|
+
// ── Metadata files & cross-version containment (spec 260629-03 §4.9/§4.10) ───
|
|
206
|
+
|
|
207
|
+
test('functionally identical to head (only skill.json differs) → certified, no functional extras', async () => {
|
|
208
|
+
const local = skill({
|
|
209
|
+
files: [{ path: 'SKILL.md', blob_sha: 'b_skill' }, { path: 'skill.json', blob_sha: 'b_sj_local' }],
|
|
210
|
+
})
|
|
211
|
+
const deps = make_deps({
|
|
212
|
+
results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_remote' })] }],
|
|
213
|
+
manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
|
|
214
|
+
files: [{ path: 'SKILL.md', blob_sha: 'b_skill' }, { path: 'skill.json', blob_sha: 'b_sj_remote' }] }],
|
|
215
|
+
})
|
|
216
|
+
const [err, out] = await run_funnel([local], deps)
|
|
217
|
+
assert.equal(err, null)
|
|
218
|
+
assert.equal(out.buckets.certified.length, 1, 'only skill.json differs → functional content identical → certified')
|
|
219
|
+
assert.equal(out.buckets.near.length, 0)
|
|
220
|
+
assert.equal(out.buckets.certified[0].match.version, '1.0.0')
|
|
221
|
+
assert.equal(out.buckets.certified[0].match.is_latest, true, 'matches head → is_latest')
|
|
222
|
+
assert.equal(out.buckets.certified[0].local_only_files, undefined, 'skill.json is ignored, not a functional extra')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('CHANGELOG.md is also ignored — a CHANGELOG-only difference is still certified (case-insensitive)', async () => {
|
|
226
|
+
const local = skill({
|
|
227
|
+
files: [{ path: 'SKILL.md', blob_sha: 'b_skill' }, { path: 'CHANGELOG.md', blob_sha: 'b_cl_local' }],
|
|
228
|
+
})
|
|
229
|
+
const deps = make_deps({
|
|
230
|
+
results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_remote' })] }],
|
|
231
|
+
manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
|
|
232
|
+
files: [{ path: 'SKILL.md', blob_sha: 'b_skill' }, { path: 'CHANGELOG.md', blob_sha: 'b_cl_remote' }] }],
|
|
233
|
+
})
|
|
234
|
+
const [err, out] = await run_funnel([local], deps)
|
|
235
|
+
assert.equal(err, null)
|
|
236
|
+
assert.equal(out.buckets.certified.length, 1)
|
|
237
|
+
assert.equal(out.buckets.certified[0].local_only_files, undefined)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('a real (SKILL.md) difference is NOT certified even when skill.json also differs; differing_files excludes the ignored file', async () => {
|
|
241
|
+
// Name match (SKILL.md changed → different hash). skill.json ALSO differs, but
|
|
242
|
+
// it must not count: the reported difference is the real one (SKILL.md), and
|
|
243
|
+
// skill.json is filtered out of differing_files + the similarity denominator.
|
|
244
|
+
const local = skill({
|
|
245
|
+
skill_md_sha: 'h_local', tree_sha: 't_local',
|
|
246
|
+
files: [{ path: 'SKILL.md', blob_sha: 'x_local' }, { path: 'skill.json', blob_sha: 'sj_local' }, { path: 'a.md', blob_sha: 'shared' }],
|
|
247
|
+
})
|
|
248
|
+
const deps = make_deps({
|
|
249
|
+
name_results: [{ name: 'webapp-testing', candidates: [cand({ skill_md_sha: 'h_other', tree_sha: 't_remote' })] }],
|
|
250
|
+
manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
|
|
251
|
+
files: [{ path: 'SKILL.md', blob_sha: 'x_remote' }, { path: 'skill.json', blob_sha: 'sj_remote' }, { path: 'a.md', blob_sha: 'shared' }] }],
|
|
252
|
+
})
|
|
253
|
+
const [err, out] = await run_funnel([local], deps, { threshold: 0.5 })
|
|
254
|
+
assert.equal(err, null)
|
|
255
|
+
assert.equal(out.buckets.certified.length, 0, 'SKILL.md differs → real difference → not certified')
|
|
256
|
+
const entry = out.buckets.near[0] || out.buckets.likely[0]
|
|
257
|
+
assert.ok(entry, 'lands in near or likely')
|
|
258
|
+
assert.deepEqual(entry.differing_files, ['SKILL.md'], 'skill.json excluded from differing_files (it does not count)')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('cross-version: local matches an OLDER version by SKILL.md + files → certified at that version, not head', async () => {
|
|
262
|
+
// Head 0.3.2 has a newer SKILL.md; the local content equals 0.3.1. It's a NAME
|
|
263
|
+
// match (local SKILL.md != head SKILL.md), so only the cross-version scan finds it.
|
|
264
|
+
const local = skill({
|
|
265
|
+
name: 'tiny-gemini-image', skill_md_sha: 'h_v031', tree_sha: 't_local',
|
|
266
|
+
files: [{ path: 'SKILL.md', blob_sha: 'md_v031' }, { path: 'references/x.md', blob_sha: 'ref_v031' }],
|
|
267
|
+
})
|
|
268
|
+
const deps = make_deps({
|
|
269
|
+
name_results: [{ name: 'tiny-gemini-image', candidates: [
|
|
270
|
+
{ workspace: 'nicolasdao', name: 'tiny-gemini-image', version: '0.3.2', tree_sha: 't_v032', skill_md_sha: 'md_v032', is_source_of_truth: true }] }],
|
|
271
|
+
manifests: [
|
|
272
|
+
{ workspace: 'nicolasdao', name: 'tiny-gemini-image', version: '0.3.2', tree_sha: 't_v032',
|
|
273
|
+
files: [{ path: 'SKILL.md', blob_sha: 'md_v032' }, { path: 'references/x.md', blob_sha: 'ref_v032' }, { path: 'skill.json', blob_sha: 'sj_032' }] },
|
|
274
|
+
{ workspace: 'nicolasdao', name: 'tiny-gemini-image', version: '0.3.1', tree_sha: 't_v031',
|
|
275
|
+
files: [{ path: 'SKILL.md', blob_sha: 'md_v031' }, { path: 'references/x.md', blob_sha: 'ref_v031' }, { path: 'skill.json', blob_sha: 'sj_031' }] },
|
|
276
|
+
{ workspace: 'nicolasdao', name: 'tiny-gemini-image', version: '0.3.0', tree_sha: 't_v030',
|
|
277
|
+
files: [{ path: 'SKILL.md', blob_sha: 'md_v030' }, { path: 'references/x.md', blob_sha: 'ref_v030' }, { path: 'skill.json', blob_sha: 'sj_030' }] },
|
|
278
|
+
],
|
|
279
|
+
})
|
|
280
|
+
const [err, out] = await run_funnel([local], deps)
|
|
281
|
+
assert.equal(err, null)
|
|
282
|
+
assert.equal(out.buckets.certified.length, 1, 'local content == v0.3.1 → certified')
|
|
283
|
+
assert.equal(out.buckets.near.length + out.buckets.likely.length, 0)
|
|
284
|
+
assert.equal(out.buckets.certified[0].match.version, '0.3.1', 'pinned to the exact older version its content matches')
|
|
285
|
+
assert.equal(out.buckets.certified[0].match.latest_version, '0.3.2')
|
|
286
|
+
assert.equal(out.buckets.certified[0].match.is_latest, false)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('when content matches several versions (metadata-only re-releases), certify the EARLIEST (origin)', async () => {
|
|
290
|
+
// Local content is functionally identical to BOTH 1.5.0 and 1.5.1 (1.5.1 only
|
|
291
|
+
// bumped CHANGELOG/skill.json). Report 1.5.0 — where the content originated.
|
|
292
|
+
const local = skill({
|
|
293
|
+
name: 'update-doc', skill_md_sha: 'h_md', tree_sha: 't_local',
|
|
294
|
+
files: [{ path: 'SKILL.md', blob_sha: 'md' }, { path: 'references/x.md', blob_sha: 'ref' }],
|
|
295
|
+
})
|
|
296
|
+
const deps = make_deps({
|
|
297
|
+
results: [{ skill_md_sha: 'h_md', candidates: [
|
|
298
|
+
{ workspace: 'nicolasdao', name: 'update-doc', version: '1.9.0', tree_sha: 't_head', skill_md_sha: 'h_md', is_source_of_truth: true }] }],
|
|
299
|
+
manifests: [
|
|
300
|
+
{ workspace: 'nicolasdao', name: 'update-doc', version: '1.5.1', tree_sha: 't_151',
|
|
301
|
+
files: [{ path: 'SKILL.md', blob_sha: 'md' }, { path: 'references/x.md', blob_sha: 'ref' }, { path: 'CHANGELOG.md', blob_sha: 'cl_151' }, { path: 'skill.json', blob_sha: 'sj_151' }] },
|
|
302
|
+
{ workspace: 'nicolasdao', name: 'update-doc', version: '1.5.0', tree_sha: 't_150',
|
|
303
|
+
files: [{ path: 'SKILL.md', blob_sha: 'md' }, { path: 'references/x.md', blob_sha: 'ref' }, { path: 'CHANGELOG.md', blob_sha: 'cl_150' }, { path: 'skill.json', blob_sha: 'sj_150' }] },
|
|
304
|
+
],
|
|
305
|
+
})
|
|
306
|
+
const [err, out] = await run_funnel([local], deps)
|
|
307
|
+
assert.equal(err, null)
|
|
308
|
+
assert.equal(out.buckets.certified.length, 1)
|
|
309
|
+
assert.equal(out.buckets.certified[0].match.version, '1.5.0', 'both 1.5.0 and 1.5.1 match → report the earliest (origin)')
|
|
310
|
+
assert.equal(out.buckets.certified[0].match.latest_version, '1.9.0')
|
|
311
|
+
assert.equal(out.buckets.certified[0].match.is_latest, false)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('dirt tolerance: local has an extra __pycache__/*.pyc (and no skill.json) → certified, with local_only_files', async () => {
|
|
315
|
+
const local = skill({
|
|
316
|
+
name: 'init-doc', skill_md_sha: 'h_md', tree_sha: 't_local',
|
|
317
|
+
files: [
|
|
318
|
+
{ path: 'SKILL.md', blob_sha: 'md1' },
|
|
319
|
+
{ path: 'scripts/build.py', blob_sha: 'py1' },
|
|
320
|
+
{ path: 'scripts/__pycache__/build.cpython-310.pyc', blob_sha: 'pyc1' }, // local dirt
|
|
321
|
+
],
|
|
322
|
+
})
|
|
323
|
+
const deps = make_deps({
|
|
324
|
+
results: [{ skill_md_sha: 'h_md', candidates: [cand({ name: 'init-doc', tree_sha: 't_remote' })] }],
|
|
325
|
+
manifests: [{ workspace: 'anthropics', name: 'init-doc', version: '1.0.0', tree_sha: 't_remote',
|
|
326
|
+
files: [{ path: 'SKILL.md', blob_sha: 'md1' }, { path: 'scripts/build.py', blob_sha: 'py1' }, { path: 'skill.json', blob_sha: 'sj1' }] }],
|
|
327
|
+
})
|
|
328
|
+
const [err, out] = await run_funnel([local], deps)
|
|
329
|
+
assert.equal(err, null)
|
|
330
|
+
assert.equal(out.buckets.certified.length, 1, 'catalog functional files ⊆ local → certified despite the extra .pyc')
|
|
331
|
+
assert.equal(out.buckets.near.length, 0)
|
|
332
|
+
assert.deepEqual(out.buckets.certified[0].local_only_files, ['scripts/__pycache__/build.cpython-310.pyc'])
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('a local copy MISSING one of the version files is NOT certified (containment fails)', async () => {
|
|
336
|
+
const local = skill({
|
|
337
|
+
name: 'partial', skill_md_sha: 'h_md', tree_sha: 't_local',
|
|
338
|
+
files: [{ path: 'SKILL.md', blob_sha: 'md1' }], // missing references/x.md the version ships
|
|
339
|
+
})
|
|
340
|
+
const deps = make_deps({
|
|
341
|
+
results: [{ skill_md_sha: 'h_md', candidates: [cand({ name: 'partial', tree_sha: 't_remote' })] }],
|
|
342
|
+
manifests: [{ workspace: 'anthropics', name: 'partial', version: '1.0.0', tree_sha: 't_remote',
|
|
343
|
+
files: [{ path: 'SKILL.md', blob_sha: 'md1' }, { path: 'references/x.md', blob_sha: 'ref1' }, { path: 'skill.json', blob_sha: 'sj1' }] }],
|
|
344
|
+
})
|
|
345
|
+
const [err, out] = await run_funnel([local], deps)
|
|
346
|
+
assert.equal(err, null)
|
|
347
|
+
assert.equal(out.buckets.certified.length, 0, 'missing a functional file → not fully contained → not certified')
|
|
348
|
+
assert.equal(out.buckets.near.length + out.buckets.likely.length, 1)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('diff_manifests with an ignore predicate excludes ignored files from the functional diff + similarity', () => {
|
|
352
|
+
const d = diff_manifests(
|
|
353
|
+
[{ path: 'SKILL.md', blob_sha: 'a' }, { path: 'skill.json', blob_sha: 'v1' }, { path: 'keep', blob_sha: 'k' }],
|
|
354
|
+
[{ path: 'SKILL.md', blob_sha: 'a' }, { path: 'skill.json', blob_sha: 'v2' }, { path: 'keep', blob_sha: 'k' }],
|
|
355
|
+
{ ignore: (p) => p.toLowerCase() === 'skill.json' },
|
|
356
|
+
)
|
|
357
|
+
assert.deepEqual(d.differing, [], 'skill.json excluded → no functional differences')
|
|
358
|
+
assert.deepEqual(d.ignored_differing, ['skill.json'])
|
|
359
|
+
assert.equal(d.similarity, 1, 'similarity computed over functional files only (2/2 identical)')
|
|
360
|
+
})
|
|
361
|
+
|
|
146
362
|
test('diff_manifests classifies identical / modified / added / removed', () => {
|
|
147
363
|
const d = diff_manifests(
|
|
148
364
|
[{ path: 'SKILL.md', blob_sha: 'a' }, { path: 'keep', blob_sha: 'k' }, { path: 'local-only', blob_sha: 'z' }],
|