happyskills 0.35.5 → 0.37.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 +25 -0
- package/package.json +1 -1
- package/src/api/repos.js +1 -1
- package/src/commands/diff.js +159 -9
- 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/utils/text_diff.js +247 -0
- package/src/validation/dependency_rules.js +309 -0
- package/src/validation/dependency_rules.test.js +231 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight unified diff — no external dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Uses the Myers diff algorithm for line-level comparison, then formats
|
|
5
|
+
* the result as a standard unified diff with configurable context lines.
|
|
6
|
+
*
|
|
7
|
+
* Designed for memory efficiency:
|
|
8
|
+
* - No intermediate copies of full file content
|
|
9
|
+
* - Hunks are built incrementally
|
|
10
|
+
* - Input strings are split once, not retained
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ─── Myers diff ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute the shortest edit script between two line arrays using Myers' O(ND) algorithm.
|
|
17
|
+
* Returns an array of { type: 'equal'|'delete'|'insert', old_idx?, new_idx? } operations.
|
|
18
|
+
*
|
|
19
|
+
* For files with small edit distance D, this runs in O((M+N)*D) time and O(D²) space
|
|
20
|
+
* for the trace. Skill files are typically < 1000 lines with small diffs, so this is
|
|
21
|
+
* well within acceptable bounds.
|
|
22
|
+
*/
|
|
23
|
+
const myers_edit_script = (old_lines, new_lines) => {
|
|
24
|
+
const N = old_lines.length
|
|
25
|
+
const M = new_lines.length
|
|
26
|
+
|
|
27
|
+
if (N === 0 && M === 0) return []
|
|
28
|
+
if (N === 0) return new_lines.map((_, j) => ({ type: 'insert', new_idx: j }))
|
|
29
|
+
if (M === 0) return old_lines.map((_, i) => ({ type: 'delete', old_idx: i }))
|
|
30
|
+
|
|
31
|
+
const MAX = N + M
|
|
32
|
+
const V = new Map([[1, 0]])
|
|
33
|
+
const trace = []
|
|
34
|
+
|
|
35
|
+
for (let d = 0; d <= MAX; d++) {
|
|
36
|
+
// Snapshot V for backtracking
|
|
37
|
+
trace.push(new Map(V))
|
|
38
|
+
|
|
39
|
+
for (let k = -d; k <= d; k += 2) {
|
|
40
|
+
let x
|
|
41
|
+
if (k === -d || (k !== d && (V.get(k - 1) ?? -1) < (V.get(k + 1) ?? -1))) {
|
|
42
|
+
x = V.get(k + 1) ?? 0 // move down (insert)
|
|
43
|
+
} else {
|
|
44
|
+
x = (V.get(k - 1) ?? 0) + 1 // move right (delete)
|
|
45
|
+
}
|
|
46
|
+
let y = x - k
|
|
47
|
+
|
|
48
|
+
// Follow diagonal (equal lines)
|
|
49
|
+
while (x < N && y < M && old_lines[x] === new_lines[y]) {
|
|
50
|
+
x++
|
|
51
|
+
y++
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
V.set(k, x)
|
|
55
|
+
|
|
56
|
+
if (x >= N && y >= M) {
|
|
57
|
+
return backtrack(trace, old_lines, new_lines, d)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Should never reach here
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Backtrack through the trace to reconstruct the edit script.
|
|
68
|
+
*/
|
|
69
|
+
const backtrack = (trace, old_lines, new_lines, d) => {
|
|
70
|
+
const ops = []
|
|
71
|
+
let x = old_lines.length
|
|
72
|
+
let y = new_lines.length
|
|
73
|
+
|
|
74
|
+
for (let step = d; step > 0; step--) {
|
|
75
|
+
// trace[step] = V snapshot taken at the START of iteration d=step,
|
|
76
|
+
// which is V AFTER d=step-1 completed — i.e., the previous step's result
|
|
77
|
+
const v_prev = trace[step]
|
|
78
|
+
const k = x - y
|
|
79
|
+
|
|
80
|
+
let prev_k
|
|
81
|
+
if (k === -step || (k !== step && (v_prev.get(k - 1) ?? -1) < (v_prev.get(k + 1) ?? -1))) {
|
|
82
|
+
prev_k = k + 1 // came from above (insert)
|
|
83
|
+
} else {
|
|
84
|
+
prev_k = k - 1 // came from left (delete)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const prev_x = v_prev.get(prev_k) ?? 0
|
|
88
|
+
const prev_y = prev_x - prev_k
|
|
89
|
+
|
|
90
|
+
// Diagonal moves (equal lines) — collect in reverse
|
|
91
|
+
while (x > prev_x + (prev_k < k ? 1 : 0) && y > prev_y + (prev_k > k ? 1 : 0)) {
|
|
92
|
+
x--
|
|
93
|
+
y--
|
|
94
|
+
ops.push({ type: 'equal', old_idx: x, new_idx: y })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The actual edit
|
|
98
|
+
if (prev_k < k) {
|
|
99
|
+
// Moved right → delete
|
|
100
|
+
x--
|
|
101
|
+
ops.push({ type: 'delete', old_idx: x })
|
|
102
|
+
} else {
|
|
103
|
+
// Moved down → insert
|
|
104
|
+
y--
|
|
105
|
+
ops.push({ type: 'insert', new_idx: y })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Remaining diagonal at the start
|
|
110
|
+
while (x > 0 && y > 0) {
|
|
111
|
+
x--
|
|
112
|
+
y--
|
|
113
|
+
ops.push({ type: 'equal', old_idx: x, new_idx: y })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ops.reverse()
|
|
117
|
+
return ops
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Hunk building ───────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Group edit operations into unified diff hunks with context lines.
|
|
124
|
+
*/
|
|
125
|
+
const build_hunks = (ops, old_lines, new_lines, context) => {
|
|
126
|
+
// Find ranges of changes (non-equal ops)
|
|
127
|
+
const change_indices = []
|
|
128
|
+
for (let i = 0; i < ops.length; i++) {
|
|
129
|
+
if (ops[i].type !== 'equal') change_indices.push(i)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (change_indices.length === 0) return []
|
|
133
|
+
|
|
134
|
+
// Group changes that are within (2 * context) lines of each other
|
|
135
|
+
const groups = []
|
|
136
|
+
let group_start = change_indices[0]
|
|
137
|
+
let group_end = change_indices[0]
|
|
138
|
+
|
|
139
|
+
for (let i = 1; i < change_indices.length; i++) {
|
|
140
|
+
const gap = change_indices[i] - change_indices[i - 1]
|
|
141
|
+
if (gap <= context * 2 + 1) {
|
|
142
|
+
group_end = change_indices[i]
|
|
143
|
+
} else {
|
|
144
|
+
groups.push([group_start, group_end])
|
|
145
|
+
group_start = change_indices[i]
|
|
146
|
+
group_end = change_indices[i]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
groups.push([group_start, group_end])
|
|
150
|
+
|
|
151
|
+
// Build hunks from groups
|
|
152
|
+
const hunks = []
|
|
153
|
+
for (const [start, end] of groups) {
|
|
154
|
+
const hunk_start = Math.max(0, start - context)
|
|
155
|
+
const hunk_end = Math.min(ops.length - 1, end + context)
|
|
156
|
+
|
|
157
|
+
let old_start = null
|
|
158
|
+
let new_start = null
|
|
159
|
+
let old_count = 0
|
|
160
|
+
let new_count = 0
|
|
161
|
+
const lines = []
|
|
162
|
+
|
|
163
|
+
for (let i = hunk_start; i <= hunk_end; i++) {
|
|
164
|
+
const op = ops[i]
|
|
165
|
+
if (op.type === 'equal') {
|
|
166
|
+
if (old_start === null) old_start = op.old_idx
|
|
167
|
+
if (new_start === null) new_start = op.new_idx
|
|
168
|
+
lines.push(` ${old_lines[op.old_idx]}`)
|
|
169
|
+
old_count++
|
|
170
|
+
new_count++
|
|
171
|
+
} else if (op.type === 'delete') {
|
|
172
|
+
if (old_start === null) old_start = op.old_idx
|
|
173
|
+
if (new_start === null) {
|
|
174
|
+
// Find the new_idx from the nearest equal op before this
|
|
175
|
+
new_start = find_new_start(ops, i)
|
|
176
|
+
}
|
|
177
|
+
lines.push(`-${old_lines[op.old_idx]}`)
|
|
178
|
+
old_count++
|
|
179
|
+
} else if (op.type === 'insert') {
|
|
180
|
+
if (new_start === null) new_start = op.new_idx
|
|
181
|
+
if (old_start === null) {
|
|
182
|
+
old_start = find_old_start(ops, i)
|
|
183
|
+
}
|
|
184
|
+
lines.push(`+${new_lines[op.new_idx]}`)
|
|
185
|
+
new_count++
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
hunks.push({
|
|
190
|
+
old_start: (old_start ?? 0) + 1, // 1-based
|
|
191
|
+
old_count,
|
|
192
|
+
new_start: (new_start ?? 0) + 1, // 1-based
|
|
193
|
+
new_count,
|
|
194
|
+
lines
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return hunks
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const find_new_start = (ops, from) => {
|
|
202
|
+
for (let i = from - 1; i >= 0; i--) {
|
|
203
|
+
if (ops[i].new_idx !== undefined) return ops[i].new_idx + 1
|
|
204
|
+
}
|
|
205
|
+
return 0
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const find_old_start = (ops, from) => {
|
|
209
|
+
for (let i = from - 1; i >= 0; i--) {
|
|
210
|
+
if (ops[i].old_idx !== undefined) return ops[i].old_idx + 1
|
|
211
|
+
}
|
|
212
|
+
return 0
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Compute a unified diff between two text strings.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} old_text - Original text
|
|
221
|
+
* @param {string} new_text - Modified text
|
|
222
|
+
* @param {string} old_label - Label for original (e.g., "base/SKILL.md")
|
|
223
|
+
* @param {string} new_label - Label for modified (e.g., "local/SKILL.md")
|
|
224
|
+
* @param {number} [context=3] - Number of context lines around each change
|
|
225
|
+
* @returns {string} Unified diff string, empty if no differences
|
|
226
|
+
*/
|
|
227
|
+
const unified_diff = (old_text, new_text, old_label, new_label, context = 3) => {
|
|
228
|
+
if (old_text === new_text) return ''
|
|
229
|
+
|
|
230
|
+
const old_lines = old_text.split('\n')
|
|
231
|
+
const new_lines = new_text.split('\n')
|
|
232
|
+
|
|
233
|
+
const ops = myers_edit_script(old_lines, new_lines)
|
|
234
|
+
const hunks = build_hunks(ops, old_lines, new_lines, context)
|
|
235
|
+
|
|
236
|
+
if (hunks.length === 0) return ''
|
|
237
|
+
|
|
238
|
+
const header = `--- ${old_label}\n+++ ${new_label}`
|
|
239
|
+
const hunk_strings = hunks.map(h => {
|
|
240
|
+
const range = `@@ -${h.old_start},${h.old_count} +${h.new_start},${h.new_count} @@`
|
|
241
|
+
return `${range}\n${h.lines.join('\n')}`
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
return `${header}\n${hunk_strings.join('\n')}`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = { unified_diff }
|
|
@@ -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 }
|