Skip to content

Commit ecb02a8

Browse files
reggimartinrrmCopilot
authored
fix(view): avoid wrapping array results (#9745)
npm 12.0.0 breaks downstream updaters by returning nested arrays for `npm view <pkg> versions --json`. ## The bug On npm 12.0.0, a single array-valued field is wrapped in the outer results array: ``` $ npm view abbrev versions --json [["1.0.3","1.0.4", ...]] # should be ["1.0.3","1.0.4", ...] ``` This happens in `lib/commands/view.js` `#packageOutput`: for a single-field query it maps to `res.map(m => m[first[0]])`, and when that field's value is itself an array (e.g. `versions`), it gets double-wrapped. ## The fix Return a sole array-valued JSON result directly instead of adding a second result wrapper. Existing output shapes are preserved: - scalar and object results still return in an array (`["1.0.0"]`, `[{...}]`) - multiple matching versions keep the result boundary (`[[...],[...]]`) - a single array-valued result is returned directly (`["1.0.0","1.0.1"]`) Docs and tests updated to cover flat array, nested array, object-wrapper, workspace, and multi-match cases. Co-authored-by: Martin Ruiz <martin.ruiz.mares@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 47fc8b1 commit ecb02a8

3 files changed

Lines changed: 156 additions & 4 deletions

File tree

docs/lib/content/commands/npm-view.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ If only a single string field for a single version is output, then it will not b
174174
If the field is an object, it will be output as a JavaScript object literal.
175175

176176
If the `--json` flag is given, the outputted fields will be JSON.
177-
The output is always an array, even if only a single version matches.
177+
Scalar and object results are returned in an array, even if only a single version matches.
178+
When the output contains one array-valued result, that array is returned directly without an additional result wrapper.
179+
Multiple array-valued results remain separate items in the outer results array.
178180

179181
If the version range matches multiple versions then each printed value will be prefixed with the version it applies to.
180182

lib/commands/view.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,12 +240,15 @@ class View extends BaseCommand {
240240
})
241241

242242
if (json) {
243-
// Users can expect an array .
244243
const first = Object.keys(res[0] || {})
245244
const jsonRes = first.length === 1 ? res.map(m => m[first[0]]) : res
246245
if (jsonRes.length === 0) {
247246
return
248247
}
248+
// Avoid wrapping a single array-valued result in another array.
249+
if (jsonRes.length === 1 && Array.isArray(jsonRes[0])) {
250+
return jsonRes[0]
251+
}
249252
return jsonRes
250253
}
251254

