happyskills 1.15.0 → 1.16.1
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 +16 -0
- package/package.json +1 -1
- package/src/api/repos.js +13 -1
- package/src/commands/match.js +16 -2
- package/src/commands/match.test.js +1 -0
- package/src/match/diff.js +17 -2
- package/src/match/funnel.js +155 -61
- package/src/match/funnel.test.js +175 -5
- package/src/ui/envelope.test.js +1 -1
- package/src/utils/skill_update_check.js +23 -8
- package/src/utils/skill_update_check.test.js +41 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.16.1] - 2026-07-02
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Make the passive daily skill-update check watch **every** installed skill, not just top-level (`__root__`) ones. Bundled family satellites installed as transitive dependencies (e.g. `happyskills-design`, `happyskills-help`) are recorded in the lock as `requested_by: [<parent>]`, so the previous `__root__`-only filter silently never nudged when they fell behind the registry — even though the check already spans both the project-local and global locks. Candidate selection now mirrors `happyskills check` (every installed skill), so an outdated bundled satellite is surfaced both in the stderr nudge and in the `SKILLS_UPDATE_AVAILABLE` envelope warning.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- The "installed skills behind the registry" nudge now names the outdated skills in its suggested command (`npx happyskills update <owner/name> …`, grouped by scope so global skills get `-g`) instead of suggesting `update --all` — which only re-checks top-level skills and therefore could never refresh an outdated bundled satellite.
|
|
19
|
+
|
|
20
|
+
## [1.16.0] - 2026-07-01
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- 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.)
|
|
25
|
+
|
|
10
26
|
## [1.15.0] - 2026-06-30
|
|
11
27
|
|
|
12
28
|
### Changed
|
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]
|
|
@@ -238,4 +250,4 @@ const manifests = (repo_versions) => catch_errors('Manifests lookup failed', asy
|
|
|
238
250
|
}
|
|
239
251
|
})
|
|
240
252
|
|
|
241
|
-
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,19 +130,32 @@ 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`)
|
|
133
134
|
|
|
134
135
|
// A certified match to an OLDER version is still an exact match — surface the
|
|
135
136
|
// version gap so the principal knows it is the real skill, just behind.
|
|
136
137
|
const behind = data.certified.filter(en => en.match && en.match.latest_version && en.match.is_latest === false)
|
|
137
138
|
if (behind.length) {
|
|
138
|
-
print_info('\
|
|
139
|
+
print_info('\nCertified to an older published version (you are behind):')
|
|
139
140
|
for (const en of behind) {
|
|
140
141
|
const where = en.match ? `${en.match.workspace}/${en.match.name}` : '(catalog)'
|
|
141
142
|
print_info(` ${en.local.name} → ${where} @ ${en.match.version} (latest ${en.match.latest_version})`)
|
|
142
143
|
}
|
|
143
144
|
}
|
|
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
|
+
}
|
|
158
|
+
|
|
145
159
|
if (data.near.length) {
|
|
146
160
|
print_info('\nSkills with small differences:')
|
|
147
161
|
for (const en of data.near) {
|
|
@@ -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
|
}
|
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,16 +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,
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// candidate came from
|
|
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.
|
|
48
77
|
latest_version: c.latest_version || null,
|
|
49
78
|
is_latest: c.latest_version ? c.version === c.latest_version : null,
|
|
50
79
|
})
|
|
51
80
|
|
|
52
|
-
// 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.
|
|
53
84
|
// opts: { threshold, concurrency }
|
|
54
85
|
const run_funnel = (skills, deps, opts = {}) =>
|
|
55
86
|
catch_errors('Match funnel failed', async () => {
|
|
@@ -61,9 +92,6 @@ const run_funnel = (skills, deps, opts = {}) =>
|
|
|
61
92
|
if (!skills.length) return { buckets, warnings }
|
|
62
93
|
|
|
63
94
|
// ── Stage 1: one batched match for the whole folder ──────────────────
|
|
64
|
-
// Hashes, names AND whole-skill tree shas ride the same call. The tree
|
|
65
|
-
// probe (spec §4.8) is what lets an OLDER local copy match its exact
|
|
66
|
-
// published version instead of drifting against head.
|
|
67
95
|
const hashes = [...new Set(skills.map(s => s.skill_md_sha).filter(Boolean))]
|
|
68
96
|
const names = [...new Set(skills.map(s => s.name).filter(Boolean))]
|
|
69
97
|
const tree_shas = [...new Set(skills.map(s => s.tree_sha).filter(Boolean))]
|
|
@@ -74,42 +102,76 @@ const run_funnel = (skills, deps, opts = {}) =>
|
|
|
74
102
|
const by_name = new Map((match_data.name_results || []).map(r => [String(r.name).toLowerCase(), r.candidates || []]))
|
|
75
103
|
const by_tree = new Map((match_data.tree_results || []).map(r => [r.tree_sha, r.candidates || []]))
|
|
76
104
|
|
|
77
|
-
// ── Stage 2:
|
|
78
|
-
const pending = []
|
|
79
|
-
const none = []
|
|
80
|
-
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 }
|
|
81
109
|
|
|
82
110
|
for (const skill of skills) {
|
|
83
111
|
const tree_cands = (skill.tree_sha && by_tree.get(skill.tree_sha)) || []
|
|
84
112
|
const hash_cands = (skill.skill_md_sha && by_hash.get(skill.skill_md_sha)) || []
|
|
85
113
|
const name_cands = by_name.get(String(skill.name || '').toLowerCase()) || []
|
|
86
114
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (certified) {
|
|
94
|
-
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) })
|
|
95
121
|
continue
|
|
96
122
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 })
|
|
107
135
|
continue
|
|
108
136
|
}
|
|
109
137
|
none.push(skill)
|
|
110
138
|
}
|
|
111
139
|
|
|
112
|
-
// ── 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
|
+
}
|
|
113
175
|
const manifest_by_key = new Map()
|
|
114
176
|
const needs = [...manifest_needs.values()]
|
|
115
177
|
if (needs.length) {
|
|
@@ -125,23 +187,55 @@ const run_funnel = (skills, deps, opts = {}) =>
|
|
|
125
187
|
}
|
|
126
188
|
}
|
|
127
189
|
|
|
128
|
-
// ──
|
|
190
|
+
// ── Stage 3.5: cross-version containment classification ──────────────
|
|
129
191
|
for (const { skill, cand, via } of pending) {
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 }
|
|
137
210
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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)
|
|
143
233
|
} else {
|
|
144
|
-
|
|
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)
|
|
145
239
|
}
|
|
146
240
|
}
|
|
147
241
|
|
|
@@ -188,4 +282,4 @@ const semantic_match_view = (hit) => ({
|
|
|
188
282
|
score: typeof hit.score === 'number' ? hit.score : null,
|
|
189
283
|
})
|
|
190
284
|
|
|
191
|
-
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
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 () => {
|
|
@@ -189,6 +202,163 @@ test('a semantic search that REJECTS still lands the skill in unmatched (never s
|
|
|
189
202
|
assert.ok(out.warnings.some(w => w.code === 'SEMANTIC_SEARCH_FAILED'))
|
|
190
203
|
})
|
|
191
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
|
+
|
|
192
362
|
test('diff_manifests classifies identical / modified / added / removed', () => {
|
|
193
363
|
const d = diff_manifests(
|
|
194
364
|
[{ path: 'SKILL.md', blob_sha: 'a' }, { path: 'keep', blob_sha: 'k' }, { path: 'local-only', blob_sha: 'z' }],
|
package/src/ui/envelope.test.js
CHANGED
|
@@ -34,7 +34,7 @@ test('drift set → appends a SKILLS_UPDATE_AVAILABLE warning without touching d
|
|
|
34
34
|
assert.strictEqual(env.warnings[0].code, 'SKILLS_UPDATE_AVAILABLE')
|
|
35
35
|
assert.match(env.warnings[0].message, /2 installed skill\(s\) behind the registry/)
|
|
36
36
|
assert.match(env.warnings[0].message, /acme\/x, acme\/y/)
|
|
37
|
-
assert.match(env.warnings[0].message, /npx happyskills update
|
|
37
|
+
assert.match(env.warnings[0].message, /npx happyskills update acme\/x acme\/y/)
|
|
38
38
|
})
|
|
39
39
|
})
|
|
40
40
|
|
|
@@ -44,6 +44,16 @@ const write_scope = (key, outdated) => {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Selects which locked skills to check for registry drift: EVERY installed skill,
|
|
48
|
+
// not just __root__ entries. A bundled family satellite (e.g. happyskills-help) is
|
|
49
|
+
// pulled in as a transitive dependency of core — its lock entry reads
|
|
50
|
+
// requested_by:['happyskillsai/happyskills'], NOT ['__root__'] — so a __root__-only
|
|
51
|
+
// filter silently never nudged about it even when it fell behind. Mirrors the
|
|
52
|
+
// "check every installed skill" enumeration in commands/check.js. A null value is a
|
|
53
|
+
// removed-skill tombstone — skip it. Extracted as a pure function for unit testing.
|
|
54
|
+
const select_drift_candidates = (skills) =>
|
|
55
|
+
Object.entries(skills || {}).filter(([, d]) => d && typeof d === 'object')
|
|
56
|
+
|
|
47
57
|
// Background refresh — NOT awaited, all errors swallowed. Reads the lock directly
|
|
48
58
|
// (NOT via lock/reader.read_lock, which prints a stderr warning on version mismatch
|
|
49
59
|
// — unwanted noise from a silent background task), batch-checks root skills against
|
|
@@ -60,8 +70,7 @@ const refresh_cache = (key, opts) => {
|
|
|
60
70
|
const lock_path = lock_file_path(lock_root(opts.is_global, opts.project_root))
|
|
61
71
|
const [, lock_data] = await read_json(lock_path)
|
|
62
72
|
const skills = (lock_data && lock_data.skills) || {}
|
|
63
|
-
const candidates =
|
|
64
|
-
.filter(([, d]) => d && Array.isArray(d.requested_by) && d.requested_by.includes('__root__'))
|
|
73
|
+
const candidates = select_drift_candidates(skills)
|
|
65
74
|
|
|
66
75
|
if (candidates.length === 0) { write_scope(key, []); return }
|
|
67
76
|
|
|
@@ -140,11 +149,17 @@ const format_skill_drift = (verdict) => {
|
|
|
140
149
|
const items = verdict.outdated
|
|
141
150
|
const names = items.slice(0, 3).map(s => s.skill).join(', ')
|
|
142
151
|
const more = items.length > 3 ? `, +${items.length - 3} more` : ''
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
152
|
+
// Name the outdated skills explicitly rather than suggesting `update --all`.
|
|
153
|
+
// `update --all` only re-checks __root__ skills, so it can never refresh a bundled
|
|
154
|
+
// satellite installed as a transitive dependency — but `update <owner/name>` fixes
|
|
155
|
+
// ANY locked skill. Group by scope: a global skill needs -g, and one update run
|
|
156
|
+
// writes a single scope. Items with no scope default to local.
|
|
157
|
+
const local = items.filter(i => i.scope !== 'global').map(i => i.skill)
|
|
158
|
+
const global = items.filter(i => i.scope === 'global').map(i => i.skill)
|
|
159
|
+
const cmds = []
|
|
160
|
+
if (local.length) cmds.push(`npx happyskills update ${local.join(' ')}`)
|
|
161
|
+
if (global.length) cmds.push(`npx happyskills update ${global.join(' ')} -g`)
|
|
162
|
+
return `${items.length} installed skill(s) behind the registry (${names}${more}). Run: ${cmds.join(' && ')}`
|
|
148
163
|
}
|
|
149
164
|
|
|
150
|
-
module.exports = { check_skills_for_update, format_skill_drift, get_cache_path, scope_key }
|
|
165
|
+
module.exports = { check_skills_for_update, format_skill_drift, select_drift_candidates, get_cache_path, scope_key }
|
|
@@ -4,7 +4,7 @@ const fs = require('fs')
|
|
|
4
4
|
const os = require('os')
|
|
5
5
|
const path = require('path')
|
|
6
6
|
|
|
7
|
-
const { check_skills_for_update, format_skill_drift, get_cache_path, scope_key } = require('./skill_update_check')
|
|
7
|
+
const { check_skills_for_update, format_skill_drift, select_drift_candidates, get_cache_path, scope_key } = require('./skill_update_check')
|
|
8
8
|
|
|
9
9
|
const PROJECT_ROOT = '/proj'
|
|
10
10
|
const PKEY = scope_key({ is_global: false, project_root: PROJECT_ROOT })
|
|
@@ -53,7 +53,7 @@ test('project-only drift → verdict flagged local, no -g in the message', () =>
|
|
|
53
53
|
assert.strictEqual(v.outdated.length, 1)
|
|
54
54
|
assert.strictEqual(v.has_local, true)
|
|
55
55
|
assert.strictEqual(v.has_global, false)
|
|
56
|
-
assert.match(format_skill_drift(v), /update
|
|
56
|
+
assert.match(format_skill_drift(v), /Run: npx happyskills update acme\/x$/)
|
|
57
57
|
})
|
|
58
58
|
})
|
|
59
59
|
|
|
@@ -64,7 +64,7 @@ test('global-only drift → verdict flagged global, message uses -g', () => {
|
|
|
64
64
|
assert.ok(v)
|
|
65
65
|
assert.strictEqual(v.has_local, false)
|
|
66
66
|
assert.strictEqual(v.has_global, true)
|
|
67
|
-
assert.match(format_skill_drift(v), /update
|
|
67
|
+
assert.match(format_skill_drift(v), /Run: npx happyskills update happyskillsai\/happyskills -g$/)
|
|
68
68
|
})
|
|
69
69
|
})
|
|
70
70
|
|
|
@@ -75,7 +75,9 @@ test('drift in BOTH scopes → merged, both flags set, message mentions -g for g
|
|
|
75
75
|
assert.strictEqual(v.outdated.length, 2)
|
|
76
76
|
assert.strictEqual(v.has_local, true)
|
|
77
77
|
assert.strictEqual(v.has_global, true)
|
|
78
|
-
|
|
78
|
+
const msg = format_skill_drift(v)
|
|
79
|
+
assert.match(msg, /npx happyskills update acme\/x/)
|
|
80
|
+
assert.match(msg, /npx happyskills update happyskillsai\/happyskills -g/)
|
|
79
81
|
})
|
|
80
82
|
})
|
|
81
83
|
|
|
@@ -116,3 +118,38 @@ test('localhost:0 test API sentinel short-circuits to null', () => {
|
|
|
116
118
|
assert.strictEqual(check_skills_for_update({ project_root: PROJECT_ROOT }), null)
|
|
117
119
|
})
|
|
118
120
|
})
|
|
121
|
+
|
|
122
|
+
// ─── Regression: the passive check must watch the WHOLE installed family ─────────
|
|
123
|
+
// A bundled family satellite (design/publish/sync/search/help) is installed as a
|
|
124
|
+
// transitive dependency of core, so its lock entry is requested_by:[core], NOT
|
|
125
|
+
// [__root__]. A __root__-only enumeration silently never nudged about it even when it
|
|
126
|
+
// fell behind the registry — the gap this suite pins shut. See docs/cli-overview.md § 3.
|
|
127
|
+
|
|
128
|
+
test('select_drift_candidates watches transitive dependencies, not just __root__ skills', () => {
|
|
129
|
+
const skills = {
|
|
130
|
+
'happyskillsai/happyskills': { version: '2.5.4', base_commit: 'aaa', requested_by: ['__root__'] },
|
|
131
|
+
'happyskillsai/happyskills-help': { version: '0.8.2', base_commit: 'bbb', requested_by: ['happyskillsai/happyskills'] },
|
|
132
|
+
}
|
|
133
|
+
const names = select_drift_candidates(skills).map(([n]) => n)
|
|
134
|
+
assert.ok(names.includes('happyskillsai/happyskills-help'),
|
|
135
|
+
'a bundled satellite pulled in as a transitive dependency must still be checked for updates')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('select_drift_candidates keeps __root__ skills and skips null (removed-skill) tombstones', () => {
|
|
139
|
+
const skills = {
|
|
140
|
+
'acme/root': { version: '1.0.0', requested_by: ['__root__'] },
|
|
141
|
+
'acme/removed': null,
|
|
142
|
+
}
|
|
143
|
+
const names = select_drift_candidates(skills).map(([n]) => n)
|
|
144
|
+
assert.ok(names.includes('acme/root'))
|
|
145
|
+
assert.ok(!names.includes('acme/removed'))
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('format_skill_drift names the skills (works for transitive deps), never suggests update --all', () => {
|
|
149
|
+
// `update --all` only re-checks __root__ skills, so it can never refresh a bundled
|
|
150
|
+
// satellite. The remedy must name the skill: `update <owner/name>` fixes ANY locked skill.
|
|
151
|
+
const verdict = { outdated: [{ skill: 'happyskillsai/happyskills-help', installed: '0.8.2', latest: '0.9.0', scope: 'local' }], has_local: true, has_global: false }
|
|
152
|
+
const msg = format_skill_drift(verdict)
|
|
153
|
+
assert.match(msg, /npx happyskills update happyskillsai\/happyskills-help/)
|
|
154
|
+
assert.doesNotMatch(msg, /update --all/)
|
|
155
|
+
})
|