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.
@@ -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 }