test/lib/commands/view.js

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,14 +189,37 @@ const packument = (nv, opts) => {
189189
},
190190
purple: {
191191
name: 'purple',
192+
'dist-tags': {
193+
latest: '1.0.0',
194+
},
192195
versions: {
193196
'1.0.0': {
197+
version: '1.0.0',
194198
foo: 1,
199+
metadata: {
200+
channels: ['latest', 'next'],
201+
empty: [],
202+
release: {
203+
stable: true,
204+
},
205+
},
206+
items: [
207+
{ tags: ['one', 'two'] },
208+
],
195209
maintainers: [
196210
{ name: 'claudia' },
197211
],
198212
},
199-
'1.0.1': {},
213+
'1.0.1': {
214+
version: '1.0.1',
215+
metadata: {
216+
channels: ['next'],
217+
empty: [],
218+
release: {
219+
stable: false,
220+
},
221+
},
222+
},
200223
},
201224
},
202225
green: {
@@ -508,6 +531,118 @@ t.test('package with --json and single string arg', async t => {
508531
t.strictSame(JSON.parse(joinedOutput()), ['1.0.0'], 'returns single string value as array')
509532
})
510533

534+
t.test('package with --json and array-valued field', async t => {
535+
const { view, joinedOutput } = await loadMockNpm(t, { config: { json: true } })
536+
await view.exec(['blue', 'versions'])
537+
t.strictSame(
538+
JSON.parse(joinedOutput()),
539+
['1.0.0', '1.0.1'],
540+
'returns the field value without an additional result wrapper'
541+
)
542+
})
543+
544+
t.test('package with --json and array-valued field from multiple matches', async t => {
545+
const { view, joinedOutput } = await loadMockNpm(t, { config: { json: true } })
546+
await view.exec(['blue@^1', 'versions'])
547+
t.strictSame(
548+
JSON.parse(joinedOutput()),
549+
[
550+
['1.0.0', '1.0.1'],
551+
['1.0.0', '1.0.1'],
552+
],
553+
'preserves the result boundary for each matching version'
554+
)
555+
})
556+
557+
t.test('package field access with --json preserves value shapes', async t => {
558+
const cases = [
559+
{
560+
name: 'nested scalar field',
561+
args: ['purple@1.0.0', 'metadata.release.stable'],
562+
expected: [true],
563+
},
564+
{
565+
name: 'nested object field',
566+
args: ['purple@1.0.0', 'metadata.release'],
567+
expected: [{ stable: true }],
568+
},
569+
{
570+
name: 'nested empty array field',
571+
args: ['purple@1.0.0', 'metadata.empty'],
572+
expected: [],
573+
},
574+
{
575+
name: 'nested single-item array field',
576+
args: ['purple@1.0.1', 'metadata.channels'],
577+
expected: ['next'],
578+
},
579+
{
580+
name: 'nested multi-item array field',
581+
args: ['purple@1.0.0', 'metadata.channels'],
582+
expected: ['latest', 'next'],
583+
},
584+
{
585+
name: 'array field with bracket notation',
586+
args: ['purple@1.0.0', 'metadata[channels]'],
587+
expected: ['latest', 'next'],
588+
},
589+
{
590+
name: 'indexed array element',
591+
args: ['purple@1.0.0', 'metadata.channels[0]'],
592+
expected: ['latest'],
593+
},
594+
{
595+
name: 'expanded array subfield',
596+
args: ['pink@1.0.0', 'maintainers.url'],
597+
expected: [{
598+
'maintainers[0].url': 'http://c.pink.com',
599+
'maintainers[1].url': 'http://i.pink.com',
600+
}],
601+
},
602+
{
603+
name: 'expanded array-valued subfield',
604+
args: ['purple@1.0.0', 'items.tags'],
605+
expected: ['one', 'two'],
606+
},
607+
{
608+
name: 'multiple requested fields',
609+
args: ['purple@1.0.0', 'metadata.channels', 'metadata.release'],
610+
expected: [{
611+
'metadata.channels': ['latest', 'next'],
612+
'metadata.release': { stable: true },
613+
}],
614+
},
615+
{
616+
name: 'multiple requested fields with one missing',
617+
args: ['purple@1.0.0', 'metadata.channels', 'missing'],
618+
expected: ['latest', 'next'],
619+
},
620+
{
621+
name: 'array field from multiple matching versions',
622+
args: ['purple@^1', 'metadata.channels'],
623+
expected: [
624+
['latest', 'next'],
625+
['next'],
626+
],
627+
},
628+
{
629+
name: 'array field present in one of multiple matching versions',
630+
args: ['purple@^1', 'items'],
631+
expected: [
632+
{ tags: ['one', 'two'] },
633+
],
634+
},
635+
]
636+
637+
for (const { name, args, expected } of cases) {
638+
await t.test(name, async t => {
639+
const { view, joinedOutput } = await loadMockNpm(t, { config: { json: true } })
640+
await view.exec(args)
641+
t.strictSame(JSON.parse(joinedOutput()), expected)
642+
})
643+
}
644+
})
645+
511646
t.test('package with single version', async t => {
512647
t.test('full json', async t => {
513648
const { view, joinedOutput } = await loadMockNpm(t, { config: { json: true } })
@@ -519,7 +654,7 @@ t.test('package with single version', async t => {
519654
const { view, joinedOutput } = await loadMockNpm(t, { config: { json: true } })
520655
await view.exec(['single-version', 'versions'])
521656
const parsed = JSON.parse(joinedOutput())
522-
t.strictSame(parsed, [['1.0.0']], 'does not unwrap single item arrays in json')
657+
t.strictSame(parsed, ['1.0.0'], 'preserves the array-valued field')
523658
})
524659

525660
t.test('no json and versions arg', async t => {
@@ -766,6 +901,18 @@ t.test('workspaces', async t => {
766901
t.matchSnapshot(joinedOutput())
767902
})
768903

904+
t.test('all workspaces array field --json', async t => {
905+
const { view, joinedOutput } = await loadMockNpm(t, {
906+
prefixDir,
907+
config: { unicode: false, workspaces: true, json: true },
908+
})
909+
await view.exec(['.', 'versions'])
910+
t.strictSame(JSON.parse(joinedOutput()), {
911+
green: ['1.0.0', '1.0.1'],
912+
orange: ['1.0.0', '1.0.1'],
913+
})
914+
})
915+
769916
t.test('single workspace --json', async t => {
770917
const { view, joinedOutput } = await loadMockNpm(t, {
771918
prefixDir,

0 commit comments

Comments
 (0)