happyskills 0.35.5 → 0.36.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 +14 -0
- package/package.json +1 -1
- package/src/api/repos.js +1 -1
- package/src/commands/install.js +5 -2
- package/src/commands/publish.js +39 -13
- package/src/commands/search.js +1 -1
- package/src/commands/validate.js +46 -3
- package/src/engine/installer.js +29 -3
- package/src/engine/resolver.js +2 -2
- package/src/utils/errors.js +25 -27
- package/src/validation/dependency_rules.js +309 -0
- package/src/validation/dependency_rules.test.js +231 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.36.0] - 2026-04-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add 6 dependency validation rules to `validate` command: `dep-self-reference` (skill depends on itself), `dep-exists` (all declared deps exist in registry), `dep-visibility-direct` (public skill's direct deps must be public), `dep-visibility-transitive` (full transitive tree visibility check), `dep-visibility-workspace` (warning for workspace-scoped deps on public skills), `dep-circular` (DFS cycle detection on the full dependency tree). Registry checks run when authenticated; local-only checks run otherwise with an info message suggesting login.
|
|
14
|
+
- Add `extract_root_cause()` and `format_error_chain()` utilities in `utils/errors.js` for cleaner error diagnostics across all commands
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Replace ad-hoc dependency existence check in `publish` with the new validation rules — dependency visibility errors now block publish with clear, actionable messages
|
|
18
|
+
- Change `install` resolver to return `{ packages, skipped }` instead of a flat packages array. Installer prints categorized warnings for skipped deps after install: access-denied suggests `happyskills login`, not-found lists missing skill names. `skipped_deps` is included in the result object and JSON output.
|
|
19
|
+
- Show root cause in `install` error messages (e.g. "Access denied: nicolasdao/init-mission") instead of the generic wrapper ("Install failed"). JSON mode error output now includes a `details` array with the full error chain.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Fix `search --workspace <slug>` triggering AuthError when not logged in — only `--mine` and `--personal` flags require authentication; `--workspace` returns public skills without auth
|
|
23
|
+
|
|
10
24
|
## [0.35.5] - 2026-04-11
|
|
11
25
|
|
|
12
26
|
### Fixed
|
package/package.json
CHANGED
package/src/api/repos.js
CHANGED
|
@@ -10,7 +10,7 @@ const search = (query, options = {}) => catch_errors('Search failed', async () =
|
|
|
10
10
|
if (options.workspace) params.set('workspace', options.workspace)
|
|
11
11
|
if (options.tags) params.set('tags', options.tags)
|
|
12
12
|
if (options.type) params.set('type', options.type)
|
|
13
|
-
const needs_auth =
|
|
13
|
+
const needs_auth = options.scope && options.scope !== 'public'
|
|
14
14
|
const [errors, data] = await client.get(`/repos/search?${params}`, { auth: needs_auth || false })
|
|
15
15
|
if (errors) throw errors[errors.length - 1]
|
|
16
16
|
return data
|
package/src/commands/install.js
CHANGED
|
@@ -107,7 +107,8 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
107
107
|
const version = flag_version || inline_version
|
|
108
108
|
const [errors, result] = await install(skill, { ...base_options, version })
|
|
109
109
|
if (errors) {
|
|
110
|
-
const
|
|
110
|
+
const chain = errors?.map ? errors.map(x => x?.message).filter(Boolean) : [errors?.message || String(errors)]
|
|
111
|
+
const msg = chain[chain.length - 1] || chain[0] || 'Unknown error'
|
|
111
112
|
print_warn(`Failed to install ${skill}: ${msg}`)
|
|
112
113
|
failures.push({ skill, error: msg })
|
|
113
114
|
} else {
|
|
@@ -116,7 +117,8 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
if (results.length === 0 && failures.length > 0) {
|
|
119
|
-
|
|
120
|
+
const detail = failures.map(f => `${f.skill}: ${f.error}`).join('; ')
|
|
121
|
+
throw new Error(detail)
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
if (args.flags.json) {
|
|
@@ -125,6 +127,7 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
125
127
|
version: result.version,
|
|
126
128
|
installed: result.installed || [],
|
|
127
129
|
skipped: result.skipped || [],
|
|
130
|
+
skipped_deps: result.skipped_deps || [],
|
|
128
131
|
warnings: result.warnings || [],
|
|
129
132
|
forced: result.forced || []
|
|
130
133
|
}))
|
package/src/commands/publish.js
CHANGED
|
@@ -18,6 +18,7 @@ const { validate_skill_json } = require('../validation/skill_json_rules')
|
|
|
18
18
|
const { validate_cross } = require('../validation/cross_rules')
|
|
19
19
|
const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
|
|
20
20
|
const { validate_file_sizes } = require('../validation/file_size_rules')
|
|
21
|
+
const { validate_dependencies } = require('../validation/dependency_rules')
|
|
21
22
|
const { create_spinner } = require('../ui/spinner')
|
|
22
23
|
const { print_help, print_success, print_error, print_warn, print_hint, print_json, code, summarize_warnings } = require('../ui/output')
|
|
23
24
|
const { exit_with_error, UsageError, CliError } = require('../utils/errors')
|
|
@@ -144,21 +145,46 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
144
145
|
|
|
145
146
|
if (manifest.dependencies && Object.keys(manifest.dependencies).length > 0) {
|
|
146
147
|
spinner.update('Checking dependencies...')
|
|
147
|
-
const
|
|
148
|
-
const dep_results = await
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
148
|
+
const full_name = `${workspace.slug}/${manifest.name}`
|
|
149
|
+
const [dep_err, dep_results] = await validate_dependencies(dir, manifest, {
|
|
150
|
+
registry: true,
|
|
151
|
+
visibility,
|
|
152
|
+
full_name
|
|
153
|
+
})
|
|
154
|
+
if (!dep_err && dep_results) {
|
|
155
|
+
const dep_errors = dep_results.filter(r => r.severity === 'error')
|
|
156
|
+
const dep_warnings = dep_results.filter(r => r.severity === 'warning')
|
|
157
|
+
|
|
158
|
+
if (dep_errors.length > 0) {
|
|
159
|
+
spinner.fail('Dependency check failed')
|
|
160
|
+
if (is_json_mode()) {
|
|
161
|
+
const structured = dep_errors.map(({ severity, ...rest }) => rest)
|
|
162
|
+
print_json({
|
|
163
|
+
error: {
|
|
164
|
+
code: 'DEPENDENCY_VALIDATION_FAILED',
|
|
165
|
+
message: `${dep_errors.length} dependency error(s) found. Fix these issues and try again.`,
|
|
166
|
+
exit_code: EXIT_CODES.ERROR,
|
|
167
|
+
validation_errors: structured
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
return process.exit(EXIT_CODES.ERROR)
|
|
171
|
+
}
|
|
172
|
+
for (const r of dep_errors) {
|
|
173
|
+
print_error(r.message)
|
|
174
|
+
}
|
|
175
|
+
return process.exit(EXIT_CODES.ERROR)
|
|
160
176
|
}
|
|
177
|
+
|
|
178
|
+
if (dep_warnings.length > 0 && !is_json_mode()) {
|
|
179
|
+
for (const r of dep_warnings) {
|
|
180
|
+
print_warn(r.message)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Accumulate dep warnings for JSON output
|
|
185
|
+
validation_warnings.push(...dep_warnings)
|
|
161
186
|
}
|
|
187
|
+
spinner.update('Preparing to publish...')
|
|
162
188
|
}
|
|
163
189
|
|
|
164
190
|
// Read base_commit and merge_parents from lock file for divergence check
|
package/src/commands/search.js
CHANGED
|
@@ -236,7 +236,7 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
236
236
|
|
|
237
237
|
const scope = mine ? 'mine' : personal ? 'personal' : undefined
|
|
238
238
|
|
|
239
|
-
if (
|
|
239
|
+
if (mine || personal) {
|
|
240
240
|
const [, token_data] = await load_token()
|
|
241
241
|
if (!token_data) {
|
|
242
242
|
throw new AuthError('Authentication required. Run `happyskills login` first.')
|
package/src/commands/validate.js
CHANGED
|
@@ -6,9 +6,12 @@ const { validate_cross } = require('../validation/cross_rules')
|
|
|
6
6
|
const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
|
|
7
7
|
const { validate_file_sizes } = require('../validation/file_size_rules')
|
|
8
8
|
const { validate_changelog_version } = require('../validation/changelog_rules')
|
|
9
|
+
const { validate_dependencies } = require('../validation/dependency_rules')
|
|
9
10
|
const { file_exists, read_json } = require('../utils/fs')
|
|
10
|
-
const { skills_dir, find_project_root } = require('../config/paths')
|
|
11
|
-
const {
|
|
11
|
+
const { skills_dir, find_project_root, lock_root } = require('../config/paths')
|
|
12
|
+
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
13
|
+
const { load_token } = require('../auth/token_store')
|
|
14
|
+
const { print_help, print_json, print_info } = require('../ui/output')
|
|
12
15
|
const { bold, green, yellow, red, dim } = require('../ui/colors')
|
|
13
16
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
14
17
|
const { EXIT_CODES, SKILL_MD, SKILL_JSON, SKILL_TYPES } = require('../constants')
|
|
@@ -154,7 +157,47 @@ const run = (args) => catch_errors('Validate failed', async () => {
|
|
|
154
157
|
const [cl_err, cl_results] = await validate_changelog_version(skill_dir, json_data.manifest)
|
|
155
158
|
if (cl_err) throw cl_err
|
|
156
159
|
|
|
157
|
-
|
|
160
|
+
// Dependency validation — registry checks when authenticated
|
|
161
|
+
let dep_results = []
|
|
162
|
+
const manifest = json_data.manifest || {}
|
|
163
|
+
if (manifest.dependencies && Object.keys(manifest.dependencies).length > 0) {
|
|
164
|
+
const [, token_data] = await load_token()
|
|
165
|
+
const has_registry = !!token_data
|
|
166
|
+
|
|
167
|
+
// Resolve the full owner/name for registry lookups
|
|
168
|
+
let full_name = null
|
|
169
|
+
let visibility = 'private'
|
|
170
|
+
if (has_registry) {
|
|
171
|
+
const project_root = find_project_root()
|
|
172
|
+
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
173
|
+
if (lock_data) {
|
|
174
|
+
const all_skills = get_all_locked_skills(lock_data)
|
|
175
|
+
const suffix = `/${skill_name}`
|
|
176
|
+
const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
|
|
177
|
+
if (lock_key) full_name = lock_key
|
|
178
|
+
}
|
|
179
|
+
// Try to get visibility from registry if we have the full name
|
|
180
|
+
if (full_name) {
|
|
181
|
+
const repos_api = require('../api/repos')
|
|
182
|
+
const [owner, name] = full_name.split('/')
|
|
183
|
+
const [, repo_data] = await repos_api.get_repo(owner, name, { auth: true })
|
|
184
|
+
if (repo_data) visibility = repo_data.visibility || 'private'
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const [dep_err, dep_result] = await validate_dependencies(skill_dir, manifest, {
|
|
189
|
+
registry: has_registry,
|
|
190
|
+
visibility,
|
|
191
|
+
full_name
|
|
192
|
+
})
|
|
193
|
+
if (!dep_err && dep_result) dep_results = dep_result
|
|
194
|
+
|
|
195
|
+
if (!has_registry && !args.flags.json) {
|
|
196
|
+
print_info('Dependency registry checks skipped (not logged in). Log in for full validation.')
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results, ...dep_results]
|
|
158
201
|
const type_label = is_kit ? ' [kit]' : ''
|
|
159
202
|
|
|
160
203
|
if (args.flags.json) {
|
package/src/engine/installer.js
CHANGED
|
@@ -73,14 +73,17 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
73
73
|
const spinner = create_spinner(`Resolving dependencies for ${skill}...`)
|
|
74
74
|
|
|
75
75
|
let packages
|
|
76
|
+
let skipped_deps = []
|
|
76
77
|
if (force) {
|
|
77
78
|
const [errors, result] = await resolve_with_force(skill, version || 'latest', {})
|
|
78
79
|
if (errors) { spinner.fail('Resolution failed'); throw e('Resolution failed', errors) }
|
|
79
|
-
packages = result
|
|
80
|
+
packages = result.packages
|
|
81
|
+
skipped_deps = result.skipped || []
|
|
80
82
|
} else {
|
|
81
83
|
const [errors, result] = await resolve(skill, version || 'latest', {})
|
|
82
84
|
if (errors) { spinner.fail('Resolution failed'); throw e('Resolution failed', errors) }
|
|
83
|
-
packages = result
|
|
85
|
+
packages = result.packages
|
|
86
|
+
skipped_deps = result.skipped || []
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
// Filter out packages already installed at the resolved version (handles @latest efficiently)
|
|
@@ -270,6 +273,29 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
270
273
|
}
|
|
271
274
|
}
|
|
272
275
|
|
|
276
|
+
// Warn about skipped dependencies (access-denied, not found, etc.)
|
|
277
|
+
if (skipped_deps.length > 0) {
|
|
278
|
+
const access_denied = skipped_deps.filter(s => s.reason === 'access_denied')
|
|
279
|
+
const not_found = skipped_deps.filter(s => s.reason === 'not_found')
|
|
280
|
+
const other = skipped_deps.filter(s => s.reason !== 'access_denied' && s.reason !== 'not_found')
|
|
281
|
+
if (access_denied.length > 0) {
|
|
282
|
+
print_warn(`${access_denied.length} dependenc${access_denied.length === 1 ? 'y' : 'ies'} skipped (private, requires login):`)
|
|
283
|
+
for (const s of access_denied) {
|
|
284
|
+
print_warn(` ${s.skill} (required by ${s.required_by})`)
|
|
285
|
+
}
|
|
286
|
+
print_info(`Log in to install: happyskills login`)
|
|
287
|
+
}
|
|
288
|
+
if (not_found.length > 0) {
|
|
289
|
+
print_warn(`${not_found.length} dependenc${not_found.length === 1 ? 'y' : 'ies'} skipped (not found in registry):`)
|
|
290
|
+
for (const s of not_found) {
|
|
291
|
+
print_warn(` ${s.skill} (required by ${s.required_by})`)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const s of other) {
|
|
295
|
+
print_warn(`Skipped ${s.skill}: ${s.message}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
273
299
|
const installed_set = new Set(packages_to_install.map(p => p.skill))
|
|
274
300
|
const installed = packages_to_install.map(p => ({ skill: p.skill, version: p.version }))
|
|
275
301
|
const skipped = packages.filter(p => !installed_set.has(p.skill)).map(p => ({ skill: p.skill, version: p.version, reason: 'already installed' }))
|
|
@@ -277,7 +303,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
277
303
|
const forced = forced_pkgs.map(p => ({ skill: p.skill, version: p.version }))
|
|
278
304
|
|
|
279
305
|
const linked_agents = agents.map(a => a.id)
|
|
280
|
-
return { skill, version: packages[0]?.version, no_op: false, installed, skipped, warnings, forced, packages: packages_to_install.length, linked_agents }
|
|
306
|
+
return { skill, version: packages[0]?.version, no_op: false, installed, skipped, skipped_deps, warnings, forced, packages: packages_to_install.length, linked_agents }
|
|
281
307
|
} catch (err) {
|
|
282
308
|
await remove_dir(temp_dir)
|
|
283
309
|
throw err
|
package/src/engine/resolver.js
CHANGED
|
@@ -12,7 +12,7 @@ const resolve = (skill, version, installed = {}) => catch_errors('Dependency res
|
|
|
12
12
|
throw new Error(`Dependency conflicts detected:\n${conflict_msg}\n\nUse --force to install anyway (takes highest version).`)
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
return result.packages || []
|
|
15
|
+
return { packages: result.packages || [], skipped: result.skipped || [] }
|
|
16
16
|
})
|
|
17
17
|
|
|
18
18
|
const resolve_with_force = (skill, version, installed = {}) => catch_errors('Forced resolution failed', async () => {
|
|
@@ -30,7 +30,7 @@ const resolve_with_force = (skill, version, installed = {}) => catch_errors('For
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
return packages
|
|
33
|
+
return { packages, skipped: result.skipped || [] }
|
|
34
34
|
})
|
|
35
35
|
|
|
36
36
|
module.exports = { resolve, resolve_with_force }
|
package/src/utils/errors.js
CHANGED
|
@@ -49,15 +49,32 @@ const is_usage_error = (err) => {
|
|
|
49
49
|
return false
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
const extract_root_cause = (err) => {
|
|
53
|
+
if (!Array.isArray(err)) return err?.message || 'An unexpected error occurred'
|
|
54
|
+
// Walk the error array to find the deepest, most specific message.
|
|
55
|
+
// catch_errors wraps errors outermost-first: [outermost, ..., root_cause].
|
|
56
|
+
// We prefer the last error (root cause) for the user-facing message,
|
|
57
|
+
// but fall back to the first CliError if one exists.
|
|
58
|
+
const messages = err.map(x => x?.message).filter(Boolean)
|
|
59
|
+
if (messages.length === 0) return 'An unexpected error occurred'
|
|
60
|
+
// The last message is typically the root cause (deepest in the chain)
|
|
61
|
+
return messages[messages.length - 1]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const format_error_chain = (err) => {
|
|
65
|
+
if (!Array.isArray(err)) return [err?.stack || err?.message || String(err)]
|
|
66
|
+
return err.map(x => x?.stack || x?.message || String(x)).filter(Boolean)
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
const get_error_info = (err) => {
|
|
53
70
|
let target = err
|
|
54
71
|
if (Array.isArray(err)) {
|
|
55
|
-
target = err.find(e => e instanceof CliError) || err[0]
|
|
72
|
+
target = err.find(e => e instanceof CliError) || err[err.length - 1] || err[0]
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
let code = 'ERROR'
|
|
59
76
|
let exit_code = EXIT_CODES.ERROR
|
|
60
|
-
const message =
|
|
77
|
+
const message = extract_root_cause(err)
|
|
61
78
|
|
|
62
79
|
if (target instanceof UsageError) {
|
|
63
80
|
code = 'USAGE_ERROR'; exit_code = EXIT_CODES.USAGE
|
|
@@ -80,7 +97,10 @@ const exit_with_error = (err) => {
|
|
|
80
97
|
|
|
81
98
|
if (is_json_mode()) {
|
|
82
99
|
const { code, message, exit_code } = get_error_info(err)
|
|
83
|
-
|
|
100
|
+
const chain = format_error_chain(err)
|
|
101
|
+
const json_err = { code, message, exit_code }
|
|
102
|
+
if (chain.length > 1) json_err.details = chain
|
|
103
|
+
console.log(JSON.stringify({ error: json_err }, null, 2))
|
|
84
104
|
process.exit(exit_code)
|
|
85
105
|
return
|
|
86
106
|
}
|
|
@@ -89,33 +109,11 @@ const exit_with_error = (err) => {
|
|
|
89
109
|
const { write_error_log } = require('./logger')
|
|
90
110
|
|
|
91
111
|
const log_path = is_usage_error(err) ? null : write_error_log(err)
|
|
112
|
+
const { message, exit_code } = get_error_info(err)
|
|
92
113
|
|
|
93
|
-
if (err instanceof CliError) {
|
|
94
|
-
print_error(err.message)
|
|
95
|
-
print_log_hint(log_path)
|
|
96
|
-
process.exit(err.exit_code)
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (Array.isArray(err)) {
|
|
101
|
-
for (const e of err) {
|
|
102
|
-
if (e instanceof CliError) {
|
|
103
|
-
print_error(e.message)
|
|
104
|
-
print_log_hint(log_path)
|
|
105
|
-
process.exit(e.exit_code)
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
print_error(err[0]?.message || 'An unexpected error occurred')
|
|
110
|
-
print_log_hint(log_path)
|
|
111
|
-
process.exit(EXIT_CODES.ERROR)
|
|
112
|
-
return
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const message = err.message || 'An unexpected error occurred'
|
|
116
114
|
print_error(message)
|
|
117
115
|
print_log_hint(log_path)
|
|
118
|
-
process.exit(
|
|
116
|
+
process.exit(exit_code)
|
|
119
117
|
}
|
|
120
118
|
|
|
121
119
|
module.exports = { CliError, UsageError, AuthError, NetworkError, ApiError, exit_with_error }
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
2
|
+
const { SKILL_JSON } = require('../constants')
|
|
3
|
+
|
|
4
|
+
const RESULT_FILE = SKILL_JSON
|
|
5
|
+
|
|
6
|
+
const result = (field, rule, severity, message, value) => ({
|
|
7
|
+
file: RESULT_FILE, field, rule, severity, message, ...(value !== undefined ? { value } : {})
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Local rules (no API needed)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const check_self_reference = (manifest) => {
|
|
15
|
+
const results = []
|
|
16
|
+
const deps = manifest.dependencies || {}
|
|
17
|
+
const dep_names = Object.keys(deps)
|
|
18
|
+
|
|
19
|
+
if (dep_names.length === 0) {
|
|
20
|
+
results.push(result('dependencies', 'dep-self-reference', 'pass', 'No dependencies declared'))
|
|
21
|
+
return results
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const dep of dep_names) {
|
|
25
|
+
const dep_short = dep.includes('/') ? dep.split('/')[1] : dep
|
|
26
|
+
if (dep_short === manifest.name || dep === manifest.name) {
|
|
27
|
+
results.push(result('dependencies', 'dep-self-reference', 'error',
|
|
28
|
+
`Skill depends on itself: "${dep}". Remove this self-reference from dependencies.`, dep))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!results.some(r => r.rule === 'dep-self-reference' && r.severity === 'error')) {
|
|
33
|
+
results.push(result('dependencies', 'dep-self-reference', 'pass', 'No self-references in dependencies'))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return results
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Registry rules (require API access)
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const check_deps_exist = async (manifest, repos_api) => {
|
|
44
|
+
const results = []
|
|
45
|
+
const deps = manifest.dependencies || {}
|
|
46
|
+
const dep_names = Object.keys(deps)
|
|
47
|
+
|
|
48
|
+
if (dep_names.length === 0) return results
|
|
49
|
+
|
|
50
|
+
const checks = await Promise.all(dep_names.map(async (dep) => {
|
|
51
|
+
const parts = dep.split('/')
|
|
52
|
+
if (parts.length !== 2) return { dep, error: `Invalid dependency format: "${dep}". Must be owner/name.` }
|
|
53
|
+
const [owner, name] = parts
|
|
54
|
+
const [err] = await repos_api.get_repo(owner, name, { auth: true })
|
|
55
|
+
return { dep, error: err ? `Dependency "${dep}" not found in the registry.` : null }
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
for (const { dep, error } of checks) {
|
|
59
|
+
if (error) {
|
|
60
|
+
results.push(result('dependencies', 'dep-exists', 'error', error, dep))
|
|
61
|
+
} else {
|
|
62
|
+
results.push(result('dependencies', 'dep-exists', 'pass', `Dependency "${dep}" exists`, dep))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return results
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const check_visibility_direct = async (manifest, visibility, repos_api) => {
|
|
70
|
+
const results = []
|
|
71
|
+
const deps = manifest.dependencies || {}
|
|
72
|
+
const dep_names = Object.keys(deps)
|
|
73
|
+
|
|
74
|
+
if (dep_names.length === 0 || visibility !== 'public') return results
|
|
75
|
+
|
|
76
|
+
const checks = await Promise.all(dep_names.map(async (dep) => {
|
|
77
|
+
const parts = dep.split('/')
|
|
78
|
+
if (parts.length !== 2) return { dep, visibility: null, error: true }
|
|
79
|
+
const [owner, name] = parts
|
|
80
|
+
const [err, repo] = await repos_api.get_repo(owner, name, { auth: true })
|
|
81
|
+
if (err) return { dep, visibility: null, error: true }
|
|
82
|
+
return { dep, visibility: repo.visibility, error: false }
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
for (const check of checks) {
|
|
86
|
+
if (check.error) continue // already reported by dep-exists
|
|
87
|
+
|
|
88
|
+
if (check.visibility === 'private') {
|
|
89
|
+
results.push(result('dependencies', 'dep-visibility-direct', 'error',
|
|
90
|
+
`Visibility mismatch: this skill is public but depends on "${check.dep}" which is private. ` +
|
|
91
|
+
`Unauthenticated users will not be able to install this skill. ` +
|
|
92
|
+
`To fix: make "${check.dep}" public, or remove it from dependencies.`,
|
|
93
|
+
check.dep))
|
|
94
|
+
} else if (check.visibility === 'workspace') {
|
|
95
|
+
results.push(result('dependencies', 'dep-visibility-workspace', 'warning',
|
|
96
|
+
`Visibility concern: this skill is public but depends on "${check.dep}" which is workspace-scoped. ` +
|
|
97
|
+
`Only workspace members will be able to install this skill. ` +
|
|
98
|
+
`To fix: make "${check.dep}" public for full availability.`,
|
|
99
|
+
check.dep))
|
|
100
|
+
} else {
|
|
101
|
+
results.push(result('dependencies', 'dep-visibility-direct', 'pass',
|
|
102
|
+
`Dependency "${check.dep}" is public`, check.dep))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return results
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const check_visibility_transitive = async (manifest, visibility, repos_api) => {
|
|
110
|
+
const results = []
|
|
111
|
+
const deps = manifest.dependencies || {}
|
|
112
|
+
|
|
113
|
+
if (Object.keys(deps).length === 0 || visibility !== 'public') return results
|
|
114
|
+
|
|
115
|
+
// Use resolve-dependencies to get the full transitive tree
|
|
116
|
+
const skill_name = manifest._full_name
|
|
117
|
+
if (!skill_name) return results // can't check without full name
|
|
118
|
+
|
|
119
|
+
const [err, data] = await repos_api.resolve_dependencies(skill_name, manifest.version || 'latest', {})
|
|
120
|
+
if (err) {
|
|
121
|
+
results.push(result('dependencies', 'dep-visibility-transitive', 'warning',
|
|
122
|
+
'Could not resolve full dependency tree to check transitive visibility. ' +
|
|
123
|
+
'Run this check again when connected to the registry.'))
|
|
124
|
+
return results
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const packages = data.packages || []
|
|
128
|
+
const skipped = data.skipped || []
|
|
129
|
+
|
|
130
|
+
// Check transitive packages returned by the server for non-public visibility
|
|
131
|
+
const direct_deps = new Set(Object.keys(deps))
|
|
132
|
+
for (const pkg of packages) {
|
|
133
|
+
if (pkg.skill === skill_name) continue // skip self
|
|
134
|
+
if (direct_deps.has(pkg.skill)) continue // already checked by dep-visibility-direct
|
|
135
|
+
if (pkg.visibility && pkg.visibility !== 'public') {
|
|
136
|
+
const chain = _build_chain_from_packages(skill_name, pkg.skill, packages)
|
|
137
|
+
const chain_str = chain.join(' → ')
|
|
138
|
+
results.push(result('dependencies', 'dep-visibility-transitive', 'error',
|
|
139
|
+
`Visibility mismatch in dependency tree: "${pkg.skill}" is ${pkg.visibility}. ` +
|
|
140
|
+
`Chain: ${chain_str}. ` +
|
|
141
|
+
`Unauthenticated users will not be able to install this skill. ` +
|
|
142
|
+
`To fix: make "${pkg.skill}" public, or remove it from "${chain[chain.length - 2]}"'s dependencies.`,
|
|
143
|
+
pkg.skill))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Skipped deps (access denied, not found) are also visibility issues
|
|
148
|
+
for (const s of skipped) {
|
|
149
|
+
if (s.reason === 'access_denied') {
|
|
150
|
+
results.push(result('dependencies', 'dep-visibility-transitive', 'error',
|
|
151
|
+
`Visibility mismatch in dependency tree: "${s.skill}" is ${s.visibility || 'private'} ` +
|
|
152
|
+
`(required by ${s.required_by}). ` +
|
|
153
|
+
`Unauthenticated users will not be able to install this skill. ` +
|
|
154
|
+
`To fix: make "${s.skill}" public, or remove it from "${s.required_by}"'s dependencies.`,
|
|
155
|
+
s.skill))
|
|
156
|
+
} else if (s.reason === 'not_found') {
|
|
157
|
+
results.push(result('dependencies', 'dep-visibility-transitive', 'error',
|
|
158
|
+
`Missing dependency in tree: "${s.skill}" not found in registry ` +
|
|
159
|
+
`(required by ${s.required_by}).`,
|
|
160
|
+
s.skill))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!results.some(r => r.rule === 'dep-visibility-transitive' && r.severity !== 'pass')) {
|
|
165
|
+
results.push(result('dependencies', 'dep-visibility-transitive', 'pass',
|
|
166
|
+
'All transitive dependencies are public'))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return results
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const check_circular = async (manifest, repos_api) => {
|
|
173
|
+
const results = []
|
|
174
|
+
const deps = manifest.dependencies || {}
|
|
175
|
+
|
|
176
|
+
if (Object.keys(deps).length === 0) return results
|
|
177
|
+
|
|
178
|
+
const skill_name = manifest._full_name
|
|
179
|
+
if (!skill_name) return results
|
|
180
|
+
|
|
181
|
+
const [err, data] = await repos_api.resolve_dependencies(skill_name, manifest.version || 'latest', {})
|
|
182
|
+
if (err) return results
|
|
183
|
+
|
|
184
|
+
const packages = data.packages || []
|
|
185
|
+
|
|
186
|
+
// Build adjacency map from declared dependencies
|
|
187
|
+
const adj = {}
|
|
188
|
+
for (const pkg of packages) {
|
|
189
|
+
adj[pkg.skill] = Object.keys(pkg.dependencies || {})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// DFS cycle detection
|
|
193
|
+
const WHITE = 0, GRAY = 1, BLACK = 2
|
|
194
|
+
const color = {}
|
|
195
|
+
const parent = {}
|
|
196
|
+
const cycles = []
|
|
197
|
+
|
|
198
|
+
const dfs = (node, path_so_far) => {
|
|
199
|
+
color[node] = GRAY
|
|
200
|
+
for (const neighbor of (adj[node] || [])) {
|
|
201
|
+
if (color[neighbor] === GRAY) {
|
|
202
|
+
// Found a cycle — extract it
|
|
203
|
+
const cycle_start = path_so_far.indexOf(neighbor)
|
|
204
|
+
if (cycle_start >= 0) {
|
|
205
|
+
cycles.push([...path_so_far.slice(cycle_start), neighbor])
|
|
206
|
+
} else {
|
|
207
|
+
cycles.push([node, neighbor])
|
|
208
|
+
}
|
|
209
|
+
} else if ((color[neighbor] || WHITE) === WHITE) {
|
|
210
|
+
dfs(neighbor, [...path_so_far, neighbor])
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
color[node] = BLACK
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const node of Object.keys(adj)) {
|
|
217
|
+
if ((color[node] || WHITE) === WHITE) {
|
|
218
|
+
dfs(node, [node])
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (cycles.length > 0) {
|
|
223
|
+
for (const cycle of cycles) {
|
|
224
|
+
results.push(result('dependencies', 'dep-circular', 'error',
|
|
225
|
+
`Circular dependency detected: ${cycle.join(' → ')}. ` +
|
|
226
|
+
`Remove one direction of the dependency to break the cycle.`,
|
|
227
|
+
cycle))
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
results.push(result('dependencies', 'dep-circular', 'pass',
|
|
231
|
+
'No circular dependencies detected'))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return results
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Helpers
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
const _build_chain_from_packages = (root, target, packages) => {
|
|
242
|
+
const adj = {}
|
|
243
|
+
for (const pkg of packages) {
|
|
244
|
+
adj[pkg.skill] = Object.keys(pkg.dependencies || {})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const queue = [[root]]
|
|
248
|
+
const seen = new Set([root])
|
|
249
|
+
while (queue.length) {
|
|
250
|
+
const path = queue.shift()
|
|
251
|
+
const node = path[path.length - 1]
|
|
252
|
+
if (node === target) return path
|
|
253
|
+
for (const child of (adj[node] || [])) {
|
|
254
|
+
if (!seen.has(child)) {
|
|
255
|
+
seen.add(child)
|
|
256
|
+
queue.push([...path, child])
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return [root, target]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Main entry point
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Validate dependency rules.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} dir - Skill directory path
|
|
271
|
+
* @param {object} manifest - Parsed skill.json
|
|
272
|
+
* @param {object} [options]
|
|
273
|
+
* @param {boolean} [options.registry] - Whether to run registry checks (default: false)
|
|
274
|
+
* @param {string} [options.visibility] - Skill's target visibility ('public'|'private'|'workspace')
|
|
275
|
+
* @param {string} [options.full_name] - Full "owner/name" for registry lookups
|
|
276
|
+
* @param {object} [options.repos_api] - API module override (for testing)
|
|
277
|
+
*/
|
|
278
|
+
const validate_dependencies = (dir, manifest, options = {}) =>
|
|
279
|
+
catch_errors('Dependency validation failed', async () => {
|
|
280
|
+
const { registry = false, visibility = 'private', full_name, repos_api: api_override } = options
|
|
281
|
+
const all_results = []
|
|
282
|
+
|
|
283
|
+
// Always run local checks
|
|
284
|
+
all_results.push(...check_self_reference(manifest))
|
|
285
|
+
|
|
286
|
+
if (!registry) {
|
|
287
|
+
return all_results
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Registry checks require the API
|
|
291
|
+
const repos_api = api_override || require('../api/repos')
|
|
292
|
+
const enriched = { ...manifest, _full_name: full_name }
|
|
293
|
+
|
|
294
|
+
const [exist_results, vis_direct_results, vis_trans_results, circ_results] = await Promise.all([
|
|
295
|
+
check_deps_exist(enriched, repos_api),
|
|
296
|
+
check_visibility_direct(enriched, visibility, repos_api),
|
|
297
|
+
check_visibility_transitive(enriched, visibility, repos_api),
|
|
298
|
+
check_circular(enriched, repos_api)
|
|
299
|
+
])
|
|
300
|
+
|
|
301
|
+
all_results.push(...exist_results)
|
|
302
|
+
all_results.push(...vis_direct_results)
|
|
303
|
+
all_results.push(...vis_trans_results)
|
|
304
|
+
all_results.push(...circ_results)
|
|
305
|
+
|
|
306
|
+
return all_results
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
module.exports = { validate_dependencies }
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
|
|
8
|
+
const { validate_dependencies } = require('./dependency_rules')
|
|
9
|
+
|
|
10
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-validate-deps-'))
|
|
11
|
+
|
|
12
|
+
let tmp
|
|
13
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
14
|
+
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }) })
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Local rules (no registry)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
describe('dep-self-reference', () => {
|
|
21
|
+
it('passes when no dependencies', async () => {
|
|
22
|
+
const manifest = { name: 'my-skill', version: '1.0.0' }
|
|
23
|
+
const [err, results] = await validate_dependencies(tmp, manifest)
|
|
24
|
+
assert.ifError(err)
|
|
25
|
+
const check = results.find(r => r.rule === 'dep-self-reference')
|
|
26
|
+
assert.strictEqual(check.severity, 'pass')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('passes when dependencies do not include self', async () => {
|
|
30
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/other': '*' } }
|
|
31
|
+
const [err, results] = await validate_dependencies(tmp, manifest)
|
|
32
|
+
assert.ifError(err)
|
|
33
|
+
const check = results.find(r => r.rule === 'dep-self-reference')
|
|
34
|
+
assert.strictEqual(check.severity, 'pass')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('errors when skill depends on itself (short name match)', async () => {
|
|
38
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/my-skill': '*' } }
|
|
39
|
+
const [err, results] = await validate_dependencies(tmp, manifest)
|
|
40
|
+
assert.ifError(err)
|
|
41
|
+
const check = results.find(r => r.rule === 'dep-self-reference' && r.severity === 'error')
|
|
42
|
+
assert.ok(check, 'should find self-reference error')
|
|
43
|
+
assert.ok(check.message.includes('depends on itself'))
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Registry rules (mocked API)
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
const make_mock_api = (repos = {}, resolve_result = null) => ({
|
|
52
|
+
get_repo: async (owner, name, opts) => {
|
|
53
|
+
const key = `${owner}/${name}`
|
|
54
|
+
if (repos[key]) return [null, repos[key]]
|
|
55
|
+
return [new Error(`Not found: ${key}`), null]
|
|
56
|
+
},
|
|
57
|
+
resolve_dependencies: async (skill, version, installed) => {
|
|
58
|
+
if (resolve_result) return [null, resolve_result]
|
|
59
|
+
return [new Error('Not implemented'), null]
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('dep-exists (registry)', () => {
|
|
64
|
+
it('passes when all deps exist', async () => {
|
|
65
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/auth': '*' } }
|
|
66
|
+
const api = make_mock_api({ 'acme/auth': { visibility: 'public' } })
|
|
67
|
+
const [err, results] = await validate_dependencies(tmp, manifest, { registry: true, repos_api: api })
|
|
68
|
+
assert.ifError(err)
|
|
69
|
+
const check = results.find(r => r.rule === 'dep-exists' && r.value === 'acme/auth')
|
|
70
|
+
assert.strictEqual(check.severity, 'pass')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('errors when dep is missing', async () => {
|
|
74
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/missing': '*' } }
|
|
75
|
+
const api = make_mock_api({})
|
|
76
|
+
const [err, results] = await validate_dependencies(tmp, manifest, { registry: true, repos_api: api })
|
|
77
|
+
assert.ifError(err)
|
|
78
|
+
const check = results.find(r => r.rule === 'dep-exists' && r.value === 'acme/missing')
|
|
79
|
+
assert.strictEqual(check.severity, 'error')
|
|
80
|
+
assert.ok(check.message.includes('not found'))
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('dep-visibility-direct (registry)', () => {
|
|
85
|
+
it('errors when public skill depends on private dep', async () => {
|
|
86
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/secret': '*' } }
|
|
87
|
+
const api = make_mock_api({ 'acme/secret': { visibility: 'private' } })
|
|
88
|
+
const [err, results] = await validate_dependencies(tmp, manifest, { registry: true, visibility: 'public', repos_api: api })
|
|
89
|
+
assert.ifError(err)
|
|
90
|
+
const check = results.find(r => r.rule === 'dep-visibility-direct' && r.severity === 'error')
|
|
91
|
+
assert.ok(check, 'should find visibility error')
|
|
92
|
+
assert.ok(check.message.includes('Visibility mismatch'))
|
|
93
|
+
assert.ok(check.message.includes('private'))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('warns when public skill depends on workspace-scoped dep', async () => {
|
|
97
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/internal': '*' } }
|
|
98
|
+
const api = make_mock_api({ 'acme/internal': { visibility: 'workspace' } })
|
|
99
|
+
const [err, results] = await validate_dependencies(tmp, manifest, { registry: true, visibility: 'public', repos_api: api })
|
|
100
|
+
assert.ifError(err)
|
|
101
|
+
const check = results.find(r => r.rule === 'dep-visibility-workspace')
|
|
102
|
+
assert.strictEqual(check.severity, 'warning')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('passes when public skill has all public deps', async () => {
|
|
106
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/auth': '*' } }
|
|
107
|
+
const api = make_mock_api({ 'acme/auth': { visibility: 'public' } })
|
|
108
|
+
const [err, results] = await validate_dependencies(tmp, manifest, { registry: true, visibility: 'public', repos_api: api })
|
|
109
|
+
assert.ifError(err)
|
|
110
|
+
const check = results.find(r => r.rule === 'dep-visibility-direct')
|
|
111
|
+
assert.strictEqual(check.severity, 'pass')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('skips visibility check for private skills', async () => {
|
|
115
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/secret': '*' } }
|
|
116
|
+
const api = make_mock_api({ 'acme/secret': { visibility: 'private' } })
|
|
117
|
+
const [err, results] = await validate_dependencies(tmp, manifest, { registry: true, visibility: 'private', repos_api: api })
|
|
118
|
+
assert.ifError(err)
|
|
119
|
+
const vis_results = results.filter(r => r.rule === 'dep-visibility-direct' || r.rule === 'dep-visibility-workspace')
|
|
120
|
+
assert.strictEqual(vis_results.length, 0)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('dep-circular (registry)', () => {
|
|
125
|
+
it('detects a simple cycle A → B → A', async () => {
|
|
126
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/b': '*' } }
|
|
127
|
+
const resolve_result = {
|
|
128
|
+
packages: [
|
|
129
|
+
{ skill: 'acme/a', version: '1.0.0', dependencies: { 'acme/b': '*' } },
|
|
130
|
+
{ skill: 'acme/b', version: '1.0.0', dependencies: { 'acme/a': '*' } }
|
|
131
|
+
],
|
|
132
|
+
skipped: []
|
|
133
|
+
}
|
|
134
|
+
const api = make_mock_api({ 'acme/b': { visibility: 'public' } }, resolve_result)
|
|
135
|
+
const [err, results] = await validate_dependencies(tmp, manifest, {
|
|
136
|
+
registry: true, full_name: 'acme/a', repos_api: api
|
|
137
|
+
})
|
|
138
|
+
assert.ifError(err)
|
|
139
|
+
const check = results.find(r => r.rule === 'dep-circular' && r.severity === 'error')
|
|
140
|
+
assert.ok(check, 'should detect cycle')
|
|
141
|
+
assert.ok(check.message.includes('Circular dependency'))
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('passes when no cycles', async () => {
|
|
145
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/b': '*' } }
|
|
146
|
+
const resolve_result = {
|
|
147
|
+
packages: [
|
|
148
|
+
{ skill: 'acme/a', version: '1.0.0', dependencies: { 'acme/b': '*' } },
|
|
149
|
+
{ skill: 'acme/b', version: '1.0.0', dependencies: {} }
|
|
150
|
+
],
|
|
151
|
+
skipped: []
|
|
152
|
+
}
|
|
153
|
+
const api = make_mock_api({ 'acme/b': { visibility: 'public' } }, resolve_result)
|
|
154
|
+
const [err, results] = await validate_dependencies(tmp, manifest, {
|
|
155
|
+
registry: true, full_name: 'acme/a', repos_api: api
|
|
156
|
+
})
|
|
157
|
+
assert.ifError(err)
|
|
158
|
+
const check = results.find(r => r.rule === 'dep-circular')
|
|
159
|
+
assert.strictEqual(check.severity, 'pass')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('dep-visibility-transitive (registry)', () => {
|
|
164
|
+
it('errors when transitive dep is private', async () => {
|
|
165
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/b': '*' } }
|
|
166
|
+
const resolve_result = {
|
|
167
|
+
packages: [
|
|
168
|
+
{ skill: 'acme/a', version: '1.0.0', visibility: 'public', dependencies: { 'acme/b': '*' } },
|
|
169
|
+
{ skill: 'acme/b', version: '1.0.0', visibility: 'public', dependencies: { 'acme/c': '*' } },
|
|
170
|
+
{ skill: 'acme/c', version: '1.0.0', visibility: 'private', dependencies: {} }
|
|
171
|
+
],
|
|
172
|
+
skipped: []
|
|
173
|
+
}
|
|
174
|
+
const api = make_mock_api(
|
|
175
|
+
{ 'acme/b': { visibility: 'public' } },
|
|
176
|
+
resolve_result
|
|
177
|
+
)
|
|
178
|
+
const [err, results] = await validate_dependencies(tmp, manifest, {
|
|
179
|
+
registry: true, visibility: 'public', full_name: 'acme/a', repos_api: api
|
|
180
|
+
})
|
|
181
|
+
assert.ifError(err)
|
|
182
|
+
const check = results.find(r => r.rule === 'dep-visibility-transitive' && r.severity === 'error')
|
|
183
|
+
assert.ok(check, 'should find transitive visibility error')
|
|
184
|
+
assert.ok(check.message.includes('acme/c'))
|
|
185
|
+
assert.ok(check.message.includes('private'))
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('reports skipped (access-denied) deps as visibility errors', async () => {
|
|
189
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/b': '*' } }
|
|
190
|
+
const resolve_result = {
|
|
191
|
+
packages: [
|
|
192
|
+
{ skill: 'acme/a', version: '1.0.0', visibility: 'public', dependencies: { 'acme/b': '*' } },
|
|
193
|
+
{ skill: 'acme/b', version: '1.0.0', visibility: 'public', dependencies: { 'acme/secret': '*' } }
|
|
194
|
+
],
|
|
195
|
+
skipped: [
|
|
196
|
+
{ skill: 'acme/secret', reason: 'access_denied', message: 'Access denied', required_by: 'acme/b', visibility: 'private' }
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
const api = make_mock_api(
|
|
200
|
+
{ 'acme/b': { visibility: 'public' } },
|
|
201
|
+
resolve_result
|
|
202
|
+
)
|
|
203
|
+
const [err, results] = await validate_dependencies(tmp, manifest, {
|
|
204
|
+
registry: true, visibility: 'public', full_name: 'acme/a', repos_api: api
|
|
205
|
+
})
|
|
206
|
+
assert.ifError(err)
|
|
207
|
+
const check = results.find(r => r.rule === 'dep-visibility-transitive' && r.severity === 'error' && r.value === 'acme/secret')
|
|
208
|
+
assert.ok(check, 'should report skipped dep as visibility error')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('passes when all transitive deps are public', async () => {
|
|
212
|
+
const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/b': '*' } }
|
|
213
|
+
const resolve_result = {
|
|
214
|
+
packages: [
|
|
215
|
+
{ skill: 'acme/a', version: '1.0.0', visibility: 'public', dependencies: { 'acme/b': '*' } },
|
|
216
|
+
{ skill: 'acme/b', version: '1.0.0', visibility: 'public', dependencies: {} }
|
|
217
|
+
],
|
|
218
|
+
skipped: []
|
|
219
|
+
}
|
|
220
|
+
const api = make_mock_api(
|
|
221
|
+
{ 'acme/b': { visibility: 'public' } },
|
|
222
|
+
resolve_result
|
|
223
|
+
)
|
|
224
|
+
const [err, results] = await validate_dependencies(tmp, manifest, {
|
|
225
|
+
registry: true, visibility: 'public', full_name: 'acme/a', repos_api: api
|
|
226
|
+
})
|
|
227
|
+
assert.ifError(err)
|
|
228
|
+
const check = results.find(r => r.rule === 'dep-visibility-transitive')
|
|
229
|
+
assert.strictEqual(check.severity, 'pass')
|
|
230
|
+
})
|
|
231
|
+
})
|