happyskills 0.47.0 → 0.48.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 +24 -0
- package/package.json +1 -1
- package/src/api/client.js +6 -1
- package/src/api/repos.js +6 -1
- package/src/commands/postlex.js +101 -10
- package/src/commands/postlex.test.js +141 -0
- package/src/commands/search.js +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.48.0] - 2026-05-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add `--search-output <file>` flag to `happyskills postlex`. When set, postlex reads the full `search --with-rerank --json` response envelope from that file and extracts `data.results` internally — the calling agent's stdin payload shrinks to just `{"ranking": [...]}`. **Recommended path** for agentic callers: the agent never has to construct the `data` array by hand, which eliminates two observed failure modes in production (missing `data` field consuming the one retry budget, and `skill` vs `name` field mismatch dropping every ranking entry). Backward-compatible: the legacy `{"ranking", "data"}` stdin shape and the separate `--data <file>` flag both still work and take precedence only when `--search-output` is absent.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Fix `to_smart_json` in `search.js` stripping the bare `name` field from `data.results` rows. The function emitted `skill` (the composite "workspace/name" slug) but consumed the underlying `name` field into the template literal, so downstream consumers of `data.results` — most notably `happyskills postlex` — received rows without a usable `name`. postlex's `validate_ranking` then dropped every ranking entry with `data row missing name`. The function now emits both `name` (raw, for downstream pipeline consumers) AND `skill` (composite, for human-readable display), keeping backward compatibility with anything reading `skill`.
|
|
17
|
+
- Add `star_count` to `to_smart_json` output (in addition to the existing `stars` field) so `postlex`'s human-readable table renderer — which expected `row.star_count` — can find the value on rows that originated from the search response. Same root cause as the `name` issue: the search-output and postlex-input shapes had drifted apart silently.
|
|
18
|
+
- Add `resolve_row_name` + `normalize_data_rows` helpers in `postlex.js` that handle the legacy case where `data.results` rows have `skill` but no `name`. `resolve_row_name` falls back to the last `/`-separated segment of `skill`; `normalize_data_rows` runs the resolution across the array. Idempotent. This is defense-in-depth — anyone passing rows from an older CLI version or a hand-crafted payload no longer trips the validator.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- `parse_input` in `postlex.js` accepts a new optional third argument carrying the raw `--search-output` content. When provided, it's parsed and `data.results` is extracted via `extract_data_array_from_search_output`, which tolerates the canonical envelope shape (`{ data: { results: [...] } }`), the legacy `{ data: [...] }` shape, a bare array, and a defensive double-wrapped `{ data: { data: [...] } }`. When both inline `data` (from the stdin payload) and `--search-output` are provided, the search-output wins.
|
|
22
|
+
- `postlex`'s help text rewritten to lead with the recommended `--search-output` recipe and demote the legacy stdin `{ranking, data}` shape to a "still supported" alternative.
|
|
23
|
+
- When `data` cannot be located from any source (no inline `data`, no `--data` file, no `--search-output`), the error message now points the caller at `--search-output` explicitly so they know which input is missing.
|
|
24
|
+
|
|
25
|
+
## [0.47.1] - 2026-05-22
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- Fix `search --with-rerank` silently dropping every rerank field from the API response. The HTTP client's default `{ data: ... }` envelope unwrap was discarding `mode`, `match_notice`, `workspace_match`, and (when opted-in) `rerank_digests`, `rerank_system_prompt`, `rerank_prompt_version`, `rerank_response_schema` from `POST /repos:search`. The CLI's envelope emission saw `digests.length === 0` and fell through to "no protocol envelope" → the agent rendered the baseline ranking instead of running the LLM rerank. With this fix, `dispatch_search()` opts out of the auto-unwrap via a new `unwrap: false` option on `client.request()` and the caller now receives the full envelope. Verified end-to-end against production: natural-language query returns `next_step.action == "rank_digests_inline"` with 50 digests + the 1519-char system prompt + the strict json_schema; slug-shape query still emits `next_step: null` (no false-positive protocol fire). The bug had been latent since `0.47.0`'s introduction of `--with-rerank` because the existing rendering code in `search.js` already had a defensive fallback for both response shapes (`Array.isArray(response) ? response : response?.data`), but the rerank path requires the top-level fields specifically.
|
|
29
|
+
- Plain `search --json` (without `--with-rerank`) now correctly carries `data.mode` (e.g. `'semantic'` / `'fuzzy_slug'` / `'fuzzy_scoped'`) in the JSON output. Previously `mode` was always `null` because the client auto-unwrap silently dropped it. The human-readable output also gains a `[mode]` annotation next to the query header, and the yellow `match_notice` warning ("No strong matches found…") now shows up on weak-result searches — both were already coded but never received their inputs.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- `client.request()` (`cli/src/api/client.js`) gains an `unwrap` option (default `true`, matching the existing behavior). Set `unwrap: false` to receive the raw response body instead of the auto-unwrapped `data.data`. Required for endpoints (currently only `POST /repos:search`) that emit metadata fields at the top level of the response alongside the `data` payload. Existing callers are unaffected because the default preserves current behavior.
|
|
33
|
+
|
|
10
34
|
## [0.47.0] - 2026-05-21
|
|
11
35
|
|
|
12
36
|
### Added
|
package/package.json
CHANGED
package/src/api/client.js
CHANGED
|
@@ -7,7 +7,7 @@ const { load_token } = require('../auth/token_store')
|
|
|
7
7
|
const get_base_url = () => process.env.HAPPYSKILLS_API_URL || API_URL
|
|
8
8
|
|
|
9
9
|
const request = (method, path, options = {}) => catch_errors(`API ${method} ${path} failed`, async () => {
|
|
10
|
-
const { body, auth = true, raw_response = false, headers: extra_headers = {} } = options
|
|
10
|
+
const { body, auth = true, raw_response = false, unwrap = true, headers: extra_headers = {} } = options
|
|
11
11
|
const url = `${get_base_url()}${path}`
|
|
12
12
|
const headers = { ...extra_headers }
|
|
13
13
|
|
|
@@ -64,6 +64,11 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
|
|
|
64
64
|
throw new ApiError(err_msg, res.status, err_code)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Auto-unwrap `{ data: ... }` envelope by default. Callers that need the
|
|
68
|
+
// full response (e.g. endpoints that emit top-level metadata fields
|
|
69
|
+
// alongside `data` — see `dispatch_search` for `/repos:search`'s
|
|
70
|
+
// `mode` / `rerank_digests` / `match_notice` fields) pass `unwrap: false`.
|
|
71
|
+
if (!unwrap) return data
|
|
67
72
|
return data?.data !== undefined ? data.data : data
|
|
68
73
|
})
|
|
69
74
|
|
package/src/api/repos.js
CHANGED
|
@@ -149,7 +149,12 @@ const dispatch_search = (query, options = {}) => catch_errors('Search failed', a
|
|
|
149
149
|
// protocol. Server returns digests + system prompt + json_schema only
|
|
150
150
|
// when this is true AND the dispatcher routes to mode='semantic'.
|
|
151
151
|
if (options.with_rerank_digests) body.with_rerank_digests = true
|
|
152
|
-
|
|
152
|
+
// `/repos:search` returns top-level metadata alongside `data` —
|
|
153
|
+
// `mode`, `match_notice`, `workspace_match`, and (when opted-in)
|
|
154
|
+
// `rerank_digests`, `rerank_system_prompt`, `rerank_prompt_version`,
|
|
155
|
+
// `rerank_response_schema`. The client's default unwrap would drop these
|
|
156
|
+
// fields entirely. Opt out so the caller sees the full envelope.
|
|
157
|
+
const [errors, data] = await client.post('/repos:search', body, { auth: true, unwrap: false })
|
|
153
158
|
if (errors) throw errors[errors.length - 1]
|
|
154
159
|
return data
|
|
155
160
|
})
|
package/src/commands/postlex.js
CHANGED
|
@@ -32,22 +32,61 @@ Required:
|
|
|
32
32
|
--ranking <file|-> Path to the ranking JSON, or \`-\` for stdin
|
|
33
33
|
|
|
34
34
|
Optional:
|
|
35
|
-
--
|
|
35
|
+
--search-output <file> Path to the full \`search --with-rerank --json\` response
|
|
36
|
+
envelope. postlex extracts \`data.results\` from it
|
|
37
|
+
internally, so the ranking payload only needs the
|
|
38
|
+
ranking array. **Recommended path** for agentic
|
|
39
|
+
callers — eliminates the join the agent would
|
|
40
|
+
otherwise have to assemble by hand.
|
|
41
|
+
--data <file> Legacy: separate data file (when --ranking does not
|
|
42
|
+
embed it AND --search-output isn't used)
|
|
36
43
|
--clarification-turns-used <N>
|
|
37
44
|
Clarification budget already spent (0-2, default 0)
|
|
38
45
|
--original-query <q> Original user query (opaque context from prior step)
|
|
39
46
|
--json Output as JSON (default: human-readable table)
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
{
|
|
43
|
-
|
|
48
|
+
Recommended shape (v0.48.0+) — agent emits ONLY the ranking, postlex does the join:
|
|
49
|
+
echo '{"ranking":[{"rank":1,"candidate_id":5,"rationale":"..."}, ...]}' | \\
|
|
50
|
+
happyskills postlex --query "deploy aws" \\
|
|
51
|
+
--search-output /tmp/search-out.json \\
|
|
52
|
+
--ranking -
|
|
44
53
|
|
|
45
|
-
|
|
54
|
+
Legacy shape (still supported):
|
|
46
55
|
echo '{"ranking":[...],"data":[...]}' | happyskills postlex --query "deploy aws" --ranking -
|
|
47
56
|
happyskills postlex --query "deploy aws" --ranking r.json --data d.json --json`
|
|
48
57
|
|
|
49
58
|
// ─── Pure logic — exported for unit testing ────────────────────────────────
|
|
50
59
|
|
|
60
|
+
// Resolve a candidate's bare name, tolerating multiple field conventions.
|
|
61
|
+
// The API row shape has `name`. The CLI's `to_smart_json` output (as of
|
|
62
|
+
// happyskills@0.48.0) emits both `name` and the composite `skill`
|
|
63
|
+
// ("workspace/name"). Older callers may pass through rows that only have
|
|
64
|
+
// `skill`, so we fall back to extracting the bare name from the slug's last
|
|
65
|
+
// `/`-separated segment. Returns null when no name can be derived.
|
|
66
|
+
const resolve_row_name = (row) => {
|
|
67
|
+
if (!row || typeof row !== 'object') return null
|
|
68
|
+
if (typeof row.name === 'string' && row.name) return row.name
|
|
69
|
+
if (typeof row.skill === 'string' && row.skill) {
|
|
70
|
+
const parts = row.skill.split('/')
|
|
71
|
+
const tail = parts[parts.length - 1]
|
|
72
|
+
if (tail) return tail
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Mutate `data` rows in-place to ensure they have a `name` field.
|
|
78
|
+
// Idempotent. Used before validate_ranking / apply_postlex / build_final_ordering
|
|
79
|
+
// so downstream code can keep its simple `row.name` access pattern.
|
|
80
|
+
const normalize_data_rows = (data) => {
|
|
81
|
+
if (!Array.isArray(data)) return
|
|
82
|
+
for (const row of data) {
|
|
83
|
+
if (row && typeof row === 'object' && !row.name) {
|
|
84
|
+
const resolved = resolve_row_name(row)
|
|
85
|
+
if (resolved) row.name = resolved
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
51
90
|
// Validate the ranking shape. Returns { valid_items, dropped } — invalid
|
|
52
91
|
// entries are dropped with a reason rather than crashing.
|
|
53
92
|
const validate_ranking = (ranking, data) => {
|
|
@@ -204,9 +243,33 @@ const read_stdin_sync = () => {
|
|
|
204
243
|
}
|
|
205
244
|
}
|
|
206
245
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
246
|
+
// Extract the data array (the rerank candidate set) from a full
|
|
247
|
+
// `happyskills search --with-rerank --json` response envelope. The
|
|
248
|
+
// envelope's shape is `{ data: { query, mode, results: [...], ... },
|
|
249
|
+
// error, next_step }` — we want `envelope.data.results`. Also tolerates a
|
|
250
|
+
// bare array, a `{data: [...]}` shape (legacy callers), and the
|
|
251
|
+
// `{data: {data: [...]}}` shape that appears if someone double-wraps.
|
|
252
|
+
// Returns the array, or null when no array can be located.
|
|
253
|
+
const extract_data_array_from_search_output = (parsed) => {
|
|
254
|
+
if (Array.isArray(parsed)) return parsed
|
|
255
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
256
|
+
const inner = parsed.data
|
|
257
|
+
if (Array.isArray(inner)) return inner
|
|
258
|
+
if (inner && typeof inner === 'object') {
|
|
259
|
+
if (Array.isArray(inner.results)) return inner.results
|
|
260
|
+
if (Array.isArray(inner.data)) return inner.data
|
|
261
|
+
}
|
|
262
|
+
if (Array.isArray(parsed.results)) return parsed.results
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const parse_input = (raw_ranking_input, raw_data_input, raw_search_output_input) => {
|
|
267
|
+
// Accept either:
|
|
268
|
+
// - combined `{ranking, data}` from stdin/one file (legacy v0.47.x shape), OR
|
|
269
|
+
// - separate ranking + data files (`--ranking <file>` + `--data <file>`), OR
|
|
270
|
+
// - just `{ranking: ...}` or a bare ranking array PLUS a `--search-output` file
|
|
271
|
+
// containing the full search envelope from which we extract `data.results`
|
|
272
|
+
// (v0.48.0+ recommended shape — agent never has to construct `data`).
|
|
210
273
|
const parse_one = (raw, label) => {
|
|
211
274
|
if (raw == null || raw === '') return { value: null, parse_error: `${label} input is empty` }
|
|
212
275
|
try {
|
|
@@ -240,8 +303,25 @@ const parse_input = (raw_ranking_input, raw_data_input) => {
|
|
|
240
303
|
else return { ranking, data: null, parse_error: 'data file does not contain a data array' }
|
|
241
304
|
}
|
|
242
305
|
|
|
306
|
+
if (raw_search_output_input != null) {
|
|
307
|
+
const so_parse = parse_one(raw_search_output_input, 'search-output')
|
|
308
|
+
if (so_parse.parse_error) return { ranking, data, parse_error: so_parse.parse_error }
|
|
309
|
+
const extracted = extract_data_array_from_search_output(so_parse.value)
|
|
310
|
+
if (!Array.isArray(extracted)) {
|
|
311
|
+
return { ranking, data: null, parse_error: 'search-output does not contain a data.results array' }
|
|
312
|
+
}
|
|
313
|
+
// search-output is the recommended source — it overrides any inline data.
|
|
314
|
+
data = extracted
|
|
315
|
+
}
|
|
316
|
+
|
|
243
317
|
if (!Array.isArray(ranking)) return { ranking: null, data, parse_error: 'ranking field is missing or not an array' }
|
|
244
|
-
if (!Array.isArray(data)) return { ranking, data: null, parse_error: 'data field is missing or not an array' }
|
|
318
|
+
if (!Array.isArray(data)) return { ranking, data: null, parse_error: 'data field is missing or not an array — provide --search-output <file> with the search response, or include "data" in the stdin payload' }
|
|
319
|
+
|
|
320
|
+
// Normalize row names (handles the `skill`-without-`name` case from older
|
|
321
|
+
// CLI versions or hand-crafted payloads). Idempotent on rows that already
|
|
322
|
+
// have a `name` field.
|
|
323
|
+
normalize_data_rows(data)
|
|
324
|
+
|
|
245
325
|
return { ranking, data, parse_error: null }
|
|
246
326
|
}
|
|
247
327
|
|
|
@@ -287,6 +367,7 @@ const run = (args) => catch_errors('Postlex failed', async () => {
|
|
|
287
367
|
const query = args.flags.query
|
|
288
368
|
const ranking_path = args.flags.ranking
|
|
289
369
|
const data_path = args.flags.data
|
|
370
|
+
const search_output_path = args.flags['search-output']
|
|
290
371
|
const clarification_turns_used = parseInt(args.flags['clarification-turns-used'] || '0', 10) || 0
|
|
291
372
|
|
|
292
373
|
if (!query || typeof query !== 'string')
|
|
@@ -309,9 +390,16 @@ const run = (args) => catch_errors('Postlex failed', async () => {
|
|
|
309
390
|
return r.content
|
|
310
391
|
})()
|
|
311
392
|
: null
|
|
393
|
+
const raw_search_output = search_output_path
|
|
394
|
+
? (() => {
|
|
395
|
+
const r = read_file(search_output_path)
|
|
396
|
+
if (r.err) throw new UsageError(`Cannot read --search-output file: ${r.err}`)
|
|
397
|
+
return r.content
|
|
398
|
+
})()
|
|
399
|
+
: null
|
|
312
400
|
|
|
313
401
|
// Parse
|
|
314
|
-
const { ranking, data, parse_error } = parse_input(raw_ranking, raw_data)
|
|
402
|
+
const { ranking, data, parse_error } = parse_input(raw_ranking, raw_data, raw_search_output)
|
|
315
403
|
if (parse_error) {
|
|
316
404
|
process.stderr.write(`postlex: ${parse_error}\n`)
|
|
317
405
|
const env = build_retry_envelope(query, parse_error, clarification_turns_used, 0)
|
|
@@ -399,4 +487,7 @@ module.exports = {
|
|
|
399
487
|
determine_next_step,
|
|
400
488
|
build_retry_envelope,
|
|
401
489
|
parse_input,
|
|
490
|
+
resolve_row_name,
|
|
491
|
+
normalize_data_rows,
|
|
492
|
+
extract_data_array_from_search_output,
|
|
402
493
|
}
|
|
@@ -16,6 +16,9 @@ const {
|
|
|
16
16
|
determine_next_step,
|
|
17
17
|
build_retry_envelope,
|
|
18
18
|
parse_input,
|
|
19
|
+
resolve_row_name,
|
|
20
|
+
normalize_data_rows,
|
|
21
|
+
extract_data_array_from_search_output,
|
|
19
22
|
} = require('./postlex')
|
|
20
23
|
|
|
21
24
|
// ─── Test fixtures ────────────────────────────────────────────────────────
|
|
@@ -283,6 +286,144 @@ describe('parse_input', () => {
|
|
|
283
286
|
|
|
284
287
|
// ─── build_final_ordering ─────────────────────────────────────────────────
|
|
285
288
|
|
|
289
|
+
// ─── resolve_row_name + normalize_data_rows (v0.48.0) ─────────────────────
|
|
290
|
+
|
|
291
|
+
describe('resolve_row_name', () => {
|
|
292
|
+
it('returns row.name when present', () => {
|
|
293
|
+
assert.equal(resolve_row_name({ name: 'foo', skill: 'acme/foo' }), 'foo')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('falls back to last segment of skill when name is missing', () => {
|
|
297
|
+
assert.equal(resolve_row_name({ skill: 'acme/deploy-aws' }), 'deploy-aws')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('returns null when neither name nor skill is present', () => {
|
|
301
|
+
assert.equal(resolve_row_name({ description: 'whatever' }), null)
|
|
302
|
+
assert.equal(resolve_row_name({}), null)
|
|
303
|
+
assert.equal(resolve_row_name(null), null)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('returns null when skill is malformed (no slash, just a bare value, returns the value itself)', () => {
|
|
307
|
+
// "deploy-aws" with no slash is a one-segment slug — last segment IS deploy-aws.
|
|
308
|
+
assert.equal(resolve_row_name({ skill: 'deploy-aws' }), 'deploy-aws')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('handles empty-string name by falling back to skill', () => {
|
|
312
|
+
assert.equal(resolve_row_name({ name: '', skill: 'acme/foo' }), 'foo')
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
describe('normalize_data_rows', () => {
|
|
317
|
+
it('adds name to rows that only have skill', () => {
|
|
318
|
+
const data = [{ skill: 'acme/deploy-aws', workspace_slug: 'acme' }]
|
|
319
|
+
normalize_data_rows(data)
|
|
320
|
+
assert.equal(data[0].name, 'deploy-aws')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('leaves rows with existing name untouched', () => {
|
|
324
|
+
const data = [{ name: 'pre-existing', skill: 'acme/different' }]
|
|
325
|
+
normalize_data_rows(data)
|
|
326
|
+
assert.equal(data[0].name, 'pre-existing')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('is idempotent', () => {
|
|
330
|
+
const data = [{ skill: 'acme/deploy-aws' }]
|
|
331
|
+
normalize_data_rows(data)
|
|
332
|
+
normalize_data_rows(data)
|
|
333
|
+
assert.equal(data[0].name, 'deploy-aws')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('handles non-array input without crashing', () => {
|
|
337
|
+
normalize_data_rows(null)
|
|
338
|
+
normalize_data_rows('not an array')
|
|
339
|
+
normalize_data_rows({})
|
|
340
|
+
// no assertion — just confirming no throw
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
// ─── extract_data_array_from_search_output (v0.48.0) ──────────────────────
|
|
345
|
+
|
|
346
|
+
describe('extract_data_array_from_search_output', () => {
|
|
347
|
+
it('extracts data.results from the canonical envelope shape', () => {
|
|
348
|
+
const env = { data: { query: 'q', mode: 'semantic', results: [{ name: 'foo' }] }, error: null, next_step: null }
|
|
349
|
+
const r = extract_data_array_from_search_output(env)
|
|
350
|
+
assert.equal(r.length, 1)
|
|
351
|
+
assert.equal(r[0].name, 'foo')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('handles legacy {data: [...]} (data is bare array)', () => {
|
|
355
|
+
const env = { data: [{ name: 'foo' }] }
|
|
356
|
+
assert.deepEqual(extract_data_array_from_search_output(env), [{ name: 'foo' }])
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('handles bare array', () => {
|
|
360
|
+
const env = [{ name: 'foo' }]
|
|
361
|
+
assert.deepEqual(extract_data_array_from_search_output(env), env)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('handles double-wrapped {data: {data: [...]}} (defensive)', () => {
|
|
365
|
+
const env = { data: { data: [{ name: 'foo' }] } }
|
|
366
|
+
assert.deepEqual(extract_data_array_from_search_output(env), [{ name: 'foo' }])
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('returns null when no data array can be found', () => {
|
|
370
|
+
assert.equal(extract_data_array_from_search_output({}), null)
|
|
371
|
+
assert.equal(extract_data_array_from_search_output({ data: null }), null)
|
|
372
|
+
assert.equal(extract_data_array_from_search_output('string'), null)
|
|
373
|
+
assert.equal(extract_data_array_from_search_output(null), null)
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// ─── parse_input with --search-output path (v0.48.0) ──────────────────────
|
|
378
|
+
|
|
379
|
+
describe('parse_input with --search-output', () => {
|
|
380
|
+
it('extracts data.results from a full search envelope passed as the third argument', () => {
|
|
381
|
+
const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'top' }] })
|
|
382
|
+
const search_out = JSON.stringify({
|
|
383
|
+
data: { query: 'q', mode: 'semantic', results: [{ name: 'deploy-aws', workspace_slug: 'acme' }] },
|
|
384
|
+
error: null,
|
|
385
|
+
next_step: null,
|
|
386
|
+
})
|
|
387
|
+
const r = parse_input(ranking, null, search_out)
|
|
388
|
+
assert.equal(r.parse_error, null)
|
|
389
|
+
assert.equal(r.ranking[0].candidate_id, 1)
|
|
390
|
+
assert.equal(r.data[0].name, 'deploy-aws')
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('search-output overrides inline data when both are provided', () => {
|
|
394
|
+
const combined = JSON.stringify({
|
|
395
|
+
ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }],
|
|
396
|
+
data: [{ name: 'stale-inline-name' }],
|
|
397
|
+
})
|
|
398
|
+
const search_out = JSON.stringify({ data: { results: [{ name: 'fresh-from-search-output' }] } })
|
|
399
|
+
const r = parse_input(combined, null, search_out)
|
|
400
|
+
assert.equal(r.parse_error, null)
|
|
401
|
+
assert.equal(r.data[0].name, 'fresh-from-search-output')
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('normalizes rows that only have skill (no name) when sourced from search-output', () => {
|
|
405
|
+
const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }] })
|
|
406
|
+
// to_smart_json before v0.48.0 emitted `skill` without `name`. Simulate that.
|
|
407
|
+
const search_out = JSON.stringify({ data: { results: [{ skill: 'acme/legacy-row', workspace_slug: 'acme' }] } })
|
|
408
|
+
const r = parse_input(ranking, null, search_out)
|
|
409
|
+
assert.equal(r.parse_error, null)
|
|
410
|
+
assert.equal(r.data[0].name, 'legacy-row')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('errors when search-output does not contain a data array', () => {
|
|
414
|
+
const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }] })
|
|
415
|
+
const search_out = JSON.stringify({ data: { query: 'q' } })
|
|
416
|
+
const r = parse_input(ranking, null, search_out)
|
|
417
|
+
assert.match(r.parse_error, /does not contain a data\.results array/)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('errors with an actionable message when data is missing from all sources', () => {
|
|
421
|
+
const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }] })
|
|
422
|
+
const r = parse_input(ranking, null, null)
|
|
423
|
+
assert.match(r.parse_error, /--search-output/)
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
|
|
286
427
|
describe('build_final_ordering', () => {
|
|
287
428
|
it('joins ranking with data rows, producing slug + rationale', () => {
|
|
288
429
|
const data = make_data(['deploy-aws', 'serverless'])
|
package/src/commands/search.js
CHANGED
|
@@ -103,12 +103,14 @@ const format_smart_result = (item, index) => {
|
|
|
103
103
|
|
|
104
104
|
const to_smart_json = (item) => ({
|
|
105
105
|
skill: `${item.workspace_slug}/${item.name}`,
|
|
106
|
+
name: item.name,
|
|
106
107
|
type: item.type || 'skill',
|
|
107
108
|
description: item.description || '',
|
|
108
109
|
version: item.latest_version || item.version || '-',
|
|
109
110
|
visibility: item.visibility || 'public',
|
|
110
111
|
workspace_slug: item.workspace_slug,
|
|
111
112
|
stars: item.star_count || 0,
|
|
113
|
+
star_count: item.star_count || 0,
|
|
112
114
|
quality_score: item.quality_score != null ? item.quality_score : null,
|
|
113
115
|
quality_tier: get_quality_tier_name(item.quality_score),
|
|
114
116
|
relevance_score: item.relevance_score != null ? item.relevance_score : null,
|